import Foundation /// Everything the old shell wrapper kept in its `profile_config()` case block. struct VPNConfig: Codable { /// Profile name — also the basename of the .ovpn/.pid/.log/.auth files. var profile: String var label: String /// Public IP of the VPN server. Pinned to the LAN gateway during connect so /// the handshake does not route into the tunnel it is building. var serverHost: String var openvpnPath: String var baseDir: String /// Explicit path to the .ovpn profile. Empty falls back to /// `baseDir/profile.ovpn`, which is where the shell wrapper kept it. var configPathOverride: String /// Address family pulled inside the tunnel, e.g. "10.8.0". var tunNetwork: String /// Pushed office LAN, e.g. "192.168.77". Empty disables the route check — /// both profiles share 10.8.0.0/24, so without a distinct LAN there is no /// honest way to tell which tunnel is up. var lanNetwork: String var keychainService: String var keychainAccount: String /// How long openvpn may sit without a tunnel before connect treats it as /// wedged rather than still handshaking. A normal connect takes ~2 s. var startupGraceSeconds: Int var configPath: String { configPathOverride.isEmpty ? "\(baseDir)/\(profile).ovpn" : configPathOverride } var pidPath: String { "\(baseDir)/\(profile).pid" } var logPath: String { "\(baseDir)/\(profile).log" } var authPath: String { "\(baseDir)/\(profile).auth" } /// Tolerant like `Config`'s: a file written by an older build is missing /// keys, and losing every other setting over one absent field would be a /// poor trade. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) let d = VPNConfig.office profile = try c.decodeIfPresent(String.self, forKey: .profile) ?? d.profile label = try c.decodeIfPresent(String.self, forKey: .label) ?? d.label serverHost = try c.decodeIfPresent(String.self, forKey: .serverHost) ?? d.serverHost openvpnPath = try c.decodeIfPresent(String.self, forKey: .openvpnPath) ?? d.openvpnPath baseDir = try c.decodeIfPresent(String.self, forKey: .baseDir) ?? d.baseDir configPathOverride = try c.decodeIfPresent( String.self, forKey: .configPathOverride) ?? d.configPathOverride tunNetwork = try c.decodeIfPresent(String.self, forKey: .tunNetwork) ?? d.tunNetwork lanNetwork = try c.decodeIfPresent(String.self, forKey: .lanNetwork) ?? d.lanNetwork keychainService = try c.decodeIfPresent( String.self, forKey: .keychainService) ?? d.keychainService keychainAccount = try c.decodeIfPresent( String.self, forKey: .keychainAccount) ?? d.keychainAccount startupGraceSeconds = try c.decodeIfPresent( Int.self, forKey: .startupGraceSeconds) ?? d.startupGraceSeconds } init( profile: String, label: String, serverHost: String, openvpnPath: String, baseDir: String, configPathOverride: String, tunNetwork: String, lanNetwork: String, keychainService: String, keychainAccount: String, startupGraceSeconds: Int ) { self.profile = profile self.label = label self.serverHost = serverHost self.openvpnPath = openvpnPath self.baseDir = baseDir self.configPathOverride = configPathOverride self.tunNetwork = tunNetwork self.lanNetwork = lanNetwork self.keychainService = keychainService self.keychainAccount = keychainAccount self.startupGraceSeconds = startupGraceSeconds } static let office = VPNConfig( profile: "office", label: "Office", serverHost: "92.255.183.64", openvpnPath: "/opt/homebrew/sbin/openvpn", baseDir: NSHomeDirectory() + "/.config/openvpn-cli", configPathOverride: "", tunNetwork: "10.8.0", lanNetwork: "192.168.77", keychainService: "ovpn-office", keychainAccount: NSUserName(), startupGraceSeconds: 60 ) } /// User-editable settings, read from ~/.config/campusnas-monitor/config.json. /// Decoding is deliberately tolerant: every field falls back to its default, so /// an older or hand-trimmed file keeps working instead of silently reverting /// everything to defaults. struct Config: Codable { var nasHost: String var nasPort: UInt16 /// SMB account used to build the mount URL. The password is never stored /// here — it lives in the login Keychain, put there by the system auth sheet. var smbUser: String /// Ask the server what it exports instead of trusting a hand-written list. /// With this off, `shares` is used verbatim. var autoDiscoverShares: Bool /// Manual share list — used only when `autoDiscoverShares` is false. var shares: [String] /// Never shown, discovered or not. var excludeShares: [String] /// Mounted automatically when the NAS becomes reachable. Deliberately /// separate from the displayed list: showing every share the server has is /// useful, silently mounting ten volumes is not. var autoMountShares: [String] /// Per-share account override. Some shares are restricted to a different /// account in the NAS's Samba config (`valid users`), and mounting those /// with the default user fails as ENOENT rather than a permission error. /// Example: {"git-storage": "campus"}. var shareUsers: [String: String] /// Share opened by "Открыть шару в Finder". var primaryShare: String /// Mount every share automatically when the NAS becomes reachable again. /// Only ever attempted silently — never pops an auth sheet on its own. var autoMountOnReconnect: Bool /// VPN is driven natively now; the `ovpn` shell wrapper is no longer called. var vpn: VPNConfig var pollIntervalSeconds: Double var probeTimeoutSeconds: Double static let `default` = Config( nasHost: "CAMPUSNAS", nasPort: 445, smbUser: NSUserName(), autoDiscoverShares: true, shares: ["campus", "develop", "backup", "downloads"], excludeShares: [], autoMountShares: ["campus", "develop", "backup", "downloads"], shareUsers: [:], primaryShare: "campus", autoMountOnReconnect: true, vpn: .office, pollIntervalSeconds: 30, probeTimeoutSeconds: 4 ) init( nasHost: String, nasPort: UInt16, smbUser: String, autoDiscoverShares: Bool, shares: [String], excludeShares: [String], autoMountShares: [String], shareUsers: [String: String], primaryShare: String, autoMountOnReconnect: Bool, vpn: VPNConfig, pollIntervalSeconds: Double, probeTimeoutSeconds: Double ) { self.vpn = vpn self.nasHost = nasHost self.nasPort = nasPort self.smbUser = smbUser self.autoDiscoverShares = autoDiscoverShares self.shares = shares self.excludeShares = excludeShares self.autoMountShares = autoMountShares self.shareUsers = shareUsers self.primaryShare = primaryShare self.autoMountOnReconnect = autoMountOnReconnect self.pollIntervalSeconds = pollIntervalSeconds self.probeTimeoutSeconds = probeTimeoutSeconds } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) let d = Config.default nasHost = try c.decodeIfPresent(String.self, forKey: .nasHost) ?? d.nasHost nasPort = try c.decodeIfPresent(UInt16.self, forKey: .nasPort) ?? d.nasPort smbUser = try c.decodeIfPresent(String.self, forKey: .smbUser) ?? d.smbUser autoDiscoverShares = try c.decodeIfPresent( Bool.self, forKey: .autoDiscoverShares) ?? d.autoDiscoverShares shares = try c.decodeIfPresent([String].self, forKey: .shares) ?? d.shares excludeShares = try c.decodeIfPresent([String].self, forKey: .excludeShares) ?? d.excludeShares autoMountShares = try c.decodeIfPresent([String].self, forKey: .autoMountShares) ?? d.autoMountShares shareUsers = try c.decodeIfPresent([String: String].self, forKey: .shareUsers) ?? d.shareUsers primaryShare = try c.decodeIfPresent(String.self, forKey: .primaryShare) ?? d.primaryShare autoMountOnReconnect = try c.decodeIfPresent( Bool.self, forKey: .autoMountOnReconnect) ?? d.autoMountOnReconnect vpn = try c.decodeIfPresent(VPNConfig.self, forKey: .vpn) ?? d.vpn pollIntervalSeconds = try c.decodeIfPresent( Double.self, forKey: .pollIntervalSeconds) ?? d.pollIntervalSeconds probeTimeoutSeconds = try c.decodeIfPresent( Double.self, forKey: .probeTimeoutSeconds) ?? d.probeTimeoutSeconds } func user(for share: String) -> String { shareUsers[share] ?? smbUser } static var configURL: URL { URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent(".config/campusnas-monitor/config.json") } static func load() -> Config { guard let data = try? Data(contentsOf: configURL) else { return .default } guard let parsed = try? JSONDecoder().decode(Config.self, from: data) else { NSLog("campusnas-monitor: config.json is malformed, using defaults") return .default } return parsed } /// Writes the current values out so the file is there to edit. func write() { let url = Config.configURL try? FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try? encoder.encode(self).write(to: url) } func writeIfAbsent() { guard !FileManager.default.fileExists(atPath: Config.configURL.path) else { return } write() } }