diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 09d5c56..cfc50f3 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -84,6 +84,15 @@ struct AttachImage: AsyncParsableCommand { abstract: L10n.tr("Attaches the backup image as the Time Machine destination.")) func run() async throws { + // After `prepare-shutdown` the image stays detached until the restart + // (`ShutdownPrep`) - this agent runs every 900 s and would otherwise undo it. + if ShutdownPrep.holdsAttach() { + print( + L10n.tr( + "The Mac is prepared for a restart - not attaching the image. This is not an error: after the restart, or %@ min after the preparation, the image is attached again.", + "\(Int(ShutdownPrep.window / 60))")) + return + } // Time Machine must not see the destination before the buffer is ready - // otherwise it decides that the backup disk has disappeared. How long we // wait and for what exactly - see `BufferReadiness`. @@ -326,31 +335,12 @@ struct PrepareShutdown: AsyncParsableCommand { "Prepares for a restart: pauses the backup, detaches the image and waits for the upload.")) func run() async throws { - // The order is not arbitrary. First Time Machine stops adding new writes, - // only then do we detach the image - otherwise the detach would fight with - // the running backup. - if await TimeMachineStatus.isRunning() { - print(L10n.tr("Pausing the backup...")) - _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["stopbackup"], timeout: 120) - try? await Task.sleep(nanoseconds: 3_000_000_000) - } - - print(L10n.tr("Detaching the image and waiting for the upload...")) - let result = await BackupImageService.detach() + let result = await ShutdownPrep.perform(progress: { print($0) }) print(result.message) - guard result.succeeded else { - print("") - print(L10n.tr("Do NOT restart yet - the buffer holds data that has not reached Drive.")) print(L10n.tr("Check the state: %@", "cloudmachine-agent drive-status")) throw ExitCode(1) } - - print("") - print( - L10n.tr( - "Safe to restart. After startup the agents will bring up the buffer and attach the image themselves." - )) } } diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 508a55e..1b51d7c 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -227,6 +227,9 @@ final class AppStatus: ObservableObject { /// `DriveBufferService.legacyPortOpen`). Until 11.10.2026 only one line of /// `drive-status` said so; the window was green. @Published var remoteControlOpen: Bool = false + /// The Mac was prepared for a restart (`ShutdownPrep`): the image is + /// detached ON PURPOSE until the restart. + @Published var preparedForRestart: Bool = false @Published var isBusy: Bool = false @Published var busyLabel: String = "" @Published var errorMessage: String? @@ -250,7 +253,11 @@ final class AppStatus: ObservableObject { } if !remoteConfigured { return L10n.tr("Google Drive not connected") } if !buffer.mounted { return L10n.tr("Buffer is not working") } - if !buffer.imageAttached { return L10n.tr("Backup image not attached") } + if !buffer.imageAttached { + return detachedForRestart + ? L10n.tr("Ready for restart - the image is detached on purpose") + : L10n.tr("Backup image not attached") + } if case .notRegistered = timeMachineState { return L10n.tr("Time Machine does not point to CloudMachine") } @@ -303,6 +310,10 @@ final class AppStatus: ObservableObject { /// /// THIRD, from 11.10.2026: `remoteControlOpen`. A backup that any web page /// can delete is not safe, however fresh it is. + /// A detached image that is not a failure: the preparation for a restart + /// detached it, and the agents attach it again after the restart. + var detachedForRestart: Bool { preparedForRestart && !buffer.imageAttached } + var healthy: Bool { guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached, case .registered = timeMachineState, buffer.uploadState.isNominal, diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 15d3ebb..8392b44 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -66,6 +66,7 @@ final class CloudMachineController: ObservableObject { // run in it. See `WatchdogHeartbeat`: without this the only symptom of an // unloaded watchdog is silence, and silence is the normal state here. status.watchdog = WatchdogHeartbeat.current() + status.preparedForRestart = ShutdownPrep.holdsAttach() status.lastRefresh = Date() } @@ -335,11 +336,22 @@ final class CloudMachineController: ObservableObject { } func attachImage() async { + // An explicit attach ends a preparation for restart that is no longer wanted. + ShutdownPrep.clear() await run(L10n.tr("Attaching the image"), log: "Attaching the image") { await BackupImageService.attach() } } + /// Pauses the backup, detaches the image and waits until everything has + /// reached Drive, then keeps the image detached until the restart + /// (`ShutdownPrep`). The menu asks for confirmation first. + func prepareForRestart() async { + await run(L10n.tr("Preparing for restart"), log: "Preparing for restart") { + await ShutdownPrep.perform() + } + } + /// 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. diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 91c18f1..dd02c26 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -424,6 +424,10 @@ private struct DashboardContent: View { private var healthTone: StatusTone { if status.healthy { return .success } if case .missing = status.dependencyState { return .danger } + // Detached on purpose before a restart - amber, not "action needed". + if status.detachedForRestart && status.remoteConfigured && status.buffer.mounted { + return .warning + } if !status.remoteConfigured || !status.buffer.mounted || !status.buffer.imageAttached { return .danger } diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index 40c19ca..a97f680 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -18,6 +18,9 @@ private struct MenuBarPanel: View { @EnvironmentObject private var controller: CloudMachineController @ObservedObject var status: AppStatus @Environment(\.openWindow) private var openWindow + /// The restart preparation asks once more in place - a dialog from a menu + /// bar panel closes the panel along with it. + @State private var confirmingRestartPrep = false /// Opens the panel and brings it TO THE FRONT. /// @@ -42,6 +45,10 @@ private struct MenuBarPanel: View { private var tone: StatusTone { if status.healthy { return .success } if case .missing = status.dependencyState { return .danger } + // Detached on purpose before a restart - amber, not "action needed". + if status.detachedForRestart && status.remoteConfigured && status.buffer.mounted { + return .warning + } if !status.remoteConfigured || !status.buffer.mounted || !status.buffer.imageAttached { return .danger } @@ -154,6 +161,27 @@ private struct MenuBarPanel: View { Divider().background(RenaCodeTheme.borderGlass) + // What the app is doing right now, or how the last action ended - the + // panel used to show neither, so a click looked like nothing happened. + if status.isBusy { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(status.busyLabel) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(RenaCodeTheme.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } else if let last = status.lastAction, Date().timeIntervalSince(last.date) < 600 { + HStack(alignment: .firstTextBaseline, spacing: 8) { + StatusDot(last.succeeded ? .success : .danger, size: 6) + Text(last.message) + .font(.system(size: 11)) + .foregroundStyle(last.succeeded ? RenaCodeTheme.textMain : StatusTone.danger.color) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + // Action buttons VStack(spacing: 6) { if status.backupProgress == nil { @@ -177,6 +205,48 @@ private struct MenuBarPanel: View { .buttonStyle(SecondaryGlassButtonStyle()) } + if confirmingRestartPrep { + Text( + L10n.tr( + "The backup is paused, the image is detached and everything waits until it has reached Google Drive. It can take several minutes." + ) + ) + .font(.system(size: 11)) + .foregroundStyle(RenaCodeTheme.textMuted) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + Button(action: { + confirmingRestartPrep = false + Task { await controller.prepareForRestart() } + }) { + HStack { + Image(systemName: "checkmark") + Text(L10n.tr("Prepare")) + Spacer() + } + } + .buttonStyle(PrimaryGradientButtonStyle()) + Button(action: { confirmingRestartPrep = false }) { + Text(L10n.tr("Cancel")) + } + .buttonStyle(SecondaryGlassButtonStyle()) + } + } else { + Button(action: { confirmingRestartPrep = true }) { + HStack { + Image(systemName: "arrow.clockwise.circle") + Text(L10n.tr("Prepare for restart")) + Spacer() + } + } + .buttonStyle(SecondaryGlassButtonStyle()) + .disabled(status.isBusy) + .help( + L10n.tr( + "Prepares for a restart: pauses the backup, detaches the image and waits for the upload." + )) + } + HStack(spacing: 6) { Button(action: showDashboard) { HStack { diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift index 1bb3ccb..770b9d7 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift @@ -163,8 +163,22 @@ extension L10nPolish { "NIE RESTARTUJ jeszcze - w buforze są dane, które nie doleciały na Dysk.", "Check the state: %@": "Sprawdź stan: %@", - "Safe to restart. After startup the agents will bring up the buffer and attach the image themselves.": - "Można restartować. Po starcie agenty podniosą bufor i podepną obraz same.", + "Safe to restart. The image stays detached for %@ min; after startup the agents will bring up the buffer and attach the image themselves.": + "Można restartować. Obraz zostaje odpięty przez %@ min; po starcie agenty podniosą bufor i podepną obraz same.", + "Safe to restart now - but the image could not be held detached, so the agent may attach it again within 15 min. Restart soon. After startup the agents will bring up the buffer and attach the image themselves.": + "Można restartować teraz - ale nie udało się zatrzymać obrazu odpiętego, więc agent może go podpiąć w ciągu 15 min. Zrestartuj wkrótce. Po starcie agenty podniosą bufor i podepną obraz same.", + "The Mac is prepared for a restart - not attaching the image. This is not an error: after the restart, or %@ min after the preparation, the image is attached again.": + "Mac jest przygotowany do restartu - obraz nie jest podpinany. To nie błąd: po restarcie albo %@ min po przygotowaniu obraz zostanie podpięty znowu.", + "Preparing for restart": + "Przygotowanie do restartu", + "Ready for restart - the image is detached on purpose": + "Gotowe do restartu - obraz odpięty celowo", + "Prepare for restart": + "Przygotuj do restartu", + "Prepare": + "Przygotuj", + "The backup is paused, the image is detached and everything waits until it has reached Google Drive. It can take several minutes.": + "Kopia zostanie wstrzymana, obraz odpięty, a wszystko poczeka, aż trafi na Google Drive. To może potrwać kilka minut.", "State of the buffer, the upload queue and Time Machine.": "Stan bufora, kolejki wysyłki i Time Machine.", "Tools: %@": diff --git a/mac-app/Sources/CloudMachineCore/MountRestart.swift b/mac-app/Sources/CloudMachineCore/MountRestart.swift index 1d1174e..48b70b7 100644 --- a/mac-app/Sources/CloudMachineCore/MountRestart.swift +++ b/mac-app/Sources/CloudMachineCore/MountRestart.swift @@ -119,7 +119,10 @@ public enum MountRestart { static let newMountTimeout: TimeInterval = 300 public static func perform() async -> CMActionResult { - await withCMLock(BackupImageService.lockName) { await run(live) } + // A restart of the mount ends with the image attached - a preparation for + // a Mac restart (`ShutdownPrep`) no longer applies. + ShutdownPrep.clear() + return await withCMLock(BackupImageService.lockName) { await run(live) } ?? CMActionResult( succeeded: false, message: L10n.tr( diff --git a/mac-app/Sources/CloudMachineCore/ShutdownPrep.swift b/mac-app/Sources/CloudMachineCore/ShutdownPrep.swift new file mode 100644 index 0000000..6490666 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/ShutdownPrep.swift @@ -0,0 +1,123 @@ +import Foundation + +/// Preparing the Mac for a restart: pause the backup, detach the image, wait +/// until everything has reached Drive - the `prepare-shutdown` command and the +/// "Prepare for restart" button in the menu bar. +/// +/// The preparation also leaves a marker, because the `gdrive-attach` agent +/// runs every 900 s and attaches whatever image it finds detached. Without the +/// marker it undid the preparation by itself: Time Machine got its destination +/// back, wrote into the buffer again, and the restart a few minutes later left +/// those writes local only - the very backlog that the preparation is there +/// to prevent (`UploadDrain`, 01.10.2026). +/// +/// The marker holds the boot time and the time of the preparation. It blocks +/// the agent only in the same boot session (after the restart the agents bring +/// the image up as usual) and only for `window`: a preparation without a +/// restart must not leave Time Machine without a destination for good. An +/// explicit attach from the window clears it. +public enum ShutdownPrep { + + /// 30 min: enough to close programs and restart, short enough that a + /// forgotten preparation costs at most two missed hourly backups. + public static let window: TimeInterval = 1800 + + public static var markerURL: URL { + CMPaths.appSupportDir.appendingPathComponent("shutdown-prepared") + } + + public struct Marker: Equatable { + public var bootTime: TimeInterval + public var preparedAt: TimeInterval + + public var text: String { "\(Int(bootTime)) \(Int(preparedAt))" } + + public init(bootTime: TimeInterval, preparedAt: TimeInterval) { + self.bootTime = bootTime + self.preparedAt = preparedAt + } + + public init?(text: String) { + let parts = text.split(whereSeparator: \.isWhitespace).compactMap { TimeInterval($0) } + guard parts.count == 2 else { return nil } + self.init(bootTime: parts[0], preparedAt: parts[1]) + } + } + + /// Whether the marker still holds the agent back. The boot time is compared + /// with a second of slack: `kern.boottime` is read with microseconds and + /// stored in whole seconds. + public static func isActive(_ marker: Marker?, bootTime: TimeInterval?, now: TimeInterval) -> Bool + { + guard let marker, let bootTime else { return false } + guard abs(marker.bootTime - bootTime) <= 1 else { return false } + let age = now - marker.preparedAt + return age >= 0 && age < window + } + + /// The marker as it stands on disk, `nil` when there is none (or it is unreadable). + public static func readMarker() -> Marker? { + guard let text = try? String(contentsOf: markerURL, encoding: .utf8) else { return nil } + return Marker(text: text) + } + + public static func holdsAttach(now: Date = Date()) -> Bool { + isActive(readMarker(), bootTime: bootTime(), now: now.timeIntervalSince1970) + } + + public static func clear() { + try? FileManager.default.removeItem(at: markerURL) + } + + /// When the Mac was started (`kern.boottime`), seconds since 1970. + public static func bootTime() -> TimeInterval? { + var tv = timeval() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_BOOTTIME] + guard sysctl(&mib, 2, &tv, &size, nil, 0) == 0, tv.tv_sec > 0 else { return nil } + return TimeInterval(tv.tv_sec) + TimeInterval(tv.tv_usec) / 1_000_000 + } + + /// The whole preparation. `progress` gets the steps as they start (the CLI + /// prints them; the app shows its own busy label). + public static func perform(progress: (String) -> Void = { _ in }) async -> CMActionResult { + // The order is not arbitrary. First Time Machine stops adding new writes, + // only then do we detach the image - otherwise the detach would fight with + // the running backup. + if await TimeMachineStatus.isRunning() { + progress(L10n.tr("Pausing the backup...")) + _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["stopbackup"], timeout: 120) + try? await Task.sleep(nanoseconds: 3_000_000_000) + } + + progress(L10n.tr("Detaching the image and waiting for the upload...")) + let detached = await BackupImageService.detach() + guard detached.succeeded else { + return CMActionResult( + succeeded: false, + message: detached.message + "\n\n" + + L10n.tr("Do NOT restart yet - the buffer holds data that has not reached Drive.")) + } + + // Written only after a successful detach: a marker after a failure would + // keep the agent from repairing a half-detached state. + var written = false + if let boot = bootTime() { + let marker = Marker(bootTime: boot, preparedAt: Date().timeIntervalSince1970) + try? FileManager.default.createDirectory( + at: markerURL.deletingLastPathComponent(), withIntermediateDirectories: true) + written = (try? marker.text.write(to: markerURL, atomically: true, encoding: .utf8)) != nil + } + let minutes = "\(Int(window / 60))" + return CMActionResult( + succeeded: true, + message: detached.message + "\n\n" + + (written + ? L10n.tr( + "Safe to restart. The image stays detached for %@ min; after startup the agents will bring up the buffer and attach the image themselves.", + minutes) + : L10n.tr( + "Safe to restart now - but the image could not be held detached, so the agent may attach it again within 15 min. Restart soon. After startup the agents will bring up the buffer and attach the image themselves." + ))) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift index a7e77d2..9877ab3 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift @@ -266,4 +266,19 @@ final class AppStatusHealthTests: XCTestCase { XCTAssertFalse(status.healthy) XCTAssertEqual(status.headline, "There is no completed backup at all") } + + /// After "Prepare for restart" the image is detached on purpose: the headline + /// says so instead of reporting a failure, and the badge is still not green. + func testDetachedForRestartHasItsOwnHeadline() { + let status = healthy() + status.buffer.imageAttached = false + XCTAssertEqual(status.headline, L10n.tr("Backup image not attached")) + status.preparedForRestart = true + XCTAssertEqual( + status.headline, L10n.tr("Ready for restart - the image is detached on purpose")) + XCTAssertFalse(status.healthy) + // The preparation does not cover an image that is attached again. + status.buffer.imageAttached = true + XCTAssertFalse(status.detachedForRestart) + } } diff --git a/mac-app/Tests/CloudMachineAppTests/ShutdownPrepTests.swift b/mac-app/Tests/CloudMachineAppTests/ShutdownPrepTests.swift new file mode 100644 index 0000000..679634a --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/ShutdownPrepTests.swift @@ -0,0 +1,57 @@ +import XCTest + +@testable import CloudMachineCore + +/// The marker left by preparing for a restart: it must hold the attach agent +/// back until the restart, and never beyond it. +final class ShutdownPrepTests: XCTestCase { + + private let boot: TimeInterval = 1_792_500_000.4 + private var prepared: ShutdownPrep.Marker { + ShutdownPrep.Marker(bootTime: 1_792_500_000, preparedAt: 1_792_560_000) + } + + /// KNOWN BAD SAMPLE: five minutes after the preparation, in the same boot + /// session, the agent attached the image again and undid it. + func testHoldsTheAgentBackInTheSameBootSession() { + XCTAssertTrue(ShutdownPrep.isActive(prepared, bootTime: boot, now: 1_792_560_300)) + } + + /// After the restart the agents must bring the image up as usual. + func testDoesNotHoldAfterARestart() { + XCTAssertFalse(ShutdownPrep.isActive(prepared, bootTime: 1_792_563_000, now: 1_792_563_100)) + } + + /// A preparation without a restart must not leave Time Machine without a + /// destination for good. + func testExpiresAfterTheWindow() { + let end = prepared.preparedAt + ShutdownPrep.window + XCTAssertTrue(ShutdownPrep.isActive(prepared, bootTime: boot, now: end - 1)) + XCTAssertFalse(ShutdownPrep.isActive(prepared, bootTime: boot, now: end)) + } + + /// A clock set back must not stretch the hold into the past. + func testDoesNotHoldBeforeThePreparation() { + XCTAssertFalse(ShutdownPrep.isActive(prepared, bootTime: boot, now: prepared.preparedAt - 60)) + } + + /// No marker, or no boot time - never hold: a doubt goes to attaching, + /// because a missing destination is the louder failure. + func testNoMarkerOrNoBootTimeNeverHolds() { + XCTAssertFalse(ShutdownPrep.isActive(nil, bootTime: boot, now: 1_792_560_300)) + XCTAssertFalse(ShutdownPrep.isActive(prepared, bootTime: nil, now: 1_792_560_300)) + } + + func testMarkerTextRoundTrips() { + XCTAssertEqual(ShutdownPrep.Marker(text: prepared.text), prepared) + XCTAssertNil(ShutdownPrep.Marker(text: "garbage")) + XCTAssertNil(ShutdownPrep.Marker(text: "")) + } + + /// The boot time read live is a real moment in the past. + func testReadsTheBootTime() throws { + let boot = try XCTUnwrap(ShutdownPrep.bootTime()) + XCTAssertLessThan(boot, Date().timeIntervalSince1970) + XCTAssertGreaterThan(boot, Date().timeIntervalSince1970 - 400 * 86_400) + } +}