import AppKit import Combine import Foundation import SwiftUI /// Single source of truth for both the menu bar item and the window, so the two /// can never disagree about what is mounted or whether the tunnel is up. @MainActor final class AppModel: ObservableObject { @Published private(set) var state: LinkState = .unknown /// share name -> mount point @Published private(set) var mounted: [String: String] = [:] @Published private(set) var busy = false @Published private(set) var lastError: String? /// Shares reported by the server itself. Empty until the first successful /// enumeration. @Published private(set) var discovered: [String] = [] /// Missing prerequisites — openvpn binary, profile, credentials. @Published private(set) var requirements: [VPNService.Requirement] = [] /// Result of the last successful VPN action, for the window. @Published private(set) var vpnMessage: String? /// Zoom steps for ⌘+ / ⌘−, as an explicit multiplier for every font size and /// padding. /// /// Not `dynamicTypeSize`: that is an iOS mechanism and AppKit ignores it for /// system fonts — measured, the window came out identical at every step. /// Not `scaleEffect` either: it stretches an already rasterised layer and /// blurs the text. Multiplying the metrics re-lays-out for real and stays /// sharp. static let zoomSteps: [CGFloat] = [0.8, 0.9, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0] /// 1.15×, one step above system size — the default the app is actually /// comfortable at. ⌘0 returns here, not to 1.0×. private static let defaultZoomIndex = 3 private static let zoomKey = "zoomIndex" @Published var zoomIndex: Int = { let stored = UserDefaults.standard.object(forKey: AppModel.zoomKey) as? Int return stored ?? AppModel.defaultZoomIndex }() { didSet { UserDefaults.standard.set(zoomIndex, forKey: Self.zoomKey) } } var zoomScale: CGFloat { Self.zoomSteps[min(max(zoomIndex, 0), Self.zoomSteps.count - 1)] } func zoomIn() { zoomIndex = min(zoomIndex + 1, Self.zoomSteps.count - 1) } func zoomOut() { zoomIndex = max(zoomIndex - 1, 0) } func zoomReset() { zoomIndex = Self.defaultZoomIndex } private(set) var config: Config private var timer: Timer? private var checking = false /// 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 /// Enumeration needs credentials, so it fails until the user has /// authenticated once. Without this flag an empty result would make every /// poll spawn another smbutil — a failing probe every 30 s forever. private var discoveryTried = false /// Fires on a transition worth announcing. The first probe only counts if it /// found a problem: being told at login that everything is fine is noise. var onNotableChange: ((LinkState) -> Void)? init(config: Config) { self.config = config } /// What the UI lists. Discovery is the default so the app cannot silently /// disagree with the server about which shares exist — the hand-written /// list was the reason it showed four of eleven. var shares: [String] { let base = config.autoDiscoverShares && !discovered.isEmpty ? discovered : config.shares return base.filter { !config.excludeShares.contains($0) } } var host: String { config.nasHost } func start() { observeVolumeChanges() refreshRequirements() timer?.invalidate() timer = Timer.scheduledTimer( withTimeInterval: config.pollIntervalSeconds, repeats: true ) { [weak self] _ in Task { @MainActor in self?.checkNow() } } checkNow() } /// Mounts happen behind this app's back — opening a share in Finder's server /// browser mounts it on the spot. macOS announces those, so react to the /// event instead of waiting out the poll interval. private func observeVolumeChanges() { let center = NSWorkspace.shared.notificationCenter for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] { center.addObserver( forName: name, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.refreshMountedNow() } } } } /// Cheap: reads the kernel mount table, touches no network. func refreshMountedNow() { let live = Mounter.mountedShares(host: config.nasHost) guard live != mounted else { return } mounted = live Log.write("mount table changed: \(live.keys.sorted().joined(separator: ", "))") } // MARK: - Probing func checkNow() { guard !checking else { return } checking = true Task { @MainActor in let next = await probe() self.mounted = Mounter.mountedShares(host: config.nasHost) let wasUnknown = self.previousState == .unknown let changed = next != self.previousState let notable = changed && !(wasUnknown && next == .ok) let becameReachable = next == .ok && self.previousState != .ok if changed { Log.write("state: \(self.previousState.rawValue) -> \(next.rawValue)") } self.previousState = next self.state = next self.checking = false if notable { self.onNotableChange?(next) } // Enumerate once per reconnect, not every poll — spawning smbutil // on a 30 s timer to re-learn a list that rarely changes is waste. if next == .ok && (becameReachable || !self.discoveryTried) { await self.refreshShares() } if becameReachable && config.autoMountOnReconnect { self.mount(config.autoMountShares, allowUI: false) } } } /// Explicit refresh always re-enumerates, unlike the polling path. func refreshAll() { Task { @MainActor in await refreshShares() checkNow() } } func refreshShares() async { guard config.autoDiscoverShares else { return } discoveryTried = true let found = await Mounter.listShares( host: config.nasHost, user: config.smbUser, timeout: max(config.probeTimeoutSeconds, 8)) guard !found.isEmpty else { return } discovered = found Log.write("discovered \(found.count) shares: \(found.joined(separator: ", "))") } private func probe() async -> LinkState { let vpnUp = await VPNService.isUp(config.vpn) guard vpnUp else { return .vpnDown } let nasUp = await Probe.tcpReachable( host: config.nasHost, port: config.nasPort, timeout: config.probeTimeoutSeconds) return nasUp ? .ok : .nasDown } // MARK: - Mounting func isMounted(_ share: String) -> Bool { mounted[share] != nil } func mountPoint(_ share: String) -> String? { mounted[share] } /// `allowUI: false` is the silent path — it succeeds only if the Keychain /// already holds the credentials. Automatic mounts always use it, so /// reconnecting never throws an unexpected password sheet at the user. func mount(_ shares: [String], allowUI: Bool) { // Re-read the mount table instead of trusting the polled snapshot: it // can be up to a poll interval stale, and Finder mounts shares behind // our back. let live = Mounter.mountedShares(host: config.nasHost) mounted = live let pending = shares.filter { live[$0] == nil } guard !pending.isEmpty, !busy else { return } busy = true lastError = nil let host = config.nasHost let users = pending.map { config.user(for: $0) } Task.detached(priority: .userInitiated) { var results: [Mounter.Result] = [] for (share, user) in zip(pending, users) { results.append( Mounter.mount(host: host, user: user, share: share, allowUI: allowUI)) } let collected = results await MainActor.run { [weak self] in self?.finish(collected) } } } private func finish(_ results: [Mounter.Result]) { busy = false let failed = results.filter { !$0.ok } lastError = failed.isEmpty ? nil : failed.map(\.reason).joined(separator: "; ") if let lastError { Log.write("mount failures: \(lastError)") } // A successful interactive mount is what puts the credentials in the // Keychain, so this is the moment enumeration can start working. if results.contains(where: { $0.ok }) && discovered.isEmpty { Task { @MainActor in await refreshShares() } } checkNow() } func mountMissing(allowUI: Bool) { mount(shares, allowUI: allowUI) } func unmount(_ shares: [String]) { let points = shares.compactMap { mounted[$0] } guard !points.isEmpty, !busy else { return } busy = true Task.detached(priority: .userInitiated) { for point in points { _ = Mounter.unmount(mountPoint: point) } await MainActor.run { [weak self] in self?.busy = false self?.checkNow() } } } func toggle(_ share: String) { if isMounted(share) { unmount([share]) } else { // Explicit click: allow the system auth sheet, since this is where // the Keychain entry gets created. mount([share], allowUI: true) } } func openInFinder(_ share: String) { if let point = mounted[share] { NSWorkspace.shared.open(URL(fileURLWithPath: point)) } else { mount([share], allowUI: true) } } // MARK: - VPN func connectVPN() { runVPN { await VPNService.connect(self.config.vpn) } } func disconnectVPN() { runVPN { await VPNService.disconnect(self.config.vpn) } } func rerouteVPN() { runVPN { await VPNService.reroute(self.config.vpn) } } /// Serialised through `busy` so two privileged batches cannot race each /// other into a half-connected state. private func runVPN(_ action: @escaping @Sendable () async -> VPNService.ActionResult) { guard !busy else { return } busy = true lastError = nil Task { @MainActor in let result = await action() self.busy = false self.lastError = result.isError ? result.message : nil self.vpnMessage = result.isError ? nil : result.message Log.write("vpn: \(result.message)") self.checkNow() } } /// Opens Terminal with a suggested fix (e.g. `brew install openvpn`) rather /// than running it silently: an install command should be visible to the /// person who asked for it, and its own output is worth seeing. func runFixInTerminal(_ command: String) { let escaped = command .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let script = """ tell application "Terminal" activate do script "\(escaped)" end tell """ let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") process.arguments = ["-e", script] do { try process.run() } catch { Log.write("failed to launch Terminal: \(error)") } } func openVPNLog() { NSWorkspace.shared.open(URL(fileURLWithPath: config.vpn.logPath)) } // MARK: - Settings /// Persists the edited config and re-reads everything that depends on it. func updateVPN(_ vpn: VPNConfig) { config.vpn = vpn config.write() Log.write("vpn config updated: profile=\(vpn.configPath) " + "server=\(vpn.serverHost) keychain=\(vpn.keychainService)") refreshRequirements() checkNow() } func updateSMBUser(_ user: String) { let trimmed = user.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, trimmed != config.smbUser else { return } config.smbUser = trimmed config.write() checkNow() } /// Stores the VPN password. The value is handed straight to the Keychain and /// kept nowhere else — not in the config file, not in the log. func saveVPNPassword(_ password: String) -> String? { guard !password.isEmpty else { return "Пароль пустой" } let error = KeychainStore.save( service: config.vpn.keychainService, account: config.vpn.keychainAccount, password: password) Log.write(error == nil ? "keychain: entry \(config.vpn.keychainService) saved" : "keychain: save failed — \(error!)") refreshRequirements() return error } func vpnPasswordStored() -> Bool { KeychainStore.exists( service: config.vpn.keychainService, account: config.vpn.keychainAccount) } func refreshRequirements() { Task { @MainActor in requirements = await VPNService.requirements(config.vpn) } } func openConfig() { config.writeIfAbsent() NSWorkspace.shared.open(Config.configURL) } }