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
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,21 @@ It works two ways:

### Upgrading and uninstalling

`brew upgrade` replaces the app without restarting the Google Drive mount.
`brew upgrade --cask renacode/tap/cloudmachine` replaces the app without
restarting the Google Drive mount. (Use the full name: a second tap with a
`cloudmachine` cask, left over from testing, makes the short name ambiguous.)
When Homebrew reopens the app, it reloads the background agents so they run
the new version; `cloudmachine-agent drive-status` shows `Agents: OK`. Changes
to the mount itself wait for its next start - a restart of the Mac, with
`prepare-shutdown` before it. One such change is rclone's control interface
moving to a private socket: until the restart `drive-status` reports
`Remote control: OPEN on 127.0.0.1:5572`. If a
new version does not show up, run `brew update` first - Homebrew refreshes the
tap only now and then. Neither `brew uninstall` nor
to the mount itself wait for its next start. One such change is rclone's
control interface moving to a private socket: until then the old mount takes
commands on `127.0.0.1:5572` without a password, and the app window shows a red
card, the menu bar an alert, and the watchdog a notification (once, then after
1, 2, 4 and every 7 days). **Restart the mount** on that card - or
`cloudmachine-agent restart-mount` - detaches the image, waits until everything
has reached Google Drive, restarts the mount and attaches the image again; it
refuses while Time Machine is backing up. Restarting the Mac after
`prepare-shutdown` does the same. If a new version does not show up, run
`brew update` first - Homebrew refreshes the tap only now and then. 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.
Expand All @@ -143,6 +149,12 @@ application itself created. Full `drive` scope would hand out read, write and
disk-image bands needs — especially with `--drive-use-trash=false`, where a
delete has no bin to recover from.

