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
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/setup-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct CloudMachineAgent: AsyncParsableCommand {
subcommands: [
InstallLaunchd.self,
ConfigureRemote.self,
SetLimit.self,
InstallDependencies.self,
BuildApp.self,
MakeDmg.self,
Expand Down
17 changes: 17 additions & 0 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions mac-app/Sources/CloudMachineAgent/SetupCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
6 changes: 6 additions & 0 deletions mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
110 changes: 110 additions & 0 deletions mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions mac-app/Sources/CloudMachineCore/BackupHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
12 changes: 12 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift
Original file line number Diff line number Diff line change
@@ -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).":
Expand Down
24 changes: 24 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+App.swift
Original file line number Diff line number Diff line change
@@ -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ć)",
Expand Down
18 changes: 18 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+System.swift
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
Loading
Loading