From 4624dffaa918f62a31269c6673921c2b4371b7ce Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Sun, 11 Oct 2026 09:19:13 +0200 Subject: [PATCH 1/5] fix(process): release pipe descriptors as soon as the process exits The timeout timers held the Process and both pipe readers until timeout + 10 s, so every call with a timeout kept two descriptors open long after the child had exited (+100 for 50 calls; 76-92 pipes in the running app against a soft limit of 256). The timers now hold a small box that is emptied in terminationHandler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig --- .../CloudMachineCore/ProcessRunner.swift | 58 +++++++++++++++++-- .../ProcessRunnerOutputTests.swift | 25 ++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/mac-app/Sources/CloudMachineCore/ProcessRunner.swift b/mac-app/Sources/CloudMachineCore/ProcessRunner.swift index b2522fe..dec93bf 100644 --- a/mac-app/Sources/CloudMachineCore/ProcessRunner.swift +++ b/mac-app/Sources/CloudMachineCore/ProcessRunner.swift @@ -138,6 +138,51 @@ final class PipeReader: @unchecked Sendable { } } +/// What the timeout timers need from one call: the process and its readers. +/// Emptied the moment the process exits. +/// +/// Until 11.10.2026 the three `asyncAfter` closures held `process` and the +/// readers themselves, and a timer that has been scheduled keeps its closure +/// until its deadline - cancelled or not. Every call with `timeout:` therefore +/// held two pipe descriptors for `timeout + 10` seconds after the process had +/// long finished: measured +100 descriptors for 50 calls of `/bin/echo` with +/// `timeout: 30`, and 76-92 pipes in the running app, against a limit of 256. +/// Past the limit `posix_spawn` fails and every tool answers "no answer" at +/// once. The timers now hold only this box, and it lets go at exit. +final class TimeoutTargets: @unchecked Sendable { + private let lock = NSLock() + private var process: Process? + private var readers: [PipeReader] + + init(process: Process, readers: [PipeReader]) { + self.process = process + self.readers = readers + } + + /// The process, while it has not exited. + var running: Process? { + lock.lock() + defer { lock.unlock() } + return process + } + + /// The readers, taken out - for the one caller that gives up on the result. + func takeReaders() -> [PipeReader] { + lock.lock() + defer { lock.unlock() } + let taken = readers + readers = [] + return taken + } + + func release() { + lock.lock() + defer { lock.unlock() } + process = nil + readers = [] + } +} + /// A thin layer over `Process` for running external tools (rclone, tmutil, /// hdiutil, diskutil...) - shared by the GUI and the CLI. Previously it lived /// only in the GUI as `Shell.run`; moved here so that the CLI watchdogs have @@ -178,8 +223,11 @@ public enum ProcessRunner { let stdoutReader = PipeReader(stdoutPipe, queue: queue) let stderrReader = PipeReader(stderrPipe, queue: queue) let resumeGuard = ContinuationGuard() + let targets = TimeoutTargets(process: process, readers: [stdoutReader, stderrReader]) process.terminationHandler = { proc in + // The timers must not keep the pipes open - see `TimeoutTargets`. + targets.release() // IMPORTANT: readDataToEndOfFile() must NOT be called here - it blocks // until the write end of the pipe is closed by ALL of its holders. // Processes started with "--daemon" (e.g. `rclone nfsmount --daemon`) @@ -216,8 +264,10 @@ public enum ProcessRunner { } if let timeout { + // The closures below capture `targets` only - never `process` or a + // reader (see `TimeoutTargets`). DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - if process.isRunning { + if let process = targets.running, process.isRunning { process.terminate() } } @@ -225,7 +275,7 @@ public enum ProcessRunner { // rationale for the same mechanism in the old Shell.swift (a hung // child diskutil/umount ignores SIGTERM indefinitely). DispatchQueue.global().asyncAfter(deadline: .now() + timeout + 5) { - if process.isRunning { + if let process = targets.running, process.isRunning { kill(process.processIdentifier, SIGKILL) } } @@ -252,9 +302,9 @@ public enum ProcessRunner { // and discard the data - the result will not be read anyway (the // continuation below resumes with the timeout error), but the // orphaned process can freely finish writing and exit on its own. + let readers = targets.takeReaders() queue.async { - stdoutReader.discardFromNowOn() - stderrReader.discardFromNowOn() + for reader in readers { reader.discardFromNowOn() } } continuation.resume(throwing: ProcessRunnerError.timedOut(executable)) } diff --git a/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift b/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift index fb1439c..d8cccc5 100644 --- a/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift @@ -68,4 +68,29 @@ final class ProcessRunnerOutputTests: XCTestCase { XCTAssertEqual(result.stdout, "parent\n") XCTAssertLessThan(Date().timeIntervalSince(started), 5) } + + /// REGRESSION 11.10.2026: every call with `timeout:` kept its two pipe + /// descriptors until `timeout + 10` s, long after the process had exited - + /// the timers held the process and the readers. The audit measured +100 + /// descriptors for exactly this loop, and 76-92 pipes in the running app + /// against a limit of 256. They must be back within moments of the exit. + func testFinishedProcessesReleaseTheirDescriptors() async throws { + // Settle what earlier tests left behind first, so it cannot be counted. + try await Task.sleep(nanoseconds: 500_000_000) + let before = Self.openDescriptors() + for _ in 0..<50 { + let result = try await ProcessRunner.run("/bin/echo", ["hello"], timeout: 30) + XCTAssertEqual(result.stdout, "hello\n") + } + var leaked = Self.openDescriptors() - before + for _ in 0..<20 where leaked > 10 { + try await Task.sleep(nanoseconds: 100_000_000) + leaked = Self.openDescriptors() - before + } + XCTAssertLessThanOrEqual(leaked, 10, "\(leaked) descriptors still open after 50 finished calls") + } + + private static func openDescriptors() -> Int { + (0.. Date: Sun, 11 Oct 2026 09:20:26 +0200 Subject: [PATCH 2/5] fix(budget): report a usage measurement that stopped coming With a limit set, a failed rclone size left the watchdog comparing the last stored reading with the limit regardless of its age, or raising nothing when there was no reading. The limit then guarded nothing and only the panel turned amber. A reading older than usageStaleAfter (or none at all) is now a watchdog problem, drive-usage-unmeasured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig --- mac-app/Sources/CloudMachineCore/L10n.swift | 1 + .../CloudMachineCore/L10nPolish+Safety.swift | 14 ++++++++ .../CloudMachineCore/MachineBudget.swift | 32 +++++++++++++++++-- .../MachineBudgetTests.swift | 30 +++++++++++++++-- 4 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift diff --git a/mac-app/Sources/CloudMachineCore/L10n.swift b/mac-app/Sources/CloudMachineCore/L10n.swift index 29bcc44..2b77ee6 100644 --- a/mac-app/Sources/CloudMachineCore/L10n.swift +++ b/mac-app/Sources/CloudMachineCore/L10n.swift @@ -60,6 +60,7 @@ public enum L10n { L10nPolish.system, L10nPolish.app, L10nPolish.agent, + L10nPolish.safety, ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift new file mode 100644 index 0000000..b3be55e --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift @@ -0,0 +1,14 @@ +// Polish translations, keyed by the English text passed to `L10n.tr`. +// Texts added with the second audit (October 2026): the open remote-control +// port, replacing the Google credential, the usage measurement. +extension L10nPolish { + static let safety: [String: String] = [ + // MachineBudget + "Usage on Google Drive is not being measured": + "Zajętość na Google Drive nie jest mierzona", + "The last measurement (%@ GB) is from %@, %@ ago - rclone size has not answered since. Until it does, nothing watches the %@ GB limit: the 90%% and 100%% alarms stay silent. Check drive-status, or press Measure now in the window.": + "Ostatni pomiar (%@ GB) jest z %@, sprzed %@ - od tamtej pory rclone size nie odpowiada. Dopóki nie odpowie, nikt nie pilnuje limitu %@ GB: alarmy 90%% i 100%% milczą. Sprawdź drive-status albo naciśnij „Zmierz teraz” w oknie.", + "rclone size has not answered even once. Until it does, nothing watches the %@ GB limit: the 90%% and 100%% alarms stay silent. Check drive-status, or press Measure now in the window.": + "rclone size nie odpowiedział ani razu. Dopóki nie odpowie, nikt nie pilnuje limitu %@ GB: alarmy 90%% i 100%% milczą. Sprawdź drive-status albo naciśnij „Zmierz teraz” w oknie.", + ] +} diff --git a/mac-app/Sources/CloudMachineCore/MachineBudget.swift b/mac-app/Sources/CloudMachineCore/MachineBudget.swift index 2e0749a..cd043df 100644 --- a/mac-app/Sources/CloudMachineCore/MachineBudget.swift +++ b/mac-app/Sources/CloudMachineCore/MachineBudget.swift @@ -182,8 +182,19 @@ public enum MachineBudget { return .ok } - static func problems(usage: Usage?, limitGB: Int?) -> [BackupHealth.Problem] { - guard let limitGB, let usage else { return [] } + /// `now` decides whether `usage` still describes the folder. + /// + /// Until 11.10.2026 a failed measurement was silent here: `currentUsage` + /// hands back the last stored reading when `rclone size` fails, and this + /// compared that reading with the limit whatever its age - or returned + /// nothing at all when there was none. Only the panel turned amber + /// (`standing`). A limit whose usage nobody can measure watches nothing, + /// so the watchdog says so, from the same `usageStaleAfter` as the panel. + static func problems(usage: Usage?, limitGB: Int?, now: Date = Date()) -> [BackupHealth.Problem] { + guard let limitGB else { return [] } + guard let usage, now.timeIntervalSince(usage.measuredAt) <= usageStaleAfter else { + return [unmeasuredProblem(usage: usage, limitGB: limitGB, now: now)] + } let used = String(format: "%.0f", usage.gb) switch level(usageBytes: usage.bytes, limitGB: limitGB) { case .ok: @@ -209,6 +220,23 @@ public enum MachineBudget { } } + static func unmeasuredProblem(usage: Usage?, limitGB: Int, now: Date) -> BackupHealth.Problem { + let detail: String + if let usage { + detail = L10n.tr( + "The last measurement (%@ GB) is from %@, %@ ago - rclone size has not answered since. Until it does, nothing watches the %@ GB limit: the 90%% and 100%% alarms stay silent. Check drive-status, or press Measure now in the window.", + String(format: "%.0f", usage.gb), BackupHealth.stamp(usage.measuredAt), + BackupHealth.formatAge(now.timeIntervalSince(usage.measuredAt)), "\(limitGB)") + } else { + detail = L10n.tr( + "rclone size has not answered even once. Until it does, nothing watches the %@ GB limit: the 90%% and 100%% alarms stay silent. Check drive-status, or press Measure now in the window.", + "\(limitGB)") + } + return BackupHealth.Problem( + summary: L10n.tr("Usage on Google Drive is not being measured"), detail: detail, + code: "drive-usage-unmeasured") + } + /// After this long a measurement no longer describes the folder: the /// watchdog measures every `measurementMaxAge`, so twice that means the /// measuring itself has stopped. diff --git a/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift b/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift index fe8a88f..ccdeca6 100644 --- a/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift @@ -45,13 +45,39 @@ final class MachineBudgetTests: XCTestCase { .isEmpty) } - func testNoLimitOrNoMeasurementRaisesNothing() { - XCTAssertTrue(MachineBudget.problems(usage: nil, limitGB: 1000).isEmpty) + func testNoLimitRaisesNothing() { + XCTAssertTrue(MachineBudget.problems(usage: nil, limitGB: nil).isEmpty) XCTAssertTrue( MachineBudget.problems(usage: .init(bytes: 5000 * gib, measuredAt: Date()), limitGB: nil) .isEmpty) } + /// REGRESSION 11.10.2026: with a limit set, a measurement that never came + /// or stopped coming was silent - an old reading below 90% raised nothing, + /// and no reading at all raised nothing either. The watchdog must say that + /// the limit is not being watched. + func testUnmeasuredUsageIsAProblemOnceStale() { + let now = Date() + XCTAssertEqual( + MachineBudget.problems(usage: nil, limitGB: 1000, now: now).map(\.code), + ["drive-usage-unmeasured"]) + let stale = MachineBudget.Usage( + bytes: 10 * gib, measuredAt: now.addingTimeInterval(-MachineBudget.usageStaleAfter - 60)) + XCTAssertEqual( + MachineBudget.problems(usage: stale, limitGB: 1000, now: now).map(\.code), + ["drive-usage-unmeasured"]) + // Even a stale reading that was over the limit: what it says is no + // longer known, and the one problem to fix is the measuring. + let staleOver = MachineBudget.Usage(bytes: 1200 * gib, measuredAt: stale.measuredAt) + XCTAssertEqual( + MachineBudget.problems(usage: staleOver, limitGB: 1000, now: now).map(\.code), + ["drive-usage-unmeasured"]) + // Within the window an old-but-valid reading is still an answer. + let recent = MachineBudget.Usage( + bytes: 10 * gib, measuredAt: now.addingTimeInterval(-MachineBudget.usageStaleAfter + 60)) + XCTAssertTrue(MachineBudget.problems(usage: recent, limitGB: 1000, now: now).isEmpty) + } + func testLimitIsStoredUnderThisMacsFolderAndKeepsOtherMacs() { var config = MachinesConfig.empty config.machines = [MachineEntry(key: "imac-home", displayName: "iMac", limitGB: 800)] From 866a12668180ad9772b4b194343f2abc6c92acf2 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Sun, 11 Oct 2026 09:22:20 +0200 Subject: [PATCH 3/5] fix(log): trim the rclone log of a running mount without blinding the stall check The log was only rotated when the mount started, and the mount runs for days (257 MB on 10.10, +60 MB a day). The buffer guard now trims it above 100 MB: the last 32 MB go to rclone.log.1 and the live file is truncated (rclone opens it O_APPEND - checked with lsof - so it keeps writing at the new end). The stall and quota checks read the tail of .1 together with the live file when the live file is short, so a trim in the middle of a jam does not read as 'no jam'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig --- .../CloudMachineAgent/DriveCommands.swift | 3 + .../CloudMachineCore/DriveBufferService.swift | 90 +++++++++++++++-- .../RcloneLogRotationTests.swift | 97 +++++++++++++++++++ 3 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 mac-app/Tests/CloudMachineAppTests/RcloneLogRotationTests.swift diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index f0db660..8104013 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -217,6 +217,9 @@ struct BufferGuard: AsyncParsableCommand { // Forever: the guard has to outlive every backup, not just the first one. while true { + // The mount runs for days and rclone never trims its own log; the + // guard is the one process that runs as long. A `stat` per tick. + DriveBufferService.trimLogIfLarge() await guardService.step() try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) } diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index a6eb85c..c9686da 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -117,7 +117,7 @@ public enum DriveBufferService { /// Above this size the rclone log is trimmed at start-up. rclone does not /// rotate its own log, and this project has already lost 3.3 GiB once to a log /// that grew without limit. - private static let logSizeLimit: UInt64 = 100 * 1024 * 1024 + public static let logSizeLimit: UInt64 = 100 * 1024 * 1024 // MARK: - State @@ -294,11 +294,71 @@ public enum DriveBufferService { let attrs = try? FileManager.default.attributesOfItem(atPath: logFile.path), let size = attrs[.size] as? UInt64, size > logSizeLimit else { return } - let rotated = logFile.appendingPathExtension("1") + let rotated = previousLog(of: logFile) try? FileManager.default.removeItem(at: rotated) try? FileManager.default.moveItem(at: logFile, to: rotated) } + /// Where the older part of a log goes - at start-up and in `trimLogIfLarge`. + static func previousLog(of file: URL) -> URL { file.appendingPathExtension("1") } + + /// How much of the log `trimLogIfLarge` keeps in `.1`. Above the 4 MB the + /// stall detection reads (`uploadStalledState`), with room to spare. + public static let logKeepBytes: UInt64 = 32 * 1024 * 1024 + + /// Trims the log of a RUNNING mount: its last `keep` bytes go to `.1`, and + /// the log itself is truncated to zero. Returns whether it trimmed. + /// + /// `rotateLogIfLarge` only runs when the mount starts, and the mount runs + /// for days: on 10.10.2026 the log stood at 257 MB, growing ~60 MB a day. + /// + /// WHY COPY-AND-TRUNCATE, AND WHY THE TAIL GOES TO `.1` + /// + /// rclone keeps the file open, so renaming it would only move rclone's + /// writes to `.1`. It opens it with `O_APPEND` (checked live: `lsof +fg` + /// shows `W,AP` on both descriptors), so after a truncation its next line + /// lands at offset 0 - no hole, no garbage. Putting the kept tail back into + /// the live file instead was rejected: rclone appends at the same time, its + /// new lines could land before the old ones, and the detection reading + /// newest-to-oldest stops at the first line older than its window - an + /// out-of-order file reads as "no jam". The reader (`recentLog`) joins the + /// tail of `.1` and the live file in order instead, so a trim in the middle + /// of a jam does not hide it. + /// + /// rclone's own `--log-file-max-size` was rejected too: it applies only to + /// a mount started with it, so not to one that survives an upgrade, and its + /// backups get time-stamped names the reader would have to look for. + /// + /// Lines rclone writes between the last read and the truncation (micro- + /// seconds) are lost. A log, not a record - the price is accepted. + @discardableResult + public static func trimLogIfLarge( + _ file: URL = logFile, limit: UInt64 = logSizeLimit, keep: UInt64 = logKeepBytes + ) -> Bool { + guard + let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), + let size = attrs[.size] as? UInt64, size > limit, + let handle = try? FileHandle(forUpdating: file) + else { return false } + defer { try? handle.close() } + do { + let end = try handle.seekToEnd() + try handle.seek(toOffset: end > keep ? end - keep : 0) + var tail = try handle.readToEnd() ?? Data() + // Start at a whole line, so the first line of `.1` has its timestamp. + if end > keep, let newline = tail.firstIndex(of: UInt8(ascii: "\n")) { + tail = tail[tail.index(after: newline)...] + } + try tail.write(to: previousLog(of: file), options: .atomic) + try handle.truncate(atOffset: 0) + } catch { + CMLogger.log("Could not trim \(file.path): \(error.localizedDescription)") + return false + } + CMLogger.log("Trimmed \(file.path) (\(size / 1_048_576) MB); its last part is in .1") + return true + } + /// Excludes the buffer directory from Time Machine. The buffer holds a copy of /// the backup data - if Time Machine covered it, it would back up its own /// backup and grow forever. The exclusion is stored as an xattr on the @@ -733,15 +793,33 @@ public enum DriveBufferService { /// problem". The file is taken from a parameter so that both questions asked of /// this log can be tested on our own file - without `~/.cloudmachine` and /// without guessing permissions. + /// + /// When the live file holds less than `bytes` - it was just trimmed or + /// rotated - the rest is taken from the end of `.1`, which holds what came + /// before it. Without that, a trim in the middle of a jam left the detection + /// a few lines to count and it answered "no jam" for the next half hour. + /// `.1` is only an addition: when it cannot be read, the live file alone is + /// still an answer. private static func recentLog(bytes: UInt64, from file: URL) -> String? { + guard let current = tail(of: file, bytes: bytes) else { return nil } + var data = current + if UInt64(current.count) < bytes, + let older = tail(of: previousLog(of: file), bytes: bytes - UInt64(current.count)) + { + // A line cut in half at the boundary has no timestamp and is skipped. + data = older + Data("\n".utf8) + current + } + // The tail almost always starts in the middle of a multi-byte character, so + // we decode lossily - otherwise the whole read would be lost to one byte. + return String(decoding: data, as: UTF8.self) + } + + private static func tail(of file: URL, bytes: UInt64) -> Data? { guard let handle = try? FileHandle(forReadingFrom: file) else { return nil } defer { try? handle.close() } let size = (try? handle.seekToEnd()) ?? 0 try? handle.seek(toOffset: size > bytes ? size - bytes : 0) - guard let data = try? handle.readToEnd() else { return nil } - // The tail almost always starts in the middle of a multi-byte character, so - // we decode lossily - otherwise the whole read would be lost to one byte. - return String(decoding: data, as: UTF8.self) + return try? handle.readToEnd() ?? Data() } /// The locale we read rclone log timestamps with. diff --git a/mac-app/Tests/CloudMachineAppTests/RcloneLogRotationTests.swift b/mac-app/Tests/CloudMachineAppTests/RcloneLogRotationTests.swift new file mode 100644 index 0000000..efc5f82 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/RcloneLogRotationTests.swift @@ -0,0 +1,97 @@ +import XCTest + +@testable import CloudMachineCore + +/// The rclone log of a running mount grew without limit (257 MB on +/// 10.10.2026), because it was only rotated when the mount started. Trimming +/// it while rclone writes must not blind the stall detection, which reads the +/// last 30 minutes of the same file. +/// +/// Own temporary files only - the production log is neither read nor written. +final class RcloneLogRotationTests: XCTestCase { + + private var directory: URL! + private var log: URL { directory.appendingPathComponent("rclone.log") } + private var previous: URL { directory.appendingPathComponent("rclone.log.1") } + + override func setUpWithError() throws { + try super.setUpWithError() + directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cm-rclone-rotation-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + try super.tearDownWithError() + } + + private func stamp(_ minutesAgo: Double) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + formatter.timeZone = TimeZone.current + return formatter.string(from: Date().addingTimeInterval(-minutesAgo * 60)) + } + + /// A jam as the detection sees it: hundreds of limit errors, no uploads. + private func jam(minutesAgo: Double, errors: Int = 400) -> String { + (0.. Date: Sun, 11 Oct 2026 09:26:23 +0200 Subject: [PATCH 4/5] fix(remote): never trade a visible backup for an invisible one - The panel no longer suggests configure-remote --replace-existing after credentials are saved; on a connected Mac it says to keep the connection (a drive.file sign-in under another client_id cannot see the existing image). - Replacing an existing remote needs --accept-scope-change as well, and even then the new token lists the image (on an in-memory :drive: remote, token via the environment) before rclone.conf is written; if the old connection saw the image and the new one does not, nothing is written. - create-image refuses when Drive shows no image but this Mac remembers one (known-image marker written on create/attach, or the image in the vfs cache): 'check the token', not a second empty backup. - A missing rclone.conf next to cached Drive folders is 'unknown', not 'no remote'; a cached mac-studio folder alone keeps a legacy Mac in mac-studio. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig --- README.md | 6 + docs/setup-cli.md | 10 +- .../CloudMachineAgent/SetupCommands.swift | 13 +- .../Services/CloudMachineController.swift | 11 +- .../CloudMachineCore/BackupImageService.swift | 28 ++- .../CloudMachineCore/DriveFolder.swift | 22 +- .../CloudMachineCore/InstallationTraces.swift | 103 +++++++++ .../CloudMachineCore/L10nPolish+App.swift | 2 - .../CloudMachineCore/L10nPolish+Safety.swift | 21 ++ .../CloudMachineCore/RemoteConfigurer.swift | 207 ++++++++++++++++-- .../BackupVisibilityTests.swift | 182 +++++++++++++++ 11 files changed, 580 insertions(+), 25 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/InstallationTraces.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/BackupVisibilityTests.swift diff --git a/README.md b/README.md index f4c0e99..a7fdaad 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,12 @@ application itself created. Full `drive` scope would hand out read, write and disk-image bands needs — especially with `--drive-use-trash=false`, where a delete has no bin to recover from. +On a Mac that is already connected, keep the connection it has. A new sign-in +with different credentials sees only what it created itself, so the existing +backup would vanish from view; `configure-remote` refuses to replace a working +connection unless told twice, and checks the new token against the image first +(see [setup-cli](docs/setup-cli.md#replacing-the-connection)). + --- ## Documentation diff --git a/docs/setup-cli.md b/docs/setup-cli.md index 09c5492..f39dbfe 100644 --- a/docs/setup-cli.md +++ b/docs/setup-cli.md @@ -75,5 +75,13 @@ pretending otherwise. replaces the token and the scope, and credentials scoped `drive.file` cannot see files created by the previous credentials — the backup stays intact but becomes unreachable, which amounts to the same thing. Back up `~/.config/rclone/rclone.conf` -first and pass `--replace-existing` if you really mean it. The Drive folder of +first and pass `--replace-existing --accept-scope-change` if you really mean +it. Even then nothing is written until the new token has been checked: if the +current connection sees this Mac's image and the new one does not, the +replacement is abandoned and `rclone.conf` stays as it was. The Drive folder of this Mac does not change with it. + +`create-image` refuses in the same spirit: when Google Drive shows no image but +this Mac remembers one (`~/Library/Application Support/CloudMachine/known-image`, +or the image in the upload cache), it says so instead of starting a second, +empty backup next to the invisible one. diff --git a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift index 9e5ca1a..4ab3001 100644 --- a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift @@ -33,6 +33,16 @@ struct ConfigureRemote: AsyncParsableCommand { ) var replaceExisting = false + @Flag( + name: .long, + help: + ArgumentHelp( + L10n.tr( + "With --replace-existing: confirms that the new sign-in (scope 'drive.file') may not see the existing backup. The new token is still checked against the image first." + )) + ) + var acceptScopeChange = false + @Option( name: .long, help: ArgumentHelp( @@ -44,7 +54,8 @@ struct ConfigureRemote: AsyncParsableCommand { func run() async throws { let (config, key) = await CLIContext.load() let result = await RemoteConfigurer.connect( - config: config, machineKey: key, replaceExisting: replaceExisting, folder: folder) + config: config, machineKey: key, replaceExisting: replaceExisting, + acceptScopeChange: acceptScopeChange, folder: folder) print(result.message) if !result.succeeded { throw ExitCode.failure } } diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index bc2ba5f..ebcc607 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -136,16 +136,17 @@ final class CloudMachineController: ObservableObject { /// Saves the credentials and refreshes the state. /// /// Does not reconfigure the remote: a token issued for the old `client_id` keeps - /// working, so entering new values alone changes NOTHING until - /// `configure-remote --replace-existing` is run. We say so plainly. + /// working, so entering new values alone changes NOTHING on a connected Mac - + /// and must not lead to replacing the connection there (see + /// `RemoteConfigurer.credentialsSavedMessage`). func saveCredentials(clientID: String, clientSecret: String) async -> String { do { try await RemoteConfigurer.storeCredentials( clientID: clientID, clientSecret: clientSecret) await refreshCredentials() - return L10n.tr( - "Saved in the Keychain. Note: the existing connection still uses the old client_id - to use the new one, run configure-remote --replace-existing." - ) + return RemoteConfigurer.credentialsSavedMessage( + remoteConfigured: await RemoteConfigurer.isConfigured( + remoteName: DriveBufferService.remoteName)) } catch { return L10n.tr("Not saved: %@", error.localizedDescription) } diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index 8d51ce4..d86482d 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -348,7 +348,9 @@ public enum BackupImageService { // confirming anyway. switch await remoteImagePresence() { case .absent: - break + if let refusal = absentImageVerdict(trace: InstallationTraces.knownImageTrace()) { + return refusal + } case .present: return CMActionResult( succeeded: false, @@ -384,6 +386,7 @@ public enum BackupImageService { message: L10n.tr( "Could not create the image: %@", result?.stderr ?? L10n.tr("unknown error"))) } + InstallationTraces.rememberImage() return CMActionResult( succeeded: true, message: L10n.tr( @@ -391,6 +394,27 @@ public enum BackupImageService { "\(bandSectors * 512 / 1024 / 1024)")) } + /// What "Google Drive has no image" means when this Mac remembers one. + /// + /// The listing can be wrong without failing: a token with the `drive.file` + /// scope sees only what its own `client_id` created, so after the remote is + /// replaced (the panel used to suggest `configure-remote + /// --replace-existing` after every credential change) the existing image - + /// 632 GB on this project's own Mac - is simply not there. `create` then + /// made a second image next to it, Drive allowing duplicate names, and the + /// old backup sat orphaned, still using space. A remembered image that + /// cannot be seen is a question about the token, not a reason to start + /// over. `nil` = no trace, go ahead. + static func absentImageVerdict(trace: String?) -> CMActionResult? { + guard let trace else { return nil } + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Google Drive does not show the image %@, but this Mac has already used it (trace: %@). NOT creating a new one - that would start an empty backup and orphan the existing one. Most likely the token cannot see it: a connection with the 'drive.file' scope sees only files created under its own client_id. Check the token's permissions (scope and client_id of the remote in rclone.conf). Only if the image really was deleted on purpose: remove %@ and run again.", + "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)/\(imageName).sparsebundle", + trace, trace)) + } + // MARK: - Image on the remote /// Whether the image is on Google Drive - asked WITHOUT going through the mount. @@ -477,6 +501,7 @@ public enum BackupImageService { let reading = await attachmentReading() switch reading.attachment { case .attached: + InstallationTraces.rememberImage() return CMActionResult( succeeded: true, message: L10n.tr("Already attached: %@", targetPath.path)) case .unknown: @@ -585,6 +610,7 @@ public enum BackupImageService { message: L10n.tr( "Could not attach the image: %@", result?.stderr ?? L10n.tr("unknown error"))) } + InstallationTraces.rememberImage() return CMActionResult(succeeded: true, message: L10n.tr("Attached: %@", targetPath.path)) } diff --git a/mac-app/Sources/CloudMachineCore/DriveFolder.swift b/mac-app/Sources/CloudMachineCore/DriveFolder.swift index 02f6d6e..7f4b2c7 100644 --- a/mac-app/Sources/CloudMachineCore/DriveFolder.swift +++ b/mac-app/Sources/CloudMachineCore/DriveFolder.swift @@ -46,11 +46,29 @@ public enum DriveFolder { /// there yet; on an installation that predates `drive-folder` it is, and its /// backup lives under the legacy name. Weaker traces were rejected: the /// buffer directory appears after any mount attempt, and the old - /// `mount-desired.state` is no longer written by anything. + /// `mount-desired.state` is no longer written by anything. The cached + /// legacy folder is not such a weak trace - rclone writes it only once the + /// remote was opened, i.e. after a mount that worked. /// /// `nil` = rclone did not answer; `decide` then refuses rather than guess. + /// + /// A cached `mac-studio` folder is evidence on its own (see + /// `InstallationTraces`): only an installation from before `drive-folder` + /// ever mounted that folder, because a new one gets its file before its + /// first mount. Until 11.10.2026 the remote was the only witness, and a Mac + /// whose `rclone.conf` was lost or replaced got a new, empty folder. static func hasLegacyInstallation() async -> Bool? { - await RemoteConfigurer.isConfigured(remoteName: DriveBufferService.remoteName) + legacyEvidence( + remoteConfigured: await RemoteConfigurer.isConfigured( + remoteName: DriveBufferService.remoteName), + cachedFolders: InstallationTraces.cachedFolders()) + } + + /// Pure: the cache trace of the legacy folder outweighs the remote's answer, + /// including "I do not know". + static func legacyEvidence(remoteConfigured: Bool?, cachedFolders: [String]) -> Bool? { + if cachedFolders.contains(legacyName) { return true } + return remoteConfigured } public enum Decision: Equatable { diff --git a/mac-app/Sources/CloudMachineCore/InstallationTraces.swift b/mac-app/Sources/CloudMachineCore/InstallationTraces.swift new file mode 100644 index 0000000..6894712 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/InstallationTraces.swift @@ -0,0 +1,103 @@ +import Foundation + +/// What this Mac remembers of a backup it has already made - read from the +/// local disk, without rclone and without Google. +/// +/// The decisions that can orphan a backup (which Drive folder to use, whether +/// to create an image) used to rest on one witness: what rclone answered with +/// the current configuration. That witness can be wrong without failing. A +/// missing `rclone.conf` makes `rclone listremotes` succeed with an empty list +/// (checked: exit 0), and a token with the `drive.file` scope makes a folder +/// created under another `client_id` simply not there (`directory not found`). +/// Both read as "nothing here yet", and both lead to a new, empty backup next +/// to the real one. These traces are the second witness: they exist only +/// where a mount of this remote really ran. +public enum InstallationTraces { + + /// Folders of `remoteRoot` that a mount of `remoteName` has cached on this + /// Mac - `cache/vfs//CloudMachine/`. + /// + /// rclone names the cache directory after the remote, with a suffix when + /// the mount overrides backend options: on this project's own Mac it is + /// `gdrive{hekjD}`, not `gdrive`. Both forms are accepted, and nothing + /// else - `gdrive2` is another remote. + /// + /// rclone creates the directory only once the remote has been opened, so + /// a mount that failed for want of a remote leaves none. + public static func cachedFolders( + cacheDir: URL = DriveBufferService.cacheDir, + remoteName: String = DriveBufferService.remoteName, + remoteRoot: String = DriveBufferService.remoteRoot + ) -> [String] { + let fm = FileManager.default + let vfs = cacheDir.appendingPathComponent("vfs") + guard let remotes = try? fm.contentsOfDirectory(atPath: vfs.path) else { return [] } + var folders: [String] = [] + for remote in remotes where remote == remoteName || remote.hasPrefix(remoteName + "{") { + let root = vfs.appendingPathComponent(remote).appendingPathComponent(remoteRoot) + let names = (try? fm.contentsOfDirectory(atPath: root.path)) ?? [] + folders += names.filter { DriveFolder.isValid($0) } + } + return Array(Set(folders)).sorted() + } + + /// The cached copy of `folder`'s image, if a mount has ever held it. + public static func cachedImage( + folder: String, imageName: String, + cacheDir: URL = DriveBufferService.cacheDir, + remoteName: String = DriveBufferService.remoteName, + remoteRoot: String = DriveBufferService.remoteRoot + ) -> URL? { + let fm = FileManager.default + let vfs = cacheDir.appendingPathComponent("vfs") + guard let remotes = try? fm.contentsOfDirectory(atPath: vfs.path) else { return nil } + for remote in remotes.sorted() where remote == remoteName || remote.hasPrefix(remoteName + "{") + { + let image = vfs.appendingPathComponent(remote).appendingPathComponent(remoteRoot) + .appendingPathComponent(folder).appendingPathComponent("\(imageName).sparsebundle") + if fm.fileExists(atPath: image.path) { return image } + } + return nil + } + + // MARK: - The image this Mac has used + + /// Written when an image is created or attached: the Drive path of the + /// image this Mac backs up to. Unlike the cache it survives clearing the + /// buffer. + public static var knownImageFile: URL { + CMPaths.appSupportDir.appendingPathComponent("known-image") + } + + /// Remembers that the image at `remotePath` exists. Best effort: a failed + /// write leaves the cache trace as the only witness, as before. + public static func rememberImage( + remotePath: String = DriveBufferService.remotePath, file: URL = knownImageFile + ) { + let line = "\(DriveBufferService.remoteName):\(remotePath)" + if (try? String(contentsOf: file, encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) == line + { + return + } + try? line.write(to: file, atomically: true, encoding: .utf8) + } + + /// Where this Mac keeps proof that the image of `folder` has existed - + /// `nil` when it keeps none. A path, so a refusal can say what to remove + /// when the image really was deleted on purpose. + public static func knownImageTrace( + folder: String = DriveFolder.name, + imageName: String = BackupImageService.imageName, + remotePath: String = DriveBufferService.remotePath, + knownImageFile: URL = knownImageFile, + cacheDir: URL = DriveBufferService.cacheDir + ) -> String? { + let remembered = (try? String(contentsOf: knownImageFile, encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) + if remembered == "\(DriveBufferService.remoteName):\(remotePath)" { + return knownImageFile.path + } + return cachedImage(folder: folder, imageName: imageName, cacheDir: cacheDir)?.path + } +} diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift index 06c289a..57a1ef4 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -68,8 +68,6 @@ extension L10nPolish { "Ready": "Gotowe", // CloudMachineController - "Saved in the Keychain. Note: the existing connection still uses the old client_id - to use the new one, run configure-remote --replace-existing.": - "Zapisane w Keychainie. Uwaga: istniejące połączenie nadal działa na starym client_id - żeby użyć nowego, przejdź configure-remote --replace-existing.", "Not saved: %@": "Nie zapisano: %@", "Installing rclone": "Instaluję rclone", "Creating the backup image": "Tworzę obraz backupu", diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift index b3be55e..7bc331a 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift @@ -10,5 +10,26 @@ extension L10nPolish { "Ostatni pomiar (%@ GB) jest z %@, sprzed %@ - od tamtej pory rclone size nie odpowiada. Dopóki nie odpowie, nikt nie pilnuje limitu %@ GB: alarmy 90%% i 100%% milczą. Sprawdź drive-status albo naciśnij „Zmierz teraz” w oknie.", "rclone size has not answered even once. Until it does, nothing watches the %@ GB limit: the 90%% and 100%% alarms stay silent. Check drive-status, or press Measure now in the window.": "rclone size nie odpowiedział ani razu. Dopóki nie odpowie, nikt nie pilnuje limitu %@ GB: alarmy 90%% i 100%% milczą. Sprawdź drive-status albo naciśnij „Zmierz teraz” w oknie.", + // RemoteConfigurer: a missing rclone.conf + "Or rclone.conf is missing: this Mac has already backed up to Google Drive (folders %@), so it is not connected anew blindly. Restore the file from a backup. To connect anyway, create an empty file at the path `%@ config file` prints and run again - the existing folder is kept.": + "Albo brakuje rclone.conf: ten Mac robił już kopie na Google Drive (foldery %@), więc nie jest łączony od nowa na ślepo. Odtwórz plik z kopii. Żeby mimo to połączyć, utwórz pusty plik pod ścieżką, którą wypisuje `%@ config file`, i uruchom ponownie - istniejący folder zostanie zachowany.", + // Replacing the Google credential + "Saved in the Keychain. Connecting Google Drive (configure-remote) will use them.": + "Zapisane w Keychainie. Łączenie z Google Drive (configure-remote) ich użyje.", + "Saved in the Keychain. The existing Google Drive connection keeps its old client_id, and on this Mac it should stay that way: a connection made with the new keys (the 'drive.file' scope) cannot see the backup created by the old one. Do not replace the connection here; the new keys are for a Mac that is not connected yet.": + "Zapisane w Keychainie. Istniejące połączenie z Google Drive zostaje przy starym client_id i na tym Macu tak ma zostać: połączenie z nowymi kluczami (zakres 'drive.file') nie widzi kopii założonej przez stare. Nie wymieniaj tu połączenia; nowe klucze są dla Maca, który jeszcze nie jest połączony.", + "Remote '%@' was NOT replaced. The new sign-in gets the 'drive.file' scope, which sees only files created under its own client_id. The existing backup was most likely created by the current connection and would become invisible: the mount would show an empty folder, and create-image could start a second, empty backup next to it. Before anything is written, the new token is checked against the existing image, and the replacement is abandoned if it cannot see it. To go ahead, run again with --replace-existing --accept-scope-change.": + "Remote '%@' NIE został wymieniony. Nowe logowanie dostaje zakres 'drive.file', który widzi tylko pliki założone przez jego własny client_id. Istniejącą kopię najpewniej założyło obecne połączenie i stałaby się niewidoczna: mount pokazałby pusty folder, a create-image mógłby założyć obok drugą, pustą kopię. Zanim cokolwiek zostanie zapisane, nowy token jest sprawdzany na istniejącym obrazie, a wymiana jest porzucana, jeśli go nie widzi. Żeby kontynuować, uruchom ponownie z --replace-existing --accept-scope-change.", + "Could not check with the current connection whether the backup image is on Google Drive (%@), so remote '%@' was NOT replaced: without that answer the new sign-in cannot be checked against it.": + "Nie udało się sprawdzić obecnym połączeniem, czy obraz kopii jest na Google Drive (%@), więc remote '%@' NIE został wymieniony: bez tej odpowiedzi nie ma z czym porównać nowego logowania.", + "it is not there for the new token": + "dla nowego tokenu go nie ma", + "The new sign-in does NOT see the existing backup image (%@), so remote '%@' was NOT replaced and rclone.conf is unchanged. With this token the mount would show an empty folder. Keep the current connection, or sign in with the credential (client_id) that created the image.": + "Nowe logowanie NIE widzi istniejącego obrazu kopii (%@), więc remote '%@' NIE został wymieniony, a rclone.conf jest bez zmian. Z tym tokenem mount pokazałby pusty folder. Zostaw obecne połączenie albo zaloguj się poświadczeniem (client_id), które założyło obraz.", + "With --replace-existing: confirms that the new sign-in (scope 'drive.file') may not see the existing backup. The new token is still checked against the image first.": + "Z --replace-existing: potwierdza, że nowe logowanie (zakres 'drive.file') może nie widzieć istniejącej kopii. Nowy token i tak jest najpierw sprawdzany na obrazie.", + // BackupImageService.create + "Google Drive does not show the image %@, but this Mac has already used it (trace: %@). NOT creating a new one - that would start an empty backup and orphan the existing one. Most likely the token cannot see it: a connection with the 'drive.file' scope sees only files created under its own client_id. Check the token's permissions (scope and client_id of the remote in rclone.conf). Only if the image really was deleted on purpose: remove %@ and run again.": + "Google Drive nie pokazuje obrazu %@, a ten Mac już go używał (ślad: %@). NIE tworzę nowego - to zaczęłoby pustą kopię i osierociło istniejącą. Najpewniej token go nie widzi: połączenie z zakresem 'drive.file' widzi tylko pliki założone przez własny client_id. Sprawdź uprawnienia tokenu (scope i client_id remote'a w rclone.conf). Tylko jeśli obraz naprawdę został celowo usunięty: usuń %@ i uruchom ponownie.", ] } diff --git a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift index da5920a..3c09a7d 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift @@ -20,8 +20,50 @@ public enum RemoteConfigurer { /// to overwrite the working token, and `DriveFolder` took an installation /// whose backup lives in `mac-studio` for a new Mac and gave it a new, empty /// folder. + /// + /// "Not listed" is an answer only when rclone READ a configuration. Without + /// `rclone.conf` it lists nothing and exits 0 - and on a Mac whose mount has + /// already run (`InstallationTraces`), a lost file is far likelier than a + /// remote that never was. That case is `nil` too: answered "no remote", it + /// gave such a Mac a new, empty Drive folder for good (second audit, S4). public static func isConfigured(remoteName: String) async -> Bool? { - remoteListed(remoteName, in: try? await CMTooling.runRclone(["listremotes"], timeout: 30)) + let listed = remoteListed( + remoteName, in: try? await CMTooling.runRclone(["listremotes"], timeout: 30)) + guard listed == false else { return listed } + let configFile = configFileExists( + in: try? await CMTooling.runRclone(["config", "file"], timeout: 30)) + let folders = InstallationTraces.cachedFolders() + let answer = absentAnswer(configFileExists: configFile, cachedFolders: folders) + if answer == nil { + CMLogger.log( + "rclone lists no '\(remoteName)' remote, but its configuration file " + + (configFile == false ? "does not exist" : "could not be checked") + + " while this Mac has cached Drive folders (\(folders.joined(separator: ", "))) -" + + " treating it as UNKNOWN, not as 'no remote'") + } + return answer + } + + /// Pure: what "the remote is not listed" means, given whether rclone's + /// configuration file exists (`nil` = could not tell) and which Drive + /// folders a mount has already cached here. + static func absentAnswer(configFileExists: Bool?, cachedFolders: [String]) -> Bool? { + if configFileExists == true { return false } + return cachedFolders.isEmpty ? false : nil + } + + /// Reads `rclone config file`. rclone says outright when the file is + /// missing ("Configuration file doesn't exist, but rclone will use this + /// path:"); otherwise the last line is the path, checked on disk. `nil` = + /// no usable answer. + static func configFileExists(in result: ProcessResult?) -> Bool? { + guard let result, result.succeeded else { return nil } + if result.stdout.lowercased().contains("doesn't exist") { return false } + guard + let path = result.stdout.split(whereSeparator: \.isNewline).last + .map({ $0.trimmingCharacters(in: .whitespaces) }), path.hasPrefix("/") + else { return nil } + return FileManager.default.fileExists(atPath: path) } /// Pure reading of `rclone listremotes`, so the "no answer" cases can be @@ -80,10 +122,15 @@ public enum RemoteConfigurer { /// `config` and `machineKey` stay in the signature because the GUI and the /// CLI have them, but from now on the remote's name is decided by the same /// constant that builds the mount command. One source of truth or none. + /// + /// `acceptScopeChange` is the second, explicit "yes" that replacing an + /// existing remote needs - see `replacementGate`. Even with it, the new + /// token is checked against the existing image before anything is written + /// (`replacementVerdict`). @discardableResult public static func connect( config: MachinesConfig, machineKey: String, replaceExisting: Bool = false, - folder: String? = nil + acceptScopeChange: Bool = false, folder: String? = nil ) async -> CMActionResult { let remoteName = DriveBufferService.remoteName @@ -99,18 +146,24 @@ public enum RemoteConfigurer { // not know that there is nothing to overwrite. Even with // `--replace-existing` - the folder decision below needs this answer too. guard let configured else { - return CMActionResult( - succeeded: false, - message: L10n.tr( - "Could not check whether remote '%@' already exists (rclone listremotes did not answer or failed), so nothing was changed. Run `%@ listremotes` to see the error, then try again.", - remoteName, CMTooling.managedRclonePath.path)) + var message = L10n.tr( + "Could not check whether remote '%@' already exists (rclone listremotes did not answer or failed), so nothing was changed. Run `%@ listremotes` to see the error, then try again.", + remoteName, CMTooling.managedRclonePath.path) + let folders = InstallationTraces.cachedFolders() + if !folders.isEmpty { + message += + "\n" + + L10n.tr( + "Or rclone.conf is missing: this Mac has already backed up to Google Drive (folders %@), so it is not connected anew blindly. Restore the file from a backup. To connect anyway, create an empty file at the path `%@ config file` prints and run again - the existing folder is kept.", + folders.joined(separator: ", "), CMTooling.managedRclonePath.path) + } + return CMActionResult(succeeded: false, message: message) } - if !replaceExisting, configured { - return CMActionResult( - succeeded: false, - message: L10n.tr( - "Remote '%@' already exists and was NOT touched.\nOverwriting it replaces the token and the permission scope; a credential with the 'drive.file' scope does not see files created by the previous one, so the existing backup becomes unreachable.\nIf you really want to replace it, first back up ~/.config/rclone/rclone.conf and run again with --replace-existing.", - remoteName)) + if let refusal = replacementGate( + configured: configured, replaceExisting: replaceExisting, + acceptScopeChange: acceptScopeChange, remoteName: remoteName) + { + return refusal } // The folder is decided BEFORE the remote is created: an existing remote @@ -125,6 +178,22 @@ public enum RemoteConfigurer { } let remotePath = "\(remoteName):\(DriveBufferService.remotePath)" + // What the CURRENT connection sees, asked before it is replaced: the new + // token will be held against it. No answer stops here - the check below + // would have nothing to compare with. + var imageBefore: BackupImageService.RemotePresence? + if configured { + let presence = await BackupImageService.remoteImagePresence() + if case .unknown(let why) = presence { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Could not check with the current connection whether the backup image is on Google Drive (%@), so remote '%@' was NOT replaced: without that answer the new sign-in cannot be checked against it.", + why, remoteName)) + } + imageBefore = presence + } + // Own OAuth credentials from the Keychain. On rclone's shared `client_id` // we compete for the rate limit with all rclone users, and that // `client_id` itself is being retired in 2026. @@ -190,6 +259,16 @@ public enum RemoteConfigurer { succeeded: false, message: L10n.tr("Could not read the token from the output of rclone authorize.")) } + if let imageBefore { + let imageAfter = await imagePresence( + token: token, clientID: clientID, clientSecret: clientSecret) + if let refusal = replacementVerdict( + before: imageBefore, after: imageAfter, remoteName: remoteName) + { + return refusal + } + } + // Here the keys MUST go as arguments - `config create` is supposed to // write them to `rclone.conf`, so the environment would not help. The // process takes a fraction of a second and runs once, unlike `authorize` @@ -224,4 +303,106 @@ public enum RemoteConfigurer { message: L10n.tr( "Connected to Google Drive as remote '%@', folder %@.", remoteName, remotePath)) } + + // MARK: - Replacing an existing remote + + /// Whether `connect` may go on, given what the user asked for. `nil` = go + /// on; otherwise the refusal. + /// + /// Replacing a working remote takes TWO flags. The first, + /// `--replace-existing`, is easy to reach for - until 11.10.2026 the panel + /// itself suggested it after every change of the Google credentials. On a + /// Mac like this project's own (`scope = drive`, its own `client_id`), the + /// new sign-in gets `drive.file` and does not see the existing image: the + /// mount shows an empty folder and `create-image` sees nothing to protect. + /// The second flag is there so that the user has read that sentence. + static func replacementGate( + configured: Bool, replaceExisting: Bool, acceptScopeChange: Bool, remoteName: String + ) -> CMActionResult? { + guard configured else { return nil } + guard replaceExisting else { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Remote '%@' already exists and was NOT touched.\nOverwriting it replaces the token and the permission scope; a credential with the 'drive.file' scope does not see files created by the previous one, so the existing backup becomes unreachable.\nIf you really want to replace it, first back up ~/.config/rclone/rclone.conf and run again with --replace-existing.", + remoteName)) + } + guard acceptScopeChange else { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Remote '%@' was NOT replaced. The new sign-in gets the 'drive.file' scope, which sees only files created under its own client_id. The existing backup was most likely created by the current connection and would become invisible: the mount would show an empty folder, and create-image could start a second, empty backup next to it. Before anything is written, the new token is checked against the existing image, and the replacement is abandoned if it cannot see it. To go ahead, run again with --replace-existing --accept-scope-change.", + remoteName)) + } + return nil + } + + /// Whether the new token may replace the remote, given what the old one and + /// the new one see. `nil` = it may. + /// + /// Only an image that WAS visible can be lost: with none before, there is + /// nothing for the new token to miss. When there was one, the new token + /// has to see it too - "absent" and "no answer" both stop the replacement, + /// because writing the token is the step that cannot be taken back (the + /// old token is gone from `rclone.conf` with it). + static func replacementVerdict( + before: BackupImageService.RemotePresence, after: BackupImageService.RemotePresence, + remoteName: String + ) -> CMActionResult? { + guard before == .present else { return nil } + let why: String + switch after { + case .present: return nil + case .absent: why = L10n.tr("it is not there for the new token") + case .unknown(let reason): why = reason + } + return CMActionResult( + succeeded: false, + message: L10n.tr( + "The new sign-in does NOT see the existing backup image (%@), so remote '%@' was NOT replaced and rclone.conf is unchanged. With this token the mount would show an empty folder. Keep the current connection, or sign in with the credential (client_id) that created the image.", + why, remoteName)) + } + + /// Whether `token` sees this Mac's image - the same `lsf` as + /// `BackupImageService.remoteImagePresence`, on a remote defined only for + /// this call (`:drive:`), so nothing is written to `rclone.conf`. The token + /// and the keys go through the environment, not the command line, for the + /// reason given at `authorize`. + static func imagePresence(token: String, clientID: String?, clientSecret: String?) async + -> BackupImageService.RemotePresence + { + var env = ["RCLONE_DRIVE_TOKEN": token, "RCLONE_DRIVE_SCOPE": "drive.file"] + if let clientID, let clientSecret { + env["RCLONE_DRIVE_CLIENT_ID"] = clientID + env["RCLONE_DRIVE_CLIENT_SECRET"] = clientSecret + } + guard + let result = try? await ProcessRunner.run( + CMTooling.managedRclonePath.path, + ["lsf", "--dirs-only", ":drive:\(DriveBufferService.remotePath)"], env: env, timeout: 120) + else { + return .unknown(L10n.tr("rclone did not answer")) + } + return BackupImageService.classifyRemoteListing( + succeeded: result.succeeded, stdout: result.stdout, stderr: result.stderr) + } + + // MARK: - Saving credentials + + /// What the panel says after the Google credentials are saved. + /// + /// Until 11.10.2026 it always ended with "to use the new one, run + /// configure-remote --replace-existing" - on a working installation, the + /// one command that can make its backup invisible (see `replacementGate`). + /// `nil` (not known yet) is answered as "connected": advice that is + /// harmless on a new Mac, instead of advice that is dangerous on an old one. + public static func credentialsSavedMessage(remoteConfigured: Bool?) -> String { + if remoteConfigured == false { + return L10n.tr( + "Saved in the Keychain. Connecting Google Drive (configure-remote) will use them.") + } + return L10n.tr( + "Saved in the Keychain. The existing Google Drive connection keeps its old client_id, and on this Mac it should stay that way: a connection made with the new keys (the 'drive.file' scope) cannot see the backup created by the old one. Do not replace the connection here; the new keys are for a Mac that is not connected yet." + ) + } } diff --git a/mac-app/Tests/CloudMachineAppTests/BackupVisibilityTests.swift b/mac-app/Tests/CloudMachineAppTests/BackupVisibilityTests.swift new file mode 100644 index 0000000..5655cd8 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/BackupVisibilityTests.swift @@ -0,0 +1,182 @@ +import XCTest + +@testable import CloudMachineCore + +/// Two ways to end up with a second, empty backup next to an invisible one +/// (second audit, 10.10.2026): +/// +/// - N-W1: the panel suggested `configure-remote --replace-existing` after +/// every credential change. The new sign-in (`drive.file`, another +/// `client_id`) does not see the existing image; `create-image` then sees +/// nothing to protect. +/// - S4: without `rclone.conf`, `rclone listremotes` exits 0 with nothing, +/// which read as "no remote" - and a Mac whose backup lives in +/// `mac-studio` got a new folder for good. +final class BackupVisibilityTests: XCTestCase { + + private var directory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cm-visibility-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + try super.tearDownWithError() + } + + private func makeDirectory(_ path: String) throws { + try FileManager.default.createDirectory( + at: directory.appendingPathComponent(path), withIntermediateDirectories: true) + } + + // MARK: - Traces on the local disk + + /// The cache directory carries a suffix when the mount overrides backend + /// options - `gdrive{hekjD}` on this project's own Mac. + func testCachedFoldersAreFoundUnderTheSuffixedRemoteOnly() throws { + try makeDirectory("vfs/gdrive{hekjD}/CloudMachine/mac-studio/mac-studio.sparsebundle") + try makeDirectory("vfs/gdrive2/CloudMachine/someone-else") + try makeDirectory("vfs/gdrive/Other/not-ours") + + XCTAssertEqual(InstallationTraces.cachedFolders(cacheDir: directory), ["mac-studio"]) + XCTAssertEqual( + InstallationTraces.cachedImage( + folder: "mac-studio", imageName: "mac-studio", cacheDir: directory)?.lastPathComponent, + "mac-studio.sparsebundle") + XCTAssertNil( + InstallationTraces.cachedImage( + folder: "marcin-mac-studio-3", imageName: "marcin-mac-studio-3", cacheDir: directory)) + } + + func testNoCacheMeansNoTraces() { + XCTAssertEqual(InstallationTraces.cachedFolders(cacheDir: directory), []) + } + + func testRememberedImageIsATraceOnlyForTheSameRemotePath() throws { + let marker = directory.appendingPathComponent("known-image") + InstallationTraces.rememberImage(remotePath: "CloudMachine/mac-studio", file: marker) + + XCTAssertEqual( + InstallationTraces.knownImageTrace( + folder: "mac-studio", imageName: "mac-studio", remotePath: "CloudMachine/mac-studio", + knownImageFile: marker, cacheDir: directory), + marker.path) + XCTAssertNil( + InstallationTraces.knownImageTrace( + folder: "other", imageName: "other", remotePath: "CloudMachine/other", + knownImageFile: marker, cacheDir: directory)) + } + + // MARK: - S4: a missing rclone.conf is not "no remote" + + func testConfigFileAnswerIsRead() throws { + let missing = ProcessResult( + stdout: + "Configuration file doesn't exist, but rclone will use this path:\n/Users/x/.config/rclone/rclone.conf\n", + stderr: "", exitCode: 0) + XCTAssertEqual(RemoteConfigurer.configFileExists(in: missing), false) + + let file = directory.appendingPathComponent("rclone.conf") + try "".write(to: file, atomically: true, encoding: .utf8) + let present = ProcessResult( + stdout: "Configuration file is stored at:\n\(file.path)\n", stderr: "", exitCode: 0) + XCTAssertEqual(RemoteConfigurer.configFileExists(in: present), true) + + XCTAssertNil(RemoteConfigurer.configFileExists(in: nil)) + XCTAssertNil( + RemoteConfigurer.configFileExists(in: ProcessResult(stdout: "", stderr: "x", exitCode: 1))) + } + + /// THE case: no file, and a mount of this remote has run here. + func testMissingConfigNextToABackupIsUnknown() { + XCTAssertNil( + RemoteConfigurer.absentAnswer(configFileExists: false, cachedFolders: ["mac-studio"])) + XCTAssertNil( + RemoteConfigurer.absentAnswer(configFileExists: nil, cachedFolders: ["mac-studio"])) + } + + func testNewMacWithoutConfigHasNoRemote() { + XCTAssertEqual(RemoteConfigurer.absentAnswer(configFileExists: false, cachedFolders: []), false) + XCTAssertEqual(RemoteConfigurer.absentAnswer(configFileExists: nil, cachedFolders: []), false) + // A file rclone read and found no remote in is an answer. + XCTAssertEqual( + RemoteConfigurer.absentAnswer(configFileExists: true, cachedFolders: ["mac-studio"]), false) + } + + /// Whatever rclone says, a cached `mac-studio` keeps this Mac in it. + func testCachedLegacyFolderKeepsTheLegacyName() { + for remote in [false, nil] as [Bool?] { + let evidence = DriveFolder.legacyEvidence( + remoteConfigured: remote, cachedFolders: ["mac-studio"]) + XCTAssertEqual(evidence, true) + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: nil, legacyEvidence: evidence, + machineKey: "marcin-mac-studio-3"), + .assign("mac-studio")) + } + XCTAssertEqual(DriveFolder.legacyEvidence(remoteConfigured: false, cachedFolders: []), false) + XCTAssertNil(DriveFolder.legacyEvidence(remoteConfigured: nil, cachedFolders: [])) + XCTAssertEqual( + DriveFolder.legacyEvidence(remoteConfigured: false, cachedFolders: ["marcin-mac-studio-3"]), + false) + } + + // MARK: - N-W1: replacing the connection + + /// The panel must never steer a connected Mac towards replacing the remote. + func testSavedCredentialsMessageDoesNotSuggestReplacing() { + for configured in [true, nil] as [Bool?] { + let message = RemoteConfigurer.credentialsSavedMessage(remoteConfigured: configured) + XCTAssertFalse(message.contains("--replace-existing"), message) + XCTAssertTrue(message.contains("drive.file"), message) + } + XCTAssertFalse( + RemoteConfigurer.credentialsSavedMessage(remoteConfigured: false) + .contains("--replace-existing")) + } + + func testReplacingTakesTwoFlags() { + XCTAssertNil( + RemoteConfigurer.replacementGate( + configured: false, replaceExisting: false, acceptScopeChange: false, remoteName: "gdrive")) + let none = RemoteConfigurer.replacementGate( + configured: true, replaceExisting: false, acceptScopeChange: false, remoteName: "gdrive") + XCTAssertEqual(none?.succeeded, false) + let one = RemoteConfigurer.replacementGate( + configured: true, replaceExisting: true, acceptScopeChange: false, remoteName: "gdrive") + XCTAssertEqual(one?.succeeded, false) + XCTAssertTrue(one?.message.contains("--accept-scope-change") == true) + XCTAssertTrue(one?.message.contains("drive.file") == true) + XCTAssertNil( + RemoteConfigurer.replacementGate( + configured: true, replaceExisting: true, acceptScopeChange: true, remoteName: "gdrive")) + } + + func testNewTokenMustSeeTheImageTheOldOneSaw() { + // The old token saw the image: the new one has to as well. + XCTAssertNil( + RemoteConfigurer.replacementVerdict(before: .present, after: .present, remoteName: "gdrive")) + XCTAssertEqual( + RemoteConfigurer.replacementVerdict(before: .present, after: .absent, remoteName: "gdrive")? + .succeeded, false) + XCTAssertEqual( + RemoteConfigurer.replacementVerdict( + before: .present, after: .unknown("timeout"), remoteName: "gdrive")?.succeeded, false) + // Nothing visible before: nothing for the new token to miss. + XCTAssertNil( + RemoteConfigurer.replacementVerdict(before: .absent, after: .absent, remoteName: "gdrive")) + } + + /// `create-image` with an image this Mac remembers but Drive does not show. + func testRememberedButInvisibleImageIsNotCreatedAgain() { + XCTAssertNil(BackupImageService.absentImageVerdict(trace: nil)) + let refusal = BackupImageService.absentImageVerdict(trace: "/tmp/known-image") + XCTAssertEqual(refusal?.succeeded, false) + XCTAssertTrue(refusal?.message.contains("/tmp/known-image") == true) + } +} From 8c4d8642740c56d85409b22bf4955cfa984cfcb7 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Sun, 11 Oct 2026 09:31:44 +0200 Subject: [PATCH 5/5] fix(rc): make the open remote-control port visible and closable without a reboot A mount started before the private socket survives brew upgrade and keeps taking unauthenticated commands on 127.0.0.1:5572 until the Mac restarts. Only one drive-status line said so. - One check (no socket + mount + something accepts on the port) feeds the window, the watchdog and the CLI. - Window: a red card in every section with the explanation, the prepare-shutdown command and a Restart the mount button (confirmation dialog, disabled during a backup); the green badge goes, the headline and the menu bar say it. - Watchdog: a notification of its own (not a backup failure, so not in the exit code): once, then after 1, 2, 4 and every 7 days; undelivered ones are retried. - restart-mount / the button: refuses while Time Machine runs or when that is unknown; detach and drain as prepare-shutdown, then SIGTERM to the mount job (KeepAlive starts the new code; a job launchd cannot spawn is reloaded once it has exited), wait for the socket, attach. Holds the image lock; any doubt before the stop leaves the mount alone and the image re-attached. - drive-status no longer says 'the mount is not running' when the mount table could not be read (N2); MachineIdentity doc comment back on its symbol (N5); docs use the full cask name (S2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig --- README.md | 20 +- docs/design.md | 16 +- .../CloudMachineAgent/CloudMachineAgent.swift | 1 + .../CloudMachineAgent/DriveCommands.swift | 31 +++ .../CloudMachineApp/Models/AppStatus.swift | 22 +- .../Services/CloudMachineController.swift | 10 + .../CloudMachineApp/Views/DashboardView.swift | 71 ++++++ .../Views/MenuBarContentView.swift | 15 ++ .../CloudMachineCore/BackupImageService.swift | 4 +- .../CloudMachineCore/DriveBufferService.swift | 45 ++++ .../CloudMachineCore/L10nPolish+Agent.swift | 6 +- .../CloudMachineCore/L10nPolish+Safety.swift | 52 +++++ .../CloudMachineCore/MachineIdentity.swift | 16 +- .../CloudMachineCore/MountRestart.swift | 202 ++++++++++++++++ .../CloudMachineCore/OpenPortAlert.swift | 89 +++++++ .../CloudMachineCore/StatusLines.swift | 8 +- .../AppStatusHealthTests.swift | 27 +++ .../OpenRemoteControlTests.swift | 218 ++++++++++++++++++ packaging/homebrew/cloudmachine.rb.in | 8 +- 19 files changed, 835 insertions(+), 26 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/MountRestart.swift create mode 100644 mac-app/Sources/CloudMachineCore/OpenPortAlert.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/OpenRemoteControlTests.swift diff --git a/README.md b/README.md index a7fdaad..3877e52 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,21 @@ It works two ways: ### Upgrading and uninstalling -`brew upgrade` replaces the app without restarting the Google Drive mount. +`brew upgrade --cask renacode/tap/cloudmachine` replaces the app without +restarting the Google Drive mount. (Use the full name: a second tap with a +`cloudmachine` cask, left over from testing, makes the short name ambiguous.) When Homebrew reopens the app, it reloads the background agents so they run 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 +to the mount itself wait for its next start. One such change is rclone's +control interface moving to a private socket: until then the old mount takes +commands on `127.0.0.1:5572` without a password, and the app window shows a red +card, the menu bar an alert, and the watchdog a notification (once, then after +1, 2, 4 and every 7 days). **Restart the mount** on that card - or +`cloudmachine-agent restart-mount` - detaches the image, waits until everything +has reached Google Drive, restarts the mount and attaches the image again; it +refuses while Time Machine is backing up. Restarting the Mac after +`prepare-shutdown` does the same. 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`, which may hold backups that have not reached Google Drive yet. Run `cloudmachine-agent prepare-shutdown` before uninstalling. diff --git a/docs/design.md b/docs/design.md index 82cf718..9ae8479 100644 --- a/docs/design.md +++ b/docs/design.md @@ -122,7 +122,21 @@ 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. +address until it restarts. The window, the menu bar, the watchdog's +notification and `drive-status` all say so - the window, the watchdog and the +CLI from one check: no socket, a mount, and something accepting connections on +the port. `restart-mount` (or the button on that card) closes it without a +restart of the Mac: it is `prepare-shutdown`'s detach-and-drain, then SIGTERM +to the mount job so launchd's `KeepAlive` starts the new code, then the attach - +holding the image lock throughout, and stopping before the mount is touched if +anything has not reached Drive. + +**The rclone log is trimmed while the mount runs.** rclone never rotates it, +and a mount runs for days (257 MB on 10 October 2026). Above 100 MB the buffer +guard copies the last 32 MB to `rclone.log.1` and truncates the live file; +rclone opens it with `O_APPEND`, so it carries on at the new end. The stall and +quota checks read the end of `.1` together with the live file, so a trim in the +middle of a jam does not hide it. 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 diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index c619bbb..40d73b1 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -30,6 +30,7 @@ struct CloudMachineAgent: AsyncParsableCommand { BufferGuard.self, BackupHealthCommand.self, PrepareShutdown.self, + RestartMount.self, DriveStatus.self, ] ) diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 8104013..09d5c56 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -288,6 +288,17 @@ struct BackupHealthCommand: AsyncParsableCommand { print(L10n.tr("WAITING (system startup): %@", problem.summary)) } + // Not a backup failure, so not in `report` and not in the exit code - + // but a notification of its own, see `OpenPortAlert`. + let portOpen = DriveBufferService.legacyPortOpenNow() + if portOpen { + print( + L10n.tr( + "WARNING: remote control is OPEN on %@ - run restart-mount (or prepare-shutdown, then restart the Mac).", + DriveBufferService.legacyRCAddress)) + } + if !quiet { await OpenPortAlert.report(open: portOpen) } + guard !report.healthy else { print(L10n.tr("Backup cycle: OK")) if !quiet { await HealthAlert.report(report) } @@ -343,6 +354,26 @@ struct PrepareShutdown: AsyncParsableCommand { } } +// MARK: - Restarting the mount + +struct RestartMount: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "restart-mount", + abstract: L10n.tr( + "Restarts the Google Drive mount on the private socket: detaches the image, waits for the upload, restarts the mount and attaches the image again." + )) + + func run() async throws { + print( + L10n.tr( + "Detaching the image and waiting for the upload, then restarting the mount. This can take several minutes; Time Machine has no destination meanwhile." + )) + let result = await MountRestart.perform() + print(result.message) + if !result.succeeded { throw ExitCode(1) } + } +} + // MARK: - Status struct DriveStatus: AsyncParsableCommand { diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 3a599e5..508a55e 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -222,6 +222,11 @@ final class AppStatus: ObservableObject { /// Whether this Mac's image exists on the mounted Drive; `nil` while the /// Drive is not mounted, because then nobody can know. @Published var imageExists: Bool? + /// The Drive mount still takes commands on the open TCP port - a mount from + /// before the private socket that survived the upgrade (see + /// `DriveBufferService.legacyPortOpen`). Until 11.10.2026 only one line of + /// `drive-status` said so; the window was green. + @Published var remoteControlOpen: Bool = false @Published var isBusy: Bool = false @Published var busyLabel: String = "" @Published var errorMessage: String? @@ -263,6 +268,11 @@ final class AppStatus: ObservableObject { // copy; that is precisely why `UploadState` puts them ahead of the daily limit. let upload = buffer.uploadState if !upload.isNominal { return upload.headline } + // Backups work in this state, but any web page can delete them - so it + // comes before every sentence that sounds like "all is well". + if remoteControlOpen { + return L10n.tr("Drive remote control is open - restart the mount") + } if backupProgress != nil { return L10n.tr("Backup in progress") } // The state of the devices can be flawless while there has been no backup for two days. // This sentence must come BEFORE "Ready", otherwise the headline contradicts the @@ -290,14 +300,24 @@ final class AppStatus: ObservableObject { /// and each of them can be met when no backup has been made for two days. /// The green badge is meant to say "the data is safe", and that follows /// solely from a backup having BEEN MADE - not from the disk being attached. + /// + /// THIRD, from 11.10.2026: `remoteControlOpen`. A backup that any web page + /// can delete is not safe, however fresh it is. var healthy: Bool { guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached, case .registered = timeMachineState, buffer.uploadState.isNominal, - backupCycle.isFresh() + backupCycle.isFresh(), !remoteControlOpen else { return false } return true } + /// Whether "Restart the mount" may be offered: the port is open, nothing + /// else is running, and Time Machine is not backing up. `MountRestart` + /// checks the backup itself again - this only keeps the button honest. + var canRestartMount: Bool { + remoteControlOpen && !isBusy && backupProgress == nil + } + /// 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 diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index ebcc607..15d3ebb 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -184,6 +184,7 @@ final class CloudMachineController: ObservableObject { buffer.outOfSpace = stats.outOfSpace } status.buffer = buffer + status.remoteControlOpen = DriveBufferService.legacyPortOpenNow() status.imageExists = buffer.mounted ? FileManager.default.fileExists(atPath: BackupImageService.imagePath.path) : nil @@ -339,6 +340,15 @@ final class CloudMachineController: ObservableObject { } } + /// Restarts the Drive mount so it comes back on the private socket. The + /// view asks for confirmation first; `MountRestart` refuses by itself + /// while Time Machine is backing up. + func restartMount() async { + await run(L10n.tr("Restarting the Google Drive mount"), log: "Restarting the Drive mount") { + await MountRestart.perform() + } + } + func verifyImage() async { await run(L10n.tr("Checking image consistency"), log: "Checking image consistency") { await BackupImageService.verify() diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 4baa4f3..91c18f1 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -70,6 +70,7 @@ private struct DashboardContent: View { @State private var driveFolder = "" /// The limit being typed; empty = show the stored one. @State private var limitText = "" + @State private var confirmingMountRestart = false var body: some View { HStack(spacing: 0) { @@ -95,6 +96,12 @@ private struct DashboardContent: View { errorBanner(error) } + // Every section too: this is not a state of one card, but a door + // left open on the whole backup. + if status.remoteControlOpen { + openRemoteControlCard + } + switch section { case .overview: overviewSection case .backups: backupsSection @@ -571,6 +578,70 @@ private struct DashboardContent: View { .toneCard(.danger, cornerRadius: 12, padding: 14) } + // MARK: - Open remote control + + /// The mount still takes commands on the open TCP port. Says what that + /// means, and offers the one fix that does not need a restart of the Mac. + private var openRemoteControlCard: some View { + VStack(alignment: .leading, spacing: 12) { + cardTitle( + L10n.tr("Remote control of the Drive mount is open"), systemImage: "lock.open.fill", + tone: .danger) + + Text( + L10n.tr( + "The mount was started by an older version of CloudMachine and still takes commands on %@ without a password. Any web page open in a browser can send it one - including deleting the backup on Google Drive, with no trash to restore it from. It moves to a private socket the next time it starts.", + DriveBufferService.legacyRCAddress) + ) + .font(.system(size: 13)) + .foregroundStyle(RenaCodeTheme.textMain) + .fixedSize(horizontal: false, vertical: true) + + Text( + L10n.tr( + "Restart the mount: the image is detached, everything waits until it has reached Google Drive, the mount starts again and the image is attached. It takes a few minutes, during which Time Machine has no destination. Or prepare the shutdown and restart the Mac:" + ) + ) + .font(.system(size: 13)) + .foregroundStyle(RenaCodeTheme.textMuted) + .fixedSize(horizontal: false, vertical: true) + + commandBox("\(CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent") prepare-shutdown") + + HStack(spacing: 12) { + Button(action: { confirmingMountRestart = true }) { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.2.circlepath") + Text(L10n.tr("Restart the mount")) + } + } + .buttonStyle(PrimaryGradientButtonStyle()) + .disabled(!status.canRestartMount) + + if status.backupProgress != nil { + Text(L10n.tr("Wait until the backup has finished.")) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.textMuted) + } + Spacer() + } + } + .toneCard(.danger) + .confirmationDialog( + L10n.tr("Restart the Google Drive mount now?"), isPresented: $confirmingMountRestart + ) { + Button(L10n.tr("Restart the mount"), role: .destructive) { + Task { await controller.restartMount() } + } + Button(L10n.tr("Cancel"), role: .cancel) {} + } message: { + Text( + L10n.tr( + "Time Machine loses its destination for a few minutes. Nothing is stopped until everything waiting has reached Google Drive; if that does not happen, the mount is left as it is." + )) + } + } + // MARK: - Backups private var backupCycleTone: StatusTone { diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index 35daeb9..40c19ca 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -87,6 +87,21 @@ private struct MenuBarPanel: View { } .accessibilityElement(children: .combine) + // The headline says it only while nothing worse is going on; this row + // stays as long as the port is open. + if status.remoteControlOpen, + status.headline != L10n.tr("Drive remote control is open - restart the mount") + { + HStack(alignment: .firstTextBaseline, spacing: 8) { + StatusDot(.danger) + Text(L10n.tr("Drive remote control is open - restart the mount")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(StatusTone.danger.color) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + // Progress of the running backup if let progress = status.backupProgress, let percent = progress.percent { VStack(alignment: .leading, spacing: 6) { diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index d86482d..24fe2d6 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -478,7 +478,7 @@ public enum BackupImageService { ?? busyResult("Image attach", displayName: L10n.tr("Image attach")) } - private static func attachLocked() async -> CMActionResult { + static func attachLocked() async -> CMActionResult { switch DriveBufferService.mountedState() { case .some(true): break @@ -631,7 +631,7 @@ public enum BackupImageService { ?? busyResult("Image detach", displayName: L10n.tr("Image detach")) } - private static func detachLocked(force: Bool = false, waitForUpload: Bool = true) async + static func detachLocked(force: Bool = false, waitForUpload: Bool = true) async -> CMActionResult { let stillMounted = await unmountBrowsedSnapshots() diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index c9686da..9d9d132 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -99,6 +99,51 @@ public enum DriveBufferService { FileManager.default.fileExists(atPath: rcSocket.path) ? .socket : .legacyTCP } + /// Whether the running mount still takes commands on the open TCP port - + /// a mount started by 1.3.7 or older that survived the upgrade. + /// + /// Until 11.10.2026 only `drive-status` said so, in one line; the panel and + /// the watchdog stayed silent, and such a mount runs until the next restart + /// of the Mac - on this project's own Mac, for days. All three now ask here. + /// + /// Three conditions, each needed: no socket (a mount of this version has + /// one), a mount (no mount, nothing listens), and an answer on the port. + /// The last one tells the old mount apart from a socket removed under a new + /// one, where nothing listens on TCP and nothing is exposed. + public static func legacyPortOpen( + transport: RCTransport, mounted: Bool?, portAnswers: () -> Bool + ) -> Bool { + guard transport == .legacyTCP, mounted == true else { return false } + return portAnswers() + } + + /// `legacyPortOpen` for this Mac, now. + public static func legacyPortOpenNow() -> Bool { + legacyPortOpen( + transport: rcTransport, mounted: mountedState(), + portAnswers: { tcpPortAnswers(port: legacyRCPort) }) + } + + static let legacyRCPort: UInt16 = 5572 + + /// Whether something accepts a connection on `127.0.0.1:port`. Connects + /// and closes - nothing is sent, so a listener gets no request. + static func tcpPortAnswers(port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + return withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) == 0 + } + } + } + /// Arguments that point `rclone rc` at the running mount. static func rcClientArguments(_ transport: RCTransport) -> [String] { switch transport { diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift index 4c6eef7..1bb3ccb 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift @@ -191,8 +191,10 @@ extension L10nPolish { "Sterowanie rc: %@", "private socket": "prywatne gniazdo", - "OPEN on %@ - the mount was started by an older version, and any web page can send it commands. It switches to the private socket on its next start: run prepare-shutdown, then restart the Mac.": - "OTWARTE na %@ - montowanie uruchomiła starsza wersja i każda strona WWW może wysyłać mu polecenia. Przejdzie na prywatne gniazdo przy następnym starcie: uruchom prepare-shutdown, potem zrestartuj Maca.", + "OPEN on %@ - the mount was started by an older version, and any web page can send it commands. It switches to the private socket on its next start: run restart-mount (or prepare-shutdown, then restart the Mac).": + "OTWARTE na %@ - montowanie uruchomiła starsza wersja i każda strona WWW może wysyłać mu polecenia. Przejdzie na prywatne gniazdo przy następnym starcie: uruchom restart-mount (albo prepare-shutdown, potem zrestartuj Maca).", + "no socket - UNKNOWN whether the mount is running": + "brak gniazda - NIE WIADOMO, czy montowanie działa", "no socket - the mount is not running": "brak gniazda - montowanie nie działa", "Cache on disk: %@": diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift index 7bc331a..1ff9078 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Safety.swift @@ -31,5 +31,57 @@ extension L10nPolish { // BackupImageService.create "Google Drive does not show the image %@, but this Mac has already used it (trace: %@). NOT creating a new one - that would start an empty backup and orphan the existing one. Most likely the token cannot see it: a connection with the 'drive.file' scope sees only files created under its own client_id. Check the token's permissions (scope and client_id of the remote in rclone.conf). Only if the image really was deleted on purpose: remove %@ and run again.": "Google Drive nie pokazuje obrazu %@, a ten Mac już go używał (ślad: %@). NIE tworzę nowego - to zaczęłoby pustą kopię i osierociło istniejącą. Najpewniej token go nie widzi: połączenie z zakresem 'drive.file' widzi tylko pliki założone przez własny client_id. Sprawdź uprawnienia tokenu (scope i client_id remote'a w rclone.conf). Tylko jeśli obraz naprawdę został celowo usunięty: usuń %@ i uruchom ponownie.", + + // The open remote-control port and restarting the mount + "WARNING: remote control is OPEN on %@ - run restart-mount (or prepare-shutdown, then restart the Mac).": + "UWAGA: sterowanie rc jest OTWARTE na %@ - uruchom restart-mount (albo prepare-shutdown, potem zrestartuj Maca).", + "Restarts the Google Drive mount on the private socket: detaches the image, waits for the upload, restarts the mount and attaches the image again.": + "Restartuje montowanie Google Drive na prywatnym gnieździe: odłącza obraz, czeka na wysyłkę, restartuje montowanie i podłącza obraz z powrotem.", + "Detaching the image and waiting for the upload, then restarting the mount. This can take several minutes; Time Machine has no destination meanwhile.": + "Odłączam obraz i czekam na wysyłkę, potem restartuję montowanie. To może potrwać kilka minut; Time Machine nie ma w tym czasie celu.", + "Restarting the Google Drive mount": + "Restartowanie montowania Google Drive", + "Remote control of the Drive mount is open": + "Sterowanie montowaniem Dysku jest otwarte", + "The mount was started by an older version of CloudMachine and still takes commands on %@ without a password. Any web page open in a browser can send it one - including deleting the backup on Google Drive, with no trash to restore it from. It moves to a private socket the next time it starts.": + "Montowanie uruchomiła starsza wersja CloudMachine i nadal przyjmuje polecenia na %@ bez hasła. Każda strona otwarta w przeglądarce może je wysłać - łącznie z usunięciem kopii na Google Drive, bez kosza, z którego dałoby się ją odzyskać. Przejdzie na prywatne gniazdo przy następnym starcie.", + "Restart the mount: the image is detached, everything waits until it has reached Google Drive, the mount starts again and the image is attached. It takes a few minutes, during which Time Machine has no destination. Or prepare the shutdown and restart the Mac:": + "Zrestartuj montowanie: obraz zostanie odłączony, wszystko poczeka, aż dotrze na Google Drive, montowanie wystartuje od nowa, a obraz zostanie podłączony. To trwa kilka minut, w tym czasie Time Machine nie ma celu. Albo przygotuj zamknięcie i zrestartuj Maca:", + "Wait until the backup has finished.": + "Poczekaj, aż kopia się skończy.", + "Restart the Google Drive mount now?": + "Zrestartować teraz montowanie Google Drive?", + "Restart the mount": + "Zrestartuj montowanie", + "Cancel": + "Anuluj", + "Time Machine loses its destination for a few minutes. Nothing is stopped until everything waiting has reached Google Drive; if that does not happen, the mount is left as it is.": + "Time Machine straci cel na kilka minut. Nic nie zostanie zatrzymane, dopóki wszystko, co czeka, nie dotrze na Google Drive; jeśli to się nie uda, montowanie zostaje bez zmian.", + "Drive remote control is open - restart the mount": + "Sterowanie Dyskiem jest otwarte - zrestartuj montowanie", + "The mount was restarted and now listens only on the private socket. The image is attached again.": + "Montowanie zostało zrestartowane i słucha już tylko na prywatnym gnieździe. Obraz jest znowu podłączony.", + "Another operation on the image is in progress - the mount was NOT restarted. Try again in a moment.": + "Trwa inna operacja na obrazie - montowanie NIE zostało zrestartowane. Spróbuj ponownie za chwilę.", + "Could not read the mount table - it is UNKNOWN whether the image is attached.": + "Nie udało się odczytać tablicy montowań - NIE WIADOMO, czy obraz jest podłączony.", + "Time Machine is backing up right now - the mount was NOT restarted. Try again when the backup has finished.": + "Time Machine właśnie robi kopię - montowanie NIE zostało zrestartowane. Spróbuj ponownie, gdy kopia się skończy.", + "Could not tell whether Time Machine is backing up (tmutil did not answer) - the mount was NOT restarted.": + "Nie wiadomo, czy Time Machine robi kopię (tmutil nie odpowiedział) - montowanie NIE zostało zrestartowane.", + "The Google Drive mount is not running (or its state is unknown) - there is nothing to restart. The agents start it by themselves.": + "Montowanie Google Drive nie działa (albo jego stan jest nieznany) - nie ma czego restartować. Agenci uruchamiają je sami.", + "The mount was NOT restarted: %@\nImage: %@": + "Montowanie NIE zostało zrestartowane: %@\nObraz: %@", + "Could not stop the mount (launchctl refused) - nothing was restarted. Image: %@": + "Nie udało się zatrzymać montowania (launchctl odmówił) - nic nie zostało zrestartowane. Obraz: %@", + "The mount was stopped, but did not come back on the private socket in time. The agents keep trying (backup-health reloads one launchd cannot start). Check: cloudmachine-agent drive-status; if it stays down, restart the Mac.": + "Montowanie zostało zatrzymane, ale nie wróciło na czas na prywatnym gnieździe. Agenci dalej próbują (backup-health przeładowuje agenta, którego launchd nie potrafi uruchomić). Sprawdź: cloudmachine-agent drive-status; jeśli dalej nie działa, zrestartuj Maca.", + "The mount runs on the private socket now, but the image did not attach: %@ The attach agent retries every 15 minutes.": + "Montowanie działa już na prywatnym gnieździe, ale obraz się nie podłączył: %@ Agent podłączania próbuje co 15 minut.", + "CloudMachine: Drive remote control is open": + "CloudMachine: sterowanie Dyskiem jest otwarte", + "Any web page can send it commands, including deleting the backup. Open CloudMachine and restart the mount, or run prepare-shutdown and restart the Mac.": + "Każda strona WWW może wysyłać mu polecenia, łącznie z usunięciem kopii. Otwórz CloudMachine i zrestartuj montowanie albo uruchom prepare-shutdown i zrestartuj Maca.", ] } diff --git a/mac-app/Sources/CloudMachineCore/MachineIdentity.swift b/mac-app/Sources/CloudMachineCore/MachineIdentity.swift index a8183de..fa9b5c2 100644 --- a/mac-app/Sources/CloudMachineCore/MachineIdentity.swift +++ b/mac-app/Sources/CloudMachineCore/MachineIdentity.swift @@ -8,14 +8,6 @@ public enum MachineIdentity { CMPaths.appSupportDir.appendingPathComponent("machine-id") } - /// Persistent key of this machine - determined ONCE and written to disk, NOT - /// recomputed live from `scutil --get ComputerName` on every call. Without - /// this, a computer rename (manual, or automatic, e.g. Migration Assistant - /// appending "(2)" on a duplicate) would silently fragment the backup - /// identity: new key = new folder on Google Drive, new entry in - /// machines.json, and the whole existing backup under the old key is left - /// orphaned (and still counts against the quota). Once written, the key - /// survives every later rename of the Mac. /// The key already written to disk, without deriving one. `nil` = none /// stored yet. For synchronous readers that only LOOK FOR existing data /// under the key (see `MachineBudget.limitGB`). @@ -25,6 +17,14 @@ public enum MachineIdentity { return trimmed.isEmpty ? nil : trimmed } + /// Persistent key of this machine - determined ONCE and written to disk, NOT + /// recomputed live from `scutil --get ComputerName` on every call. Without + /// this, a computer rename (manual, or automatic, e.g. Migration Assistant + /// appending "(2)" on a duplicate) would silently fragment the backup + /// identity: new key = new folder on Google Drive, new entry in + /// machines.json, and the whole existing backup under the old key is left + /// orphaned (and still counts against the quota). Once written, the key + /// survives every later rename of the Mac. public static func currentKey() async -> String { if let saved = try? String(contentsOf: identityFilePath, encoding: .utf8) { let trimmed = saved.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/mac-app/Sources/CloudMachineCore/MountRestart.swift b/mac-app/Sources/CloudMachineCore/MountRestart.swift new file mode 100644 index 0000000..1d1174e --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/MountRestart.swift @@ -0,0 +1,202 @@ +import Foundation + +/// Restarts the Google Drive mount without restarting the Mac - the way out of +/// a mount that predates the private socket (`DriveBufferService +/// .legacyPortOpen`). An upgrade deliberately leaves the mount running, and +/// "prepare-shutdown, then restart the Mac" is a price few pay soon. +/// +/// It is the shutdown path, in the same order, with a start at the end: +/// +/// 1. Refuse while Time Machine is backing up, or when that is not known, or +/// when the mount is not up. +/// 2. Detach the image and wait until everything has reached Drive - the same +/// `detach` as `prepare-shutdown`, whose verdict counts abandoned bands +/// too. If the image was not attached, drain the queue all the same. Any +/// doubt stops here, with the mount untouched. +/// 3. `launchctl kill SIGTERM` the mount. rclone unmounts and exits, and +/// launchd's `KeepAlive` starts `mount-drive` again - the new one, on the +/// socket. Not `bootout`: that unloads the job, and a failed `bootstrap` +/// after it would leave no mount and no agent to bring one back. Not +/// `kickstart -k`, which does not promise SIGTERM. If launchd cannot spawn +/// the new binary (`AgentRepair`), the job is reloaded once it has exited. +/// 4. Wait for the new mount on the private socket, then attach the image. +/// +/// The whole run holds the image lock, so the attach agent cannot attach the +/// image between steps 2 and 3, under a mount about to be stopped. +public enum MountRestart { + + /// Why the restart must not start now; `nil` = it may. + public static func refusal(backupRunning: Bool?, mounted: Bool?) -> String? { + switch backupRunning { + case .some(true): + return L10n.tr( + "Time Machine is backing up right now - the mount was NOT restarted. Try again when the backup has finished." + ) + case .none: + return L10n.tr( + "Could not tell whether Time Machine is backing up (tmutil did not answer) - the mount was NOT restarted." + ) + case .some(false): + break + } + guard mounted == true else { + return L10n.tr( + "The Google Drive mount is not running (or its state is unknown) - there is nothing to restart. The agents start it by themselves." + ) + } + return nil + } + + /// The steps, replaceable so the order and every early exit can be tested + /// without launchd, hdiutil or rclone. + struct Steps { + var backupRunning: () async -> Bool? + var mounted: () -> Bool? + /// Detaches the image (or drains the queue when it is not attached) and + /// says whether everything reached Drive. + var detachAndDrain: () async -> CMActionResult + /// Stops the running mount. `false` = it could not be stopped. + var stopMount: () async -> Bool + /// Waits for the new mount on the private socket. + var waitForNewMount: () async -> Bool + var attach: () async -> CMActionResult + } + + static func run(_ steps: Steps) async -> CMActionResult { + if let reason = refusal(backupRunning: await steps.backupRunning(), mounted: steps.mounted()) { + return CMActionResult(succeeded: false, message: reason) + } + + let drained = await steps.detachAndDrain() + guard drained.succeeded else { + // The mount was not touched. Put the image back where it was, so the + // refusal does not leave Time Machine without a destination. + let reattached = await steps.attach() + return CMActionResult( + succeeded: false, + message: L10n.tr( + "The mount was NOT restarted: %@\nImage: %@", drained.message, reattached.message)) + } + + guard await steps.stopMount() else { + let reattached = await steps.attach() + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Could not stop the mount (launchctl refused) - nothing was restarted. Image: %@", + reattached.message)) + } + + guard await steps.waitForNewMount() else { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "The mount was stopped, but did not come back on the private socket in time. The agents keep trying (backup-health reloads one launchd cannot start). Check: cloudmachine-agent drive-status; if it stays down, restart the Mac." + )) + } + + let attached = await steps.attach() + guard attached.succeeded else { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "The mount runs on the private socket now, but the image did not attach: %@ The attach agent retries every 15 minutes.", + attached.message)) + } + return CMActionResult( + succeeded: true, + message: L10n.tr( + "The mount was restarted and now listens only on the private socket. The image is attached again." + )) + } + + // MARK: - Live + + static let mountAgent = "gdrive-buffer" + /// How long the new mount may take to come up. After a restart with an + /// empty queue it is seconds; `KeepAlive` waits `ThrottleInterval` (30 s) + /// before a respawn, and a respawn launchd refuses costs one more cycle. + static let newMountTimeout: TimeInterval = 300 + + public static func perform() async -> CMActionResult { + await withCMLock(BackupImageService.lockName) { await run(live) } + ?? CMActionResult( + succeeded: false, + message: L10n.tr( + "Another operation on the image is in progress - the mount was NOT restarted. Try again in a moment." + ), + didNotRun: true) + } + + static var live: Steps { + Steps( + backupRunning: { await TimeMachineStatus.runningState() }, + mounted: { DriveBufferService.mountedState() }, + detachAndDrain: { + switch BackupImageService.attachedState() { + case .some(true): + return await BackupImageService.detachLocked() + case .some(false): + CMLogger.log( + BackupImageService.expiryLogLine(await DriveBufferService.expireQueuedUploads())) + return BackupImageService.detachVerdict( + settled: await DriveBufferService.statsWhenIdle(timeout: 600)) + case .none: + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Could not read the mount table - it is UNKNOWN whether the image is attached.")) + } + }, + stopMount: { + CMLogger.log("Restarting the Drive mount: SIGTERM to \(mountAgent)") + let result = try? await ProcessRunner.run( + "/bin/launchctl", + ["kill", "SIGTERM", "gui/\(getuid())/\(AgentRepair.prefix)\(mountAgent)"], timeout: 30) + return result?.succeeded == true + }, + waitForNewMount: { await waitForNewMount() }, + attach: { + let ready = await BufferReadiness.wait( + sleep: { try? await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000)) }, + probe: { + BufferReadiness.isReady( + mounted: DriveBufferService.isMounted, imageVisible: BackupImageService.exists) + }) + guard ready else { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "The buffer did not come up within %@ min - not attaching the image.", + "\(Int(BufferReadiness.defaultTimeout / 60))")) + } + return await BackupImageService.attachLocked() + }) + } + + /// Polls for the new mount: mounted, and answering on the private socket. + /// Once the old rclone has gone, a job launchd cannot spawn (a replaced + /// app bundle - see `AgentRepair`) is reloaded; the mount is down by then, + /// so the reload costs nothing. + static func waitForNewMount() async -> Bool { + let deadline = Date().addingTimeInterval(newMountTimeout) + var repaired = false + while Date() < deadline { + try? await Task.sleep(nanoseconds: 2_000_000_000) + if DriveBufferService.mountedState() == true, + DriveBufferService.socketAnswers(DriveBufferService.rcSocket.path) + { + CMLogger.log("Drive mount is back, on the private socket") + return true + } + if !repaired, let output = await AgentRepair.printOutput(label: mountAgent), + AgentRepair.cannotStart(printOutput: output) + { + repaired = true + CMLogger.log("\(mountAgent) cannot start after the restart - reloading it") + await AgentRepair.reload(mountAgent) + } + } + return false + } +} diff --git a/mac-app/Sources/CloudMachineCore/OpenPortAlert.swift b/mac-app/Sources/CloudMachineCore/OpenPortAlert.swift new file mode 100644 index 0000000..7aea864 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/OpenPortAlert.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Tells the user, by a system notification, that the Drive mount still +/// takes commands on the open TCP port (see +/// `DriveBufferService.legacyPortOpen`). +/// +/// Not a `HealthAlert` problem, on purpose. Backups work in this state, so +/// "CloudMachine: backup is not working" would be false, `backup-health` +/// would exit 1 for a working cycle, and the 12-hour reminder would turn a +/// state that lasts until the next restart of the Mac into noise. +/// +/// Instead: once at once, then with a growing gap - 1, 2, 4 and then every 7 +/// days. A notice that never comes back is forgotten after one dismissal; one +/// that comes back twice a day is muted. A notification that was not +/// delivered is retried at the next watchdog run, as in `HealthAlert`. +public enum OpenPortAlert { + + public static var stateFile: URL { + CMPaths.appSupportDir.appendingPathComponent("open-port-alert.json") + } + + struct State: Codable, Equatable { + /// Notifications delivered so far for this episode. + var delivered: Int + var lastAttemptAt: Date + var lastDelivered: Bool + } + + /// The gap after the `count`-th delivered notification. + static func gap(afterDelivered count: Int) -> TimeInterval { + let days = min(7.0, pow(2.0, Double(max(0, count - 1)))) + return days * 86_400 + } + + static func shouldNotify(_ state: State?, now: Date) -> Bool { + guard let state else { return true } + if !state.lastDelivered { return true } + return now.timeIntervalSince(state.lastAttemptAt) >= gap(afterDelivered: state.delivered) + } + + /// Reports the open port if it is time to. `open == false` ends the + /// episode: the state is removed, so a later one is reported at once. + /// Returns `true` when a notification was delivered. + /// + /// `stateFile`, `deliver` and `log` are replaceable for the reason given at + /// `HealthAlert.report`: tests must not write to the user's directory, show + /// notifications or add made-up lines to the real log. + @discardableResult + public static func report( + open: Bool, + now: Date = Date(), + stateFile: URL = OpenPortAlert.stateFile, + deliver: @Sendable (String, String) async -> Bool = { + await HealthAlert.notify(title: $0, message: $1) + }, + log: @Sendable (String) -> Void = { CMLogger.log($0) } + ) async -> Bool { + guard open else { + try? FileManager.default.removeItem(at: stateFile) + return false + } + let state = load(stateFile) + guard shouldNotify(state, now: now) else { return false } + + log( + "SECURITY: rclone remote control is OPEN on \(DriveBufferService.legacyRCAddress) without a password - the mount predates the private socket. Restart the mount (window or restart-mount), or prepare-shutdown and restart the Mac." + ) + let delivered = await deliver( + L10n.tr("CloudMachine: Drive remote control is open"), + L10n.tr( + "Any web page can send it commands, including deleting the backup. Open CloudMachine and restart the mount, or run prepare-shutdown and restart the Mac." + )) + if !delivered { + log("FAILED to show the open-port notification; will retry at the next check.") + } + let next = State( + delivered: (state?.delivered ?? 0) + (delivered ? 1 : 0), lastAttemptAt: now, + lastDelivered: delivered) + if let data = try? JSONEncoder().encode(next) { + try? data.write(to: stateFile, options: .atomic) + } + return delivered + } + + static func load(_ file: URL) -> State? { + guard let data = try? Data(contentsOf: file) else { return nil } + return try? JSONDecoder().decode(State.self, from: data) + } +} diff --git a/mac-app/Sources/CloudMachineCore/StatusLines.swift b/mac-app/Sources/CloudMachineCore/StatusLines.swift index 0ed58d1..2a3e9c1 100644 --- a/mac-app/Sources/CloudMachineCore/StatusLines.swift +++ b/mac-app/Sources/CloudMachineCore/StatusLines.swift @@ -40,10 +40,14 @@ public enum StatusLines { return L10n.tr("private socket") case .legacyTCP where mounted == true: return L10n.tr( - "OPEN on %@ - the mount was started by an older version, and any web page can send it commands. It switches to the private socket on its next start: run prepare-shutdown, then restart the Mac.", + "OPEN on %@ - the mount was started by an older version, and any web page can send it commands. It switches to the private socket on its next start: run restart-mount (or prepare-shutdown, then restart the Mac).", DriveBufferService.legacyRCAddress) - case .legacyTCP: + case .legacyTCP where mounted == false: return L10n.tr("no socket - the mount is not running") + case .legacyTCP: + // Not "the mount is not running": the mount table could not be read, + // and the mount may be up - on the open port, for all we know. + return L10n.tr("no socket - UNKNOWN whether the mount is running") } } diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift index 4042e3f..a7e77d2 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift @@ -55,6 +55,33 @@ final class AppStatusHealthTests: XCTestCase { XCTAssertEqual(status.buffer.uploadState, .queueUnknown) } + // MARK: - Open remote control + + /// REGRESSION 11.10.2026 (N-K1): the old mount kept listening on the open + /// TCP port after the upgrade, and the window stayed green - only one line + /// of `drive-status` said so. + func testOpenRemoteControlTakesAwayGreenAndSaysSo() { + let status = healthy() + XCTAssertTrue(status.healthy) + status.remoteControlOpen = true + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Drive remote control is open - restart the mount") + XCTAssertTrue(status.canRestartMount) + // Backups still work in this state. + XCTAssertTrue(status.canStartBackup) + } + + func testMountRestartIsNotOfferedDuringABackupOrAnotherAction() { + let status = healthy() + XCTAssertFalse(status.canRestartMount, "Nothing to restart while the port is closed.") + status.remoteControlOpen = true + status.backupProgress = BackupProgressInfo() + XCTAssertFalse(status.canRestartMount) + status.backupProgress = nil + status.isBusy = true + XCTAssertFalse(status.canRestartMount) + } + // MARK: - "Back up now" /// REGRESSION 09.10.2026: the button followed `healthy`, so an OLD backup - diff --git a/mac-app/Tests/CloudMachineAppTests/OpenRemoteControlTests.swift b/mac-app/Tests/CloudMachineAppTests/OpenRemoteControlTests.swift new file mode 100644 index 0000000..756be90 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/OpenRemoteControlTests.swift @@ -0,0 +1,218 @@ +import XCTest + +@testable import CloudMachineCore + +/// N-K1 (second audit, 10.10.2026): after `brew upgrade` the old mount keeps +/// listening on 127.0.0.1:5572 without a password, for as long as the Mac is +/// not restarted. Only one line of `drive-status` said so; the window and the +/// watchdog were silent. These tests cover the detection, the notification, +/// and the restart that closes the port without a restart of the Mac. +final class OpenRemoteControlTests: XCTestCase { + + // MARK: - Detection + + func testOldMountOnTheOpenPortIsDetected() { + XCTAssertTrue( + DriveBufferService.legacyPortOpen(transport: .legacyTCP, mounted: true, portAnswers: { true }) + ) + } + + func testEveryConditionIsNeeded() { + // A mount of this version: the socket is there. + XCTAssertFalse( + DriveBufferService.legacyPortOpen(transport: .socket, mounted: true, portAnswers: { true })) + // No mount, or not known: nothing to warn about here (other alarms say so). + XCTAssertFalse( + DriveBufferService.legacyPortOpen( + transport: .legacyTCP, mounted: false, portAnswers: { true })) + XCTAssertFalse( + DriveBufferService.legacyPortOpen(transport: .legacyTCP, mounted: nil, portAnswers: { true })) + // The socket removed under a new mount: nothing listens on TCP. + XCTAssertFalse( + DriveBufferService.legacyPortOpen( + transport: .legacyTCP, mounted: true, portAnswers: { false })) + } + + /// The port probe itself, against a listener of our own - never 5572. + func testPortProbeSeesAListenerAndItsAbsence() throws { + let fd = socket(AF_INET, SOCK_STREAM, 0) + XCTAssertGreaterThanOrEqual(fd, 0) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, length) == 0 && Darwin.listen(fd, 1) == 0 + && getsockname(fd, $0, &length) == 0 + } + } + XCTAssertTrue(bound) + let port = UInt16(bigEndian: address.sin_port) + + XCTAssertTrue(DriveBufferService.tcpPortAnswers(port: port)) + close(fd) + XCTAssertFalse(DriveBufferService.tcpPortAnswers(port: port)) + } + + /// N2: a mount table that could not be read is not "the mount is not running". + func testUnknownMountIsNotReportedAsStopped() { + let line = StatusLines.remoteControl(.legacyTCP, mounted: nil) + XCTAssertFalse(line.contains("is not running"), line) + XCTAssertTrue(line.contains("UNKNOWN"), line) + XCTAssertTrue(StatusLines.remoteControl(.legacyTCP, mounted: true).contains("restart-mount")) + } + + // MARK: - Notification + + private func stateFile() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cm-open-port-\(UUID().uuidString).json") + } + + private final class Deliveries: @unchecked Sendable { + var count = 0 + var succeed = true + } + + private func report( + _ open: Bool, at now: Date, file: URL, _ deliveries: Deliveries + ) async -> Bool { + await OpenPortAlert.report( + open: open, now: now, stateFile: file, + deliver: { _, _ in + deliveries.count += 1 + return deliveries.succeed + }, log: { _ in }) + } + + /// Once at once, then after 1, 2, 4 days, then weekly - never twice a day. + func testNotifiesOnceThenWithAGrowingGap() async { + let file = stateFile() + defer { try? FileManager.default.removeItem(at: file) } + let deliveries = Deliveries() + let start = Date(timeIntervalSince1970: 1_800_000_000) + let day: TimeInterval = 86_400 + // The watchdog runs every 30 minutes; walk 30 days of runs. + var times: [TimeInterval] = [] + var now = start + while now < start.addingTimeInterval(30 * day) { + if await report(true, at: now, file: file, deliveries) { + times.append(now.timeIntervalSince(start) / day) + } + now = now.addingTimeInterval(1800) + } + XCTAssertEqual(times.first, 0) + let gaps = zip(times.dropFirst(), times).map { $0 - $1 } + XCTAssertEqual(gaps.prefix(4).map { Int($0.rounded()) }, [1, 2, 4, 7]) + XCTAssertTrue(gaps.dropFirst(3).allSatisfy { Int($0.rounded()) == 7 }) + } + + func testUndeliveredNotificationIsRetriedAtTheNextRun() async { + let file = stateFile() + defer { try? FileManager.default.removeItem(at: file) } + let deliveries = Deliveries() + let now = Date() + deliveries.succeed = false + let first = await report(true, at: now, file: file, deliveries) + XCTAssertFalse(first) + deliveries.succeed = true + let retried = await report(true, at: now.addingTimeInterval(1800), file: file, deliveries) + XCTAssertTrue(retried) + let quiet = await report(true, at: now.addingTimeInterval(3600), file: file, deliveries) + XCTAssertFalse(quiet) + XCTAssertEqual(deliveries.count, 2) + } + + func testClosedPortEndsTheEpisode() async { + let file = stateFile() + defer { try? FileManager.default.removeItem(at: file) } + let deliveries = Deliveries() + let now = Date() + _ = await report(true, at: now, file: file, deliveries) + _ = await report(false, at: now.addingTimeInterval(60), file: file, deliveries) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + // Open again (say, a downgrade): told at once, not after the old gap. + let again = await report(true, at: now.addingTimeInterval(120), file: file, deliveries) + XCTAssertTrue(again) + XCTAssertEqual(deliveries.count, 2) + } + + // MARK: - Restarting the mount + + private final class Calls: @unchecked Sendable { + var log: [String] = [] + } + + private func steps( + _ calls: Calls, backupRunning: Bool? = false, mounted: Bool? = true, + drained: Bool = true, stopped: Bool = true, cameBack: Bool = true + ) -> MountRestart.Steps { + MountRestart.Steps( + backupRunning: { backupRunning }, + mounted: { mounted }, + detachAndDrain: { + calls.log.append("drain") + return CMActionResult(succeeded: drained, message: drained ? "drained" : "not drained") + }, + stopMount: { + calls.log.append("stop") + return stopped + }, + waitForNewMount: { + calls.log.append("wait") + return cameBack + }, + attach: { + calls.log.append("attach") + return CMActionResult(succeeded: true, message: "attached") + }) + } + + func testRestartRunsTheShutdownPathInOrder() async { + let calls = Calls() + let result = await MountRestart.run(steps(calls)) + XCTAssertTrue(result.succeeded, result.message) + XCTAssertEqual(calls.log, ["drain", "stop", "wait", "attach"]) + } + + /// Nothing is touched while Time Machine writes, or when that is unknown. + func testRestartRefusesDuringABackupOrWithoutAnAnswer() async { + for running in [true, nil] as [Bool?] { + let calls = Calls() + let result = await MountRestart.run(steps(calls, backupRunning: running)) + XCTAssertFalse(result.succeeded) + XCTAssertEqual(calls.log, []) + } + let calls = Calls() + let result = await MountRestart.run(steps(calls, mounted: nil)) + XCTAssertFalse(result.succeeded) + XCTAssertEqual(calls.log, []) + } + + /// THE rule: the mount is stopped only after everything reached Drive. + /// A drain that did not finish leaves the mount alone and the image back. + func testUnfinishedDrainNeverStopsTheMount() async { + let calls = Calls() + let result = await MountRestart.run(steps(calls, drained: false)) + XCTAssertFalse(result.succeeded) + XCTAssertEqual(calls.log, ["drain", "attach"]) + XCTAssertTrue(result.message.contains("not drained")) + } + + func testRefusedStopPutsTheImageBack() async { + let calls = Calls() + let result = await MountRestart.run(steps(calls, stopped: false)) + XCTAssertFalse(result.succeeded) + XCTAssertEqual(calls.log, ["drain", "stop", "attach"]) + } + + func testMountThatDoesNotComeBackIsReported() async { + let calls = Calls() + let result = await MountRestart.run(steps(calls, cameBack: false)) + XCTAssertFalse(result.succeeded) + XCTAssertEqual(calls.log, ["drain", "stop", "wait"]) + } +} diff --git a/packaging/homebrew/cloudmachine.rb.in b/packaging/homebrew/cloudmachine.rb.in index baae869..11df4af 100644 --- a/packaging/homebrew/cloudmachine.rb.in +++ b/packaging/homebrew/cloudmachine.rb.in @@ -55,9 +55,11 @@ cask "cloudmachine" do remaining setup steps for this Mac, with a button for each. See https://github.com/RenaCode/CloudMachine#getting-started - `brew upgrade` keeps the Google Drive mount running; the app reloads the - background agents when it reopens. `cloudmachine-agent drive-status` - shows "Agents: OK" once they run the new version. + `brew upgrade --cask renacode/tap/cloudmachine` keeps the Google Drive + mount running; the app reloads the background agents when it reopens. + `cloudmachine-agent drive-status` shows "Agents: OK" once they run the + new version. If the window shows "Remote control of the Drive mount is + open", press Restart the mount there (or: cloudmachine-agent restart-mount). Uninstalling does NOT stop the launchd agents or delete the upload buffer in ~/.cloudmachine (it may hold backups not yet sent to Google Drive).