On a Mac that is already connected, keep the connection it has. A new sign-in
with different credentials sees only what it created itself, so the existing
backup would vanish from view; `configure-remote` refuses to replace a working
connection unless told twice, and checks the new token against the image first
(see [setup-cli](docs/setup-cli.md#replacing-the-connection)).

---

## Documentation
Expand Down
16 changes: 15 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,21 @@ and loopback is not private: a web page can POST a form there without a CORS
preflight, and rclone does not check `Origin`. It now listens only on
`~/.cloudmachine/run/rc.sock`, in a directory only the owner can enter, which a
browser cannot reach at all. A mount started by an older version keeps the TCP
address until it restarts; `drive-status` says so on its "Remote control" line.
address until it restarts. The window, the menu bar, the watchdog's
notification and `drive-status` all say so - the window, the watchdog and the
CLI from one check: no socket, a mount, and something accepting connections on
the port. `restart-mount` (or the button on that card) closes it without a
restart of the Mac: it is `prepare-shutdown`'s detach-and-drain, then SIGTERM
to the mount job so launchd's `KeepAlive` starts the new code, then the attach -
holding the image lock throughout, and stopping before the mount is touched if
anything has not reached Drive.

**The rclone log is trimmed while the mount runs.** rclone never rotates it,
and a mount runs for days (257 MB on 10 October 2026). Above 100 MB the buffer
guard copies the last 32 MB to `rclone.log.1` and truncates the live file;
rclone opens it with `O_APPEND`, so it carries on at the new end. The stall and
quota checks read the end of `.1` together with the live file, so a trim in the
middle of a jam does not hide it.

The Drive trash stays off, after weighing it as a second line. Bands are
rewritten in place, so uploads would not fill it; deletions would — and the big
Expand Down
10 changes: 9 additions & 1 deletion docs/setup-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,13 @@ pretending otherwise.
replaces the token and the scope, and credentials scoped `drive.file` cannot see
files created by the previous credentials — the backup stays intact but becomes
unreachable, which amounts to the same thing. Back up `~/.config/rclone/rclone.conf`
first and pass `--replace-existing` if you really mean it. The Drive folder of
first and pass `--replace-existing --accept-scope-change` if you really mean
it. Even then nothing is written until the new token has been checked: if the
current connection sees this Mac's image and the new one does not, the
replacement is abandoned and `rclone.conf` stays as it was. The Drive folder of
this Mac does not change with it.

`create-image` refuses in the same spirit: when Google Drive shows no image but
this Mac remembers one (`~/Library/Application Support/CloudMachine/known-image`,
or the image in the upload cache), it says so instead of starting a second,
empty backup next to the invisible one.
1 change: 1 addition & 0 deletions mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct CloudMachineAgent: AsyncParsableCommand {
BufferGuard.self,
BackupHealthCommand.self,
PrepareShutdown.self,
RestartMount.self,
DriveStatus.self,
]
)
Expand Down
34 changes: 34 additions & 0 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ struct BufferGuard: AsyncParsableCommand {

// Forever: the guard has to outlive every backup, not just the first one.
while true {
// The mount runs for days and rclone never trims its own log; the
// guard is the one process that runs as long. A `stat` per tick.
DriveBufferService.trimLogIfLarge()
await guardService.step()
try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000)
}
Expand Down Expand Up @@ -285,6 +288,17 @@ struct BackupHealthCommand: AsyncParsableCommand {
print(L10n.tr("WAITING (system startup): %@", problem.summary))
}

// Not a backup failure, so not in `report` and not in the exit code -
// but a notification of its own, see `OpenPortAlert`.
let portOpen = DriveBufferService.legacyPortOpenNow()
if portOpen {
print(
L10n.tr(
"WARNING: remote control is OPEN on %@ - run restart-mount (or prepare-shutdown, then restart the Mac).",
DriveBufferService.legacyRCAddress))
}
if !quiet { await OpenPortAlert.report(open: portOpen) }

guard !report.healthy else {
print(L10n.tr("Backup cycle: OK"))
if !quiet { await HealthAlert.report(report) }
Expand Down Expand Up @@ -340,6 +354,26 @@ struct PrepareShutdown: AsyncParsableCommand {
}
}

// MARK: - Restarting the mount

struct RestartMount: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "restart-mount",
abstract: L10n.tr(
"Restarts the Google Drive mount on the private socket: detaches the image, waits for the upload, restarts the mount and attaches the image again."
))

func run() async throws {
print(
L10n.tr(
"Detaching the image and waiting for the upload, then restarting the mount. This can take several minutes; Time Machine has no destination meanwhile."
))
let result = await MountRestart.perform()
print(result.message)
if !result.succeeded { throw ExitCode(1) }
}
}

// MARK: - Status

