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:
@@ -0,0 +1,8 @@
|
||||
# SwiftPM build artefacts
|
||||
.build/
|
||||
|
||||
# Assembled .app and distribution archives — rebuildable, and megabytes of them
|
||||
build/
|
||||
dist/
|
||||
|
||||
.DS_Store
|
||||
@@ -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)]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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})"
|
||||
Reference in New Issue
Block a user