diff --git a/README.md b/README.md index 7ac9f35..3332c99 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,7 @@ else on the account. `operations/about` through the rclone rc gives real bytes. - macOS 14 (Sonoma) or newer. Administrator rights for two commands, listed below. - A Google account with room to spare. -- Homebrew, to install a release — or Xcode / a Swift 5.9+ toolchain, to build - the app yourself (see below). +- [Homebrew](https://brew.sh). - Nothing else at runtime. CloudMachine installs its own `rclone` and its own copy of FUSE-T. @@ -119,75 +118,74 @@ so they read the same whoever sends them to you. ### Current limitations -Worth knowing before you start, because none of them announce themselves: - -- **One Mac per Google account.** The Drive folder is a constant, - `gdrive:CloudMachine/mac-studio`, whatever the Mac is called, so a second Mac - on the same account would share the folder and the image with the first. - `config/machines.example.json` describes several machines with per-machine - `limit_gb` budgets, but no code enforces those budgets — what is checked is - the real free space on Drive, reported by rclone. -- **Not notarised.** Releases are signed with a self-signed certificate (local - builds ad hoc or with a local one, see below), not with an Apple Developer ID. - The Homebrew cask clears the quarantine flag; a DMG downloaded by hand gets - Gatekeeper's "unidentified developer" warning. +- **Not notarised.** Releases are signed with a self-signed certificate, not + with an Apple Developer ID. The Homebrew cask clears the quarantine flag; a + DMG downloaded by hand gets Gatekeeper's "unidentified developer" warning. +- **Per-machine budgets are not enforced.** `config/machines.example.json` + describes `limit_gb` per Mac, but nothing acts on it — what is checked is the + real free space on Drive, reported by rclone. Several Macs on one account + share that space. --- -## Installing +## Getting started ```sh brew install --cask renacode/tap/cloudmachine +open -a CloudMachine ``` -The cask installs a universal (Apple Silicon + Intel) `CloudMachine.app` into -`/Applications`. `brew upgrade` does not restart the Google Drive mount, and -neither `brew uninstall` nor `--zap` touches the launchd agents or the upload -buffer in `~/.cloudmachine`, which may hold backups that have not reached -Google Drive yet. Run `cloudmachine-agent prepare-shutdown` before -uninstalling. Releases, signing and the cask are described in -[`packaging/README.md`](packaging/README.md). - -### Building the app - -```sh -cd mac-app -swift run cloudmachine-agent setup-signing-cert # optional, once per Mac -swift run cloudmachine-agent build-app # -> mac-app/build/CloudMachine.app -rm -rf /Applications/CloudMachine.app # never copy over a live bundle -cp -R build/CloudMachine.app /Applications/ -``` - -Removing the installed copy first is not optional. `cp -R` onto an existing -bundle overwrites its files in place; macOS still holds the old signature for -them and kills every agent started from the bundle -(`last exit reason = OS_REASON_CODESIGNING`), while `codesign --verify` keeps -passing. Removed and copied anew, the files get new identities. The mount -survives this: the rclone process that holds it lives outside the bundle. - -`build-app` puts the menu-bar app and `cloudmachine-agent` side by side in -`Contents/MacOS/`, with the launchd templates as resources, so the installed app -does not need the repository next to it. It must live in `/Applications`: the -launchd agent that starts the app opens `/Applications/CloudMachine.app`. - -`setup-signing-cert` creates a local, self-signed code-signing certificate in the -login keychain. Without it every build is signed ad hoc with a new identity, and -macOS revokes permissions such as Full Disk Access after each rebuild. With it, -`build-app` signs with that certificate automatically. - -`swift run cloudmachine-agent make-dmg` packs the built app into -`mac-app/build/CloudMachine-.dmg`; `build-app --universal` builds for -both architectures, as releases do. Releases are built by -`.github/workflows/release.yml` from a `vX.Y.Z` tag. The version -comes from `mac-app/VERSION`, and `cloudmachine-agent version` prints it -together with the build number and the commit the binary was built from. - ---- - -## Setup - -`cloudmachine-agent` lives inside the app bundle. `install-launchd` symlinks it -into `/usr/local/bin`; until then, call it by its full path: +CloudMachine lives in the menu bar. Its window opens on a **Required Setup +Steps** card listing what is left to do on this Mac, in order, each with a +button — or, for the two steps an app cannot do, a command to copy: + +1. **Install rclone** and **Install FUSE-T.** CloudMachine downloads its own + copies; nothing else is installed system-wide. +2. **Connect Google Drive** — a command to run in Terminal. It opens Google's + sign-in in the browser and waits for your approval. Set up your own OAuth + credentials first (see [below](#your-own-google-oauth-credentials)); the + card for them is at the bottom of the window. +3. **Grant Full Disk Access** — opens the right pane of System Settings. + Without it CloudMachine cannot read when Time Machine last *finished* a + backup, which is the one check that matters. +4. **Install agents** — the launchd agents that keep the Drive mounted, attach + the image and watch the backup. +5. **Create image**, then **Attach image** — the backup image on Google Drive. + The size is a ceiling, not an allocation: the image is sparse, and Drive + only holds what has been written. +6. **Point Time Machine at CloudMachine** — a `sudo tmutil setdestination` + command to copy, because only an administrator can change it. + +When the card disappears, setup is done. Turn on automatic backups in System +Settings → General → Time Machine, or run `sudo tmutil enable`. + +### Several Macs, one Google account + +Each Mac backs up into its own folder, `gdrive:CloudMachine/`, holding +`.sparsebundle`. The name is chosen once, when that Mac connects to +Google Drive: by default it comes from the computer name, or pass your own — +`cloudmachine-agent configure-remote --folder office-imac`. It cannot be changed +afterwards, because a new name is a new, empty backup; CloudMachine refuses +rather than orphan the old one. `cloudmachine-agent drive-status` shows the +folder in use. Installations set up before per-Mac folders keep +`mac-studio`, which is where their backup already is. + +### Upgrading and uninstalling + +`brew upgrade` replaces the app without restarting the Google Drive mount; the +agents pick up the new version on their next run. Neither `brew uninstall` nor +`--zap` touches the launchd agents or the upload buffer in `~/.cloudmachine`, +which may hold backups that have not reached Google Drive yet. Run +`cloudmachine-agent prepare-shutdown` before uninstalling. + +Building from source, releases and the measurement harnesses are covered in +[docs/building.md](docs/building.md). + +### Setting up from Terminal + +The same steps without the app. `cloudmachine-agent` lives inside the app +bundle; `install-launchd` links it into `/usr/local/bin`, so until then call it +by its full path: ```sh /Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help @@ -196,10 +194,10 @@ into `/usr/local/bin`; until then, call it by its full path: ```sh cloudmachine-agent install-rclone # official binary — the Homebrew build cannot mount cloudmachine-agent install-fuse # FUSE-T, inside CloudMachine, no separate app -cloudmachine-agent configure-remote # Google OAuth in the browser -cloudmachine-agent create-image --size-gb 4000 +cloudmachine-agent configure-remote # Google OAuth in the browser; --folder NAME to choose this Mac's folder +cloudmachine-agent install-launchd # agents that mount Drive and keep it running +cloudmachine-agent create-image --size-gb 4000 # needs the mount from the step above cloudmachine-agent attach-image -cloudmachine-agent install-launchd # agents that keep it running ``` Two steps need `sudo`, because they change system-wide settings: @@ -255,6 +253,17 @@ first and pass `--replace-existing` if you really mean it. ## Running it +Day to day, the menu-bar icon is the whole interface. Its menu shows whether a +backup is running and what is still waiting to upload, with **Back up now** and +**Stop backup**. The window answers one question — is the backup reaching Google +Drive, and if not, why: the time of the last *completed* backup, the state of +the local buffer and the upload, and a red line naming the problem when there is +one. Problems also arrive as macOS notifications, so nothing depends on someone +opening the window. + +Everything the window shows is also available in Terminal, for scripts and for +checking over SSH: + ```sh cloudmachine-agent drive-status ``` @@ -262,6 +271,7 @@ cloudmachine-agent drive-status ``` Tools: OK Drive mount: OK +Drive folder: gdrive:CloudMachine/mac-studio Image attached: OK (/Volumes/CloudMachine) Cache on disk: 103 GB of 100G To upload: ~14 GB (462 items) @@ -362,7 +372,7 @@ hours old. ```sh # Did anything fail today? -grep "^\[$(date +%Y-%m-%d)" ~/Library/Logs/CloudMachine/cloudmachine.log | grep -i awaria +grep "^\[$(date +%Y-%m-%d)" ~/Library/Logs/CloudMachine/cloudmachine.log | grep -i "backup failure" # Is the upload actually moving, or only erroring? Successes vs refusals per minute. grep "^$(date +%Y/%m/%d)" ~/.cloudmachine/rclone.log \ @@ -576,16 +586,6 @@ do it when you can leave the Mac alone. --- -## Measurement harnesses - -The measurements behind these decisions — write amplification per band size, and -what survives the cloud layer dying mid-write — are written up in -[gdrive/README.md](gdrive/README.md). The shell harnesses that produced them are -gone; that work now lives as subcommands of `cloudmachine-agent`, which is why -`gdrive/` holds nothing but the write-up. - ---- - ## Licence MIT. FUSE-T and rclone keep their own licences; see above. diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..cd97832 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,74 @@ +# Building CloudMachine from source + +Most people install with Homebrew (see the [README](../README.md)). This page is +for working on CloudMachine itself: building, running a local build, releases +and the measurement harnesses. + +## Requirements + +- macOS 14 (Sonoma) or newer +- Xcode, or a Swift 5.9+ toolchain +- `swift-format` for the lint CI runs: `brew install swift-format` + +## Build and install a local copy + +```sh +cd mac-app +swift run cloudmachine-agent setup-signing-cert # optional, once per Mac +swift run cloudmachine-agent build-app # -> mac-app/build/CloudMachine.app +rm -rf /Applications/CloudMachine.app # never copy over a live bundle +cp -R build/CloudMachine.app /Applications/ +``` + +Removing the installed copy first is not optional. `cp -R` onto an existing +bundle overwrites its files in place; macOS still holds the old signature for +them and kills every agent started from the bundle +(`last exit reason = OS_REASON_CODESIGNING`), while `codesign --verify` keeps +passing. Removed and copied anew, the files get new identities. The mount +survives this: the rclone process that holds it lives outside the bundle. + +`build-app` puts the menu-bar app and `cloudmachine-agent` side by side in +`Contents/MacOS/`, with the launchd templates as resources, so the installed app +does not need the repository next to it. It must live in `/Applications`: the +launchd agent that starts the app opens `/Applications/CloudMachine.app`. + +`setup-signing-cert` creates a local, self-signed code-signing certificate in the +login keychain. Without it every build is signed ad hoc with a new identity, and +macOS revokes permissions such as Full Disk Access after each rebuild. With it, +`build-app` signs with that certificate automatically. A Homebrew release is +signed with a different certificate, so switching between a local build and a +release needs Full Disk Access granted once more. + +`swift run cloudmachine-agent make-dmg` packs the built app into +`mac-app/build/CloudMachine-.dmg`; `build-app --universal` builds for +both architectures, as releases do. The version comes from `mac-app/VERSION`, +and `cloudmachine-agent version` prints it together with the build number and +the commit the binary was built from. + +## Tests and lint + +```sh +cd mac-app +swift test +swift format lint --strict --recursive Sources Tests +``` + +`L10nTests` fail on any Polish left outside the Polish translation tables, and +on any `L10n.tr` key without a Polish entry. User-facing text goes through +`L10n.tr("English text")` with a Polish entry in +`Sources/CloudMachineCore/L10nPolish+*.swift`; logs stay English. + +## Releases + +Releases are built by `.github/workflows/release.yml` from a `vX.Y.Z` tag and +published to Homebrew. The procedure and the one-time setup (signing +certificate, tap token) are in [`packaging/README.md`](../packaging/README.md). + +## Measurement harnesses + +The measurements behind the design — write amplification per band size, and +what survives the cloud layer dying mid-write — are written up in +[`gdrive/README.md`](../gdrive/README.md). The harnesses that produced them are +the separate `cloudmachine-poc` executable (`swift run cloudmachine-poc --help`). +It is deliberately not part of the app bundle: every harness creates and +deletes disk images. diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 89c128f..0713471 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -347,6 +347,10 @@ struct DriveStatus: AsyncParsableCommand { ? "OK" : L10n.tr("missing: %@", readiness.missing.joined(separator: ", ")))) let mounted = DriveBufferService.mountedState() print(L10n.tr("Drive mount: %@", StatusLines.mounted(mounted))) + print( + L10n.tr( + "Drive folder: %@", + "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)")) // Since 26.09.2026 the readability probe has a time limit - which is why // this tool does not hang on a dead mount (on 25.09.2026 it hung for over // 25 s and had to be killed), but reports that there is no answer. diff --git a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift index eb96bff..5804724 100644 --- a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift @@ -33,10 +33,18 @@ struct ConfigureRemote: AsyncParsableCommand { ) var replaceExisting = false + @Option( + name: .long, + help: ArgumentHelp( + L10n.tr( + "Name of this Mac's folder on Google Drive (default: derived from the computer name). Set once; it cannot be changed later." + ))) + var folder: String? + func run() async throws { let (config, key) = await CLIContext.load() let result = await RemoteConfigurer.connect( - config: config, machineKey: key, replaceExisting: replaceExisting) + config: config, machineKey: key, replaceExisting: replaceExisting, folder: folder) print(result.message) if !result.succeeded { throw ExitCode.failure } } diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 2d397b7..8b29b78 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -188,6 +188,11 @@ final class AppStatus: ObservableObject { @Published var backupProgress: BackupProgressInfo? @Published var lastAction: LastRunResult? @Published var hasFullDiskAccess: Bool = false + /// Whether the mount agent is loaded in launchd; `nil` until asked. + @Published var agentsInstalled: Bool? + /// Whether this Mac's image exists on the mounted Drive; `nil` while the + /// Drive is not mounted, because then nobody can know. + @Published var imageExists: Bool? @Published var isBusy: Bool = false @Published var busyLabel: String = "" @Published var errorMessage: String? diff --git a/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift b/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift new file mode 100644 index 0000000..b7164f0 --- /dev/null +++ b/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift @@ -0,0 +1,104 @@ +import CloudMachineCore +import Foundation + +/// What still has to be done before this Mac backs up, in the order it has to +/// be done. +/// +/// Installed from Homebrew, the app is the first thing a person opens, so the +/// setup lives here: each step either has a button that does it, or - for the +/// two that cannot run from the app - a command to copy. Google sign-in needs a +/// browser round trip that `configure-remote` waits on in a terminal, and +/// pointing Time Machine at a disk needs `sudo`. +/// +/// A pure function of what the controller measured, so the order and the +/// conditions are tested without a GUI. `nil` inputs mean "not measured +/// yet" and never produce a step: telling someone to create an image that +/// already exists is worse than showing the step a few seconds late. +struct SetupStep: Equatable { + enum Action: Equatable { + case installRclone + case installFuse + case grantFullDiskAccess + case installAgents + case createImage + case attachImage + } + + let title: String + /// Shown with a Copy button; for steps the app cannot do itself. + let command: String? + let action: Action? +} + +enum SetupPlan { + struct Inputs: Equatable { + var hasRclone: Bool + var hasFuse: Bool + var remoteConfigured: Bool + var hasFullDiskAccess: Bool + var agentsInstalled: Bool? + var mounted: Bool + var imageExists: Bool? + var imageAttached: Bool + var timeMachineNotPointingHere: Bool + var connectCommand: String + var setDestinationCommand: String + } + + static func steps(_ input: Inputs) -> [SetupStep] { + var steps: [SetupStep] = [] + if !input.hasRclone { + steps.append( + SetupStep( + title: L10n.tr("Install rclone (the official build, which can mount)"), command: nil, + action: .installRclone)) + } + if !input.hasFuse { + steps.append( + SetupStep(title: L10n.tr("Install FUSE-T"), command: nil, action: .installFuse)) + } + // Before anything that mounts: the Drive folder of this Mac is chosen + // here, and a mount started earlier would use the legacy name. + if !input.remoteConfigured { + steps.append( + SetupStep( + title: L10n.tr("Connect Google Drive: run this in Terminal and approve in the browser"), + command: input.connectCommand, action: nil)) + return steps + } + if !input.hasFullDiskAccess { + steps.append( + SetupStep( + title: L10n.tr( + "Grant Full Disk Access to CloudMachine, so it can tell whether backups complete"), + command: nil, action: .grantFullDiskAccess)) + } + if input.agentsInstalled == false { + steps.append( + SetupStep( + title: L10n.tr("Install the background agents (mount, image attach, watchdogs)"), + command: nil, action: .installAgents)) + return steps + } + guard input.mounted else { return steps } + if input.imageExists == false { + steps.append( + SetupStep( + title: L10n.tr("Create the backup image on Google Drive"), command: nil, + action: .createImage)) + return steps + } + if input.imageExists == true, !input.imageAttached { + steps.append( + SetupStep(title: L10n.tr("Attach the backup image"), command: nil, action: .attachImage)) + return steps + } + if input.imageAttached, input.timeMachineNotPointingHere { + steps.append( + SetupStep( + title: L10n.tr("Point Time Machine at CloudMachine: run this in Terminal (needs sudo)"), + command: input.setDestinationCommand, action: nil)) + } + return steps + } +} diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index c015a8a..bb49d30 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -100,6 +100,8 @@ final class CloudMachineController: ObservableObject { // `~/Library/Application Support/com.apple.TCC`: the wrong path and a check // that proves nothing under TCC. status.hasFullDiskAccess = BackupHealth.preferencesReadable() + status.agentsInstalled = await LaunchdInstaller.isInstalled( + label: "com.renacode.cloudmachine.gdrive-buffer") } /// State of our own OAuth credentials. We check ONLY that the entry exists - @@ -159,6 +161,9 @@ final class CloudMachineController: ObservableObject { buffer.outOfSpace = stats.outOfSpace } status.buffer = buffer + status.imageExists = + buffer.mounted + ? FileManager.default.fileExists(atPath: BackupImageService.imagePath.path) : nil } /// `destinationReading()`, and NOT `currentDestinationMountPoint()`. @@ -209,6 +214,33 @@ final class CloudMachineController: ObservableObject { // MARK: - Actions + func installFuse() async { + await run(L10n.tr("Installing FUSE-T"), log: "Installing FUSE-T") { + await FuseInstaller.install() + } + } + + /// Opens System Settings at Full Disk Access. Granting it is a decision + /// only the user can make; the app can only take them to the right pane. + func openFullDiskAccessSettings() { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") + { + NSWorkspace.shared.open(url) + } + } + + var setupPlan: [SetupStep] { + SetupPlan.steps( + SetupPlan.Inputs( + hasRclone: CMTooling.hasManagedRclone, hasFuse: CMTooling.hasFuse, + remoteConfigured: status.remoteConfigured, hasFullDiskAccess: status.hasFullDiskAccess, + agentsInstalled: status.agentsInstalled, mounted: status.buffer.mounted, + imageExists: status.imageExists, imageAttached: status.buffer.imageAttached, + timeMachineNotPointingHere: status.timeMachineState == .notRegistered, + connectCommand: connectDriveCommand, setDestinationCommand: setDestinationCommand)) + } + func installRclone() async { await run(L10n.tr("Installing rclone"), log: "Installing rclone") { await RcloneInstaller.install() diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index df3d8f1..5d35e2e 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -9,6 +9,8 @@ struct DashboardView: View { @State private var clientSecret = "" @State private var credentialsMessage: String? @State private var credentialsExpanded = false + /// Logical size of a new backup image. Sparse: Drive holds only what is written. + @State private var imageSizeGB = "4000" var body: some View { ZStack { @@ -333,21 +335,58 @@ struct DashboardView: View { // MARK: - Setup Steps ("To Do") - private var setupSteps: [(String, String?)] { - var steps: [(String, String?)] = [] - if case .missing(let what, let how) = controller.status.dependencyState { - for (miss, remedy) in zip(what, how) { steps.append((L10n.tr("Missing: %@", miss), remedy)) } + private var setupSteps: [SetupStep] { controller.setupPlan } + + /// One button per step the app can do itself. Disabled while anything runs: + /// image operations share one lock, and a second click would only report + /// "another operation is in progress". + @ViewBuilder + private func setupActionButton(_ action: SetupStep.Action) -> some View { + HStack(spacing: 8) { + if action == .createImage { + Text(L10n.tr("Size (GB)")) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.textMain) + TextField("", text: $imageSizeGB) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + Button(action: { Task { await perform(action) } }) { + Text(setupActionTitle(action)) + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(SecondaryGlassButtonStyle()) + .disabled(controller.status.isBusy || (action == .createImage && parsedImageSize == nil)) } - if !controller.status.remoteConfigured { - steps.append((L10n.tr("Google Drive not connected"), controller.connectDriveCommand)) + } + + private var parsedImageSize: Int? { + guard let value = Int(imageSizeGB.trimmingCharacters(in: .whitespaces)), value >= 100 + else { return nil } + return value + } + + private func setupActionTitle(_ action: SetupStep.Action) -> String { + switch action { + case .installRclone: return L10n.tr("Install rclone") + case .installFuse: return L10n.tr("Install FUSE-T") + case .grantFullDiskAccess: return L10n.tr("Open System Settings") + case .installAgents: return L10n.tr("Install agents") + case .createImage: return L10n.tr("Create image") + case .attachImage: return L10n.tr("Attach image") } - if case .notRegistered = controller.status.timeMachineState, - controller.status.buffer.imageAttached - { - steps.append( - (L10n.tr("Time Machine does not point to CloudMachine"), controller.setDestinationCommand)) + } + + private func perform(_ action: SetupStep.Action) async { + switch action { + case .installRclone: await controller.installRclone() + case .installFuse: await controller.installFuse() + case .grantFullDiskAccess: controller.openFullDiskAccessSettings() + case .installAgents: await controller.installAgents() + case .createImage: + if let size = parsedImageSize { await controller.createImage(sizeGB: size) } + case .attachImage: await controller.attachImage() } - return steps } private var setupCard: some View { @@ -372,12 +411,16 @@ struct DashboardView: View { .background(RenaCodeTheme.colorWarning) .clipShape(Circle()) - Text(step.0) + Text(step.title) .font(.system(size: 13, weight: .medium)) .foregroundStyle(RenaCodeTheme.textMain) } - if let command = step.1 { + if let action = step.action { + setupActionButton(action) + } + + if let command = step.command { HStack { Text(command) .font(.system(size: 12, design: .monospaced)) diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index d498933..8d51ce4 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -11,7 +11,9 @@ public enum BackupImageService { // MARK: - Paths - public static let imageName = "mac-studio" + /// Named after this Mac's Drive folder (`mac-studio` on installations that + /// predate per-Mac folders, where that is the image that already exists). + public static var imageName: String { DriveFolder.name } public static let volumeName = "CloudMachine" public static var imagePath: URL { diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index eac6595..c9081b9 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -24,7 +24,10 @@ public enum DriveBufferService { public static var logFile: URL { root.appendingPathComponent("rclone.log") } public static let remoteName = "gdrive" - public static let remotePath = "CloudMachine/mac-studio" + /// Top-level folder on Drive; each Mac has its own subfolder in it. + public static let remoteRoot = "CloudMachine" + /// This Mac's folder - see `DriveFolder` for why the name is fixed once set. + public static var remotePath: String { "\(remoteRoot)/\(DriveFolder.name)" } /// Buffer size. Kept as a number, because the watchdog's thresholds are /// derived from it - otherwise changing one without the other gives /// thresholds that never fire or fire immediately. diff --git a/mac-app/Sources/CloudMachineCore/DriveFolder.swift b/mac-app/Sources/CloudMachineCore/DriveFolder.swift new file mode 100644 index 0000000..b5b6b45 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/DriveFolder.swift @@ -0,0 +1,124 @@ +import Foundation + +/// The folder on Google Drive that holds THIS Mac's backup image: +/// `gdrive:CloudMachine/`, with the image `.sparsebundle` inside. +/// +/// It used to be the constant `mac-studio` on every Mac, so two Macs on one +/// Google account would have shared one folder and one image. Each Mac now +/// keeps its own name in `~/Library/Application Support/CloudMachine/drive-folder`, +/// chosen once by `configure-remote` and never changed afterwards. +/// +/// "Never changed" is the point. A different name is a different, empty +/// folder: Time Machine starts from zero and the old backup sits orphaned on +/// Drive, still using quota. So the name is decided once, and an installation +/// that predates this file keeps `mac-studio` - it is where its backup lives. +public enum DriveFolder { + /// The name every installation used before names were per Mac. + public static let legacyName = "mac-studio" + + static var file: URL { CMPaths.appSupportDir.appendingPathComponent("drive-folder") } + + /// The name the mount, the image and the status use. + /// + /// No file means an installation from before this setting existed (a new + /// one gets the file from `configure-remote` before anything is mounted), + /// so the answer is the legacy name, not a fresh one. + public static var name: String { + stored(in: file) ?? legacyName + } + + static func stored(in file: URL) -> String? { + guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return isValid(trimmed) ? trimmed : nil + } + + /// Lowercase letters, digits and dashes; 1-63 characters, not starting with + /// a dash. Safe as a Drive folder, a file name and an rclone path segment. + public static func isValid(_ name: String) -> Bool { + name.range(of: #"^[a-z0-9][a-z0-9-]{0,62}$"#, options: .regularExpression) != nil + } + + /// Whether this Mac was set up before folder names existed. + /// + /// The evidence is the rclone remote itself. `configure-remote` decides the + /// folder BEFORE it creates the remote, so on a new Mac the remote is not + /// there yet; on an installation that predates `drive-folder` it is, and its + /// backup lives under the legacy name. Weaker traces were rejected: the + /// buffer directory appears after any mount attempt, and the old + /// `mount-desired.state` is no longer written by anything. + static func hasLegacyInstallation() async -> Bool { + await RemoteConfigurer.isConfigured(remoteName: DriveBufferService.remoteName) + } + + public enum Decision: Equatable { + /// Already decided; nothing to write. + case keep(String) + /// Write this name now. + case assign(String) + /// Refuse, with the reason to show. + case refuse(String) + } + + /// Pure decision, so every branch can be tested without touching the disk. + /// + /// - `existing`: the name already stored, if any. + /// - `requested`: a name passed with `--folder`, if any. + /// - `legacyEvidence`: `hasLegacyInstallation()`, asked before the remote + /// is created. + /// - `machineKey`: `MachineIdentity` key, the default for a new Mac. + public static func decide( + existing: String?, requested: String?, legacyEvidence: Bool, machineKey: String + ) -> Decision { + if let requested, !isValid(requested) { + return .refuse( + L10n.tr( + "'%@' is not a valid folder name: use lowercase letters, digits and dashes.", requested)) + } + if let existing { + guard let requested, requested != existing else { return .keep(existing) } + return .refuse( + L10n.tr( + "This Mac already backs up to folder '%@'. Switching to '%@' would start a new, empty backup and orphan the existing one, so nothing was changed.", + existing, requested)) + } + if legacyEvidence { + guard let requested, requested != legacyName else { return .assign(legacyName) } + return .refuse( + L10n.tr( + "This Mac already has a CloudMachine installation, whose backup is in folder '%@'. Switching to '%@' would orphan it, so nothing was changed.", + legacyName, requested)) + } + let fallback = isValid(machineKey) ? machineKey : "this-mac" + return .assign(requested ?? fallback) + } + + /// Decides and stores the name for this Mac. Returns the name to use, or + /// the reason it refused. + public static func resolve(requested: String?) async -> Result { + let decision = decide( + existing: stored(in: file), requested: requested, + legacyEvidence: await hasLegacyInstallation(), machineKey: await MachineIdentity.currentKey()) + switch decision { + case .keep(let name): + return .success(name) + case .assign(let name): + do { + try name.write(to: file, atomically: true, encoding: .utf8) + } catch { + return .failure( + RefusedError( + message: L10n.tr( + "Could not save the folder name to %@: %@", file.path, error.localizedDescription))) + } + CMLogger.log("Drive folder for this Mac set to '\(name)'") + return .success(name) + case .refuse(let reason): + return .failure(RefusedError(message: reason)) + } + } + + public struct RefusedError: Error, Equatable { + public let message: String + } +} diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift index b618618..dd8ea40 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift @@ -161,6 +161,10 @@ extension L10nPolish { "brakuje: %@", "Drive mount: %@": "Montowanie Drive: %@", + "Drive folder: %@": + "Folder na Drive: %@", + "Name of this Mac's folder on Google Drive (default: derived from the computer name). Set once; it cannot be changed later.": + "Nazwa folderu tego Maca na Google Drive (domyślnie z nazwy komputera). Ustawiana raz; później nie da się jej zmienić.", "Image attached: %@": "Obraz podpięty: %@", "Cache on disk: %@": diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift index 93d2fa7..8672b50 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -1,6 +1,27 @@ // Polish translations, keyed by the English text passed to `L10n.tr`. extension L10nPolish { static let app: [String: String] = [ + // SetupPlan / setup card + "Install rclone (the official build, which can mount)": + "Zainstaluj rclone (oficjalną wersję, która umie montować)", + "Install FUSE-T": "Zainstaluj FUSE-T", + "Connect Google Drive: run this in Terminal and approve in the browser": + "Połącz Google Drive: uruchom to w Terminalu i zatwierdź w przeglądarce", + "Grant Full Disk Access to CloudMachine, so it can tell whether backups complete": + "Nadaj CloudMachine Pełny dostęp do dysku, żeby mógł sprawdzać, czy kopie się kończą", + "Install the background agents (mount, image attach, watchdogs)": + "Zainstaluj agentów w tle (montowanie, podpinanie obrazu, czujki)", + "Create the backup image on Google Drive": "Utwórz obraz kopii na Google Drive", + "Attach the backup image": "Podepnij obraz kopii", + "Point Time Machine at CloudMachine: run this in Terminal (needs sudo)": + "Ustaw CloudMachine jako dysk Time Machine: uruchom to w Terminalu (wymaga sudo)", + "Size (GB)": "Rozmiar (GB)", + "Install rclone": "Zainstaluj rclone", + "Open System Settings": "Otwórz Ustawienia systemowe", + "Install agents": "Zainstaluj agentów", + "Create image": "Utwórz obraz", + "Attach image": "Podepnij obraz", + "Installing FUSE-T": "Instaluję FUSE-T", // AppStatus "not checked": "nie sprawdzono", "none at all": "ani jednej", diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift index 516a8f6..59ea720 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift @@ -1,6 +1,15 @@ // Polish translations, keyed by the English text passed to `L10n.tr`. extension L10nPolish { static let system: [String: String] = [ + // DriveFolder + "'%@' is not a valid folder name: use lowercase letters, digits and dashes.": + "'%@' to nieprawidłowa nazwa folderu: użyj małych liter, cyfr i myślników.", + "This Mac already backs up to folder '%@'. Switching to '%@' would start a new, empty backup and orphan the existing one, so nothing was changed.": + "Ten Mac już robi kopię do folderu '%@'. Przejście na '%@' zaczęłoby nową, pustą kopię i osierociło istniejącą, więc nic nie zmieniono.", + "This Mac already has a CloudMachine installation, whose backup is in folder '%@'. Switching to '%@' would orphan it, so nothing was changed.": + "Ten Mac ma już instalację CloudMachine, której kopia leży w folderze '%@'. Przejście na '%@' by ją osierociło, więc nic nie zmieniono.", + "Could not save the folder name to %@: %@": + "Nie udało się zapisać nazwy folderu w %@: %@", "Homebrew is not installed. Install it manually: https://brew.sh": "Homebrew nie jest zainstalowany. Zainstaluj go ręcznie: https://brew.sh", "Installing rclone failed: %@": diff --git a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift index 9e9b75a..e32489a 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift @@ -67,10 +67,10 @@ public enum RemoteConfigurer { /// constant that builds the mount command. One source of truth or none. @discardableResult public static func connect( - config: MachinesConfig, machineKey: String, replaceExisting: Bool = false + config: MachinesConfig, machineKey: String, replaceExisting: Bool = false, + folder: String? = nil ) async -> CMActionResult { let remoteName = DriveBufferService.remoteName - let remotePath = "\(remoteName):\(DriveBufferService.remotePath)" // We ABORT rather than warn. `rclone config create` overwrites an entry // with the same name without asking, and with it the token, `client_id` @@ -87,6 +87,18 @@ public enum RemoteConfigurer { remoteName)) } + // The folder is decided BEFORE the remote is created: an existing remote + // is how `DriveFolder` recognises an installation whose backup already + // lives under the legacy name. Asked after `config create`, every new Mac + // would look like an old one and land in `mac-studio`. + switch await DriveFolder.resolve(requested: folder) { + case .failure(let refused): + return CMActionResult(succeeded: false, message: refused.message) + case .success: + break + } + let remotePath = "\(remoteName):\(DriveBufferService.remotePath)" + // Own OAuth credentials from the Keychain. On rclone's shared `client_id` // we compete for the rate limit with all rclone users, and that // `client_id` itself is being retired in 2026. diff --git a/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift new file mode 100644 index 0000000..1033567 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift @@ -0,0 +1,87 @@ +import XCTest + +@testable import CloudMachineCore + +/// The cases that matter are the ones that would orphan a backup: a Mac that +/// already backs up somewhere must never be moved to another folder, whatever +/// is passed on the command line. +final class DriveFolderTests: XCTestCase { + + func testNewMacGetsItsMachineKey() { + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: nil, legacyEvidence: false, machineKey: "macbook-pro"), + .assign("macbook-pro")) + } + + func testNewMacCanChooseItsFolder() { + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: "office-imac", legacyEvidence: false, machineKey: "imac"), + .assign("office-imac")) + } + + func testInstallationFromBeforeFoldersKeepsTheLegacyFolder() { + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: nil, legacyEvidence: true, machineKey: "mac-studio-2"), + .assign(DriveFolder.legacyName)) + } + + func testInstallationFromBeforeFoldersCannotBeMovedByAccident() { + guard + case .refuse = DriveFolder.decide( + existing: nil, requested: "new-name", legacyEvidence: true, machineKey: "x") + else { return XCTFail("a legacy installation was moved to a new, empty folder") } + } + + func testStoredFolderIsKept() { + XCTAssertEqual( + DriveFolder.decide( + existing: "macbook-pro", requested: nil, legacyEvidence: true, machineKey: "other"), + .keep("macbook-pro")) + XCTAssertEqual( + DriveFolder.decide( + existing: "macbook-pro", requested: "macbook-pro", legacyEvidence: false, + machineKey: "other"), + .keep("macbook-pro")) + } + + func testStoredFolderCannotBeChanged() { + guard + case .refuse = DriveFolder.decide( + existing: "macbook-pro", requested: "imac", legacyEvidence: false, machineKey: "x") + else { return XCTFail("a Mac with a backup was moved to a new, empty folder") } + } + + func testInvalidNamesAreRefused() { + for name in [ + "", "-leading-dash", "Upper", "with space", "a/b", "..", String(repeating: "a", count: 64), + ] { + guard + case .refuse = DriveFolder.decide( + existing: nil, requested: name, legacyEvidence: false, machineKey: "x") + else { return XCTFail("accepted invalid folder name '\(name)'") } + } + } + + func testUnusableMachineKeyFallsBackToAValidName() { + XCTAssertEqual( + DriveFolder.decide(existing: nil, requested: nil, legacyEvidence: false, machineKey: ""), + .assign("this-mac")) + } + + func testStoredFileIsReadAndAMissingOrBrokenOneIsIgnored() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("drive-folder-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("drive-folder") + + XCTAssertNil(DriveFolder.stored(in: file)) + try "macbook-pro\n".write(to: file, atomically: true, encoding: .utf8) + XCTAssertEqual(DriveFolder.stored(in: file), "macbook-pro") + try "../../etc".write(to: file, atomically: true, encoding: .utf8) + XCTAssertNil(DriveFolder.stored(in: file)) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift b/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift new file mode 100644 index 0000000..bbcbb00 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift @@ -0,0 +1,88 @@ +import XCTest + +@testable import CloudMachineApp + +/// The setup card is the first thing someone sees after `brew install`. These +/// pin the order (Google before anything that mounts, because that is where +/// this Mac's Drive folder is chosen) and that unmeasured state never asks +/// for an action. +@MainActor +final class SetupPlanTests: XCTestCase { + + private func inputs() -> SetupPlan.Inputs { + SetupPlan.Inputs( + hasRclone: true, hasFuse: true, remoteConfigured: true, hasFullDiskAccess: true, + agentsInstalled: true, mounted: true, imageExists: true, imageAttached: true, + timeMachineNotPointingHere: false, connectCommand: "connect", + setDestinationCommand: "sudo set") + } + + private func actions(_ input: SetupPlan.Inputs) -> [SetupStep.Action?] { + SetupPlan.steps(input).map(\.action) + } + + func testFinishedSetupHasNoSteps() { + XCTAssertEqual(SetupPlan.steps(inputs()), []) + } + + func testFreshMacStopsAtGoogleBeforeAnythingThatMounts() { + var input = inputs() + input.hasRclone = false + input.hasFuse = false + input.remoteConfigured = false + input.hasFullDiskAccess = false + input.agentsInstalled = false + input.mounted = false + input.imageExists = nil + input.imageAttached = false + let steps = SetupPlan.steps(input) + XCTAssertEqual(steps.map(\.action), [.installRclone, .installFuse, nil]) + XCTAssertEqual(steps.last?.command, "connect") + } + + func testAfterGoogleTheAgentsComeNext() { + var input = inputs() + input.hasFullDiskAccess = false + input.agentsInstalled = false + input.mounted = false + input.imageExists = nil + input.imageAttached = false + XCTAssertEqual(actions(input), [.grantFullDiskAccess, .installAgents]) + } + + func testMountedWithoutImageOffersToCreateIt() { + var input = inputs() + input.imageExists = false + input.imageAttached = false + XCTAssertEqual(actions(input), [.createImage]) + } + + func testExistingImageIsAttachedNotCreated() { + var input = inputs() + input.imageAttached = false + XCTAssertEqual(actions(input), [.attachImage]) + } + + func testUnknownImageStateAsksForNothing() { + var input = inputs() + input.imageExists = nil + input.imageAttached = false + XCTAssertEqual(SetupPlan.steps(input), []) + } + + func testNotMountedAsksForNoImageStep() { + var input = inputs() + input.mounted = false + input.imageExists = false + input.imageAttached = false + XCTAssertEqual(SetupPlan.steps(input), []) + } + + func testTimeMachineStepIsACommandToCopy() { + var input = inputs() + input.timeMachineNotPointingHere = true + let steps = SetupPlan.steps(input) + XCTAssertEqual(steps.map(\.action), [nil]) + XCTAssertEqual(steps.first?.command, "sudo set") + } +} diff --git a/packaging/homebrew/cloudmachine.rb.in b/packaging/homebrew/cloudmachine.rb.in index 260116b..0889349 100644 --- a/packaging/homebrew/cloudmachine.rb.in +++ b/packaging/homebrew/cloudmachine.rb.in @@ -51,9 +51,9 @@ cask "cloudmachine" do ] caveats <<~EOS - First-time setup (the agent lives inside the app bundle): - /Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help - See https://github.com/RenaCode/CloudMachine#setup for the full sequence. + Open CloudMachine (`open -a CloudMachine`): its window lists the + remaining setup steps for this Mac, with a button for each. + See https://github.com/RenaCode/CloudMachine#getting-started `brew upgrade` keeps the Google Drive mount running. Afterwards, check that the backup watchdog still runs: `cloudmachine-agent drive-status`.