struct DriveStatus: AsyncParsableCommand {
Expand Down
13 changes: 12 additions & 1 deletion mac-app/Sources/CloudMachineAgent/SetupCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ struct ConfigureRemote: AsyncParsableCommand {
)
var replaceExisting = false

@Flag(
name: .long,
help:
ArgumentHelp(
L10n.tr(
"With --replace-existing: confirms that the new sign-in (scope 'drive.file') may not see the existing backup. The new token is still checked against the image first."
))
)
var acceptScopeChange = false

@Option(
name: .long,
help: ArgumentHelp(
Expand All @@ -44,7 +54,8 @@ struct ConfigureRemote: AsyncParsableCommand {
func run() async throws {
let (config, key) = await CLIContext.load()
let result = await RemoteConfigurer.connect(
config: config, machineKey: key, replaceExisting: replaceExisting, folder: folder)
config: config, machineKey: key, replaceExisting: replaceExisting,
acceptScopeChange: acceptScopeChange, folder: folder)
print(result.message)
if !result.succeeded { throw ExitCode.failure }
}
Expand Down
22 changes: 21 additions & 1 deletion mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ final class AppStatus: ObservableObject {
/// 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?
/// The Drive mount still takes commands on the open TCP port - a mount from
/// before the private socket that survived the upgrade (see
/// `DriveBufferService.legacyPortOpen`). Until 11.10.2026 only one line of
/// `drive-status` said so; the window was green.
@Published var remoteControlOpen: Bool = false
@Published var isBusy: Bool = false
@Published var busyLabel: String = ""
@Published var errorMessage: String?
Expand Down Expand Up @@ -263,6 +268,11 @@ final class AppStatus: ObservableObject {
// copy; that is precisely why `UploadState` puts them ahead of the daily limit.
let upload = buffer.uploadState
if !upload.isNominal { return upload.headline }
// Backups work in this state, but any web page can delete them - so it
// comes before every sentence that sounds like "all is well".
if remoteControlOpen {
return L10n.tr("Drive remote control is open - restart the mount")
}
if backupProgress != nil { return L10n.tr("Backup in progress") }
// The state of the devices can be flawless while there has been no backup for two days.
// This sentence must come BEFORE "Ready", otherwise the headline contradicts the
Expand Down Expand Up @@ -290,14 +300,24 @@ final class AppStatus: ObservableObject {
/// and each of them can be met when no backup has been made for two days.
/// The green badge is meant to say "the data is safe", and that follows
/// solely from a backup having BEEN MADE - not from the disk being attached.
///
/// THIRD, from 11.10.2026: `remoteControlOpen`. A backup that any web page
/// can delete is not safe, however fresh it is.
var healthy: Bool {
guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached,
case .registered = timeMachineState, buffer.uploadState.isNominal,
backupCycle.isFresh()
backupCycle.isFresh(), !remoteControlOpen
else { return false }
return true
}

/// Whether "Restart the mount" may be offered: the port is open, nothing
/// else is running, and Time Machine is not backing up. `MountRestart`
/// checks the backup itself again - this only keeps the button honest.
var canRestartMount: Bool {
remoteControlOpen && !isBusy && backupProgress == nil
}

/// Whether "Back up now" can do anything: the backup has somewhere to go.
///
/// NOT `healthy`, which until 09.10.2026 decided this too. `healthy` also
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,17 @@ final class CloudMachineController: ObservableObject {
/// Saves the credentials and refreshes the state.
///
/// Does not reconfigure the remote: a token issued for the old `client_id` keeps
/// working, so entering new values alone changes NOTHING until
/// `configure-remote --replace-existing` is run. We say so plainly.
/// working, so entering new values alone changes NOTHING on a connected Mac -
/// and must not lead to replacing the connection there (see
/// `RemoteConfigurer.credentialsSavedMessage`).
func saveCredentials(clientID: String, clientSecret: String) async -> String {
do {
try await RemoteConfigurer.storeCredentials(
clientID: clientID, clientSecret: clientSecret)
await refreshCredentials()
return L10n.tr(
"Saved in the Keychain. Note: the existing connection still uses the old client_id - to use the new one, run configure-remote --replace-existing."
)
return RemoteConfigurer.credentialsSavedMessage(
remoteConfigured: await RemoteConfigurer.isConfigured(
remoteName: DriveBufferService.remoteName))
} catch {
return L10n.tr("Not saved: %@", error.localizedDescription)
}
Expand Down Expand Up @@ -183,6 +184,7 @@ final class CloudMachineController: ObservableObject {
buffer.outOfSpace = stats.outOfSpace
}
status.buffer = buffer
status.remoteControlOpen = DriveBufferService.legacyPortOpenNow()
status.imageExists =
buffer.mounted
? FileManager.default.fileExists(atPath: BackupImageService.imagePath.path) : nil
Expand Down Expand Up @@ -338,6 +340,15 @@ final class CloudMachineController: ObservableObject {
}
}

/// Restarts the Drive mount so it comes back on the private socket. The
/// view asks for confirmation first; `MountRestart` refuses by itself
/// while Time Machine is backing up.
func restartMount() async {
await run(L10n.tr("Restarting the Google Drive mount"), log: "Restarting the Drive mount") {
await MountRestart.perform()
}
}

func verifyImage() async {
await run(L10n.tr("Checking image consistency"), log: "Checking image consistency") {
await BackupImageService.verify()
Expand Down
71 changes: 71 additions & 0 deletions mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ private struct DashboardContent: View {
@State private var driveFolder = ""
/// The limit being typed; empty = show the stored one.
@State private var limitText = ""
@State private var confirmingMountRestart = false

var body: some View {
HStack(spacing: 0) {
Expand All @@ -95,6 +96,12 @@ private struct DashboardContent: View {
errorBanner(error)
}

// Every section too: this is not a state of one card, but a door
// left open on the whole backup.
if status.remoteControlOpen {
openRemoteControlCard
}

switch section {
case .overview: overviewSection
case .backups: backupsSection
Expand Down Expand Up @@ -571,6 +578,70 @@ private struct DashboardContent: View {
.toneCard(.danger, cornerRadius: 12, padding: 14)
}

// MARK: - Open remote control

/// The mount still takes commands on the open TCP port. Says what that
/// means, and offers the one fix that does not need a restart of the Mac.
private var openRemoteControlCard: some View {
VStack(alignment: .leading, spacing: 12) {
cardTitle(
L10n.tr("Remote control of the Drive mount is open"), systemImage: "lock.open.fill",
tone: .danger)

Text(
L10n.tr(
"The mount was started by an older version of CloudMachine and still takes commands on %@ without a password. Any web page open in a browser can send it one - including deleting the backup on Google Drive, with no trash to restore it from. It moves to a private socket the next time it starts.",
DriveBufferService.legacyRCAddress)
)
.font(.system(size: 13))
.foregroundStyle(RenaCodeTheme.textMain)
.fixedSize(horizontal: false, vertical: true)

Text(
L10n.tr(
"Restart the mount: the image is detached, everything waits until it has reached Google Drive, the mount starts again and the image is attached. It takes a few minutes, during which Time Machine has no destination. Or prepare the shutdown and restart the Mac:"
)
)
.font(.system(size: 13))
.foregroundStyle(RenaCodeTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)

commandBox("\(CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent") prepare-shutdown")

HStack(spacing: 12) {
Button(action: { confirmingMountRestart = true }) {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
Text(L10n.tr("Restart the mount"))
}
}
.buttonStyle(PrimaryGradientButtonStyle())
.disabled(!status.canRestartMount)

if status.backupProgress != nil {
Text(L10n.tr("Wait until the backup has finished."))
.font(.system(size: 12))
.foregroundStyle(RenaCodeTheme.textMuted)
}
Spacer()
}
}
.toneCard(.danger)
.confirmationDialog(
L10n.tr("Restart the Google Drive mount now?"), isPresented: $confirmingMountRestart
) {
Button(L10n.tr("Restart the mount"), role: .destructive) {
Task { await controller.restartMount() }
}
Button(L10n.tr("Cancel"), role: .cancel) {}
} message: {
Text(
L10n.tr(
"Time Machine loses its destination for a few minutes. Nothing is stopped until everything waiting has reached Google Drive; if that does not happen, the mount is left as it is."
))
}
}

// MARK: - Backups

private var backupCycleTone: StatusTone {
Expand Down
15 changes: 15 additions & 0 deletions mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ private struct MenuBarPanel: View {
}
.accessibilityElement(children: .combine)

// The headline says it only while nothing worse is going on; this row
// stays as long as the port is open.
if status.remoteControlOpen,
status.headline != L10n.tr("Drive remote control is open - restart the mount")
{
HStack(alignment: .firstTextBaseline, spacing: 8) {
StatusDot(.danger)
Text(L10n.tr("Drive remote control is open - restart the mount"))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(StatusTone.danger.color)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
}

// Progress of the running backup
if let progress = status.backupProgress, let percent = progress.percent {
VStack(alignment: .leading, spacing: 6) {
Expand Down
Loading
Loading