Add CampusNAS menu bar agent
Native macOS agent that keeps the office VPN and the NAS shares working without the shell wrapper it replaces. - VPN: full port of the `ovpn` script — host-route pinning, gateway discovery via networksetup, tunnel liveness by address plus pushed route, startup grace, restart of a wedged daemon. Root steps are batched into one system authorization prompt. - SMB: mounts via NetFS, discovers shares from the server, detects mounts event-driven through NSWorkspace, matches the host by resolved alias so externally created mounts are recognised. - Credentials never pass through the process: the VPN auth file is written by `security` straight to disk, and the password is stored via SecItemAdd rather than a command line visible in ps. - UI: status window, settings editor, ⌘+/⌘− zoom that re-lays out rather than scaling a bitmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// One-shot guard so a continuation can never be resumed twice, which would trap.
|
||||
private final class ResumeOnce: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var fired = false
|
||||
|
||||
func claim() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if fired { return false }
|
||||
fired = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
enum Probe {
|
||||
/// 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
|
||||
/// a dead network otherwise.
|
||||
static func commandSucceeds(
|
||||
_ launchPath: String, args: [String], timeout: TimeInterval
|
||||
) async -> Bool {
|
||||
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
|
||||
NSLog("campusnas-monitor: not executable: %@", launchPath)
|
||||
return false
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||
process.arguments = args
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
let once = ResumeOnce()
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
process.terminationHandler = { proc in
|
||||
guard once.claim() else { return }
|
||||
continuation.resume(returning: proc.terminationStatus == 0)
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
NSLog("campusnas-monitor: failed to run %@: %@", launchPath, "\(error)")
|
||||
if once.claim() { continuation.resume(returning: false) }
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
if once.claim() { continuation.resume(returning: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RunResult: Sendable {
|
||||
let ok: Bool
|
||||
let output: String
|
||||
let error: String
|
||||
}
|
||||
|
||||
/// Like `commandOutput`, but also reports stderr and never discards output
|
||||
/// on a non-zero exit — needed to diagnose privileged steps.
|
||||
static func commandRun(
|
||||
_ launchPath: String, args: [String], timeout: TimeInterval
|
||||
) async -> RunResult {
|
||||
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
|
||||
return RunResult(ok: false, output: "", error: "not executable: \(launchPath)")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||
process.arguments = args
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
let once = ResumeOnce()
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
if once.claim() {
|
||||
continuation.resume(returning: RunResult(
|
||||
ok: false, output: "", error: "\(error)"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global().async {
|
||||
// Drain stderr on its own thread: both pipes filling at once
|
||||
// would deadlock a sequential read.
|
||||
var errorText = ""
|
||||
let errorQueue = DispatchQueue(label: "campusnas.stderr")
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
errorQueue.async {
|
||||
let data = errPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
errorText = String(data: data, encoding: .utf8) ?? ""
|
||||
group.leave()
|
||||
}
|
||||
|
||||
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
group.wait()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard once.claim() else { return }
|
||||
continuation.resume(returning: RunResult(
|
||||
ok: process.terminationStatus == 0,
|
||||
output: String(data: outData, encoding: .utf8) ?? "",
|
||||
error: errorText))
|
||||
}
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
if once.claim() {
|
||||
continuation.resume(returning: RunResult(
|
||||
ok: false, output: "", error: "timeout after \(timeout)s"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a command and returns its stdout, or nil on failure/timeout.
|
||||
///
|
||||
/// stdout is drained on a background queue rather than after exit: a process
|
||||
/// that fills the pipe buffer would otherwise block forever waiting for a
|
||||
/// reader that never comes.
|
||||
static func commandOutput(
|
||||
_ launchPath: String, args: [String], timeout: TimeInterval
|
||||
) async -> String? {
|
||||
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
|
||||
Log.write("not executable: \(launchPath)")
|
||||
return nil
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
let once = ResumeOnce()
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
Log.write("failed to run \(launchPath): \(error)")
|
||||
if once.claim() { continuation.resume(returning: nil) }
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global().async {
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
guard once.claim() else { return }
|
||||
let ok = process.terminationStatus == 0
|
||||
continuation.resume(
|
||||
returning: ok ? String(data: data, encoding: .utf8) : nil)
|
||||
}
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
if once.claim() { continuation.resume(returning: nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a TCP connection and closes it immediately. Deliberately does not
|
||||
/// touch the autofs path: an `ls` there would trigger a mount on every poll
|
||||
/// and defeat the whole point of lazy mounting.
|
||||
static func tcpReachable(
|
||||
host: String, port: UInt16, timeout: TimeInterval
|
||||
) async -> Bool {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return false }
|
||||
|
||||
let connection = NWConnection(
|
||||
host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
|
||||
let once = ResumeOnce()
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
@Sendable func finish(_ reachable: Bool) {
|
||||
guard once.claim() else { return }
|
||||
connection.cancel()
|
||||
continuation.resume(returning: reachable)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
case .waiting:
|
||||
// Host unreachable / connection refused — no point waiting out
|
||||
// the full timeout for a retry that will not happen.
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: .global())
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user