Fix CampusNAS recovery after VPN loss

This commit is contained in:
2026-08-13 01:15:14 +06:00
parent cd53b6a1a1
commit b08f0e8cf1
3 changed files with 53 additions and 5 deletions
+24 -2
View File
@@ -50,6 +50,8 @@ final class AppModel: ObservableObject {
private(set) var config: Config
private var timer: Timer?
private var checking = false
private var checkStartedAt: Date?
private var checkGeneration = 0
/// Tracked separately from `state` so "just became reachable" can be told
/// apart from "still reachable" when deciding to auto-mount.
private var previousState: LinkState = .unknown
@@ -114,12 +116,24 @@ final class AppModel: ObservableObject {
// MARK: - Probing
func checkNow() {
guard !checking else { return }
func checkNow(force: Bool = false) {
// A network framework call is supposed to be bounded, but defensive
// recovery matters here: one stuck check must not freeze the displayed
// state for days. Normal overlapping timer ticks are still ignored.
if checking && !force {
let age = checkStartedAt.map { Date().timeIntervalSince($0) } ?? 0
guard age > max(config.probeTimeoutSeconds * 3, 15) else { return }
Log.write("probe watchdog: replacing check stuck for \(Int(age)) s")
}
checkGeneration += 1
let generation = checkGeneration
checking = true
checkStartedAt = Date()
Task { @MainActor in
let next = await probe()
guard generation == self.checkGeneration else { return }
self.mounted = Mounter.mountedShares(host: config.nasHost)
let wasUnknown = self.previousState == .unknown
@@ -131,6 +145,7 @@ final class AppModel: ObservableObject {
self.previousState = next
self.state = next
self.checking = false
self.checkStartedAt = nil
if notable { self.onNotableChange?(next) }
@@ -145,6 +160,13 @@ final class AppModel: ObservableObject {
}
}
/// Sleep invalidates tunnels and may pause run-loop timers. Re-probe as
/// soon as macOS wakes, superseding a check that started before sleep.
func wake() {
Log.write("system wake: refreshing VPN and NAS state")
checkNow(force: true)
}
/// Explicit refresh always re-enumerates, unlike the polling path.
func refreshAll() {
Task { @MainActor in