Fix CampusNAS recovery after VPN loss
This commit is contained in:
@@ -12,6 +12,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
private var hosting: NSHostingController<ContentView>?
|
private var hosting: NSHostingController<ContentView>?
|
||||||
private var settingsWindow: NSWindow?
|
private var settingsWindow: NSWindow?
|
||||||
private var keyMonitor: Any?
|
private var keyMonitor: Any?
|
||||||
|
private var wakeObserver: NSObjectProtocol?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
@@ -39,6 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
installKeyMonitor()
|
installKeyMonitor()
|
||||||
|
observeWake()
|
||||||
model.start()
|
model.start()
|
||||||
render()
|
render()
|
||||||
|
|
||||||
@@ -51,6 +53,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func observeWake() {
|
||||||
|
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
// NetworkExtension and Wi-Fi routes settle just after didWake.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
Task { @MainActor in self?.model.wake() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Launching the app again while it is already running (Finder, Spotlight,
|
/// Launching the app again while it is already running (Finder, Spotlight,
|
||||||
/// `open`) brings the window up instead of doing nothing, which is what a
|
/// `open`) brings the window up instead of doing nothing, which is what a
|
||||||
/// second launch is normally asking for.
|
/// second launch is normally asking for.
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ final class AppModel: ObservableObject {
|
|||||||
private(set) var config: Config
|
private(set) var config: Config
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var checking = false
|
private var checking = false
|
||||||
|
private var checkStartedAt: Date?
|
||||||
|
private var checkGeneration = 0
|
||||||
/// Tracked separately from `state` so "just became reachable" can be told
|
/// Tracked separately from `state` so "just became reachable" can be told
|
||||||
/// apart from "still reachable" when deciding to auto-mount.
|
/// apart from "still reachable" when deciding to auto-mount.
|
||||||
private var previousState: LinkState = .unknown
|
private var previousState: LinkState = .unknown
|
||||||
@@ -114,12 +116,24 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Probing
|
// MARK: - Probing
|
||||||
|
|
||||||
func checkNow() {
|
func checkNow(force: Bool = false) {
|
||||||
guard !checking else { return }
|
// 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
|
checking = true
|
||||||
|
checkStartedAt = Date()
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let next = await probe()
|
let next = await probe()
|
||||||
|
guard generation == self.checkGeneration else { return }
|
||||||
self.mounted = Mounter.mountedShares(host: config.nasHost)
|
self.mounted = Mounter.mountedShares(host: config.nasHost)
|
||||||
|
|
||||||
let wasUnknown = self.previousState == .unknown
|
let wasUnknown = self.previousState == .unknown
|
||||||
@@ -131,6 +145,7 @@ final class AppModel: ObservableObject {
|
|||||||
self.previousState = next
|
self.previousState = next
|
||||||
self.state = next
|
self.state = next
|
||||||
self.checking = false
|
self.checking = false
|
||||||
|
self.checkStartedAt = nil
|
||||||
|
|
||||||
if notable { self.onNotableChange?(next) }
|
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.
|
/// Explicit refresh always re-enumerates, unlike the polling path.
|
||||||
func refreshAll() {
|
func refreshAll() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ private final class ResumeOnce: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Process.terminate()` only sends SIGTERM. A wedged network client (notably
|
||||||
|
/// `smbutil` after a tunnel disappears) may ignore it and keep the pipe open,
|
||||||
|
/// leaving the reader thread in `readDataToEndOfFile()` forever. Escalate to
|
||||||
|
/// SIGKILL after a short grace period so every probe is actually bounded.
|
||||||
|
private func stop(_ process: Process) {
|
||||||
|
guard process.isRunning else { return }
|
||||||
|
process.terminate()
|
||||||
|
let pid = process.processIdentifier
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
|
||||||
|
if process.isRunning { Darwin.kill(pid, SIGKILL) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Probe {
|
enum Probe {
|
||||||
/// Runs a command and reports whether it exited 0. A process that outlives
|
/// Runs a command and reports whether it exited 0. A process that outlives
|
||||||
/// `timeout` is killed and treated as failure — the VPN wrapper can block on
|
/// `timeout` is killed and treated as failure — the VPN wrapper can block on
|
||||||
@@ -51,7 +64,7 @@ enum Probe {
|
|||||||
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||||
guard process.isRunning else { return }
|
guard process.isRunning else { return }
|
||||||
process.terminate()
|
stop(process)
|
||||||
if once.claim() { continuation.resume(returning: false) }
|
if once.claim() { continuation.resume(returning: false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +132,7 @@ enum Probe {
|
|||||||
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||||
guard process.isRunning else { return }
|
guard process.isRunning else { return }
|
||||||
process.terminate()
|
stop(process)
|
||||||
if once.claim() {
|
if once.claim() {
|
||||||
continuation.resume(returning: RunResult(
|
continuation.resume(returning: RunResult(
|
||||||
ok: false, output: "", error: "timeout after \(timeout)s"))
|
ok: false, output: "", error: "timeout after \(timeout)s"))
|
||||||
@@ -170,7 +183,7 @@ enum Probe {
|
|||||||
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||||
guard process.isRunning else { return }
|
guard process.isRunning else { return }
|
||||||
process.terminate()
|
stop(process)
|
||||||
if once.claim() { continuation.resume(returning: nil) }
|
if once.claim() { continuation.resume(returning: nil) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user