Files

392 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}