conductor-remote 1.35.2 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The Mac's own Wi-Fi, read and (narrowly) written through `networksetup`.
3
+ *
4
+ * Used by the funnel watchdog to move this Mac onto a phone hotspot when the link it was
5
+ * on disappears. Two things shape every function here:
6
+ *
7
+ * - **The SSID is not readable.** Since macOS Sonoma, reporting the associated network
8
+ * needs Location Services, and `airport -I` is gone. On this Mac `-getairportnetwork`
9
+ * answers "You are not associated with an AirPort network" while a default route is
10
+ * live on the same interface. So `currentSsid()` is INFORMATIONAL ONLY — never branch
11
+ * on it. `hasDefaultRoute()` is the signal that can be trusted.
12
+ * - **Passwords are never stored here.** `-setairportnetwork` takes an optional password
13
+ * and *writes* it to the keychain; for a network macOS already knows, the argument is
14
+ * unnecessary. So this only ever joins networks already in the preferred list, and an
15
+ * unknown SSID is reported rather than guessed at. Nothing secret reaches settings.json
16
+ * or the log, which matters because `/api/logs` is a wire surface.
17
+ *
18
+ * Stdlib only, strip-clean — see CLAUDE.md.
19
+ */
20
+ import { execFile } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+ const execFileP = promisify(execFile);
23
+ const NETWORKSETUP = '/usr/sbin/networksetup';
24
+ let cachedDevice;
25
+ /**
26
+ * Every reader here is async, and that is not cosmetic. The relay is one thread serving
27
+ * polls at 1s / 2s / 2.5s, and `networksetup` is slowest exactly when this module matters
28
+ * most: a wedged Wi-Fi interface is the case the fallback network exists for, and it is
29
+ * also the case where these calls sit on their timeouts. Synchronously that is up to 18
30
+ * seconds of a frozen relay behind one tap on the Connect sheet.
31
+ */
32
+ /**
33
+ * The Wi-Fi interface (`en0` on most Macs, but never assume — a Mac with a Thunderbolt
34
+ * dock renumbers). Parsed from the hardware-port table: the device on the line after
35
+ * `Hardware Port: Wi-Fi`. Cached, since it can't change without a reboot.
36
+ */
37
+ export async function wifiDevice() {
38
+ if (cachedDevice !== undefined)
39
+ return cachedDevice;
40
+ cachedDevice = null;
41
+ try {
42
+ const { stdout } = await execFileP(NETWORKSETUP, ['-listallhardwareports'], { encoding: 'utf8', timeout: 5000 });
43
+ const lines = stdout.split('\n');
44
+ for (let i = 0; i < lines.length; i++) {
45
+ if (!/^Hardware Port:\s*Wi-Fi\s*$/.test(lines[i]))
46
+ continue;
47
+ const m = lines[i + 1]?.match(/^Device:\s*(\S+)/);
48
+ if (m) {
49
+ cachedDevice = m[1];
50
+ break;
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // no networksetup / not macOS — stays null, and every caller treats that as "no opinion"
56
+ }
57
+ return cachedDevice;
58
+ }
59
+ /**
60
+ * Is there a route off this machine at all? The one link signal that doesn't need a
61
+ * permission grant, and the only one the rejoin branch is allowed to act on.
62
+ */
63
+ export async function hasDefaultRoute() {
64
+ try {
65
+ const { stdout } = await execFileP('route', ['-n', 'get', 'default'], { timeout: 5000 });
66
+ return /^\s*gateway:\s*\S+/m.test(stdout);
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** Networks macOS already holds credentials for, in its own preference order. */
73
+ export async function preferredNetworks() {
74
+ const dev = await wifiDevice();
75
+ if (!dev)
76
+ return [];
77
+ try {
78
+ const { stdout } = await execFileP(NETWORKSETUP, ['-listpreferredwirelessnetworks', dev], {
79
+ encoding: 'utf8',
80
+ timeout: 8000
81
+ });
82
+ // First line is the "Preferred networks on enN:" header; the rest are tab-indented names.
83
+ return stdout
84
+ .split('\n')
85
+ .slice(1)
86
+ .map(l => l.trim())
87
+ .filter(Boolean);
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ /**
94
+ * macOS's own **Auto-join Hotspot** setting: `Never`, `Ask`, or `Automatic`
95
+ * (System Settings ▸ Wi-Fi). Null when it can't be read.
96
+ *
97
+ * This is the closest thing to hotspot awareness that is actually available to us, and it
98
+ * matters more than an icon would. On `Never` — the default here — the Mac will not join
99
+ * your iPhone on its own even when nothing else is in range, so the "I forgot to turn the
100
+ * hotspot on" case cannot resolve itself no matter what this relay does. Worth reporting;
101
+ * not worth changing behind someone's back, so nothing here writes it.
102
+ *
103
+ * Read from the world-readable airport prefs. The richer per-network store,
104
+ * `/Library/Preferences/com.apple.wifi.known-networks.plist`, is `-rw------- root` and
105
+ * stays out of reach on purpose: widening the root helper to read it would trade a real
106
+ * security boundary for a nicer picker.
107
+ */
108
+ export async function autoJoinHotspotMode() {
109
+ try {
110
+ const { stdout } = await execFileP('/usr/bin/plutil', [
111
+ '-extract',
112
+ 'AutoHotspotMode',
113
+ 'raw',
114
+ '-o',
115
+ '-',
116
+ '/Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist'
117
+ ], { encoding: 'utf8', timeout: 5000 });
118
+ return stdout.trim() || null;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ /**
125
+ * Networks whose name reads like a phone hotspot. A **guess**, and labelled as one wherever
126
+ * it surfaces: macOS knows the real answer over Continuity/BLE, which is private, and every
127
+ * public source is blocked. `system_profiler SPAirPortDataType` does scan live and reports
128
+ * `Network Type`, but it redacts every SSID without Location Services, so it can't name what
129
+ * it finds. So this only ever reorders a list — it never decides anything.
130
+ *
131
+ * Apple names a hotspot after the device ("Han's iPhone"), localised, hence matching the
132
+ * device word rather than a possessive form.
133
+ */
134
+ export function looksLikeHotspot(ssid) {
135
+ return /\b(iphone|ipad|hotspot|androidap)\b/i.test(ssid);
136
+ }
137
+ /**
138
+ * The associated SSID, or null when macOS won't say. Null is the common case without
139
+ * Location Services, so it means "unknown", never "not connected". Display only.
140
+ */
141
+ export async function currentSsid() {
142
+ const dev = await wifiDevice();
143
+ if (!dev)
144
+ return null;
145
+ try {
146
+ const { stdout } = await execFileP(NETWORKSETUP, ['-getairportnetwork', dev], { encoding: 'utf8', timeout: 5000 });
147
+ const m = stdout.match(/^Current Wi-Fi Network:\s*(.+?)\s*$/m);
148
+ return m ? m[1] : null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Join a network macOS already knows. No password argument by design — see the header.
156
+ * `networksetup` exits 0 even when the join fails, printing the reason, so the text is
157
+ * the result and an empty one is success.
158
+ */
159
+ export async function joinNetwork(ssid) {
160
+ const dev = await wifiDevice();
161
+ if (!dev)
162
+ return { ok: false, error: 'no Wi-Fi interface on this Mac' };
163
+ try {
164
+ const { stdout, stderr } = await execFileP(NETWORKSETUP, ['-setairportnetwork', dev, ssid], { timeout: 30_000 });
165
+ const text = `${stdout}${stderr}`.trim();
166
+ if (!text)
167
+ return { ok: true };
168
+ return { ok: false, error: text };
169
+ }
170
+ catch (err) {
171
+ return { ok: false, error: err instanceof Error ? err.message.trim() : String(err) };
172
+ }
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.35.2",
3
+ "version": "1.36.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
@@ -50,11 +50,12 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:applescript",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:applescript && yarn check:nosleep",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
57
- "check:applescript": "node scripts/check-applescript.ts"
57
+ "check:applescript": "node scripts/check-applescript.ts",
58
+ "check:nosleep": "node scripts/check-nosleep.ts"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@biomejs/biome": "^2.3.8",