From d6e11f72063005a11753008149288f326a2faa68 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 09:04:27 +0200 Subject: [PATCH 1/3] feat(drive): one Drive folder per Mac, so several Macs can share an account The folder and the image were the constant `mac-studio` on every Mac, so a second Mac on the same Google account would have shared both. drive.file access is per OAuth client, not per token, so two Macs with the same credentials do see each other's files. Each Mac now keeps its folder name in Application Support/drive-folder, decided once by `configure-remote` (default: the machine key, or `--folder NAME`) BEFORE the remote is created. An installation that already has the rclone remote predates this setting and keeps `mac-studio`, where its backup lives; with no file at all the legacy name is used too. A stored or legacy folder can never be switched - that would start an empty backup and orphan the old one - so such a request is refused. `drive-status` shows the folder. Co-Authored-By: Claude Opus 5.5 --- .../CloudMachineAgent/DriveCommands.swift | 4 + .../CloudMachineAgent/SetupCommands.swift | 10 +- .../CloudMachineCore/BackupImageService.swift | 4 +- .../CloudMachineCore/DriveBufferService.swift | 5 +- .../CloudMachineCore/DriveFolder.swift | 124 ++++++++++++++++++ .../CloudMachineCore/L10nPolish+Agent.swift | 4 + .../CloudMachineCore/L10nPolish+System.swift | 9 ++ .../CloudMachineCore/RemoteConfigurer.swift | 16 ++- .../DriveFolderTests.swift | 87 ++++++++++++ 9 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/DriveFolder.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift 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/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+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)) + } +} From 61d7798df13037ce110ab2d3cbc775517498ee34 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 09:06:21 +0200 Subject: [PATCH 2/3] feat(app): set up from the menu-bar app, step by step After `brew install` the app is the first thing a person opens, but the setup card only listed commands to copy, and the controller's install, create and attach actions were wired to nothing. The card now shows the next steps in order, each with a button: install rclone, install FUSE-T, open Full Disk Access settings, install the agents, create the image (with a size field), attach it. Two steps stay commands to copy: Google sign-in (`configure-remote` waits for the browser in a terminal) and pointing Time Machine at the volume (needs sudo). Google comes before anything that mounts, because that is where this Mac's Drive folder is chosen. State not measured yet (`nil`) never produces a step. The plan is a pure function, covered by SetupPlanTests. Co-Authored-By: Claude Opus 5.5 --- .../CloudMachineApp/Models/AppStatus.swift | 5 + .../CloudMachineApp/Models/SetupPlan.swift | 104 ++++++++++++++++++ .../Services/CloudMachineController.swift | 32 ++++++ .../CloudMachineApp/Views/DashboardView.swift | 71 +++++++++--- .../CloudMachineCore/L10nPolish+App.swift | 21 ++++ .../CloudMachineAppTests/SetupPlanTests.swift | 88 +++++++++++++++ 6 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift 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/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/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") + } +} From 2f4b3412cf5d9bcab383c550d1d643485feddf49 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 09:08:06 +0200 Subject: [PATCH 3/3] docs: README installs with Homebrew and sets up from the app - Getting started: `brew install`, open the app, follow the setup card; per-Mac Drive folders; upgrading and uninstalling. - Setting up from Terminal kept as the alternative, in an order that works: `install-launchd` (which mounts Drive) before `create-image`, which refuses without the mount. The old order failed at that step. - Running it starts with the menu bar and the window; the CLI follows. - Building from source, local installs, tests, releases and the harnesses move to docs/building.md; the README covers Homebrew only. - Limitations: one-Mac-per-account and Polish-only are gone; unenforced per-machine budgets stay. The log grep looks for "backup failure", which is what the English logs now say. - Cask caveats point to the app and #getting-started. Co-Authored-By: Claude Opus 5.5 --- README.md | 154 +++++++++++++------------- docs/building.md | 74 +++++++++++++ packaging/homebrew/cloudmachine.rb.in | 6 +- 3 files changed, 154 insertions(+), 80 deletions(-) create mode 100644 docs/building.md 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/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`.