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
2 changes: 2 additions & 0 deletions mac-app/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,7 @@
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>NSLocalNetworkUsageDescription</key>
<string>CloudMachine checks that Samba in its own machine (a virtual machine on this Mac) answers at the address Time Machine backs up to.</string>
</dict>
</plist>
64 changes: 62 additions & 2 deletions mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,64 @@ struct MachineStatus: Equatable {
var punchfsFileLimit: Int?
var bundlePrepared: Bool?
var imageCount: Int?
/// One byte read through the share's path in the machine; `nil` = not read.
var shareRead: ShareReadProbe.Verdict?
/// Samba's answer to this Mac over the address Time Machine uses; `nil` = not asked.
var smb: SMBProbe.Verdict?

var isRunning: Bool { state == .running }

/// Both probes confirmed the path Time Machine uses. Not read is not confirmed.
var shareConfirmed: Bool {
shareRead?.isConfirmed == true && smb?.answers == true
}

/// `nil` = not known (no answer from the machine).
var servicesHealthy: Bool? {
guard isRunning, !services.isEmpty else { return nil }
return VMStatus.servicesHealthy(services)
return VMStatus.servicesHealthy(services, bundlePrepared: bundlePrepared)
}

/// Units that are not up, by the same rule as `servicesHealthy`: an
/// inactive bind counts once the image is prepared.
var servicesDown: [String] {
VMLayout.services.filter {
let state = services[$0]
if state == "active" { return false }
if $0 == "tm-bands-bind" && state == "inactive" { return bundlePrepared != false }
return true
}
}

/// The "Services" row of the machine card. All services up is green only
/// with the share confirmed by both probes - otherwise the row says what
/// the probes found.
var servicesText: String {
switch servicesHealthy {
case .some(true):
if let summary = shareProblemSummary { return summary }
return L10n.tr("All running")
case .some(false):
return L10n.tr("Not running: %@", servicesDown.joined(separator: ", "))
case .none: return L10n.tr("not read")
}
}

var servicesTone: StatusTone {
switch servicesHealthy {
case .some(true):
if shareRead?.isFailure == true || smb?.isFailure == true { return .danger }
return shareConfirmed ? .success : .warning
case .some(false): return .danger
case .none: return isRunning ? .warning : .neutral
}
}

/// What the probes found, first the read, then Samba; `nil` = both
/// confirmed. Not read counts as unknown.
var shareProblemSummary: String? {
(shareRead ?? .noAnswer(hungSeconds: nil)).problemSummary
?? (smb ?? .noAnswer(seconds: 0)).problemSummary
}

/// Whether the counters below come from a reading at all. A fresh
Expand Down Expand Up @@ -260,6 +311,13 @@ final class AppStatus: ObservableObject {
if machine.servicesHealthy == false {
return L10n.tr("A service in the CloudMachine machine is not running")
}
// Every service "active" says nothing about whether the path through
// them works - and a probe that did not answer is not a path that works.
if machine.isRunning, machine.servicesHealthy == true,
let summary = machine.shareProblemSummary
{
return summary
}
if case .notRegistered = timeMachineState {
return L10n.tr("Time Machine does not point to CloudMachine")
}
Expand Down Expand Up @@ -294,7 +352,8 @@ final class AppStatus: ObservableObject {
/// safe", and that follows solely from a backup having BEEN MADE.
var healthy: Bool {
guard case .ready = dependencyState, remoteConfigured, machine.isRunning,
machine.servicesHealthy == true, case .registered = timeMachineState,
machine.servicesHealthy == true, machine.shareConfirmed,
case .registered = timeMachineState,
machine.uploadState.isNominal, backupCycle.isFresh(), !remoteControlOpen
else { return false }
return true
Expand Down Expand Up @@ -330,6 +389,7 @@ extension AppStatus {
case .unknown, .running: break
}
if machine.servicesHealthy == false { return .danger }
if machine.shareRead?.isFailure == true || machine.smb?.isFailure == true { return .danger }
if case .notRegistered = timeMachineState { return .danger }
if machine.uploadState.needsAttention { return .danger }
if remoteControlOpen { return .danger }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ final class CloudMachineController: ObservableObject {
hasClientID: false, hasClientSecret: false)

private var refreshTask: Task<Void, Never>?
/// Samba is asked again after an answer only once a minute, not at every 10 s refresh.
private var smbCache = SMBProbe.Cache()
private var lastBytesDone: Double?
private var lastBytesSampledAt: Date?

Expand Down Expand Up @@ -159,7 +161,12 @@ final class CloudMachineController: ObservableObject {
machine.punchfsFileLimit = report.punchfsFileLimit
machine.bundlePrepared = report.bundlePrepared
machine.imageCount = report.imageCount
machine.shareRead = report.shareRead
machine.address = await VMLayout.currentAddress()
// Off the main actor inside, bounded by its own limit.
var cache = smbCache
machine.smb = await cache.probe(address: machine.address)
smbCache = cache
}
status.machine = machine
status.remoteControlOpen = VMStatus.remoteControlOpenOnMac()
Expand Down
21 changes: 2 additions & 19 deletions mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -480,26 +480,9 @@ private struct DashboardContent: View {
}
}

private var servicesTone: StatusTone {
switch status.machine.servicesHealthy {
case .some(true): return .success
case .some(false): return .danger
case .none: return status.machine.isRunning ? .warning : .neutral
}
}
private var servicesTone: StatusTone { status.machine.servicesTone }

private var servicesText: String {
switch status.machine.servicesHealthy {
case .some(true): return L10n.tr("All running")
case .some(false):
let down = VMLayout.services.filter {
let state = status.machine.services[$0]
return state != "active" && !($0 == "tm-bands-bind" && state == "inactive")
}
return L10n.tr("Not running: %@", down.joined(separator: ", "))
case .none: return L10n.tr("not read")
}
}
private var servicesText: String { status.machine.servicesText }

private var disksTone: StatusTone {
machineTone.worst(servicesTone == .neutral ? .warning : servicesTone)
Expand Down
26 changes: 23 additions & 3 deletions mac-app/Sources/CloudMachineCore/BackupHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,12 @@ public enum BackupHealth {
// Whether the startup grace period is in progress - see `startupGraceMinutes`.
withinStartupGrace: Bool = false,
// Whether Time Machine is RIGHT NOW performing a run (`tmutil status`).
backupRunning: Bool? = nil
backupRunning: Bool? = nil,
// The read through the share's path in the machine and Samba's answer to
// this Mac (`ShareReadProbe`, `SMBProbe`); `nil` = not asked.
shareRead: ShareReadProbe.Verdict? = nil,
smb: SMBProbe.Verdict? = nil,
smbAddress: String? = nil
) -> Report {
var problems: [Problem] = []
var deferred: [Problem] = []
Expand Down Expand Up @@ -349,6 +354,15 @@ public enum BackupHealth {
),
code: "rclone-rc-no-answer"))
}
// The services say "active"; these two say whether the path Time Machine
// uses works. Only for a machine whose services all answered, like the rc
// message above. "No answer" alarms at once and is never folded into fine;
// only Samba refusing (smbd still starting) waits out the startup grace.
if machineRunning == true && servicesHealthy == true {
let share = shareProblems(read: shareRead, smb: smb, address: smbAddress)
problems.append(contentsOf: share.problems)
share.notReadyYet.forEach(notReadyYet)
}
if punchfsNearFileLimit {
problems.append(
Problem(
Expand Down Expand Up @@ -585,7 +599,9 @@ public enum BackupHealth {
}
let report = machineRunning == true ? await VMStatus.currentReport() : nil
let reading = report?.reading
let servicesHealthy = report.flatMap { VMStatus.servicesHealthy($0.services) }
let servicesHealthy = report.flatMap {
VMStatus.servicesHealthy($0.services, bundlePrepared: $0.bundlePrepared)
}
let address = machineRunning == true ? await VMLayout.currentAddress() : nil
let registered = await VMStatus.timeMachineTargetsMachine(address: address)

Expand Down Expand Up @@ -626,7 +642,11 @@ public enum BackupHealth {
// uptime computed from it would come out negative, i.e. "startup in
// progress".
withinStartupGrace: (systemUptime() ?? .infinity) < startupGraceMinutes * 60,
backupRunning: await TimeMachineStatus.runningState())
backupRunning: await TimeMachineStatus.runningState(),
// `report == nil` (no answer) is already "machine does not answer".
shareRead: report?.shareRead,
smb: machineRunning == true ? await SMBProbe.probe(address: address) : nil,
smbAddress: address)

health.problems.append(contentsOf: unmeasuredLocalDiskProblems(localFreeGB: localFree))
health.problems.append(contentsOf: await MachineBudget.currentProblems())
Expand Down
30 changes: 30 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+VM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,5 +226,35 @@ extension L10nPolish {
"rclone ma pasma w trakcie wysyłki, ale od %@ żadne nie zostało wysłane - zwykle to zerwane połączenie sieciowe, którego rclone nie zgłasza jako błędu. Kopie od tego czasu są tylko w buforze maszyny. Gdy sieć wróci, wysyłka wznowi się sama (28.10.2026 w 80 s); jeśli nie, restart maszyny (prepare-shutdown, limactl start) zrywa martwe połączenia.",
"tmutil did not answer - not restarting the machine while its state is unknown.":
"tmutil nie odpowiedział - nie restartuję maszyny, póki jej stan jest nieznany.",
"The machine's share cannot be read":
"Nie da się czytać z udziału maszyny",
"Unknown whether the machine's share can be read":
"Nie wiadomo, czy z udziału maszyny da się czytać",
"Samba in the machine does not answer this Mac":
"Samba w maszynie nie odpowiada temu Macowi",
"Unknown whether Samba in the machine answers this Mac":
"Nie wiadomo, czy Samba w maszynie odpowiada temu Macowi",
"Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm":
"Odczyt 1 bajtu z %@ w maszynie nie powiódł się: %@. Każda usługa twierdzi, że działa, a Time Machine nie może tam korzystać z obrazu. Nic samo tego nie naprawi. Sprawdź: cloudmachine-agent vm-status, potem w maszynie: journalctl -u punchfs -u tm-bands-bind -u rclone-tm",
"Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm":
"Odczyt 1 bajtu z %@ w maszynie nie wrócił od %@ s. Tak zachowuje się zawieszony punchfs albo montowanie rclone. Póki stan jest nieznany, nic nie jest restartowane - restart pod trwającym zapisem mógłby go zgubić. Sprawdź w maszynie: journalctl -u punchfs -u rclone-tm",
"An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status":
"Połączenie SMB z %@ nie dostało odpowiedzi w %@ s. Pod tym adresem Time Machine łączy się z maszyną. Sprawdź sieć maszyny i smbd: cloudmachine-agent vm-status",
"The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status":
"Maszyna nie podała swojego adresu, więc nikt nie mógł spróbować połączenia SMB. Sprawdź: cloudmachine-agent vm-status",
"An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status":
"Połączenie SMB z %@ nie powiodło się (%@). Pod tym adresem Time Machine łączy się z maszyną, więc kopia nie powstaje. Sprawdź sieć maszyny i smbd: cloudmachine-agent vm-status",
"readable":
"czytelny",
"no image yet":
"jeszcze bez obrazu",
"answers":
"odpowiada",
"Share read: %@":
"Odczyt udziału: %@",
"Samba from Mac: %@":
"Samba z Maca: %@",
"This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status":
"Ten Mac nie znalazł trasy do %@ (%@). Albo trasa do maszyny zniknęła, albo prywatność „Sieć lokalna” w macOS nie pozwoliła CloudMachine - wtedy zezwól w Ustawieniach systemowych > Prywatność i ochrona > Sieć lokalna. Sprawdź: cloudmachine-agent vm-status",
]
}
30 changes: 30 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nTranslations+ar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -947,5 +947,35 @@ extension L10nTranslations {
"لدى rclone أجزاء قيد التنفيذ، لكن لم يُرفع أي منها منذ %@ - عادةً بسبب انقطاع اتصال الشبكة، ولا يبلّغ rclone عنه كخطأ. النسخ الاحتياطية منذ ذلك الحين موجودة في المخزن المؤقت للجهاز الافتراضي فقط. حين تعود الشبكة يستأنف الرفع تلقائيًا (في 28.10.2026 خلال 80 s)؛ وإن لم يحدث، فإعادة تشغيل الجهاز الافتراضي (prepare-shutdown ثم limactl start) تُسقط الاتصالات الميتة.",
"tmutil did not answer - not restarting the machine while its state is unknown.":
"لم يستجب tmutil - لن يُعاد تشغيل الجهاز الافتراضي ما دامت حالته غير معروفة.",
"The machine's share cannot be read":
"تعذّرت قراءة مشاركة الجهاز",
"Unknown whether the machine's share can be read":
"غير معروف ما إذا كانت مشاركة الجهاز قابلة للقراءة",
"Samba in the machine does not answer this Mac":
"Samba في الجهاز لا يستجيب لهذا الـ Mac",
"Unknown whether Samba in the machine answers this Mac":
"غير معروف ما إذا كان Samba في الجهاز يستجيب لهذا الـ Mac",
"Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm":
"فشلت قراءة بايت واحد من %@ في الجهاز: %@. كل خدمة تقول إنها نشطة، ومع ذلك لا يستطيع Time Machine استخدام الصورة هناك. لا شيء يعيد تشغيله تلقائيًا. تحقّق: cloudmachine-agent vm-status، ثم داخل الجهاز: journalctl -u punchfs -u tm-bands-bind -u rclone-tm",
"Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm":
"لم تعد قراءة بايت واحد من %@ في الجهاز منذ %@ ث. هكذا يتصرف تركيب punchfs أو rclone المعلّق. لا يُعاد تشغيل أي شيء ما دامت الحالة غير معروفة - إعادة التشغيل أثناء كتابة جارية قد تُضيّعها. تحقّق داخل الجهاز: journalctl -u punchfs -u rclone-tm",
"An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status":
"لم يتلقَّ اتصال SMB بـ %@ أي رد خلال %@ ث. يصل Time Machine إلى الجهاز عبر هذا العنوان. تحقّق من شبكة الجهاز وsmbd: cloudmachine-agent vm-status",
"The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status":
"لم يُبلغ الجهاز عن عنوانه، لذا لم يتمكن أحد من تجربة اتصال SMB. تحقّق: cloudmachine-agent vm-status",
"An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status":
"فشل اتصال SMB بـ %@ (%@). يصل Time Machine إلى الجهاز عبر هذا العنوان، لذا لا تُنشأ أي نسخة احتياطية. تحقّق من شبكة الجهاز وsmbd: cloudmachine-agent vm-status",
"readable":
"قابلة للقراءة",
"no image yet":
"لا توجد صورة بعد",
"answers":
"يستجيب",
"Share read: %@":
"قراءة المشاركة: %@",
"Samba from Mac: %@":
"Samba من الـ Mac: %@",
"This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status":
"لم يجد هذا الـ Mac مسارًا إلى %@ (%@). إما أن المسار إلى الجهاز اختفى، أو أن خصوصية «الشبكة المحلية» في macOS لم تسمح لـ CloudMachine - عندها اسمح بذلك من إعدادات النظام > الخصوصية والأمن > الشبكة المحلية. تحقّق: cloudmachine-agent vm-status",
]
}
Loading
Loading