392 lines
14 KiB
Swift
392 lines
14 KiB
Swift
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
|
||
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
|
||
/// 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(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
|
||
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
|
||
self.checkStartedAt = nil
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
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)
|
||
}
|
||
}
|