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%)") + } +}