diff --git a/README.md b/README.md index 1729a62..6aa398d 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,12 @@ use the same Google account and the same OAuth credentials. Each Mac backs up into its own folder, `gdrive:CloudMachine/`, holding `.sparsebundle`, so their backups never mix. -The folder name is chosen once, at **Connect Google Drive**, from the computer -name. To pick it yourself, add `--folder NAME` to the command the card gives -you, e.g. `… 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. Installations set up before per-Mac folders +The folder name is chosen once, in the **Connect Google Drive** step: the card +has a **Folder on Google Drive** field, filled in from the computer name, and +the command to copy includes whatever you type there. Give each Mac its own +name. It cannot be changed afterwards, because a new name is a new, empty +backup; CloudMachine refuses rather than orphan the old one. The window 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 diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 8b29b78..e1de193 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -188,6 +188,10 @@ final class AppStatus: ObservableObject { @Published var backupProgress: BackupProgressInfo? @Published var lastAction: LastRunResult? @Published var hasFullDiskAccess: Bool = false + /// Folder name offered for this Mac before Google Drive is connected + /// (from the computer name), and the folder in use once it is. + @Published var suggestedDriveFolder = "" + @Published var driveFolderPath = "" /// 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 diff --git a/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift b/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift index b7164f0..96f0c29 100644 --- a/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift +++ b/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift @@ -28,6 +28,9 @@ struct SetupStep: Equatable { /// Shown with a Copy button; for steps the app cannot do itself. let command: String? let action: Action? + /// The step where this Mac's Drive folder is chosen: the card shows a name + /// field and puts `--folder NAME` into the command. + var choosesFolder = false } enum SetupPlan { @@ -45,6 +48,14 @@ enum SetupPlan { var setDestinationCommand: String } + /// `configure-remote`, with `--folder` only for a valid name - an invalid + /// one is left out rather than passed on to be refused. + static func connectCommand(agent: String, folder: String?) -> String { + let base = "\(agent) configure-remote" + guard let folder, DriveFolder.isValid(folder) else { return base } + return "\(base) --folder \(folder)" + } + static func steps(_ input: Inputs) -> [SetupStep] { var steps: [SetupStep] = [] if !input.hasRclone { @@ -63,7 +74,7 @@ enum SetupPlan { steps.append( SetupStep( title: L10n.tr("Connect Google Drive: run this in Terminal and approve in the browser"), - command: input.connectCommand, action: nil)) + command: input.connectCommand, action: nil, choosesFolder: true)) return steps } if !input.hasFullDiskAccess { diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 47a9486..d87ff44 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -109,6 +109,11 @@ 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.driveFolderPath = "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)" + if !status.remoteConfigured, status.suggestedDriveFolder.isEmpty { + let key = await MachineIdentity.currentKey() + status.suggestedDriveFolder = DriveFolder.isValid(key) ? key : "this-mac" + } status.agentsInstalled = await LaunchdInstaller.isInstalled( label: "com.renacode.cloudmachine.gdrive-buffer") } @@ -258,8 +263,14 @@ final class CloudMachineController: ObservableObject { /// Connecting to Google Drive is done from the terminal, not from the GUI: OAuth opens /// the browser and waits for approval, and we read the keys from the Keychain. - var connectDriveCommand: String { - "\(CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent") configure-remote" + var connectDriveCommand: String { connectDriveCommand(folder: nil) } + + /// With `folder`, the command fixes this Mac's Drive folder name. The name + /// is validated by `DriveFolder.isValid` (letters, digits, dashes), so it + /// needs no shell quoting. + func connectDriveCommand(folder: String?) -> String { + SetupPlan.connectCommand( + agent: CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent", folder: folder) } func createImage(sizeGB: Int) async { diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 5d35e2e..4670e52 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -11,6 +11,9 @@ struct DashboardView: View { @State private var credentialsExpanded = false /// Logical size of a new backup image. Sparse: Drive holds only what is written. @State private var imageSizeGB = "4000" + /// This Mac's folder on Google Drive, chosen before connecting. Empty means + /// "not edited yet" and shows the suggestion from the computer name. + @State private var driveFolder = "" var body: some View { ZStack { @@ -360,6 +363,40 @@ struct DashboardView: View { } } + private var effectiveDriveFolder: String { + driveFolder.isEmpty ? controller.status.suggestedDriveFolder : driveFolder + } + + /// Each Mac on the same Google account needs its own folder, otherwise + /// they would share one backup image. Chosen once: a Mac that already has a + /// folder refuses to switch, because the new one would be an empty backup. + private var folderField: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(L10n.tr("Folder on Google Drive")) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.textMain) + Text("CloudMachine/") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(RenaCodeTheme.textMain.opacity(0.6)) + TextField(controller.status.suggestedDriveFolder, text: $driveFolder) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .frame(width: 180) + } + Text( + DriveFolder.isValid(effectiveDriveFolder) + ? L10n.tr( + "One folder per Mac. Set once - it cannot be changed after connecting.") + : L10n.tr("Use lowercase letters, digits and dashes.") + ) + .font(.system(size: 11)) + .foregroundStyle( + DriveFolder.isValid(effectiveDriveFolder) + ? RenaCodeTheme.textMain.opacity(0.6) : RenaCodeTheme.colorWarning) + } + } + private var parsedImageSize: Int? { guard let value = Int(imageSizeGB.trimmingCharacters(in: .whitespaces)), value >= 100 else { return nil } @@ -420,7 +457,13 @@ struct DashboardView: View { setupActionButton(action) } - if let command = step.command { + if step.choosesFolder { + folderField + } + + if let command = step.choosesFolder + ? controller.connectDriveCommand(folder: effectiveDriveFolder) : step.command + { HStack { Text(command) .font(.system(size: 12, design: .monospaced)) @@ -481,6 +524,16 @@ struct DashboardView: View { Divider().background(RenaCodeTheme.borderGlass) + if controller.status.remoteConfigured { + row( + L10n.tr("Folder on Google Drive"), + controller.status.driveFolderPath, + ok: true + ) + + Divider().background(RenaCodeTheme.borderGlass) + } + row( L10n.tr("Backup disk image (.sparsebundle)"), controller.status.buffer.imageAttached diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift index 8672b50..360cb64 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -16,6 +16,10 @@ extension L10nPolish { "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)", + "Folder on Google Drive": "Folder na Google Drive", + "One folder per Mac. Set once - it cannot be changed after connecting.": + "Jeden folder na Maca. Ustawiany raz - po połączeniu nie da się go zmienić.", + "Use lowercase letters, digits and dashes.": "Użyj małych liter, cyfr i myślników.", "Install rclone": "Zainstaluj rclone", "Open System Settings": "Otwórz Ustawienia systemowe", "Install agents": "Zainstaluj agentów", diff --git a/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift b/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift index bbcbb00..55bc8d4 100644 --- a/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift @@ -85,4 +85,23 @@ final class SetupPlanTests: XCTestCase { XCTAssertEqual(steps.map(\.action), [nil]) XCTAssertEqual(steps.first?.command, "sudo set") } + + func testConnectStepIsWhereTheFolderIsChosen() { + var input = inputs() + input.remoteConfigured = false + let steps = SetupPlan.steps(input) + XCTAssertEqual(steps.last?.choosesFolder, true) + XCTAssertEqual(steps.filter(\.choosesFolder).count, 1) + } + + func testConnectCommandCarriesAValidFolderOnly() { + XCTAssertEqual( + SetupPlan.connectCommand(agent: "agent", folder: "office-imac"), + "agent configure-remote --folder office-imac") + XCTAssertEqual(SetupPlan.connectCommand(agent: "agent", folder: nil), "agent configure-remote") + // Rejected names never reach the shell: no quoting is needed for what passes. + XCTAssertEqual( + SetupPlan.connectCommand(agent: "agent", folder: "My Mac; rm -rf ~"), + "agent configure-remote") + } }