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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
name: Build Swift package
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: install swift-format
run: brew install swift-format
# The Homebrew binary by name, not `swift format`: on macos-15 the latter
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
skip: ${{ steps.decide.outputs.skip }}
version: ${{ steps.decide.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- id: decide
run: |
base="$(tr -d '[:space:]' < mac-app/VERSION)"
Expand Down Expand Up @@ -127,7 +127,7 @@ jobs:
# written into mac-app/VERSION, which would mark every release dirty.
CM_RELEASE_VERSION: ${{ needs.decide.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0

Expand Down Expand Up @@ -257,7 +257,7 @@ jobs:

- name: Upload artifacts (dry run)
if: env.IS_RELEASE != 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cloudmachine-${{ env.VERSION }}-dry-run
path: |
Expand All @@ -267,7 +267,7 @@ jobs:

- name: Publish GitHub Release
if: env.IS_RELEASE == 'true'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
# Creates the tag on this commit when the run did not start from one.
tag_name: v${{ env.VERSION }}
Expand All @@ -290,7 +290,7 @@ jobs:

- name: Check out tap
if: env.IS_RELEASE == 'true'
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: RenaCode/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ It works two ways:

`brew upgrade` replaces the app without restarting the Google Drive mount.
When Homebrew reopens the app, it reloads the background agents so they run
the new version; `cloudmachine-agent drive-status` shows `Agents: OK`. If a
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
`--zap` touches the launchd agents or the upload buffer in `~/.cloudmachine`,
Expand Down
32 changes: 31 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Measured on a Mac Studio, 332 Mbit/s uplink:
| Full backup | 210 GiB, about two hours |
| Incremental backup | ~370 MB of new data, a few minutes |
| Google Drive used | 589 GiB (29 Sep 2026) — of which only 417 GiB is live data |
| Actually uploaded per day | **327–595 GB** — see below |
| Actually uploaded per day | **327–793 GB** — see below |

That last row is not a typo, and it is the number that surprises people. What
Time Machine *writes* and what rclone *sends* are different quantities, because
Expand All @@ -25,6 +25,16 @@ September 2026 that put 823 GB on the wire in 24 hours for roughly 45 GB of real
change — past Google's 750 GB/day write ceiling, which blocked *all* uploads for
several hours.

It is not history either. Counted from `rclone.log` on 9 October 2026, with
`--vfs-write-back 10m` in place: 630 GB went out in the last 24 hours, and the
highest rolling 24-hour window over eight days reached **793 GB**; 36 of the
193 hourly windows were above 750 GB. Not a single upload-limit error followed,
so Google is not enforcing the ceiling on this account right now — but nothing
here warns before it starts to: the app only notices the limit once uploads
have stalled on it (`logShowsUploadStalled`). The count is `Copied` lines × 32
MiB, so a share of smaller-than-a-band uploads may put the true figure
somewhat lower.

So the daily cap is not just a first-backup concern, and it does not require a
source larger than 750 GB. A 265 GiB backup reached it. `--vfs-write-back` is
the lever that keeps it in check — see [Why the pieces are what they
Expand Down Expand Up @@ -104,6 +114,26 @@ expiries forward through rclone's `vfs/queue-set-expiry`, so detach still drains
in seconds. Attach does the same before waiting, since `hdiutil` on FUSE-T
rejects mounts more often while rclone is busy.

**Remote control on a private socket.** The queue, the expiries and the buffer
guard all go through rclone's remote control interface, which can also delete
anything in the backup folder (`operations/purge`) — with the trash off, for
good. Until 9 October 2026 it listened on `127.0.0.1:5572` without a password,
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.

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
ones are deliberate: re-creating the image, clearing an old folder. Each would
keep hundreds of GB counted against the account for 30 days, where running out
of space stops the mount (`storageQuotaExceeded`), and the free-space alarm
(`operations/about` counts the trash) would report space no folder shows. What
the trash would still catch after the socket is a process of this same user
deleting through the mount — and such a process can just as well run its own
`rclone purge --drive-use-trash=false` with the same `rclone.conf`.

**Its own rclone.** The Homebrew build is compiled without FUSE and refuses to
mount outright. CloudMachine installs the official binary beside it, verified by
SHA256.
Expand Down
1 change: 1 addition & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ cloudmachine-agent drive-status
```
Tools: OK
Drive mount: OK
Remote control: private socket
Drive folder: gdrive:CloudMachine/mac-studio
Image attached: OK (/Volumes/CloudMachine)
Cache on disk: 103 GB of 100G
Expand Down
2 changes: 0 additions & 2 deletions gdrive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,3 @@ swift run cloudmachine-poc pullplug --clean
- Whether `tmutil setdestination` accepts a destination outside `/Volumes`. That
determines whether the need for manual intervention after an unclean detach
can be removed.
- Whether `--rc-no-auth` on the loopback interface is acceptable. Any local
process can control the mount through that interface.
4 changes: 4 additions & 0 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,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(
"Remote control: %@",
StatusLines.remoteControl(DriveBufferService.rcTransport, mounted: mounted)))
print(
L10n.tr(
"Drive folder: %@",
Expand Down
33 changes: 33 additions & 0 deletions mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ struct BufferStatus: Equatable {

var draining: Bool { uploadsInProgress > 0 || uploadsQueued > 0 }

/// The "Cloud sync queue" row.
///
/// An empty queue is "everything uploaded" only when rclone abandoned
/// nothing on the way: an abandoned band drops out of the queue exactly like
/// an uploaded one (see `DriveBufferService.QueueStats.isQuiet`). Until
/// 09.10.2026 the row then said "Everything uploaded" - in red, above a row
/// counting the errors.
var queueSummary: String {
guard queueKnown else { return L10n.tr("not read") }
if draining {
return L10n.tr(
"%@ in progress, %@ queued", "\(uploadsInProgress)", "\(uploadsQueued)")
}
if erroredFiles > 0 {
return L10n.tr("%@ fragments abandoned - only on this Mac", "\(erroredFiles)")
}
return L10n.tr("Everything uploaded")
}

/// The single source of truth on whether the backup reaches the Drive - and why not.
var uploadState: UploadState {
UploadState.from(
Expand Down Expand Up @@ -278,4 +297,18 @@ final class AppStatus: ObservableObject {
else { return false }
return true
}

/// 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
/// asks for a fresh backup - so after two days without one, with every
/// device in place, the only button that fixes that was grey. Likewise with
/// an unread queue or the Google daily limit: Time Machine still writes into
/// the buffer then. Only what makes `tmutil startbackup` pointless blocks it.
var canStartBackup: Bool {
guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached,
case .registered = timeMachineState, !isBusy
else { return false }
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ final class CloudMachineController: ObservableObject {
/// good as one from five seconds ago.
private static let backupCycleInterval: TimeInterval = 300
private var backupCycleCheckedAt: Date?
/// One failed read of the Time Machine preferences is not "no Full Disk Access".
private var preferencesReads = BackupHealth.ReadConfirmation()

// MARK: - Refresh cycle

Expand Down Expand Up @@ -101,14 +103,20 @@ final class CloudMachineController: ObservableObject {
let readiness = CMTooling.checkReadiness()
status.dependencyState =
readiness.ready ? .ready : .missing(readiness.missing, readiness.remedies)
status.remoteConfigured = await RemoteConfigurer.isConfigured(
// No answer keeps what we knew: flipping to "not connected" would offer
// the "Connect Google Drive" card on a working installation.
if let configured = await RemoteConfigurer.isConfigured(
remoteName: DriveBufferService.remoteName)
{
status.remoteConfigured = configured
}
// A REAL read of the file that actually matters - see
// `BackupHealth.preferencesReadable`. Previously this was
// `isReadableFile` (that is, `access(R_OK)`) on the DIRECTORY
// `~/Library/Application Support/com.apple.TCC`: the wrong path and a check
// that proves nothing under TCC.
status.hasFullDiskAccess = BackupHealth.preferencesReadable()
status.hasFullDiskAccess = preferencesReads.readable(
after: BackupHealth.preferencesReadable(), shown: status.hasFullDiskAccess)
status.driveFolderPath = "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)"
if !status.remoteConfigured, status.suggestedDriveFolder.isEmpty {
let key = await MachineIdentity.currentKey()
Expand Down
39 changes: 21 additions & 18 deletions mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ private struct DashboardContent: View {
case .storage:
let tone = uploadTone(status.buffer.uploadState)
.worst(freeSpaceTone)
.worst(status.budgetLimitGB == nil || budgetOK ? .success : .warning)
.worst(budgetTone == .neutral ? .success : budgetTone)
return tone == .success ? nil : tone
}
}
Expand Down Expand Up @@ -539,7 +539,7 @@ private struct DashboardContent: View {
}
}
.buttonStyle(PrimaryGradientButtonStyle())
.disabled(!status.healthy || status.isBusy)
.disabled(!status.canStartBackup)
} else {
Button(action: { Task { await controller.stopBackup() } }) {
HStack(spacing: 6) {
Expand Down Expand Up @@ -912,7 +912,7 @@ private struct DashboardContent: View {
row(
L10n.tr("Used on Google Drive"),
MachineBudget.summary(limitGB: status.budgetLimitGB, usage: status.budgetUsage),
tone: budgetOK ? .success : .danger)
tone: budgetTone)

if let usage = status.budgetUsage {
Text(
Expand Down Expand Up @@ -970,13 +970,21 @@ private struct DashboardContent: View {
commandBox(command)
}
}
.toneCard(budgetOK ? .neutral : .warning)
.toneCard(budgetTone == .success || budgetTone == .neutral ? .neutral : .warning)
}

private var budgetOK: Bool {
guard let limit = status.budgetLimitGB, let usage = status.budgetUsage
else { return status.budgetLimitGB != nil }
return MachineBudget.level(usageBytes: usage.bytes, limitGB: limit) == .ok
/// Green only for a measurement that is recent and within the limit. Until
/// 09.10.2026 "no measurement" was green as well, with the text "usage not
/// measured yet" next to it, and a measurement from days ago passed as
/// current. No limit is a choice, not a fault - neutral, as the sidebar
/// already treated it.
private var budgetTone: StatusTone {
switch MachineBudget.standing(limitGB: status.budgetLimitGB, usage: status.budgetUsage) {
case .notSet: return .neutral
case .unmeasured, .near: return .warning
case .ok: return .success
case .over: return .danger
}
}

// MARK: - Buffer and Upload Details
Expand Down Expand Up @@ -1019,20 +1027,15 @@ private struct DashboardContent: View {
row(
L10n.tr("Free space on the local volume"),
status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } ?? L10n.tr("not measured"),
tone: (status.buffer.freeDiskGB ?? 0) > 80 ? .success : .danger)
tone: freeSpaceTone)

Divider().background(RenaCodeTheme.borderGlass)

// The tone of the upload verdict, not its own: a queue at zero with the
// daily limit used up is not green - nothing is going out.
row(
L10n.tr("Cloud sync queue"),
!status.buffer.queueKnown
? L10n.tr("not read")
: (status.buffer.draining
? L10n.tr(
"%@ in progress, %@ queued", "\(status.buffer.uploadsInProgress)",
"\(status.buffer.uploadsQueued)")
: L10n.tr("Everything uploaded")),
tone: status.buffer.queueKnown && status.buffer.erroredFiles == 0 ? .success : .danger)
L10n.tr("Cloud sync queue"), status.buffer.queueSummary,
tone: uploadTone(status.buffer.uploadState))

if status.buffer.erroredFiles > 0 {
Divider().background(RenaCodeTheme.borderGlass)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ private struct MenuBarPanel: View {
}
}
.buttonStyle(PrimaryGradientButtonStyle())
.disabled(!status.healthy)
.disabled(!status.canStartBackup)
} else {
Button(action: { Task { await controller.stopBackup() } }) {
HStack {
Expand Down
70 changes: 63 additions & 7 deletions mac-app/Sources/CloudMachineCore/BackupHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,62 @@ public enum BackupHealth {
(try? Data(contentsOf: URL(fileURLWithPath: preferencesFile))) != nil
}

/// How many times the Time Machine preferences are read before "cannot read"
/// counts, and how long to wait between the reads.
///
/// One failed read is not evidence: on 08.10.2026 at 19:52, two minutes into
/// a backup, the watchdog could not read the file and reported "most often
/// Full Disk Access is missing" - while the runs 30 minutes before and after
/// read it fine. backupd rewrites this file during a backup, and a read can
/// land in the middle. Missing Full Disk Access fails EVERY read, so asking
/// again costs a real alarm only these seconds, never the alarm itself.
public static let preferencesReadAttempts = 3
public static let preferencesRetryPause: TimeInterval = 5

/// The file read and parsed; `nil` for either failure.
static func loadPreferences(_ path: String) -> [String: Any]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
return (try? PropertyListSerialization.propertyList(from: data, format: nil))
as? [String: Any]
}

/// Reads until one read works, at most `attempts` times. `nil` only when
/// every read failed. Pure in its inputs, so the confirmation can be tested.
static func readPreferences(
_ read: () -> [String: Any]?, attempts: Int = preferencesReadAttempts,
pause: () async -> Void
) async -> [String: Any]? {
for attempt in 1...max(1, attempts) {
if let plist = read() { return plist }
if attempt < attempts { await pause() }
}
return nil
}

/// The panel's "Full Disk Access" answer, which reads the file every 10 s:
/// it says "missing" only after `required` failed reads in a row, and
/// "granted" again at the first good one. Without it a read landing in a
/// backupd rewrite brought back the "Grant Full Disk Access" setup step for
/// one refresh. Until the first failure is confirmed the earlier answer
/// stands - and at launch that is `false`, so a missing permission still
/// shows from the start.
public struct ReadConfirmation: Equatable, Sendable {
public let required: Int
public private(set) var failuresInARow = 0

public init(required: Int = 2) { self.required = required }

/// The answer to show after this read, given the one shown so far.
public mutating func readable(after readSucceeded: Bool, shown: Bool) -> Bool {
if readSucceeded {
failuresInARow = 0
return true
}
failuresInARow += 1
return failuresInARow >= required ? false : shown
}
}

/// `preferencesFile` can be replaced so that the WHOLE watchdog path can be
/// run on a known bad sample - reading the file, parsing, choosing the
/// destination, assessment, reporting, exit code - without breaking the
Expand All @@ -458,14 +514,14 @@ public enum BackupHealth {
/// failures in this project were.
public static func currentReport(
now: Date = Date(), maxAgeHours: Double = BackupHealth.maxAgeHours,
preferencesFile: String = BackupHealth.preferencesPath
preferencesFile: String = BackupHealth.preferencesPath,
preferencesRetryPause: TimeInterval = BackupHealth.preferencesRetryPause
) async -> Report {
let plist =
(try? Data(contentsOf: URL(fileURLWithPath: preferencesFile)))
.flatMap {
try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any]
}
?? nil
let plist = await readPreferences(
{ loadPreferences(preferencesFile) },
pause: {
try? await Task.sleep(nanoseconds: UInt64(preferencesRetryPause * 1_000_000_000))
})

guard let plist else {
return Report(
Expand Down
Loading
Loading