From 5a42bae691859f8887a3284f1cfa4e5c9451ce02 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 27 Oct 2026 20:29:41 +0100 Subject: [PATCH] fix(budget): read the Time Machine quota in the unit tmutil prints tmutil formats the quota for the user's region whatever LC_ALL says: the 2450 GB quota set on 27.10.2026 came out as `2,45 TB`, the parser read only " GB", and vm-status kept asking to set a quota that was set. Units GB/TB/MB and a decimal comma are read now, and the match allows for the rounding of what tmutil prints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AU1MhxJW1e9oTB2XvjX8WT --- .../CloudMachineCore/MachineBudget.swift | 4 +++- .../CloudMachineCore/TimeMachineStatus.swift | 21 ++++++++++++++++--- .../TimeMachineStatusTests.swift | 14 +++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/mac-app/Sources/CloudMachineCore/MachineBudget.swift b/mac-app/Sources/CloudMachineCore/MachineBudget.swift index 788493e..c0a64e1 100644 --- a/mac-app/Sources/CloudMachineCore/MachineBudget.swift +++ b/mac-app/Sources/CloudMachineCore/MachineBudget.swift @@ -283,7 +283,9 @@ public enum MachineBudget { /// 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) + // tmutil rounds what it prints (`2,45 TB` = anything from 2445 to 2455). + let wanted = Double(timeMachineQuotaGB(forLimitGB: limitGB)) + return abs(currentQuotaGB - wanted) <= max(1, wanted * 0.005) } /// One status line: usage against the limit, or why there is none. diff --git a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift index 326fd95..efdc6ca 100644 --- a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift +++ b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift @@ -252,9 +252,7 @@ public enum TimeMachineStatus { case "URL": block.url = value case "Mount Point": block.mountPoint = value case "ID": block.id = value - case "Quota": - let number = value.split(separator: " ").first.map(String.init) ?? value - block.quotaGB = Double(number) + case "Quota": block.quotaGB = quotaGB(value) default: break } current = block @@ -263,6 +261,23 @@ public enum TimeMachineStatus { return result } + /// `Quota` as tmutil prints it, in GB. tmutil formats it for the user's + /// region whatever `LC_ALL` says: 2450 GB came out as `2,45 TB` on + /// 27.10.2026, and reading only " GB" took that for no quota. + /// Decimal units, as macOS shows sizes. `nil` = not a size. + static func quotaGB(_ value: String) -> Double? { + let parts = value.split(separator: " ") + guard let first = parts.first, + let number = Double(first.replacingOccurrences(of: ",", with: ".")) + else { return nil } + switch parts.count > 1 ? parts[1].uppercased() : "GB" { + case "TB": return number * 1000 + case "GB": return number + case "MB": return number / 1000 + default: return nil + } + } + /// The registered destinations; `nil` = tmutil DID NOT ANSWER, `[]` = it /// answered that there are none. public static func destinationsReading() async -> [Destination]? { diff --git a/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift b/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift index e62eda9..0bb32f8 100644 --- a/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift @@ -145,4 +145,18 @@ final class TimeMachineStatusTests: XCTestCase { XCTAssertEqual(TimeMachineStatus.destinations(destinationInfoOutput: answer!), []) } } + + /// 27.10.2026: tmutil printed the 2450 GB quota as `2,45 TB` (Polish + /// region), and the status kept asking to set a quota that was set. + func testQuotaIsReadInTheUnitTmutilPrints() { + XCTAssertEqual(TimeMachineStatus.quotaGB("300 GB"), 300) + XCTAssertEqual(TimeMachineStatus.quotaGB("2,45 TB"), 2450) + XCTAssertEqual(TimeMachineStatus.quotaGB("2.45 TB"), 2450) + XCTAssertNil(TimeMachineStatus.quotaGB("none")) + let parsed = TimeMachineStatus.destinations( + destinationInfoOutput: "Name : CloudMachine\nQuota : 2,45 TB\n") + XCTAssertEqual(parsed.first?.quotaGB, 2450) + XCTAssertTrue(MachineBudget.quotaMatches(currentQuotaGB: 2450, limitGB: 3500)) + XCTAssertFalse(MachineBudget.quotaMatches(currentQuotaGB: 2000, limitGB: 3500)) + } }