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:
2026-07-31 13:19:31 +06:00
co-authored by Claude Opus 5
commit cd53b6a1a1
20 changed files with 2897 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# SwiftPM build artefacts
.build/
# Assembled .app and distribution archives — rebuildable, and megabytes of them
build/
dist/
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "CampusNASMonitor",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "CampusNASMonitor",
path: "Sources/CampusNASMonitor",
swiftSettings: [.swiftLanguageMode(.v5)]
)
]
)
+288
View File
@@ -0,0 +1,288 @@
# CampusNAS Monitor
Menu bar agent that watches the office OpenVPN tunnel and the office NAS, and
mounts the SMB shares itself.
Replaces the autofs approach entirely: autofs is silent by design, so a share
that stops working just looks like an empty folder, and "empty because the
tunnel is down" is indistinguishable from "empty because the Keychain entry went
stale". This app mounts the shares directly and says which of those it is.
## What it does
Every `pollIntervalSeconds` (default 30):
1. `ovpn office status` — exit code 0 means the tunnel is up.
2. TCP connect to `192.168.77.43:445` — does the NAS answer SMB?
Three states, and the distinction is the point — they have different causes and
different fixes:
| State | Menu bar icon | Meaning |
|---|---|---|
| `ok` | `externaldrive.connected.to.line.below` | NAS reachable |
| `nasDown` | `externaldrive.badge.exclamationmark` | tunnel up, NAS silent on 445 |
| `vpnDown` | `externaldrive.badge.xmark` | tunnel down |
The menu bar item is the actual indicator: the icon changes with state, and a
short label (`VPN` / `NAS`) appears beside it when something is broken. Nothing
is drawn when healthy — a permanent label gets ignored within a day.
Notifications are attempted on state transitions but **do not work under ad-hoc
signing** — see Known limits. The badge is what you will actually see.
When the NAS becomes reachable, configured shares are mounted automatically —
silently, using credentials already in the login Keychain. A silent mount never
raises an auth sheet on its own; if credentials are missing it reports that and
lets you click.
## VPN
The `ovpn <profile> connect|disconnect|status|reroute` shell wrapper is ported
natively; the script is no longer invoked. Ported behaviour, including the parts
that exist for non-obvious reasons:
- **Host-route pinning.** The server IP is pinned to the LAN gateway before the
tunnel comes up, otherwise the handshake tries to route through the tunnel it
is establishing. The stale route is deleted first, so it self-heals after a
subnet change — and disconnect always drops it, or it blackholes the server
later.
- **Gateway detection** walks `networksetup` services and takes the first with a
valid IPv4 `Router:`. Deliberately not the system default route: once the
tunnel is up, that points into it. The IPv4 check matters — the first service
here reports `(null)`.
- **Liveness is not the pid.** On an auth or TLS failure openvpn soft-restarts
forever, so the process stays up with no tunnel. Up means a `10.8.0.x` address
*and* a route to the pushed LAN. With no known LAN the check falls back to the
pid, because both profiles share `10.8.0.0/24` and the address alone would lie.
- **Startup grace.** A live pid with no tunnel is "still connecting" for 60 s,
and a wedged process after that — restarted as part of the same privileged
batch.
- `kill(pid, 0)` returning `EPERM` counts as alive: openvpn runs as root, this
app does not.
### Credentials
**The password never enters this process.** If `<profile>.auth` exists it is
used as-is. Otherwise the auth file is written by `security` redirecting
straight to a `umask 077` temp file — Keychain to disk, with the app knowing
only the path. The temp file is removed once the daemon has read it.
The requirement check probes the Keychain entry *without* `-w`, so even the
existence test reads no secret.
### Privileges
Route changes and launching openvpn need root. They are batched into a single
`do shell script … with administrator privileges`, so macOS shows its own
authorization dialog **once per action** and collects the password itself.
The alternative — an `SMJobBless` helper — needs a Developer ID signature, which
an ad-hoc build does not have.
### Settings window
**Настройки** (⌘, from the status menu, or the button in the main window) edits
what differs between machines:
- the `.ovpn` profile — file picker, or the default `baseDir/profile.ovpn`
- path to the openvpn binary
- server IP, tunnel network, office LAN
- Keychain service and login
- the VPN password
- the SMB login
The password is written with `SecItemAdd` / `SecItemUpdate`, never by shelling
out to `security add-generic-password -w <password>`: command-line arguments are
readable in `ps` by every process on the machine. It goes straight to the
Keychain and is kept nowhere else — not in the config file, not in the log. The
account name is persisted before the password, so the secret cannot land under a
stale login where connect would not look for it.
The SMB password is not handled here at all: macOS collects it in its own
mount dialog.
### Requirements
Checked at launch and listed in the window when unmet: the openvpn binary, the
`.ovpn` profile, and credentials (auth file or Keychain entry). A missing
dependency with an obvious fix gets an **Установить** button that opens Terminal
with the command — visible and user-initiated, never a silent install.
## Share discovery
The list of shares comes from the server, via `smbutil view`. A hand-written
list was the original design and it was wrong: the app showed four shares while
the NAS exported eleven, and nothing in the UI hinted that the list was partial.
NetFS does have a share-enumeration API (`NetFSOpenSession` /
`NetFSEnumerateShares`), but it is not declared in the public headers — using it
would mean `dlsym` against private symbols, which breaks on an OS update.
`smbutil` is a documented system tool and shares the same Keychain credentials.
Enumeration **requires credentials**: with no Keychain entry for the host it
exits 77 (`Authentication error`). So on a fresh host name the app falls back to
the manual `shares` list until the first interactive mount succeeds, at which
point it enumerates immediately. It is retried once per reconnect, not per poll.
Displayed shares and auto-mounted shares are deliberately separate lists:
showing everything the server has is useful, silently mounting ten volumes is
not.
## Mounting
Uses `NetFS` (`NetFSMountURLSync`) rather than shelling out to `mount_smbfs`:
NetFS reads the login Keychain by itself and, when UI is allowed, presents the
standard system auth sheet with the "remember password" checkbox. That sheet is
the only supported way to create the Keychain entry — **this app never handles
the password**.
- automatic mounts → silent (`NoUI`), fail fast if credentials are missing
- clicking a share or "Подключить все" → auth sheet allowed
Shares mount at `/Volumes/<share>`, soft-mounted so a NAS that vanishes
mid-session returns errors instead of wedging every Finder window.
Mount state is read from the kernel mount table (`getmntinfo`, filtered to
`smbfs` and this host), not by listing `/Volumes` — a leftover empty directory
is never mistaken for a live mount.
Mounts appear and vanish without this app doing anything: opening a share in
Finder's server browser mounts it on the spot. Two consequences, both handled:
- **Detection is event-driven.** `NSWorkspace.didMountNotification` /
`didUnmountNotification` refresh the state immediately. Polling alone left the
UI up to 30 s stale, which read as "the app shows fewer shares than are
actually connected".
- **Host matching goes by alias, not string.** The mount record carries whatever
host string the mounter used — `CAMPUSNAS`, `campusnas`, or the IP. The
configured host is resolved with `getaddrinfo` and compared
case-insensitively against that set, so an externally created mount is still
recognised as ours.
Mounting an already-mounted share returns `EEXIST` (17) and is treated as
success — the share can be mounted between the UI reading its state and the
click landing, and a red error for "already how you wanted it" is wrong.
## Build
```bash
./build.sh
```
Produces `build/CampusNAS.app`, ad-hoc signed. The bundle is not optional:
`UNUserNotificationCenter` and `SMAppService` both refuse to operate on a bare
binary.
Install:
```bash
cp -R build/CampusNAS.app /Applications/
```
## Config
Written on first launch to `~/.config/campusnas-monitor/config.json`. Decoding is
tolerant — every missing key falls back to its default, so a hand-trimmed file
keeps working. Restart the app to apply.
| Key | Default | Notes |
|---|---|---|
| `nasHost` | `192.168.77.43` | |
| `nasPort` | `445` | SMB |
| `smbUser` | current user | password lives in the Keychain, never here |
| `autoDiscoverShares` | `true` | ask the server instead of trusting a list |
| `shares` | `campus`, `develop`, `backup`, `downloads` | manual list, used only when discovery is off or has not succeeded yet |
| `excludeShares` | `[]` | never shown, discovered or not |
| `autoMountShares` | `campus`, `develop`, `backup`, `downloads` | mounted automatically on reconnect |
| `shareUsers` | `{}` | per-share account override, see below |
| `primaryShare` | `campus` | opened by "Открыть шару в Finder" |
| `autoMountOnReconnect` | `true` | silent mounts when the NAS comes back |
| `vpnStatusCommand` / `vpnStatusArgs` | `~/.local/bin/ovpn` `office status` | exit 0 = up |
| `vpnConnectCommand` | `~/.local/bin/ovpn office connect` | run in Terminal |
| `vpnDisconnectCommand` | `~/.local/bin/ovpn office disconnect` | run in Terminal |
| `pollIntervalSeconds` | `30` | |
| `probeTimeoutSeconds` | `4` | per probe |
### shareUsers
Some shares are restricted to a different account in the NAS's Samba config. On
this NAS, `git-storage` has:
```
[git-storage]
valid users=nobody,campus,nobody
```
so `romanv` cannot mount it. The server answers with a name-not-found error,
which macOS surfaces as `ENOENT` — "No such file or directory", not a permission
error. Misleading enough that the app spells it out in the failure message.
To use such a share, mount it under the account that is allowed:
```json
"shares": ["campus", "develop", "backup", "downloads", "git-storage"],
"shareUsers": { "git-storage": "campus" }
```
The first mount will ask for that account's password via the system auth sheet.
## Window
Opened from the menu (**Показать окно**, ⌘O) or by launching the app again
while it is running — Finder, Spotlight or `open` all bring it up.
- current state, host, and a spinner while mounting
- **VPN**: Подключить / Отключить, each enabled only when it makes sense
- one row per share: mounted indicator, mount point, Открыть, and
Подключить/Отключить
- **Подключить все** / **Отключить все** / **Обновить**
- mount failures shown inline in red
Both connect and disconnect run in Terminal on purpose: the wrapper needs sudo
for the host route in either direction, and a background agent has no way to
show that password prompt.
The window and the menu bar item read the same `AppModel`, so they cannot
disagree about what is mounted.
## Zoom
⌘+ / ⌘− step through eight scales (0.8× … 2.0×), ⌘0 resets. The step is
persisted in `UserDefaults` under `zoomIndex`, and the window resizes to fit.
Implemented as an explicit multiplier applied to every font size and padding.
Two obvious approaches were tried and rejected:
- `dynamicTypeSize` — an iOS mechanism. AppKit ignores it for system fonts;
measured, the window came out identical at all seven steps.
- `scaleEffect` — stretches an already rasterised layer, so text blurs.
An `.accessory` app has no menu bar, so the shortcuts cannot be menu items:
they are caught by a local `NSEvent` monitor while the window is key. The same
three actions are also in the status bar menu.
## Menu
- current state and a mounted-share count
- **Показать окно** (⌘O)
- **Проверить сейчас** (⌘R)
- **Запускать при входе** — `SMAppService.mainApp`
- **Открыть конфиг**
## Known limits
- **Notifications do not work.** `requestAuthorization` fails with
`UNErrorDomain code=1` ("Notifications are not allowed for this application").
Verified to persist from `/Applications`, after deduplicating the
LaunchServices records and restarting `usernotificationsd`, and with
`LSUIElement` disabled. The app never appears in
`~/Library/Preferences/com.apple.ncprefs.plist`, so there is nothing to enable
in System Settings either. Cause is the ad-hoc signature; a Developer ID
signature would be required. The menu bar badge covers the same need without
any permission, so this is left as-is. The posting code stays in place and
starts working by itself if the app is ever signed properly.
- Diagnostics go to `~/Library/Logs/CampusNAS.log`, because `NSLog` from an
ad-hoc bundle does not reliably reach the unified log.
- Detects that the NAS stopped answering on 445, not why.
- Assumes the `ovpn` wrapper is the single source of truth for tunnel state.
+290
View File
@@ -0,0 +1,290 @@
import AppKit
import Combine
import ServiceManagement
import SwiftUI
import UserNotifications
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var statusItem: NSStatusItem!
private var model: AppModel!
private var window: NSWindow?
private var hosting: NSHostingController<ContentView>?
private var settingsWindow: NSWindow?
private var keyMonitor: Any?
private var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
let config = Config.load()
config.writeIfAbsent()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.menu = NSMenu()
statusItem.isVisible = true
statusItem.behavior = []
// Survives across launches so macOS restores the same slot instead of
// treating every rebuild as a brand new item.
statusItem.autosaveName = "CampusNASStatusItem"
requestNotificationAuthorization()
model = AppModel(config: config)
model.onNotableChange = { [weak self] state in
self?.postNotification(state.notificationBody)
}
// The menu bar item mirrors the model, same as the window does.
model.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.render() }
.store(in: &cancellables)
installKeyMonitor()
model.start()
render()
if let button = statusItem.button {
Log.write("status item: visible=\(statusItem.isVisible) "
+ "width=\(button.frame.width) hasImage=\(button.image != nil) "
+ "screens=\(NSScreen.screens.count)")
} else {
Log.write("status item has no button — menu bar is full")
}
}
/// Launching the app again while it is already running (Finder, Spotlight,
/// `open`) brings the window up instead of doing nothing, which is what a
/// second launch is normally asking for.
func applicationShouldHandleReopen(
_ sender: NSApplication, hasVisibleWindows: Bool
) -> Bool {
showWindow()
return true
}
// MARK: - Status item
private func render() {
let state = model.state
if let button = statusItem.button {
let image = NSImage(
systemSymbolName: state.symbolName, accessibilityDescription: state.title)
image?.isTemplate = true
button.image = image
// Without this fallback an unresolved SF Symbol leaves the item with
// no image and no title, i.e. zero width — present but invisible and
// unclickable, which is indistinguishable from the app not running.
if image == nil {
Log.write("SF Symbol missing: \(state.symbolName)")
}
let label = image == nil ? (state.badge.isEmpty ? "NAS" : state.badge)
: state.badge
button.imagePosition = label.isEmpty ? .imageOnly : .imageLeading
button.title = label.isEmpty ? "" : " " + label
button.toolTip = state.title
}
let menu = NSMenu()
let status = NSMenuItem(title: state.title, action: nil, keyEquivalent: "")
status.isEnabled = false
menu.addItem(status)
let mountedCount = model.mounted.count
let summary = NSMenuItem(
title: "Смонтировано шар: \(mountedCount) из \(model.shares.count)",
action: nil, keyEquivalent: "")
summary.isEnabled = false
menu.addItem(summary)
menu.addItem(.separator())
menu.addItem(action("Показать окно", #selector(showWindow), key: "o"))
menu.addItem(action("Проверить сейчас", #selector(checkNow), key: "r"))
menu.addItem(.separator())
menu.addItem(action("Крупнее (⌘+)", #selector(zoomIn)))
menu.addItem(action("Мельче (⌘−)", #selector(zoomOut)))
menu.addItem(action("Обычный размер (⌘0)", #selector(zoomReset)))
menu.addItem(.separator())
let launchItem = NSMenuItem(
title: "Запускать при входе", action: #selector(toggleLoginItem), keyEquivalent: "")
launchItem.state = (SMAppService.mainApp.status == .enabled) ? .on : .off
launchItem.target = self
menu.addItem(launchItem)
menu.addItem(action("Настройки…", #selector(showSettings), key: ","))
menu.addItem(action("Открыть конфиг", #selector(openConfig)))
menu.addItem(.separator())
menu.addItem(NSMenuItem(
title: "Выйти", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem.menu = menu
}
private func action(_ title: String, _ selector: Selector, key: String = "") -> NSMenuItem {
let item = NSMenuItem(title: title, action: selector, keyEquivalent: key)
item.target = self
return item
}
// MARK: - Window
@objc private func showWindow() {
if window == nil {
let hosting = NSHostingController(
rootView: ContentView(model: model) { [weak self] in self?.showSettings() })
self.hosting = hosting
let win = NSWindow(contentViewController: hosting)
win.title = "CampusNAS"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable]
// The delegate is an accessory app, so the window is closed and
// reopened rather than destroyed — keep the instance alive.
win.isReleasedWhenClosed = false
win.delegate = self
win.center()
window = win
}
// An .accessory app is not activated by default; without this the
// window opens behind whatever is in front.
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
model.checkNow()
}
/// Rebuilt on each open so the editor starts from the current config rather
/// than whatever was on screen last time.
@objc private func showSettings() {
settingsWindow?.close()
let hosting = NSHostingController(
rootView: SettingsView(model: model) { [weak self] in
self?.settingsWindow?.close()
})
let win = NSWindow(contentViewController: hosting)
win.title = "Настройки CampusNAS"
win.styleMask = [.titled, .closable, .resizable]
win.isReleasedWhenClosed = false
win.center()
settingsWindow = win
NSApp.activate(ignoringOtherApps: true)
win.makeKeyAndOrderFront(nil)
}
// MARK: - Zoom
/// An .accessory app has no menu bar, so ⌘+ / ⌘− / ⌘0 cannot come from menu
/// items — they are caught here while our window is key.
private func installKeyMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
event.modifierFlags.contains(.command),
self.window?.isKeyWindow == true
else { return event }
switch event.charactersIgnoringModifiers {
case "+", "=":
self.model.zoomIn()
self.fitWindowToContent()
return nil
case "-":
self.model.zoomOut()
self.fitWindowToContent()
return nil
case "0":
self.model.zoomReset()
self.fitWindowToContent()
return nil
default:
return event
}
}
}
/// Resizes after SwiftUI has re-laid-out at the new type size — reading
/// fittingSize in the same turn returns the previous layout.
private func fitWindowToContent() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let window = self?.window, let hosting = self?.hosting else { return }
let size = hosting.view.fittingSize
guard size.width > 0, size.height > 0 else { return }
window.setContentSize(size)
}
}
@objc private func zoomIn() { model.zoomIn(); fitWindowToContent() }
@objc private func zoomOut() { model.zoomOut(); fitWindowToContent() }
@objc private func zoomReset() { model.zoomReset(); fitWindowToContent() }
// MARK: - Actions
@objc private func checkNow() {
model.checkNow()
}
@objc private func openConfig() {
model.openConfig()
}
@objc private func toggleLoginItem() {
do {
if SMAppService.mainApp.status == .enabled {
try SMAppService.mainApp.unregister()
} else {
try SMAppService.mainApp.register()
}
} catch {
Log.write("login item toggle failed: \(error)")
}
render()
}
// MARK: - Notifications
private func requestNotificationAuthorization() {
guard let bundleID = Bundle.main.bundleIdentifier else {
Log.write("no bundle identifier, notifications disabled")
return
}
Log.write("bundle \(bundleID) at \(Bundle.main.bundlePath)")
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound]) { granted, error in
if let error = error as NSError? {
Log.write("notification auth error: domain=\(error.domain) "
+ "code=\(error.code) \(error.localizedDescription)")
} else if !granted {
Log.write("notifications denied")
} else {
Log.write("notifications authorized")
}
}
}
/// Posts unconditionally rather than gating on the authorization result:
/// gating meant a missed permission prompt silenced the app forever. Under
/// ad-hoc signing this never actually delivers — the menu bar badge is the
/// real indicator — but the code costs nothing and starts working if the
/// app is ever signed with a Developer ID.
private func postNotification(_ body: String) {
guard Bundle.main.bundleIdentifier != nil, !body.isEmpty else { return }
let content = UNMutableNotificationContent()
content.title = "campusnas"
content.body = body
content.sound = .default
let request = UNNotificationRequest(
identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { error in
if let error = error as NSError? {
Log.write("notification post failed: code=\(error.code)")
}
}
}
}
+369
View File
@@ -0,0 +1,369 @@
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)
}
}
+225
View File
@@ -0,0 +1,225 @@
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()
}
}
+237
View File
@@ -0,0 +1,237 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var model: AppModel
/// Injected rather than owned: window management belongs to the delegate.
var onOpenSettings: () -> Void = {}
/// Zoom multiplier. Every size below is expressed in terms of it, which is
/// what makes ⌘+ / ⌘− an actual re-layout instead of a stretched bitmap.
private var s: CGFloat { model.zoomScale }
private func font(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size * s, weight: weight)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
if !missingRequirements.isEmpty {
requirementsSection
Divider()
}
vpnSection
Divider()
sharesSection
Divider()
footer
}
// Width is a floor, not a fixture: larger zoom steps need more room,
// and the window is resizable.
.frame(minWidth: 430 * s)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 10 * s) {
Image(systemName: model.state.symbolName)
.font(.system(size: 22 * s))
.foregroundStyle(tint)
.frame(width: 28 * s)
VStack(alignment: .leading, spacing: 2 * s) {
Text(model.state.title)
.font(font(13, .semibold))
Text(model.host)
.font(font(11))
.foregroundStyle(.secondary)
}
Spacer()
if model.busy {
ProgressView().controlSize(.small)
}
}
.padding(12 * s)
}
private var tint: Color {
switch model.state {
case .ok: return .green
case .nasDown: return .orange
case .vpnDown: return .red
case .unknown: return .secondary
}
}
// MARK: - Requirements
/// Only unmet requirements are listed. A permanent green checklist of things
/// that are fine is noise; a missing dependency is not.
private var missingRequirements: [VPNService.Requirement] {
model.requirements.filter { !$0.satisfied }
}
private var requirementsSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Не хватает для запуска VPN")
.font(font(12, .medium))
.foregroundStyle(.orange)
ForEach(missingRequirements) { requirement in
HStack(alignment: .firstTextBaseline, spacing: 8 * s) {
VStack(alignment: .leading, spacing: 1 * s) {
Text(requirement.title).font(font(12))
Text(requirement.detail)
.font(font(10))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
if let fix = requirement.fix {
Button("Установить") { model.runFixInTerminal(fix) }
.buttonStyle(ScaledButtonStyle(scale: s))
}
}
}
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 10 * s)
}
// MARK: - VPN
private var vpnSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
HStack(spacing: 8 * s) {
Text("VPN")
.font(font(12, .medium))
.frame(width: 40 * s, alignment: .leading)
Button("Подключить") { model.connectVPN() }
.disabled(model.busy || model.state.isTunnelUp)
Button("Отключить") { model.disconnectVPN() }
.disabled(model.busy || !model.state.isTunnelUp)
Button("Маршрут") { model.rerouteVPN() }
.disabled(model.busy)
Button("Лог") { model.openVPNLog() }
Spacer()
}
.buttonStyle(ScaledButtonStyle(scale: s))
if let message = model.vpnMessage {
Text(message)
.font(font(10))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 10 * s)
}
// MARK: - Shares
private var sharesSection: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Шары")
.font(font(12, .medium))
Spacer()
Text("смонтировано \(model.mounted.count) из \(model.shares.count)")
.font(font(11))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12 * s)
.padding(.top, 10 * s)
.padding(.bottom, 6 * s)
// The list is whatever the server reports, so it can be long —
// scroll rather than growing the window past the screen.
MeasuredScrollView(minHeight: 48 * s, maxHeight: 420 * s) {
VStack(alignment: .leading, spacing: 0) {
ForEach(model.shares, id: \.self) { share in
shareRow(share)
if share != model.shares.last {
Divider().padding(.leading, 12 * s)
}
}
}
}
}
.padding(.bottom, 8 * s)
}
private func shareRow(_ share: String) -> some View {
let isMounted = model.isMounted(share)
return HStack(spacing: 8 * s) {
Circle()
.fill(isMounted ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 8 * s, height: 8 * s)
VStack(alignment: .leading, spacing: 1 * s) {
Text(share)
.font(font(13))
Text(model.mountPoint(share) ?? "не подключена")
.font(font(10))
.foregroundStyle(.secondary)
}
Spacer()
if isMounted {
Button("Открыть") { model.openInFinder(share) }
.buttonStyle(ScaledButtonStyle(scale: s, borderless: true))
}
Button(isMounted ? "Отключить" : "Подключить") {
model.toggle(share)
}
.buttonStyle(ScaledButtonStyle(scale: s))
.disabled(model.busy || (!isMounted && model.state != .ok))
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 6 * s)
}
// MARK: - Footer
private var footer: some View {
VStack(alignment: .leading, spacing: 8 * s) {
if let error = model.lastError {
Text(error)
.font(font(11))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8 * s) {
Button("Подключить все") { model.mountMissing(allowUI: true) }
.disabled(model.busy || model.state != .ok)
.fixedSize()
Button("Отключить все") { model.unmount(model.shares) }
.disabled(model.busy || model.mounted.isEmpty)
.fixedSize()
Spacer()
Button("Настройки") { onOpenSettings() }
.fixedSize()
Button("Обновить") { model.refreshAll() }
.fixedSize()
}
.buttonStyle(ScaledButtonStyle(scale: s))
}
.padding(12 * s)
}
}
@@ -0,0 +1,63 @@
import Foundation
import Security
/// Writes the VPN password into the login Keychain.
///
/// Uses the Security framework directly rather than shelling out to
/// `security add-generic-password -w <password>`: command-line arguments are
/// visible in `ps` to every process on the machine, so a password must never
/// travel that way.
///
/// This is the one place the app touches the secret at all, and only because
/// the user typed it here on purpose. It goes straight into the Keychain and is
/// never stored, logged or echoed anywhere else — reading it back is left to
/// `security` at connect time.
enum KeychainStore {
static func exists(service: String, account: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: false,
]
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
/// Adds the item, or replaces the password if it is already there.
static func save(service: String, account: String, password: String) -> String? {
guard let data = password.data(using: .utf8) else {
return "Не удалось закодировать пароль"
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status: OSStatus
if exists(service: service, account: account) {
status = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
} else {
var attributes = query
attributes[kSecValueData as String] = data
status = SecItemAdd(attributes as CFDictionary, nil)
}
guard status != errSecSuccess else { return nil }
let message = SecCopyErrorMessageString(status, nil) as String?
return message ?? "Ошибка Keychain \(status)"
}
static func delete(service: String, account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
enum LinkState: String {
case unknown
case ok
case nasDown
case vpnDown
var title: String {
switch self {
case .unknown: return "Проверяю…"
case .ok: return "NAS доступен"
case .nasDown: return "VPN жив, NAS не отвечает"
case .vpnDown: return "Офисный VPN не поднят"
}
}
var notificationBody: String {
switch self {
case .unknown: return ""
case .ok: return "Офисный NAS снова доступен"
case .nasDown: return "Туннель поднят, но NAS не отвечает на 445"
case .vpnDown: return "Офисный VPN отвалился"
}
}
/// Shown next to the menu bar icon. Empty when healthy — a permanent label
/// would be ignored within a day, which is the whole reason it is only
/// drawn when something is actually wrong.
var badge: String {
switch self {
case .unknown, .ok: return ""
case .nasDown: return "NAS"
case .vpnDown: return "VPN"
}
}
var symbolName: String {
switch self {
case .unknown: return "externaldrive.badge.questionmark"
case .ok: return "externaldrive.connected.to.line.below"
case .nasDown: return "externaldrive.badge.exclamationmark"
case .vpnDown: return "externaldrive.badge.xmark"
}
}
var isTunnelUp: Bool {
self == .ok || self == .nasDown
}
}
+33
View File
@@ -0,0 +1,33 @@
import Foundation
/// Appends to ~/Library/Logs/CampusNAS.log.
///
/// NSLog output from an ad-hoc signed bundle does not reliably reach the
/// unified log, which made real errors invisible while debugging notification
/// delivery. A plain file is boring and always readable.
enum Log {
static let fileURL = URL(fileURLWithPath: NSHomeDirectory())
.appendingPathComponent("Library/Logs/CampusNAS.log")
private static let queue = DispatchQueue(label: "campusnas.log")
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
static func write(_ message: String) {
NSLog("campusnas-monitor: %@", message)
let line = "\(formatter.string(from: Date())) \(message)\n"
queue.async {
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: fileURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: fileURL)
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import AppKit
@main
enum Main {
@MainActor
static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
// Menu bar only — no Dock icon, no main window.
app.setActivationPolicy(.accessory)
app.run()
}
}
@@ -0,0 +1,38 @@
import SwiftUI
/// A ScrollView that is as tall as its content, up to a cap.
///
/// Exists because a plain `ScrollView` inside a `VStack` has a tiny ideal
/// height: given only `.frame(maxHeight:)` it collapses to nearly nothing. That
/// bug shipped twice — once emptying the share list, once emptying the whole
/// settings window — so the fix lives in one place now.
/// File scope, not nested: a generic type cannot hold the static storage a
/// PreferenceKey needs.
private struct HeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MeasuredScrollView<Content: View>: View {
var minHeight: CGFloat = 0
var maxHeight: CGFloat
@ViewBuilder var content: Content
@State private var measured: CGFloat = 0
var body: some View {
ScrollView {
content
.background(
GeometryReader { geometry in
Color.clear.preference(
key: HeightKey.self, value: geometry.size.height)
}
)
}
.frame(height: min(max(measured, minHeight), maxHeight))
.onPreferenceChange(HeightKey.self) { measured = $0 }
}
}
+218
View File
@@ -0,0 +1,218 @@
import Foundation
import NetFS
/// SMB mounting via NetFS. Chosen over shelling out to `mount_smbfs` because
/// NetFS reads credentials from the login Keychain on its own, and — when UI is
/// allowed — presents the standard system auth sheet with the "remember
/// password" checkbox. That sheet is the only supported way to create the
/// Keychain entry, and this app must never handle the password itself.
enum Mounter {
// Option keys from NetFS.h.
private static let uiOptionKey = "UIOption" as CFString
private static let uiOptionNoUI = "NoUI" as CFString
private static let softMountKey = "SoftMount" as CFString
struct Result: Sendable {
let share: String
let status: Int32
/// Already-mounted counts as success. The share can be mounted between
/// the UI reading its state and the click landing — most often by Finder
/// — and showing a red error for "it is already how you wanted it" is
/// just wrong.
var ok: Bool { status == 0 || status == EEXIST || status == EBUSY }
/// True when the failure looks like missing or rejected credentials,
/// i.e. retrying with the auth sheet is worth offering.
var needsCredentials: Bool {
status == EAUTH || status == EACCES || status == EPERM
}
/// Short human explanation. ENOENT gets a specific one: a share the
/// account is not listed in (`valid users` on the NAS) comes back as
/// "no such file or directory", not as a permission error, which is
/// otherwise a very misleading thing to show.
var reason: String {
switch status {
case ENOENT: return "\(share): нет доступа или шара не существует"
case EAUTH, EACCES, EPERM: return "\(share): нужен пароль"
case ETIMEDOUT: return "\(share): таймаут"
default: return "\(share): ошибка \(status)"
}
}
}
static func url(host: String, user: String, share: String) -> URL? {
var components = URLComponents()
components.scheme = "smb"
components.host = host
components.user = user
components.path = "/" + share
return components.url
}
/// Blocking — call off the main thread.
///
/// `allowUI: false` is the silent path: it succeeds only if the Keychain
/// already holds the credentials, and fails fast otherwise. Automatic
/// mounts always use it, so reconnecting never throws an unexpected
/// password sheet at the user.
static func mount(
host: String, user: String, share: String, allowUI: Bool
) -> Result {
guard let url = url(host: host, user: user, share: share) else {
return Result(share: share, status: EINVAL)
}
let openOptions = NSMutableDictionary()
if !allowUI {
openOptions[uiOptionKey] = uiOptionNoUI
}
// Soft mount: a NAS that disappears mid-session must return an error
// rather than wedge every Finder window that touches the volume.
let mountOptions = NSMutableDictionary()
mountOptions[softMountKey] = true
var mountpoints: Unmanaged<CFArray>?
let status = NetFSMountURLSync(
url as CFURL, nil, user as CFString, nil,
openOptions, mountOptions, &mountpoints)
mountpoints?.release()
if status != 0 {
NSLog("campusnas-monitor: mount %@ failed, status %d", share, status)
}
return Result(share: share, status: status)
}
/// Asks the server which shares it exports, via `smbutil view`.
///
/// NetFS does have a share-enumeration API, but it is not declared in the
/// public headers — reaching it would mean dlsym against private symbols,
/// which breaks on an OS update. `smbutil` is a documented system tool and
/// uses the same Keychain credentials.
///
/// Filters to disk shares and drops administrative ones (`IPC$` and
/// friends), which are never mountable as volumes.
static func listShares(
host: String, user: String, timeout: TimeInterval
) async -> [String] {
let output = await Probe.commandOutput(
"/usr/bin/smbutil",
args: ["view", "-N", "//\(user)@\(host)"],
timeout: timeout)
guard let output else {
Log.write("share enumeration failed for \(host)")
return []
}
var shares: [String] = []
for line in output.split(separator: "\n", omittingEmptySubsequences: true) {
// Columns are separated by runs of spaces: "name Disk comment"
let columns = line
.components(separatedBy: " ")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard columns.count >= 2 else { continue }
let name = columns[0]
guard columns[1] == "Disk", !name.hasSuffix("$") else { continue }
shares.append(name)
}
return shares.sorted()
}
static func unmount(mountPoint: String) -> Bool {
let status = mountPoint.withCString { Darwin.unmount($0, 0) }
if status != 0 {
NSLog("campusnas-monitor: unmount %@ failed: %d", mountPoint, errno)
}
return status == 0
}
/// Every spelling that means "this server": the configured name plus each
/// address it resolves to.
///
/// Needed because a mount can be created by something other than this app —
/// Finder mounts from the server browser, an older mount made by IP — and
/// those record a different host string. A plain case-sensitive `contains`
/// missed them and reported live mounts as "not mounted".
private static var aliasCache: (host: String, aliases: Set<String>)?
static func aliases(for host: String) -> Set<String> {
if let cached = aliasCache, cached.host == host { return cached.aliases }
var result: Set<String> = [host.lowercased()]
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_STREAM
var info: UnsafeMutablePointer<addrinfo>?
if getaddrinfo(host, nil, &hints, &info) == 0 {
var node = info
while let current = node {
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(
current.pointee.ai_addr, current.pointee.ai_addrlen,
&buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 {
result.insert(String(cString: buffer).lowercased())
}
node = current.pointee.ai_next
}
freeaddrinfo(info)
}
aliasCache = (host, result)
return result
}
/// Splits `//user@host/share` into its host and share parts.
private static func parse(mountFrom: String) -> (host: String, share: String)? {
guard mountFrom.hasPrefix("//") else { return nil }
let body = mountFrom.dropFirst(2)
guard let slash = body.firstIndex(of: "/") else { return nil }
var host = String(body[body.startIndex..<slash])
if let at = host.lastIndex(of: "@") {
host = String(host[host.index(after: at)...])
}
let share = String(body[body.index(after: slash)...])
guard !host.isEmpty, !share.isEmpty else { return nil }
return (host, share)
}
/// Currently mounted SMB shares served by `host`, mapped share -> mount point.
/// Read from the kernel mount table rather than by listing /Volumes, so a
/// leftover empty directory is never mistaken for a live mount.
static func mountedShares(host: String) -> [String: String] {
var buffer: UnsafeMutablePointer<statfs>?
let count = getmntinfo(&buffer, MNT_NOWAIT)
guard count > 0, let buffer else { return [:] }
let known = aliases(for: host)
var result: [String: String] = [:]
for index in 0..<Int(count) {
var entry = buffer[index]
let fsType = withUnsafeBytes(of: &entry.f_fstypename) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
}
guard fsType == "smbfs" else { continue }
let from = withUnsafeBytes(of: &entry.f_mntfromname) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
}
guard let parsed = parse(mountFrom: from),
known.contains(parsed.host.lowercased()) else { continue }
let on = withUnsafeBytes(of: &entry.f_mntonname) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
}
result[parsed.share] = on
}
return result
}
}
+221
View File
@@ -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)
}
}
}
}
@@ -0,0 +1,53 @@
import SwiftUI
/// Button whose chrome scales, not just its label.
///
/// The system `.bordered` style sizes its padding, height and corner radius
/// from `controlSize`, which has four fixed steps — setting a larger font on it
/// grows the text inside a button that stays the same size. This style derives
/// every dimension from the zoom multiplier instead.
struct ScaledButtonStyle: ButtonStyle {
let scale: CGFloat
/// Text-only variant, for secondary actions like "Открыть".
var borderless = false
func makeBody(configuration: Configuration) -> some View {
Label(configuration: configuration, scale: scale, borderless: borderless)
}
/// Not named `Body`: that collides with `ButtonStyle`'s own associated type.
private struct Label: View {
let configuration: ButtonStyleConfiguration
let scale: CGFloat
let borderless: Bool
/// A custom style has to dim itself — SwiftUI only does that for the
/// built-in ones.
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.font(.system(size: 12 * scale))
.lineLimit(1)
.padding(.horizontal, borderless ? 2 * scale : 10 * scale)
.padding(.vertical, borderless ? 1 * scale : 5 * scale)
.background(background)
.foregroundStyle(borderless ? Color.accentColor : Color.primary)
.opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4)
.contentShape(Rectangle())
}
@ViewBuilder
private var background: some View {
if borderless {
Color.clear
} else {
RoundedRectangle(cornerRadius: 6 * scale, style: .continuous)
.fill(Color.primary.opacity(configuration.isPressed ? 0.16 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: 6 * scale, style: .continuous)
.strokeBorder(Color.primary.opacity(0.12), lineWidth: 1)
)
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
import AppKit
import SwiftUI
/// Editor for the things that differ between machines: where the .ovpn profile
/// lives, which server it dials, and the credentials it uses.
struct SettingsView: View {
@ObservedObject var model: AppModel
var onClose: () -> Void
@State private var draft: VPNConfig
@State private var smbUser: String
@State private var password = ""
@State private var passwordStored: Bool
@State private var message: String?
@State private var messageIsError = false
private var s: CGFloat { model.zoomScale }
init(model: AppModel, onClose: @escaping () -> Void) {
self.model = model
self.onClose = onClose
_draft = State(initialValue: model.config.vpn)
_smbUser = State(initialValue: model.config.smbUser)
_passwordStored = State(initialValue: model.vpnPasswordStored())
}
private func font(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size * s, weight: weight)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Настройки")
.font(font(14, .semibold))
.padding(12 * s)
Divider()
MeasuredScrollView(maxHeight: 520 * s) {
VStack(alignment: .leading, spacing: 14 * s) {
vpnFileSection
Divider()
serverSection
Divider()
credentialsSection
Divider()
smbSection
}
.padding(12 * s)
}
Divider()
footer
}
.frame(width: 470 * s)
}
// MARK: - Profile file
private var vpnFileSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Профиль OpenVPN").font(font(12, .medium))
HStack(spacing: 8 * s) {
Text(draft.configPath)
.font(font(10))
.foregroundStyle(
FileManager.default.isReadableFile(atPath: draft.configPath)
? .secondary : Color.red)
.lineLimit(2)
.truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button("Выбрать…") { chooseConfig() }
.buttonStyle(ScaledButtonStyle(scale: s))
}
if !draft.configPathOverride.isEmpty {
Button("Вернуть путь по умолчанию") {
draft.configPathOverride = ""
}
.buttonStyle(ScaledButtonStyle(scale: s, borderless: true))
}
labelled("Путь к openvpn", text: $draft.openvpnPath)
}
}
private func chooseConfig() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = []
panel.allowsOtherFileTypes = true
panel.message = "Выберите файл .ovpn"
panel.directoryURL = URL(fileURLWithPath: draft.baseDir)
guard panel.runModal() == .OK, let url = panel.url else { return }
draft.configPathOverride = url.path
}
// MARK: - Server
private var serverSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Сервер и сети").font(font(12, .medium))
labelled("IP сервера", text: $draft.serverHost)
Text("Пинуется на шлюз локальной сети, чтобы handshake не пошёл "
+ "в поднимаемый туннель.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
labelled("Сеть туннеля", text: $draft.tunNetwork)
labelled("Офисная сеть", text: $draft.lanNetwork)
Text("Пустая офисная сеть отключает проверку маршрута — "
+ "тогда «поднят» определяется только по процессу.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Credentials
private var credentialsSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Учётные данные VPN").font(font(12, .medium))
labelled("Запись Keychain", text: $draft.keychainService)
labelled("Логин", text: $draft.keychainAccount)
HStack(spacing: 8 * s) {
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
.font(font(11))
Button("Сохранить") { savePassword() }
.buttonStyle(ScaledButtonStyle(scale: s))
.disabled(password.isEmpty)
}
Text(passwordStored
? "Пароль в Keychain есть. Ввод заменит его."
: "Пароля в Keychain нет — подключение не заработает.")
.font(font(9))
.foregroundStyle(passwordStored ? .secondary : Color.orange)
Text("Пароль уходит прямо в связку ключей и нигде больше не "
+ "сохраняется. При подключении его читает системная утилита, "
+ "не это приложение.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func savePassword() {
// Persist the account name first: saving under a stale login would put
// the secret where connect will not look for it.
model.updateVPN(draft)
if let error = model.saveVPNPassword(password) {
message = error
messageIsError = true
} else {
message = "Пароль сохранён в Keychain"
messageIsError = false
password = ""
passwordStored = true
}
}
// MARK: - SMB
private var smbSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Учётная запись SMB").font(font(12, .medium))
labelled("Логин", text: $smbUser)
Text("Пароль для шар хранит сам macOS — его спрашивает системный "
+ "диалог при первом монтировании.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Chrome
private func labelled(_ title: String, text: Binding<String>) -> some View {
HStack(spacing: 8 * s) {
Text(title)
.font(font(11))
.frame(width: 120 * s, alignment: .leading)
TextField("", text: text)
.textFieldStyle(.roundedBorder)
.font(font(11))
}
}
private var footer: some View {
HStack(spacing: 8 * s) {
if let message {
Text(message)
.font(font(10))
.foregroundStyle(messageIsError ? Color.red : .secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button("Закрыть") { onClose() }
Button("Сохранить") {
model.updateVPN(draft)
model.updateSMBUser(smbUser)
onClose()
}
}
.buttonStyle(ScaledButtonStyle(scale: s))
.padding(12 * s)
}
}
+198
View File
@@ -0,0 +1,198 @@
import Foundation
/// Runs a shell command as root through the system authorization dialog.
///
/// macOS renders the prompt and collects the password itself — nothing here
/// ever sees it. Steps are batched into one invocation so a connect asks once
/// rather than once per `route` call.
enum Privileged {
static func run(_ steps: [String]) async -> (ok: Bool, output: String) {
// AppleScript string literals cannot span lines, so the batch is a
// single `;`-joined line.
let command = steps.joined(separator: " ; ")
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "do shell script \"\(escaped)\" with administrator privileges"
let result = await Probe.commandRun(
"/usr/bin/osascript", args: ["-e", script], timeout: 90)
if !result.ok {
Log.write("privileged step failed: \(result.error.trimmingCharacters(in: .whitespacesAndNewlines))")
}
return (result.ok, result.output)
}
/// Single-quotes a path for /bin/sh.
static func quote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
extension VPNService {
enum ActionResult: Sendable {
case ok(String)
case failed(String)
var message: String {
switch self {
case .ok(let text), .failed(let text): return text
}
}
var isError: Bool {
if case .failed = self { return true }
return false
}
}
// MARK: - Credentials
/// Path to a file containing "account\npassword\n", and whether it is a
/// throwaway that must be deleted afterwards.
///
/// The secret is written by `security` redirecting straight to the file. It
/// never passes through this process, which is the whole point — the app
/// must never hold the password in memory.
private static func prepareAuthFile(
_ vpn: VPNConfig
) async -> (path: String, temporary: Bool)? {
if FileManager.default.isReadableFile(atPath: vpn.authPath) {
return (vpn.authPath, false)
}
let temp = NSTemporaryDirectory()
+ "ovpn-auth." + UUID().uuidString.prefix(8)
let script = [
"umask 077",
"printf '%s\\n' \(Privileged.quote(vpn.keychainAccount)) > \(Privileged.quote(temp))",
"/usr/bin/security find-generic-password -s \(Privileged.quote(vpn.keychainService))"
+ " -a \(Privileged.quote(vpn.keychainAccount)) -w >> \(Privileged.quote(temp))",
].joined(separator: " && ")
let ok = await Probe.commandSucceeds(
"/bin/sh", args: ["-c", script], timeout: 30)
guard ok else {
Log.write("could not build auth file from Keychain")
try? FileManager.default.removeItem(atPath: temp)
return nil
}
return (temp, true)
}
// MARK: - Actions
static func connect(_ vpn: VPNConfig) async -> ActionResult {
guard FileManager.default.isExecutableFile(atPath: vpn.openvpnPath) else {
return .failed("OpenVPN не найден: \(vpn.openvpnPath)")
}
guard FileManager.default.isReadableFile(atPath: vpn.configPath) else {
return .failed("Профиль не найден: \(vpn.configPath)")
}
var steps: [String] = []
if processAlive(vpn) {
if await tunnelUp(vpn) {
// Re-pin: the subnet or gateway may have changed since it
// started, which would leave the host route on a dead gateway.
return await reroute(vpn, note: "VPN уже поднят")
}
let age = processAge(vpn) ?? 0
if age < vpn.startupGraceSeconds {
return .ok("VPN ещё подключается (\(age) с)")
}
// Alive but no tunnel past the grace period: openvpn is stuck
// retrying. Restart it as part of the same privileged batch.
if let pid = pid(vpn) {
steps += [
"kill \(pid) 2>/dev/null || true",
"sleep 1",
"kill -9 \(pid) 2>/dev/null || true",
]
}
steps.append("rm -f \(Privileged.quote(vpn.pidPath))")
}
guard let gateway = await findGateway() else {
return .failed("Не найден шлюз локальной сети")
}
guard let auth = await prepareAuthFile(vpn) else {
return .failed("Нет учётных данных: ни файла, ни записи в Keychain")
}
// Pin the server IP to the LAN gateway so the handshake does not try to
// go through the tunnel it is establishing. Delete first, so it also
// self-heals after a subnet change.
steps += [
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost)) >/dev/null 2>&1 || true",
"/sbin/route -n add -host \(Privileged.quote(vpn.serverHost)) \(Privileged.quote(gateway))",
[
Privileged.quote(vpn.openvpnPath),
"--config", Privileged.quote(vpn.configPath),
"--auth-user-pass", Privileged.quote(auth.path),
"--auth-nocache",
"--writepid", Privileged.quote(vpn.pidPath),
"--daemon",
"--log", Privileged.quote(vpn.logPath),
].joined(separator: " "),
]
let result = await Privileged.run(steps)
if auth.temporary {
// openvpn reads the file at startup; give the daemon a moment
// before removing it.
try? await Task.sleep(nanoseconds: 3_000_000_000)
try? FileManager.default.removeItem(atPath: auth.path)
}
guard result.ok else {
return .failed("Не удалось запустить VPN — см. \(vpn.logPath)")
}
return .ok("VPN запущен, маршрут на \(vpn.serverHost) через \(gateway)")
}
static func disconnect(_ vpn: VPNConfig) async -> ActionResult {
var steps: [String] = []
if let pid = pid(vpn), processAlive(vpn) {
steps += [
"kill \(pid) 2>/dev/null || true",
"sleep 1",
"kill -9 \(pid) 2>/dev/null || true",
]
} else {
steps.append(
"pkill -f \(Privileged.quote("openvpn --config " + vpn.configPath))"
+ " 2>/dev/null || true")
}
steps.append("rm -f \(Privileged.quote(vpn.pidPath))")
// Always drop the host route: left behind, it blackholes the server
// after the next subnet change.
steps.append(
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost))"
+ " >/dev/null 2>&1 || true")
let result = await Privileged.run(steps)
guard result.ok else { return .failed("Не удалось отключить VPN") }
return .ok("VPN отключён, маршрут на \(vpn.serverHost) снят")
}
/// Re-pins the host route to the current gateway without touching a running
/// tunnel — for use after a network change.
static func reroute(_ vpn: VPNConfig, note: String = "Маршрут обновлён") async -> ActionResult {
guard let gateway = await findGateway() else {
return .failed("Не найден шлюз локальной сети")
}
let result = await Privileged.run([
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost)) >/dev/null 2>&1 || true",
"/sbin/route -n add -host \(Privileged.quote(vpn.serverHost)) \(Privileged.quote(gateway))",
])
guard result.ok else { return .failed("Не удалось обновить маршрут") }
return .ok("\(note): \(vpn.serverHost) через \(gateway)")
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
/// Native port of the `ovpn <profile> connect|disconnect|status|reroute` shell
/// wrapper. Everything the script did is here; the script is no longer invoked.
///
/// Two rules shaped the design:
///
/// 1. **The password never enters this process.** The auth file is written by
/// `security` redirecting straight to disk, so the secret goes Keychain →
/// file without passing through app memory. The app only ever knows the path.
/// 2. **Root work goes through one system prompt.** Route pinning and launching
/// openvpn are batched into a single `do shell script … with administrator
/// privileges`, so macOS shows its own auth dialog once and handles the
/// password itself.
enum VPNService {
// MARK: - Dependencies
struct Requirement: Identifiable, Sendable {
let id: String
let title: String
let detail: String
let satisfied: Bool
/// Shell command that would fix it, if there is an obvious one.
let fix: String?
}
static func requirements(_ vpn: VPNConfig) async -> [Requirement] {
var result: [Requirement] = []
let openvpnOK = FileManager.default.isExecutableFile(atPath: vpn.openvpnPath)
result.append(Requirement(
id: "openvpn",
title: "OpenVPN",
detail: openvpnOK ? vpn.openvpnPath : "не найден: \(vpn.openvpnPath)",
satisfied: openvpnOK,
fix: openvpnOK ? nil : "brew install openvpn"))
let configOK = FileManager.default.isReadableFile(atPath: vpn.configPath)
result.append(Requirement(
id: "config",
title: "Профиль .ovpn",
detail: configOK ? vpn.configPath : "не найден: \(vpn.configPath)",
satisfied: configOK,
fix: nil))
// Either source of credentials will do; the file wins if present.
let authFileOK = FileManager.default.isReadableFile(atPath: vpn.authPath)
let keychainOK = authFileOK ? true : await keychainEntryExists(vpn)
result.append(Requirement(
id: "auth",
title: "Учётные данные",
detail: authFileOK ? "файл \(vpn.authPath)"
: (keychainOK ? "Keychain: \(vpn.keychainService)"
: "нет ни файла, ни записи в Keychain"),
satisfied: authFileOK || keychainOK,
fix: keychainOK || authFileOK ? nil
: "security add-generic-password -s \(vpn.keychainService) "
+ "-a \(vpn.keychainAccount) -w"))
return result
}
/// Existence check only — deliberately without `-w`, so no secret is read.
private static func keychainEntryExists(_ vpn: VPNConfig) async -> Bool {
await Probe.commandSucceeds(
"/usr/bin/security",
args: ["find-generic-password", "-s", vpn.keychainService,
"-a", vpn.keychainAccount],
timeout: 5)
}
// MARK: - State
static func pid(_ vpn: VPNConfig) -> pid_t? {
guard let text = try? String(contentsOfFile: vpn.pidPath, encoding: .utf8),
let value = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
else { return nil }
return value
}
/// Signal 0 probes for existence without delivering anything. EPERM still
/// means the process is there — openvpn runs as root, we do not.
static func processAlive(_ vpn: VPNConfig) -> Bool {
guard let pid = pid(vpn) else { return false }
if kill(pid, 0) == 0 { return true }
return errno == EPERM
}
/// Seconds since openvpn started, from the pidfile mtime — it is written
/// once at startup. macOS `ps` has no etimes field worth parsing.
static func processAge(_ vpn: VPNConfig) -> Int? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: vpn.pidPath),
let modified = attrs[.modificationDate] as? Date
else { return nil }
return Int(Date().timeIntervalSince(modified))
}
/// A live pid proves nothing: on an auth or TLS failure openvpn soft-restarts
/// and retries forever, so the process stays up while the tunnel never comes
/// back. Check the pulled address and the pushed route instead.
static func tunnelUp(_ vpn: VPNConfig) async -> Bool {
// Both profiles land on the same 10.8.0.0/24, so the tun address alone
// cannot tell them apart. Without a known pushed LAN there is no honest
// test, so fall back to the pid check rather than one that lies.
guard !vpn.lanNetwork.isEmpty else { return processAlive(vpn) }
guard hasTunnelAddress(vpn.tunNetwork) else { return false }
return await hasLanRoute(vpn.lanNetwork)
}
/// Reads interface addresses directly instead of scraping `ifconfig`.
private static func hasTunnelAddress(_ network: String) -> Bool {
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0, let head else { return false }
defer { freeifaddrs(head) }
var node: UnsafeMutablePointer<ifaddrs>? = head
while let current = node {
defer { node = current.pointee.ifa_next }
guard let addr = current.pointee.ifa_addr,
addr.pointee.sa_family == sa_family_t(AF_INET) else { continue }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard getnameinfo(addr, socklen_t(addr.pointee.sa_len),
&buffer, socklen_t(buffer.count),
nil, 0, NI_NUMERICHOST) == 0 else { continue }
if String(cString: buffer).hasPrefix(network + ".") { return true }
}
return false
}
private static func hasLanRoute(_ network: String) async -> Bool {
guard let output = await Probe.commandOutput(
"/usr/sbin/netstat", args: ["-rn", "-f", "inet"], timeout: 5)
else { return false }
return output.split(separator: "\n").contains { line in
let first = line.split(separator: " ").first.map(String.init) ?? ""
return first == network || first.hasPrefix(network + "/")
}
}
static func isUp(_ vpn: VPNConfig) async -> Bool {
guard processAlive(vpn) else { return false }
return await tunnelUp(vpn)
}
// MARK: - Gateway
/// The LAN router, found the same way the script did: walk the configured
/// network services and take the first with an IPv4 Router. Deliberately not
/// the system default route — once the tunnel is up that points into it.
static func findGateway() async -> String? {
guard let services = await Probe.commandOutput(
"/usr/sbin/networksetup", args: ["-listallnetworkservices"], timeout: 8)
else { return nil }
for line in services.split(separator: "\n").dropFirst() {
let service = line.trimmingCharacters(in: .whitespaces)
// A leading asterisk marks a disabled service.
guard !service.isEmpty, !service.hasPrefix("*") else { continue }
guard let info = await Probe.commandOutput(
"/usr/sbin/networksetup", args: ["-getinfo", service], timeout: 8)
else { continue }
for infoLine in info.split(separator: "\n") where infoLine.hasPrefix("Router: ") {
let value = infoLine.dropFirst("Router: ".count)
.trimmingCharacters(in: .whitespaces)
if isIPv4(value) { return value }
}
}
return nil
}
private static func isIPv4(_ text: String) -> Bool {
var addr = in_addr()
return inet_pton(AF_INET, text, &addr) == 1
}
}
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# Builds CampusNAS.app — a menu bar agent, so it needs a real bundle:
# UNUserNotificationCenter and SMAppService both refuse to work on a bare binary.
set -euo pipefail
cd "$(dirname "$0")"
APP_NAME="CampusNAS"
BUNDLE_ID="com.campusagro.campusnas-monitor"
VERSION="1.0"
APP="build/${APP_NAME}.app"
# UNIVERSAL=1 builds for both architectures — needed when the .app will run on
# a machine other than this one, which may be Intel.
if [ "${UNIVERSAL:-0}" = "1" ]; then
echo "==> swift build (arm64 + x86_64)"
swift build -c release --arch arm64 --arch x86_64
BINARY=".build/apple/Products/Release/CampusNASMonitor"
else
echo "==> swift build"
swift build -c release
BINARY=".build/release/CampusNASMonitor"
fi
echo "==> assembling ${APP}"
rm -rf "$APP"
mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources"
cp "$BINARY" "${APP}/Contents/MacOS/${APP_NAME}"
cat > "${APP}/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundleDisplayName</key>
<string>${APP_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CFBundleExecutable</key>
<string>${APP_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Internal tool</string>
</dict>
</plist>
PLIST
plutil -lint "${APP}/Contents/Info.plist" >/dev/null
echo "==> ad-hoc signing"
codesign --force --sign - --timestamp=none "$APP"
codesign --verify --verbose=1 "$APP"
echo
echo "Built: $(pwd)/${APP}"
echo "Run: open '$(pwd)/${APP}'"
echo "Install: cp -R '$(pwd)/${APP}' /Applications/"
Executable
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# Builds a transferable bundle: the universal .app, the sources to rebuild it,
# and install instructions.
set -euo pipefail
cd "$(dirname "$0")"
NAME="CampusNAS"
STAMP="$(date +%Y%m%d)"
OUT="dist/${NAME}-${STAMP}"
echo "==> universal build"
UNIVERSAL=1 ./build.sh >/dev/null
ARCHS="$(lipo -archs "build/${NAME}.app/Contents/MacOS/${NAME}")"
echo " architectures: ${ARCHS}"
rm -rf "$OUT"
mkdir -p "${OUT}/source"
echo "==> collecting"
# ditto keeps the bundle structure and the ad-hoc signature intact; cp -R does
# not always preserve extended attributes on a signed bundle.
ditto "build/${NAME}.app" "${OUT}/${NAME}.app"
cp -R Sources Package.swift build.sh package.sh README.md "${OUT}/source/"
cat > "${OUT}/INSTALL.md" <<'DOC'
# CampusNAS — установка на другой Mac
## Что внутри
- `CampusNAS.app` — готовое приложение, universal (Apple Silicon + Intel)
- `source/` — исходники, чтобы пересобрать при необходимости
- `README.md` в `source/` — как всё устроено и почему
## Установка
```bash
cp -R CampusNAS.app /Applications/
xattr -dr com.apple.quarantine /Applications/CampusNAS.app
open /Applications/CampusNAS.app
```
Вторая строка обязательна. Приложение подписано ad-hoc, без Developer ID,
поэтому файл, приехавший с другой машины, помечается карантином и Gatekeeper
его не пустит. Команда снимает эту метку.
Иконка появится в меню-баре. Окно — повторный запуск приложения либо
«Показать окно» в меню.
## Настройка
Меню-бар → **Настройки…** (⌘,):
- путь к профилю `.ovpn` и к бинарю `openvpn`
- IP VPN-сервера, сеть туннеля, офисная сеть
- запись Keychain, логин и пароль VPN
- логин SMB
Адрес NAS и список шар правятся в `~/.config/campusnas-monitor/config.json`,
он создаётся при первом запуске.
## Что нужно на целевой машине
- **openvpn** — `brew install openvpn`. Приложение само проверит наличие и
покажет кнопку установки, если не найдёт.
- **профиль `.ovpn`** — скопировать отдельно, в комплект он не входит: там
сертификаты.
- **пароль VPN** — ввести в окне настроек, он ляжет в Keychain.
- **пароль SMB** — спросит сам macOS при первом монтировании. Поставьте галку
«Запомнить пароль в связке ключей», иначе автомонтирование работать не будет.
Если NAS доступен только по имени, а DNS его не отдаёт — добавьте запись:
```bash
printf '192.168.77.43\tCAMPUSNAS\n' | sudo tee -a /etc/hosts
```
## Пересборка
```bash
cd source
UNIVERSAL=1 ./build.sh
```
Нужен Xcode с командными утилитами. Собирается за несколько секунд.
## Чего приложение не делает
Не хранит паролей. Пароль VPN пишется прямо в Keychain и читается оттуда
системной утилитой при подключении. Пароль SMB приложение не видит вообще —
им занимается macOS.
DOC
echo "==> archiving"
cd dist
ditto -c -k --sequesterRsrc --keepParent "${NAME}-${STAMP}" "${NAME}-${STAMP}.zip"
cd ..
SIZE="$(du -h "dist/${NAME}-${STAMP}.zip" | cut -f1)"
echo
echo "Готово: $(pwd)/dist/${NAME}-${STAMP}.zip (${SIZE})"