Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ use the same Google account and the same OAuth credentials. Each Mac backs up
into its own folder, `gdrive:CloudMachine/<folder>`, holding
`<folder>.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
Expand Down
4 changes: 4 additions & 0 deletions mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 54 additions & 1 deletion mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading