Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 10 additions & 20 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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."
))
}
}

Expand Down
13 changes: 12 additions & 1 deletion mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: %@":
Expand Down
5 changes: 4 additions & 1 deletion mac-app/Sources/CloudMachineCore/MountRestart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
123 changes: 123 additions & 0 deletions mac-app/Sources/CloudMachineCore/ShutdownPrep.swift
Original file line number Diff line number Diff line change
@@ -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<timeval>.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."
)))
}
}
Loading
Loading