From dbf98002d512d51c1661dafbfe3ad803c84c5ca1 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 11:20:31 +0200 Subject: [PATCH] feat: space limit per Mac - Time Machine quota plus an alarm on Drive usage `limit_gb` in machines.json was read and never used, while several Macs on one Google account share its space. Now each Mac can have a limit (window card or `cloudmachine-agent set-limit --gb N`), stored under its Drive folder in machines.json, and it acts two ways: - Time Machine quota: the app offers `sudo tmutil setquota ` (root is required) at 70% of the limit, because the image on Drive is larger than the backup inside it (417 GiB of data in 589 GiB of bands, 29 Sep). Time Machine then deletes the oldest backups to stay under it. - Alarm: the watchdog measures this Mac's folder with `rclone size` (51 s, ~20 API calls, so at most every 6 hours) and reports `drive-budget-near` from 90% and `drive-budget-exceeded` above 100%. Nothing is deleted. The window and `drive-status` show usage against the limit and the quota command while the quota does not match. A machines.json that cannot be read is never overwritten. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- docs/setup-cli.md | 7 + .../CloudMachineAgent/CloudMachineAgent.swift | 1 + .../CloudMachineAgent/DriveCommands.swift | 17 ++ .../CloudMachineAgent/SetupCommands.swift | 27 +++ .../CloudMachineApp/Models/AppStatus.swift | 6 + .../Services/CloudMachineController.swift | 45 ++++ .../CloudMachineApp/Views/DashboardView.swift | 110 ++++++++++ .../CloudMachineCore/BackupHealth.swift | 1 + .../CloudMachineCore/L10nPolish+Agent.swift | 12 + .../CloudMachineCore/L10nPolish+App.swift | 24 ++ .../CloudMachineCore/L10nPolish+System.swift | 18 ++ .../CloudMachineCore/MachineBudget.swift | 207 ++++++++++++++++++ .../MachineBudgetTests.swift | 85 +++++++ 14 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/MachineBudget.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift diff --git a/README.md b/README.md index 6aa398d..8dd0b91 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,6 @@ so they read the same whoever sends them to you. - **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. --- @@ -99,6 +95,20 @@ 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. +### Space limit per Mac + +Macs on one account share its space, so each can get a limit: the **Space limit +for this Mac** card in the window (or `cloudmachine-agent set-limit --gb 1500`). +It works two ways: + +- **Time Machine quota.** The card gives a `sudo tmutil setquota` command to + copy; Time Machine then deletes this Mac's oldest backups to stay under it. + The quota is 70% of the limit, because the image on Drive grows larger than + the backup inside it (see [How it works](docs/design.md)). +- **An alarm on the real usage.** The watchdog measures this Mac's folder on + Drive every few hours and warns from 90% of the limit, and again above it. + The card and `drive-status` show the usage. + ### Upgrading and uninstalling `brew upgrade` replaces the app without restarting the Google Drive mount. diff --git a/docs/setup-cli.md b/docs/setup-cli.md index 2840b0d..09c5492 100644 --- a/docs/setup-cli.md +++ b/docs/setup-cli.md @@ -29,6 +29,13 @@ cloudmachine-agent create-image --size-gb 4000 # needs the mount from the step cloudmachine-agent attach-image ``` +Optionally, limit how much of the Google account this Mac may use; it prints +the Time Machine quota command to run next: + +```sh +cloudmachine-agent set-limit --gb 1500 +``` + Two steps need `sudo`, because they change system-wide settings: ```sh diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index 31d28a9..c619bbb 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -13,6 +13,7 @@ struct CloudMachineAgent: AsyncParsableCommand { subcommands: [ InstallLaunchd.self, ConfigureRemote.self, + SetLimit.self, InstallDependencies.self, BuildApp.self, MakeDmg.self, diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 7e276bf..ad5eaef 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -438,6 +438,23 @@ struct DriveStatus: AsyncParsableCommand { } else { print(L10n.tr("TM destination: none")) } + let limit = MachineBudget.limitGB() + print( + L10n.tr( + "Space limit: %@", + MachineBudget.summary(limitGB: limit, usage: MachineBudget.storedUsage()))) + if let limit { + let target = BackupImageService.targetPath.path + let quota = await TimeMachineStatus.destinationQuotaGB(forMountPointContaining: target) + if !MachineBudget.quotaMatches(currentQuotaGB: quota, limitGB: limit), + let id = await TimeMachineStatus.destinationID(forMountPointContaining: target) + { + print( + L10n.tr( + " Time Machine quota not set to match - run: %@", + MachineBudget.setQuotaCommand(destinationID: id, limitGB: limit))) + } + } if await TimeMachineStatus.isRunning(), let progress = await TimeMachineStatus.currentProgress() { let percent = (progress.percent ?? 0) * 100 diff --git a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift index 5804724..9e5ca1a 100644 --- a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift @@ -64,3 +64,30 @@ struct InstallDependencies: AsyncParsableCommand { if !result.succeeded { throw ExitCode.failure } } } + +struct SetLimit: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set-limit", + abstract: L10n.tr( + "Sets how much of Google Drive this Mac may use, and prints the Time Machine quota command.")) + + @Option(name: .long, help: ArgumentHelp(L10n.tr("Limit in GB."))) + var gb: Int + + func run() async throws { + do { + try MachineBudget.setLimitGB(gb) + } catch let error as MachineBudget.BudgetError { + print(error.message) + throw ExitCode.failure + } + print(L10n.tr("Limit for this Mac: %@ GB.", "\(gb)")) + if let id = await TimeMachineStatus.destinationID( + forMountPointContaining: BackupImageService.targetPath.path) + { + print(L10n.tr("Now set the Time Machine quota (needs an administrator password):")) + print(" " + MachineBudget.setQuotaCommand(destinationID: id, limitGB: gb)) + } + await MachineBudget.measureUsage() + } +} diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index e1de193..1fc71af 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -192,6 +192,12 @@ final class AppStatus: ObservableObject { /// (from the computer name), and the folder in use once it is. @Published var suggestedDriveFolder = "" @Published var driveFolderPath = "" + /// This Mac's space limit on Drive, its measured usage, and what Time + /// Machine's quota is now - see `MachineBudget`. + @Published var budgetLimitGB: Int? + @Published var budgetUsage: MachineBudget.Usage? + @Published var timeMachineQuotaGB: Double? + @Published var timeMachineDestinationID: String? /// 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/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index d87ff44..d8ebdfa 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -197,6 +197,51 @@ final class CloudMachineController: ObservableObject { status.timeMachineState = TimeMachineState.from( await TimeMachineStatus.destinationReading(), target: BackupImageService.targetPath.path) + // Cheap reads only: the limit from machines.json, the last stored usage + // measurement and `tmutil destinationinfo`. Measuring the Drive folder + // is the watchdog's job (or the Measure button). + status.budgetLimitGB = MachineBudget.limitGB() + status.budgetUsage = MachineBudget.storedUsage() + let target = BackupImageService.targetPath.path + status.timeMachineQuotaGB = await TimeMachineStatus.destinationQuotaGB( + forMountPointContaining: target) + status.timeMachineDestinationID = await TimeMachineStatus.destinationID( + forMountPointContaining: target) + } + + /// Stores this Mac's limit, then measures the Drive folder so the card + /// shows usage against it straight away. + func saveLimit(gb: Int) async { + await run(L10n.tr("Saving the space limit"), log: "Saving the space limit") { + do { + try MachineBudget.setLimitGB(gb) + } catch let error as MachineBudget.BudgetError { + return CMActionResult(succeeded: false, message: error.message) + } catch { + return CMActionResult(succeeded: false, message: error.localizedDescription) + } + await MachineBudget.measureUsage() + return CMActionResult( + succeeded: true, message: L10n.tr("Limit for this Mac: %@ GB.", "\(gb)")) + } + } + + func measureDriveUsage() async { + await run(L10n.tr("Measuring usage on Google Drive"), log: "Measuring usage on Google Drive") { + let usage = await MachineBudget.measureUsage() + return CMActionResult( + succeeded: usage != nil, + message: usage != nil + ? L10n.tr("Measured.") : L10n.tr("Google Drive did not answer - try again later.")) + } + } + + /// The quota command to copy, when the quota does not match the limit yet. + var quotaCommand: String? { + guard let limit = status.budgetLimitGB, let id = status.timeMachineDestinationID, + !MachineBudget.quotaMatches(currentQuotaGB: status.timeMachineQuotaGB, limitGB: limit) + else { return nil } + return MachineBudget.setQuotaCommand(destinationID: id, limitGB: limit) } private func refreshProgress() async { diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 4670e52..bbd76cb 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -14,6 +14,8 @@ struct DashboardView: View { /// 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 = "" + /// The limit being typed; empty = show the stored one. + @State private var limitText = "" var body: some View { ZStack { @@ -146,6 +148,11 @@ struct DashboardView: View { // Buffer and upload details bufferCard + // This Mac's space limit on Google Drive + if controller.status.remoteConfigured { + budgetCard + } + // Google OAuth credentials credentialsCard @@ -500,6 +507,109 @@ struct DashboardView: View { // MARK: - Buffer and Upload Details + /// Several Macs on one Google account share its space; each gets a limit. + /// Time Machine enforces it through its quota (deleting the oldest + /// backups), the watchdog warns from 90% of the real usage on Drive. + private var budgetCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "chart.pie.fill") + .font(.system(size: 15)) + .foregroundStyle(RenaCodeTheme.colorCyan) + Text(L10n.tr("Space limit for this Mac")) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(RenaCodeTheme.textMain) + } + + row( + L10n.tr("Used on Google Drive"), + MachineBudget.summary( + limitGB: controller.status.budgetLimitGB, usage: controller.status.budgetUsage), + ok: budgetOK) + + if let usage = controller.status.budgetUsage { + Text( + L10n.tr( + "Measured %@. The watchdog measures again every few hours.", + usage.measuredAt.formatted(date: .abbreviated, time: .shortened)) + ) + .font(.system(size: 11)) + .foregroundStyle(RenaCodeTheme.textMain.opacity(0.6)) + } + + HStack(spacing: 8) { + Text(L10n.tr("Limit (GB)")) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.textMain) + TextField( + controller.status.budgetLimitGB.map { "\($0)" } ?? "1500", text: $limitText + ) + .textFieldStyle(.roundedBorder) + .frame(width: 90) + Button(action: { + if let gb = Int(limitText.trimmingCharacters(in: .whitespaces)), gb > 0 { + Task { + await controller.saveLimit(gb: gb) + limitText = "" + } + } + }) { + Text(L10n.tr("Save")).font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(SecondaryGlassButtonStyle()) + .disabled( + controller.status.isBusy + || (Int(limitText.trimmingCharacters(in: .whitespaces)) ?? 0) <= 0) + Button(action: { Task { await controller.measureDriveUsage() } }) { + Text(L10n.tr("Measure now")).font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(SecondaryGlassButtonStyle()) + .disabled(controller.status.isBusy) + } + + if let limit = controller.status.budgetLimitGB { + Text( + L10n.tr( + "Time Machine quota: %@ GB (70%% of the limit - the copy on Drive is about a third larger than the backup inside it). Time Machine deletes the oldest backups to stay within it.", + "\(MachineBudget.timeMachineQuotaGB(forLimitGB: limit))") + ) + .font(.system(size: 11)) + .foregroundStyle(RenaCodeTheme.textMain.opacity(0.7)) + .fixedSize(horizontal: false, vertical: true) + } + + if let command = controller.quotaCommand { + Text(L10n.tr("Set the Time Machine quota: run this in Terminal (needs sudo)")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(RenaCodeTheme.colorWarning) + HStack { + Text(command) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(RenaCodeTheme.textMain) + .textSelection(.enabled) + Spacer() + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + }) { + Text(L10n.tr("Copy")).font(.system(size: 11, weight: .medium)) + } + .buttonStyle(SecondaryGlassButtonStyle()) + } + .padding(10) + .background(RenaCodeTheme.bgInset) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .glassCard(borderColor: RenaCodeTheme.colorCyan.opacity(0.35)) + } + + private var budgetOK: Bool { + guard let limit = controller.status.budgetLimitGB, let usage = controller.status.budgetUsage + else { return controller.status.budgetLimitGB != nil } + return MachineBudget.level(usageBytes: usage.bytes, limitGB: limit) == .ok + } + private var bufferCard: some View { VStack(alignment: .leading, spacing: 14) { HStack { diff --git a/mac-app/Sources/CloudMachineCore/BackupHealth.swift b/mac-app/Sources/CloudMachineCore/BackupHealth.swift index cd47aca..ebe92f8 100644 --- a/mac-app/Sources/CloudMachineCore/BackupHealth.swift +++ b/mac-app/Sources/CloudMachineCore/BackupHealth.swift @@ -552,6 +552,7 @@ public enum BackupHealth { backupRunning: await TimeMachineStatus.runningState()) report.problems.append(contentsOf: unmeasuredLocalDiskProblems(localFreeGB: localFree)) + report.problems.append(contentsOf: await MachineBudget.currentProblems()) return report } diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift index 04d57ea..9181e09 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift @@ -1,6 +1,18 @@ // Polish translations, keyed by the English text passed to `L10n.tr`. extension L10nPolish { static let agent: [String: String] = [ + "Space limit: %@": + "Limit miejsca: %@", + " Time Machine quota not set to match - run: %@": + " Limit Time Machine nie pasuje - uruchom: %@", + "Sets how much of Google Drive this Mac may use, and prints the Time Machine quota command.": + "Ustawia, ile miejsca na Google Drive może zająć ten Mac, i wypisuje polecenie ustawiające limit Time Machine.", + "Limit in GB.": + "Limit w GB.", + "Limit for this Mac: %@ GB.": + "Limit dla tego Maca: %@ GB.", + "Now set the Time Machine quota (needs an administrator password):": + "Teraz ustaw limit Time Machine (wymaga hasła administratora):", "CloudMachine - verification, Google Drive setup and installation. Called by launchd on a schedule, or by hand from Terminal.": "CloudMachine - weryfikacja, konfiguracja Google Drive i instalacja. Wołane przez launchd według harmonogramu albo ręcznie z Terminala.", "Generates and installs the launchd agents (Drive buffer, image attach, buffer guard).": diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift index 360cb64..f8dfece 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -1,6 +1,30 @@ // Polish translations, keyed by the English text passed to `L10n.tr`. extension L10nPolish { static let app: [String: String] = [ + "Saving the space limit": + "Zapisuję limit miejsca", + "Measuring usage on Google Drive": + "Mierzę zajętość na Google Drive", + "Google Drive did not answer - try again later.": + "Google Drive nie odpowiedział - spróbuj później.", + "Measured.": + "Zmierzono.", + "Space limit for this Mac": + "Limit miejsca dla tego Maca", + "Used on Google Drive": + "Zajęte na Google Drive", + "Measured %@. The watchdog measures again every few hours.": + "Zmierzono %@. Czujka mierzy ponownie co kilka godzin.", + "Limit (GB)": + "Limit (GB)", + "Save": + "Zapisz", + "Measure now": + "Zmierz teraz", + "Time Machine quota: %@ GB (70%% of the limit - the copy on Drive is about a third larger than the backup inside it). Time Machine deletes the oldest backups to stay within it.": + "Limit Time Machine: %@ GB (70%% limitu - kopia na Drive jest o mniej więcej jedną trzecią większa niż backup w środku). Time Machine kasuje najstarsze kopie, żeby się w nim zmieścić.", + "Set the Time Machine quota: run this in Terminal (needs sudo)": + "Ustaw limit Time Machine: uruchom to w Terminalu (wymaga sudo)", // SetupPlan / setup card "Install rclone (the official build, which can mount)": "Zainstaluj rclone (oficjalną wersję, która umie montować)", diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift index 59ea720..5ad8162 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift @@ -1,6 +1,24 @@ // Polish translations, keyed by the English text passed to `L10n.tr`. extension L10nPolish { static let system: [String: String] = [ + "This Mac is close to its space limit on Google Drive": + "Ten Mac zbliża się do swojego limitu miejsca na Google Drive", + "%@ GB of %@ GB used. Time Machine deletes the oldest backups to stay within its quota; check that the quota is set (drive-status).": + "Zajęte %@ GB z %@ GB. Time Machine kasuje najstarsze kopie, żeby zmieścić się w limicie; sprawdź, czy limit jest ustawiony (drive-status).", + "This Mac is over its space limit on Google Drive": + "Ten Mac przekroczył swój limit miejsca na Google Drive", + "%@ GB of %@ GB used. Set the Time Machine quota (command in the app window or drive-status), or raise the limit.": + "Zajęte %@ GB z %@ GB. Ustaw limit Time Machine (polecenie w oknie aplikacji albo w drive-status) albo podnieś limit.", + "not set": + "nie ustawiony", + "%@ GB - usage not measured yet": + "%@ GB - zajętość jeszcze nie zmierzona", + "%@ of %@ GB used (%@%%)": + "zajęte %@ z %@ GB (%@%%)", + "The limit must be a whole number of GB above zero.": + "Limit musi być całkowitą liczbą GB większą od zera.", + "machines.json cannot be read, so it was not overwritten. Fix or remove it first.": + "Nie da się odczytać machines.json, więc nie został nadpisany. Najpierw go popraw albo usuń.", // 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.", diff --git a/mac-app/Sources/CloudMachineCore/MachineBudget.swift b/mac-app/Sources/CloudMachineCore/MachineBudget.swift new file mode 100644 index 0000000..bfa18e3 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/MachineBudget.swift @@ -0,0 +1,207 @@ +import Foundation + +/// How much of the Google Drive account this Mac may use. +/// +/// The limit is stored per Mac in `machines.json` (`limit_gb`, keyed by this +/// Mac's Drive folder) and acts in two ways: +/// +/// - **Time Machine quota.** Time Machine keeps a destination under its quota +/// by deleting the oldest backups. The quota is set below the limit, because +/// the image on Drive is larger than the backup inside it: freed blocks are +/// never returned to the band files (29 Sep 2026: 417 GiB of data in 589 GiB +/// of bands, 71%). Setting it needs root, so the app offers the command. +/// - **An alarm on the real usage**: the size of this Mac's folder on Drive, +/// measured with `rclone size`, compared with the limit. The watchdog warns +/// from 90% and reports a failure above 100%. Nothing is deleted by it. +/// +/// Until 6 Oct 2026 `limit_gb` was read and never used, while the README +/// described per-machine budgets; several Macs on one account simply shared +/// whatever space was left. +public enum MachineBudget { + /// Share of the limit given to Time Machine as its quota - see above. + public static let timeMachineQuotaShare = 0.7 + public static let warnPercent = 90 + /// Measuring lists every band through the Drive API (~20 requests for + /// 19,000 files), so a measurement is reused for this long. + public static let measurementMaxAge: TimeInterval = 6 * 3600 + + // MARK: - The limit + + /// The limit of the Mac whose folder is `folder`, if one is set. + public static func limitGB(in config: MachinesConfig, folder: String = DriveFolder.name) -> Int? { + guard let limit = config.limitGB(forMachineKey: folder), limit > 0 else { return nil } + return limit + } + + public static func limitGB() -> Int? { limitGB(in: ConfigStore.load()) } + + /// Stores the limit for this Mac. Refuses to touch a `machines.json` that + /// cannot be read: rewriting it would throw away the other entries. + public static func setLimitGB(_ gb: Int, folder: String = DriveFolder.name) throws { + guard gb > 0 else { throw BudgetError.invalidLimit } + var config: MachinesConfig + switch ConfigStore.loadResult() { + case .loaded(let loaded): config = loaded + case .missing: config = .empty + case .corrupt: throw BudgetError.configUnreadable + } + config = withLimit(gb, folder: folder, in: config) + try ConfigStore.save(config) + CMLogger.log("Space limit for Drive folder '\(folder)' set to \(gb) GB") + } + + static func withLimit(_ gb: Int, folder: String, in config: MachinesConfig) -> MachinesConfig { + var config = config + if let index = config.machines.firstIndex(where: { $0.key == folder }) { + config.machines[index].limitGB = gb + } else { + config.machines.append(MachineEntry(key: folder, displayName: folder, limitGB: gb)) + } + return config + } + + public enum BudgetError: Error, Equatable { + case invalidLimit + case configUnreadable + + public var message: String { + switch self { + case .invalidLimit: return L10n.tr("The limit must be a whole number of GB above zero.") + case .configUnreadable: + return L10n.tr( + "machines.json cannot be read, so it was not overwritten. Fix or remove it first.") + } + } + } + + /// The Time Machine quota that goes with a limit. + public static func timeMachineQuotaGB(forLimitGB limit: Int) -> Int { + max(1, Int((Double(limit) * timeMachineQuotaShare).rounded(.down))) + } + + /// `sudo tmutil setquota `. + public static func setQuotaCommand(destinationID: String, limitGB: Int) -> String { + "sudo tmutil setquota \(destinationID) \(timeMachineQuotaGB(forLimitGB: limitGB))" + } + + // MARK: - Usage on Drive + + public struct Usage: Codable, Equatable { + public var bytes: UInt64 + public var measuredAt: Date + + public var gb: Double { Double(bytes) / 1_073_741_824 } + } + + static var usageFile: URL { CMPaths.appSupportDir.appendingPathComponent("drive-usage.json") } + + public static func storedUsage() -> Usage? { + guard let data = try? Data(contentsOf: usageFile) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(Usage.self, from: data) + } + + /// `rclone size --json` output -> bytes. + static func bytes(fromSizeJSON text: String) -> UInt64? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let bytes = object["bytes"] as? NSNumber + else { return nil } + return bytes.uint64Value + } + + /// Measures this Mac's folder on Drive and stores the result. `nil` when + /// rclone did not answer - never zero, which would read as "empty". + @discardableResult + public static func measureUsage(now: Date = Date()) async -> Usage? { + let path = "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)" + guard let result = try? await CMTooling.runRclone(["size", "--json", path], timeout: 300), + result.succeeded, let bytes = bytes(fromSizeJSON: result.stdout) + else { + CMLogger.log("Could not measure the size of \(path) on Google Drive") + return nil + } + let usage = Usage(bytes: bytes, measuredAt: now) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(usage) { + try? data.write(to: usageFile, options: .atomic) + } + return usage + } + + /// The stored measurement, refreshed first when it is older than + /// `measurementMaxAge`. Only measures when a limit is set: without one + /// there is nothing to compare with. + public static func currentUsage(now: Date = Date()) async -> Usage? { + let stored = storedUsage() + if let stored, now.timeIntervalSince(stored.measuredAt) < measurementMaxAge { return stored } + return await measureUsage(now: now) ?? stored + } + + // MARK: - Evaluation + + public enum Level: Equatable { + case ok + case near + case over + } + + public static func level(usageBytes: UInt64, limitGB: Int) -> Level { + let percent = Double(usageBytes) / 1_073_741_824 / Double(limitGB) * 100 + if percent > 100 { return .over } + if percent >= Double(warnPercent) { return .near } + return .ok + } + + static func problems(usage: Usage?, limitGB: Int?) -> [BackupHealth.Problem] { + guard let limitGB, let usage else { return [] } + let used = String(format: "%.0f", usage.gb) + switch level(usageBytes: usage.bytes, limitGB: limitGB) { + case .ok: + return [] + case .near: + return [ + BackupHealth.Problem( + summary: L10n.tr("This Mac is close to its space limit on Google Drive"), + detail: L10n.tr( + "%@ GB of %@ GB used. Time Machine deletes the oldest backups to stay within its quota; check that the quota is set (drive-status).", + used, "\(limitGB)"), + code: "drive-budget-near") + ] + case .over: + return [ + BackupHealth.Problem( + summary: L10n.tr("This Mac is over its space limit on Google Drive"), + detail: L10n.tr( + "%@ GB of %@ GB used. Set the Time Machine quota (command in the app window or drive-status), or raise the limit.", + used, "\(limitGB)"), + code: "drive-budget-exceeded") + ] + } + } + + /// Whether the Time Machine quota matches the limit. `nil` quota = none set. + public static func quotaMatches(currentQuotaGB: Double?, limitGB: Int) -> Bool { + guard let currentQuotaGB else { return false } + return Int(currentQuotaGB.rounded()) == timeMachineQuotaGB(forLimitGB: limitGB) + } + + /// One status line: usage against the limit, or why there is none. + public static func summary(limitGB: Int?, usage: Usage?) -> String { + guard let limitGB else { return L10n.tr("not set") } + guard let usage else { + return L10n.tr("%@ GB - usage not measured yet", "\(limitGB)") + } + let percent = Int((usage.gb / Double(limitGB) * 100).rounded()) + return L10n.tr( + "%@ of %@ GB used (%@%%)", String(format: "%.0f", usage.gb), "\(limitGB)", "\(percent)") + } + + /// For the watchdog: measures if needed and compares with the limit. + public static func currentProblems() async -> [BackupHealth.Problem] { + guard let limit = limitGB() else { return [] } + return problems(usage: await currentUsage(), limitGB: limit) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift b/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift new file mode 100644 index 0000000..0faf1fe --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import CloudMachineCore + +/// The budget decides when Time Machine deletes old backups (through the +/// quota) and when the watchdog raises an alarm, so the numbers are pinned. +final class MachineBudgetTests: XCTestCase { + private let gib: UInt64 = 1_073_741_824 + + func testQuotaIsSeventyPercentOfTheLimit() { + // 29 Sep 2026: 417 GiB of data took 589 GiB of bands on Drive (71%). + XCTAssertEqual(MachineBudget.timeMachineQuotaGB(forLimitGB: 1500), 1050) + XCTAssertEqual(MachineBudget.timeMachineQuotaGB(forLimitGB: 1), 1) + } + + func testQuotaCommand() { + XCTAssertEqual( + MachineBudget.setQuotaCommand(destinationID: "ABC-123", limitGB: 1000), + "sudo tmutil setquota ABC-123 700") + } + + func testQuotaMatchOnlyForTheExpectedValue() { + XCTAssertTrue(MachineBudget.quotaMatches(currentQuotaGB: 700, limitGB: 1000)) + XCTAssertFalse(MachineBudget.quotaMatches(currentQuotaGB: 1000, limitGB: 1000)) + XCTAssertFalse(MachineBudget.quotaMatches(currentQuotaGB: nil, limitGB: 1000)) + } + + func testLevels() { + XCTAssertEqual(MachineBudget.level(usageBytes: 899 * gib, limitGB: 1000), .ok) + XCTAssertEqual(MachineBudget.level(usageBytes: 900 * gib, limitGB: 1000), .near) + XCTAssertEqual(MachineBudget.level(usageBytes: 1000 * gib, limitGB: 1000), .near) + XCTAssertEqual(MachineBudget.level(usageBytes: 1001 * gib, limitGB: 1000), .over) + } + + func testProblemsCarryStableCodes() { + let now = Date() + let near = MachineBudget.problems( + usage: .init(bytes: 950 * gib, measuredAt: now), limitGB: 1000) + XCTAssertEqual(near.map(\.code), ["drive-budget-near"]) + let over = MachineBudget.problems( + usage: .init(bytes: 1200 * gib, measuredAt: now), limitGB: 1000) + XCTAssertEqual(over.map(\.code), ["drive-budget-exceeded"]) + XCTAssertTrue( + MachineBudget.problems(usage: .init(bytes: 10 * gib, measuredAt: now), limitGB: 1000) + .isEmpty) + } + + func testNoLimitOrNoMeasurementRaisesNothing() { + XCTAssertTrue(MachineBudget.problems(usage: nil, limitGB: 1000).isEmpty) + XCTAssertTrue( + MachineBudget.problems(usage: .init(bytes: 5000 * gib, measuredAt: Date()), limitGB: nil) + .isEmpty) + } + + func testLimitIsStoredUnderThisMacsFolderAndKeepsOtherMacs() { + var config = MachinesConfig.empty + config.machines = [MachineEntry(key: "imac-home", displayName: "iMac", limitGB: 800)] + config = MachineBudget.withLimit(1500, folder: "mac-studio", in: config) + XCTAssertEqual(MachineBudget.limitGB(in: config, folder: "mac-studio"), 1500) + XCTAssertEqual(MachineBudget.limitGB(in: config, folder: "imac-home"), 800) + config = MachineBudget.withLimit(1200, folder: "mac-studio", in: config) + XCTAssertEqual(config.machines.count, 2) + XCTAssertEqual(MachineBudget.limitGB(in: config, folder: "mac-studio"), 1200) + } + + func testZeroLimitCountsAsUnset() { + var config = MachinesConfig.empty + config.machines = [MachineEntry(key: "mac-studio", displayName: "x", limitGB: 0)] + XCTAssertNil(MachineBudget.limitGB(in: config, folder: "mac-studio")) + } + + func testRcloneSizeOutputIsParsed() { + XCTAssertEqual( + MachineBudget.bytes(fromSizeJSON: #"{"count":18860,"bytes":632431263744,"sizeless":0}"#), + 632_431_263_744) + XCTAssertNil(MachineBudget.bytes(fromSizeJSON: "Failed to size: directory not found")) + } + + func testSummary() { + XCTAssertEqual(MachineBudget.summary(limitGB: nil, usage: nil), "not set") + XCTAssertEqual( + MachineBudget.summary(limitGB: 1000, usage: .init(bytes: 589 * gib, measuredAt: Date())), + "589 of 1000 GB used (59%)") + } +}