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.
package/dist/index.html CHANGED
@@ -13,8 +13,8 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-vRxdHNVK.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index--7Dp2aq8.css">
16
+ <script type="module" crossorigin src="/assets/index-qYuCQlG_.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-BnXNOJmN.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
20
20
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"b330be0c29c23959448fe8dedc5af229"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-vRxdHNVK.js",revision:null},{url:"assets/index--7Dp2aq8.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"933899cfda158a1b7c704d712eb058ed"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-qYuCQlG_.js",revision:null},{url:"assets/index-BnXNOJmN.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -0,0 +1,186 @@
1
+ // `conductor-remote nosleep setup` — pay the sudo password once, so `nosleep`
2
+ // never asks again. Called from nosleep.ts; not an entrypoint of its own.
3
+ //
4
+ // Why this exists: `pmset disablesleep` is the only lever that keeps a Mac awake
5
+ // with the lid shut, it needs root, and the login LaunchAgent has no TTY to prompt
6
+ // on. So the relay can never arm it, and neither can a phone. This installs the one
7
+ // thing that fixes both — a root-owned helper plus a sudoers rule naming it — and
8
+ // nothing else changes on the machine.
9
+ //
10
+ // The three ways a rule like this becomes a root hole, and what is done about each:
11
+ //
12
+ // 1. A helper anyone can rewrite is passwordless root for everything. `/usr/local/bin`
13
+ // is group-writable by admin on a stock Mac, so the helper goes in a
14
+ // root-owned `/usr/local/libexec` this creates, mode 0755, and the install
15
+ // reads the file back to confirm what actually landed.
16
+ // 2. A helper inside the package is a supply-chain root hole: conductor-remote
17
+ // self-updates and npm runs as you, so a rule pointing into node_modules hands
18
+ // root to every future version. The helper is COPIED out once and never
19
+ // re-copied on its own; drift is reported and re-installing is a deliberate act.
20
+ // 3. A wildcard in the rule ("pmset *") grants far more than the one thing wanted.
21
+ // The rule names an absolute path with no argument pattern, and the helper
22
+ // validates its own arguments.
23
+ //
24
+ // A broken /etc/sudoers.d entry can cost you sudo entirely, so the drop-in is
25
+ // checked with `visudo -cf` as a draft, and the whole assembled set is re-checked
26
+ // after install with the drop-in removed again on failure.
27
+ //
28
+ // Strip-clean (plain-node type-stripping), stdlib-only — see CLAUDE.md.
29
+ import { execFileSync, spawnSync } from 'node:child_process';
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { HELPER_PATH, helperFile, helperReady, installedHelper, SUDOERS_PATH, sudoersFile } from "../src/nosleep-helper.js";
34
+ const LIBEXEC_DIR = path.dirname(HELPER_PATH);
35
+ /**
36
+ * Every directory between `/` and the helper, root first. The sudoers rule is only as
37
+ * strong as the weakest link in this chain: write access to any one of them means write
38
+ * access to the path the rule names, and so passwordless root for whoever has it.
39
+ */
40
+ const ANCESTORS = (() => {
41
+ const out = [];
42
+ for (let dir = LIBEXEC_DIR;; dir = path.dirname(dir)) {
43
+ out.unshift(dir);
44
+ if (dir === path.dirname(dir))
45
+ break;
46
+ }
47
+ return out;
48
+ })();
49
+ /** The account the rule names. Under `sudo` that is the human behind it, never root. */
50
+ function targetUser() {
51
+ const raw = process.getuid?.() === 0 ? process.env.SUDO_USER : os.userInfo().username;
52
+ if (!raw || raw === 'root') {
53
+ console.error('nosleep setup: run it as yourself, not as root — the rule has to name your account.\n' +
54
+ 'It asks for your password on its own.');
55
+ process.exit(1);
56
+ }
57
+ // A username is interpolated straight into sudoers; anything exotic would either
58
+ // break the file or widen the rule, and visudo would reject it later anyway.
59
+ if (!/^[a-z_][a-z0-9._-]*$/i.test(raw)) {
60
+ console.error(`nosleep setup: refusing to write a sudoers rule for the unusual username "${raw}".`);
61
+ process.exit(1);
62
+ }
63
+ return raw;
64
+ }
65
+ function runRoot(script, args) {
66
+ const res = spawnSync('sudo', ['sh', '-c', script, 'nosleep-setup', ...args], { stdio: 'inherit' });
67
+ if (res.error) {
68
+ console.error(`nosleep setup: could not run sudo (${res.error.message})`);
69
+ return 1;
70
+ }
71
+ return res.status ?? 1;
72
+ }
73
+ /** Whether sleep is blocked *right now*, independent of who blocked it. */
74
+ function sleepDisabled() {
75
+ try {
76
+ const out = execFileSync('pmset', ['-g'], { encoding: 'utf8', timeout: 5000 });
77
+ const m = out.match(/SleepDisabled\s+(\d)/);
78
+ return m ? m[1] === '1' : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** `nosleep status` — what is installed, whether it actually works, and the live state. */
85
+ export async function status() {
86
+ const installed = installedHelper();
87
+ const ready = await helperReady();
88
+ const current = installed !== null && installed === helperFile();
89
+ const blocked = sleepDisabled();
90
+ console.info(`sleep ${blocked === null ? 'unknown' : blocked ? 'BLOCKED right now' : 'normal'}`);
91
+ console.info(`helper ${HELPER_PATH}`);
92
+ console.info(` ${installed === null ? 'not installed' : current ? 'installed, current' : 'installed, OUT OF DATE'}`);
93
+ console.info(`sudoers ${SUDOERS_PATH}`);
94
+ console.info(` ${fs.existsSync(SUDOERS_PATH) ? 'present' : 'not installed'}`);
95
+ console.info('');
96
+ console.info(ready
97
+ ? '✓ nosleep runs without a password.'
98
+ : '✗ nosleep still asks for your password. Run: conductor-remote nosleep setup');
99
+ if (ready && !current)
100
+ console.info(' The installed helper differs from this version — re-run `nosleep setup` to refresh it.');
101
+ if (blocked && !ready)
102
+ console.info(' Sleep is blocked but not by an installed helper — check for a `nosleep` running elsewhere.');
103
+ }
104
+ export async function install() {
105
+ const user = targetUser();
106
+ // 0700 and owned by us, so the drafts root is about to read can't be swapped by
107
+ // another account between writing them and installing them.
108
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'conductor-remote-nosleep-'));
109
+ const helperDraft = path.join(staging, 'helper');
110
+ const sudoersDraft = path.join(staging, 'sudoers');
111
+ const wanted = helperFile();
112
+ fs.writeFileSync(helperDraft, wanted, { mode: 0o600 });
113
+ fs.writeFileSync(sudoersDraft, sudoersFile(user), { mode: 0o600 });
114
+ const script = [
115
+ 'set -e',
116
+ `install -d -o root -g wheel -m 755 ${LIBEXEC_DIR}`,
117
+ // A root-owned helper inside a directory someone else can rename is still someone
118
+ // else's helper: replacing any ancestor replaces the path the rule names. Only the
119
+ // leaf is fixed above, so check the whole chain and refuse rather than half-secure
120
+ // the machine. This bites on Intel Macs, where Homebrew leaves /usr/local root:admin
121
+ // and group-writable — the same reason /usr/local/bin was disqualified to begin with.
122
+ `bad=$(find ${ANCESTORS.join(' ')} -maxdepth 0 \\( ! -user root -o -perm -0020 -o -perm -0002 \\) 2>/dev/null)`,
123
+ `if [ -n "$bad" ]; then`,
124
+ `\techo "nosleep setup: refusing — these are writable by someone other than root, so ${HELPER_PATH} could be swapped:" >&2`,
125
+ '\techo "$bad" >&2',
126
+ '\techo "Fix the ownership (sudo chown root:wheel <dir>; sudo chmod go-w <dir>) and run setup again." >&2',
127
+ '\texit 1',
128
+ 'fi',
129
+ `install -o root -g wheel -m 755 "$1" ${HELPER_PATH}`,
130
+ // Validate the draft before it can affect anything.
131
+ 'visudo -cf "$2" >/dev/null',
132
+ `install -o root -g wheel -m 440 "$2" ${SUDOERS_PATH}`,
133
+ // And validate the assembled set, backing the drop-in out if it broke sudo.
134
+ `if ! visudo -c >/dev/null; then rm -f ${SUDOERS_PATH}; echo 'nosleep setup: sudoers rejected the drop-in — removed it again, nothing changed.' >&2; exit 1; fi`
135
+ ].join('\n');
136
+ console.info('conductor-remote nosleep setup — one-time install, so nosleep stops asking for a password.');
137
+ console.info(` ${HELPER_PATH} (root-owned helper)`);
138
+ console.info(` ${SUDOERS_PATH} (NOPASSWD for that one command, as ${user})`);
139
+ console.info('sudo will ask for your password…\n');
140
+ const code = runRoot(script, [helperDraft, sudoersDraft]);
141
+ fs.rmSync(staging, { recursive: true, force: true });
142
+ if (code !== 0) {
143
+ console.error('\nnosleep setup: install failed — nothing was left behind.');
144
+ process.exit(code);
145
+ }
146
+ // Read back rather than trust the exit code: this is the file about to hold
147
+ // passwordless root, so confirm the bytes that landed are the bytes we wrote.
148
+ if (installedHelper() !== wanted) {
149
+ console.error(`\nnosleep setup: ${HELPER_PATH} does not match what was installed. Refusing to call this done.`);
150
+ process.exit(1);
151
+ }
152
+ if (!(await helperReady())) {
153
+ console.error('\nnosleep setup: the files are in place but sudo still wants a password.\n' +
154
+ `Check that ${SUDOERS_PATH} is mode 0440 root:wheel, and that /etc/sudoers includes /etc/sudoers.d.`);
155
+ process.exit(1);
156
+ }
157
+ console.info('\n✓ Done. `conductor-remote nosleep 2h` now runs with no prompt.');
158
+ console.info(' Undo any time: conductor-remote nosleep setup --uninstall');
159
+ }
160
+ export function uninstall() {
161
+ if (installedHelper() === null && !fs.existsSync(SUDOERS_PATH)) {
162
+ console.info('nosleep setup: nothing installed.');
163
+ return;
164
+ }
165
+ // The grant goes first: a failure part-way should never leave a rule pointing at
166
+ // a path that no longer exists (or worse, one something else could create).
167
+ const script = ['set -e', `rm -f ${SUDOERS_PATH}`, `rm -f ${HELPER_PATH}`, 'visudo -c >/dev/null'].join('\n');
168
+ console.info('Removing the nosleep helper and its sudoers rule. sudo will ask for your password…\n');
169
+ const code = runRoot(script, []);
170
+ if (code !== 0)
171
+ process.exit(code);
172
+ console.info('\n✓ Removed. `nosleep` will ask for your password again.');
173
+ }
174
+ /** `nosleep setup [--uninstall]`. */
175
+ export async function setup(flag) {
176
+ if (flag === undefined) {
177
+ await install();
178
+ return;
179
+ }
180
+ if (flag === '--uninstall' || flag === 'uninstall') {
181
+ uninstall();
182
+ return;
183
+ }
184
+ console.error(`nosleep setup: unknown option "${flag}" — the only one is --uninstall.`);
185
+ process.exit(1);
186
+ }
@@ -1,83 +1,85 @@
1
- // `conductor-remote nosleep [duration]` — keep this Mac fully awake, including with
2
- // the lid closed, so the relay stays reachable and AppleScript sends can reach
3
- // Conductor while you're away from the desk.
1
+ // `conductor-remote nosleep [duration | setup | status]` — keep this Mac fully
2
+ // awake, including with the lid closed, so the relay stays reachable and
3
+ // AppleScript sends can reach Conductor while you're away from the desk.
4
4
  //
5
5
  // The lever is `pmset -a disablesleep 1` (root): unlike a `caffeinate` idle
6
6
  // assertion — which does NOT prevent lid-close/clamshell sleep on battery — this
7
- // keeps the system genuinely awake with the lid shut. It needs sudo, so this is a
8
- // FOREGROUND command (sudo prompts for your password on this terminal); the
9
- // background LaunchAgent has no TTY and can't do it. The whole awake window runs
10
- // inside one root shell whose EXIT/INT/TERM trap restores `disablesleep 0`, so
11
- // Ctrl-C, a timeout, or a crash can't leave the Mac unable to sleep.
7
+ // keeps the system genuinely awake with the lid shut. Root is why the command is
8
+ // normally FOREGROUND: sudo prompts on this terminal, and the background
9
+ // LaunchAgent has no TTY to prompt on. `nosleep setup` (nosleep-setup.ts) is what
10
+ // lifts that, and the only reason it exists. The whole awake window runs inside one
11
+ // root shell whose EXIT trap restores the captured values, so Ctrl-C, a timeout, or
12
+ // a crash can't leave the Mac unable to sleep.
12
13
  //
13
- // Caveat: keeping the system awake lid-closed is necessary but may not be
14
- // sufficient for AppleScript *delivery* a closed lid with no display can leave
15
- // the window server non-drivable (fix: a virtual/dummy display), and a locked
16
- // screen blocks `activate`. If a send still doesn't land with nosleep on, that's
17
- // the graphics surface, not power. Strip-clean (plain-node type-stripping),
18
- // stdlib-only see CLAUDE.md.
14
+ // Caveat: awake is necessary but not sufficient for AppleScript *delivery*. A
15
+ // locked screen blocks `activate` outright (#85/#87), so a lid-closed Mac serves
16
+ // reads and notifications while every send parks in `src/parked.ts` until the
17
+ // next unlock that is the designed path, not a failure. Whether a closed lid
18
+ // with no display *also* leaves the window server undrivable is untested here,
19
+ // and untestable while the lock wall stands in front of it. Strip-clean
20
+ // (plain-node type-stripping), stdlib-only — see CLAUDE.md.
19
21
  import { spawn } from 'node:child_process';
22
+ import { HELPER_PATH, helperFile, helperReady, installedHelper, NOSLEEP_BODY } from "../src/nosleep-helper.js";
23
+ import { setup, status } from "./nosleep-setup.js";
20
24
  /** Parse `90m` / `2h` / `30s` / bare seconds into seconds; null = run until Ctrl-C. */
21
25
  function parseDuration(raw) {
22
26
  if (!raw)
23
27
  return null;
24
28
  const m = raw.match(/^(\d+)(s|m|h)?$/);
25
29
  if (!m) {
26
- console.error(`nosleep: bad duration "${raw}" — use e.g. 90m, 2h, 30s, or a number of seconds`);
30
+ console.error(`nosleep: bad duration "${raw}" — use e.g. 90m, 2h, 30s, or a number of seconds.\n` +
31
+ 'Subcommands: `nosleep setup [--uninstall]`, `nosleep status`.');
27
32
  process.exit(1);
28
33
  }
29
34
  const n = Number(m[1]);
30
35
  const unit = m[2] ?? 's';
31
36
  return unit === 'h' ? n * 3600 : unit === 'm' ? n * 60 : n;
32
37
  }
33
- function main() {
38
+ async function main() {
34
39
  if (process.platform !== 'darwin') {
35
40
  console.error('nosleep: macOS only (uses pmset).');
36
41
  process.exit(1);
37
42
  }
43
+ // Subcommands sit in the same slot as the duration, which they can never collide
44
+ // with: a duration is digits with an optional s/m/h, so anything alphabetic here
45
+ // is either a subcommand or a typo parseDuration rejects by name.
38
46
  const arg = process.argv[2];
47
+ if (arg === 'setup') {
48
+ await setup(process.argv[3]);
49
+ return;
50
+ }
51
+ if (arg === 'status') {
52
+ await status();
53
+ return;
54
+ }
39
55
  const seconds = parseDuration(arg);
40
- // Do everything as a single root shell: capture the CURRENT power settings, flip them
41
- // to keep the Mac awake, and ALWAYS restore *those captured values* on exit via the
42
- // trap never assume defaults, or we'd clobber a value you (or a config profile) had
43
- // deliberately set. One sudo prompt covers the whole window; the trap fires even on
44
- // Ctrl-C / kill. `disablesleep` is global (reported as `SleepDisabled` by `pmset -g`);
45
- // `standby`/`powernap` are per-source, so read them from the Battery Power block.
46
- const sleepStep = seconds !== null ? `sleep ${seconds}` : 'while :; do sleep 86400; done';
47
- const battValue = (key) => `pmset -g custom | awk '/^Battery Power:/{b=1;next} /^AC Power:/{b=0} b&&$1=="${key}"{print $2;exit}'`;
48
- // Confirm *inside* the root shell, after pmset applies: this line prints only once the
49
- // password was accepted and the setting actually took — without it, entering your
50
- // password drops into a silent `sleep` with no signal that anything happened. It also
51
- // carries the wall-clock **expiry**, computed here rather than before the spawn, because
52
- // the clock that matters starts when the password lands, not when the command was typed.
53
- // `arg` is regex-validated (digits + s/m/h), so it's safe to interpolate. `clock` prints an
54
- // epoch as HH:MM, weekday-prefixed when it lands on another day (a long window is otherwise
55
- // ambiguous — "until 15:45" reads as today).
56
- const clock = `clock() { if [ "$(date -r "$1" '+%j')" = "$(date '+%j')" ]; then date -r "$1" '+%H:%M'; else date -r "$1" '+%a %H:%M'; fi; }`;
57
- const armed = seconds !== null
58
- ? `echo "✓ Sleep disabled until $(clock $(( $(date +%s) + ${seconds} ))) (${arg}, incl. lid closed). Ctrl-C to restore."`
59
- : `echo "✓ Sleep disabled at $(date '+%H:%M') (incl. lid closed) — until you press Ctrl-C."`;
60
- const script = [
61
- `sb=$(${battValue('standby')})`,
62
- `pn=$(${battValue('powernap')})`,
63
- "ds=$(pmset -g | awk '/SleepDisabled/{print $2;exit}')",
64
- // Arm the restore-to-captured before changing anything, so even a failed set reverts.
65
- `trap "pmset -b standby \${sb:-1} powernap \${pn:-1}; pmset -a disablesleep \${ds:-0}" EXIT INT TERM`,
66
- 'pmset -b standby 0 powernap 0',
67
- 'pmset -a disablesleep 1',
68
- clock,
69
- "echo ''",
70
- armed,
71
- sleepStep
72
- ].join('\n');
56
+ // The shared body reads its window from an argument (0 = until killed) rather than
57
+ // having one interpolated in, because the installed helper is a fixed file the
58
+ // sudoers rule names see nosleep-helper.ts. `label` is only echoed back, and the
59
+ // script re-validates it, so the two paths print the same confirmation.
60
+ const args = [String(seconds ?? 0), arg ?? ''];
73
61
  console.info('conductor-remote nosleep — keeping this Mac awake (incl. lid-closed system sleep).');
74
62
  console.info(seconds !== null ? `Duration: ${arg} — Ctrl-C to stop early.` : 'Runs until you press Ctrl-C.');
75
- // Be honest: this handles sleep only. It does NOT enable sending with the lid shut
76
- // on its own that also needs the screen lock off (System Settings ▸ Lock Screen;
77
- // yours is set to lock immediately) and possibly a virtual display.
78
- console.info('Note: sleep only — lid-closed *sending* also needs the screen lock off.');
79
- console.info('sudo will ask for your password…\n');
80
- const child = spawn('sudo', ['sh', '-c', script], { stdio: 'inherit' });
63
+ // Be honest about the half this doesn't buy: awake is not drivable. A locked screen
64
+ // blocks every UI write (#85/#87), so a lid-closed Mac serves reads and notifications
65
+ // while sends park until the next unlock by design, not by failure.
66
+ console.info('Note: sleep only — lid-closed *sending* still parks until you unlock.');
67
+ // Prefer the root-owned helper `nosleep setup` installs: same script, no prompt, and
68
+ // it is the only path a TTY-less caller (the LaunchAgent, and so the phone) can take.
69
+ // Fall back to piping the body through `sudo sh -c`, which is the un-installed
70
+ // experience and asks for a password.
71
+ const viaHelper = await helperReady();
72
+ if (viaHelper && installedHelper() !== helperFile()) {
73
+ console.warn(`⚠ ${HELPER_PATH} is from an older version — re-run \`nosleep setup\` to refresh it.`);
74
+ }
75
+ if (!viaHelper) {
76
+ console.info('Tip: `conductor-remote nosleep setup` installs this once so it stops asking.');
77
+ console.info('sudo will ask for your password…');
78
+ }
79
+ console.info('');
80
+ const child = viaHelper
81
+ ? spawn('sudo', ['-n', HELPER_PATH, ...args], { stdio: 'inherit' })
82
+ : spawn('sudo', ['sh', '-c', NOSLEEP_BODY, 'nosleep', ...args], { stdio: 'inherit' });
81
83
  // Forward Ctrl-C / termination so the root shell's trap restores sleep before we go.
82
84
  const forward = (sig) => () => child.kill(sig);
83
85
  process.on('SIGINT', forward('SIGINT'));
@@ -90,4 +92,7 @@ function main() {
90
92
  process.exit(code ?? 0);
91
93
  });
92
94
  }
93
- main();
95
+ main().catch((err) => {
96
+ console.error(`nosleep: ${err instanceof Error ? err.message : String(err)}`);
97
+ process.exit(1);
98
+ });
@@ -28,7 +28,9 @@ import { Resolver } from 'node:dns/promises';
28
28
  import https from 'node:https';
29
29
  import net from 'node:net';
30
30
  import { promisify } from 'node:util';
31
+ import { readSettings } from "./settings.js";
31
32
  import { magicDnsName, readExposeMode, relayPort, tailscaleBin } from "./tailscale.js";
33
+ import { hasDefaultRoute, joinNetwork, preferredNetworks } from "./wifi.js";
32
34
  const execFileP = promisify(execFile);
33
35
  const PROBE_PATH = '/health'; // unauthenticated 200 on the relay — no token needed to prove reachability
34
36
  const PROBE_TIMEOUT_MS = 8000;
@@ -122,8 +124,90 @@ async function reRegisterFunnel(bin, port) {
122
124
  await execFileP(bin, ['funnel', 'reset'], { timeout: 15_000 });
123
125
  await execFileP(bin, ['funnel', '--bg', '--yes', port], { timeout: 20_000 });
124
126
  }
127
+ /**
128
+ * Move to a fallback network and, if that worked, re-register Funnel — a new network means
129
+ * a new public endpoint, which is the exact condition that leaves the old ingress stale.
130
+ * Resets the fail count so the next tick judges the new link on its own merits.
131
+ */
132
+ async function rejoinAndReRegister(bin, port) {
133
+ if (!(await tryRejoin()))
134
+ return;
135
+ try {
136
+ await reRegisterFunnel(bin, port);
137
+ lastHealAt = Date.now();
138
+ fails = 0;
139
+ dnsFails = 0;
140
+ log('re-registered funnel on the new network');
141
+ }
142
+ catch (err) {
143
+ log(`funnel re-register after rejoin failed: ${err instanceof Error ? err.message.trim() : String(err)}`);
144
+ }
145
+ }
146
+ /**
147
+ * Two counters, because they answer different questions and only one of them may spend a
148
+ * funnel reset. `fails` counts probes that reached DNS and still failed — the evidence a
149
+ * re-registration needs, and the reason the threshold is 3 rather than 1, since a reset
150
+ * briefly drops every client. `dnsFails` counts ticks where the name wouldn't resolve at
151
+ * all, which proves nothing about the funnel and only ever feeds the rejoin branch.
152
+ * Sharing one counter let two unresolvable ticks plus a single failed probe buy a reset.
153
+ */
125
154
  let fails = 0;
155
+ let dnsFails = 0;
126
156
  let lastHealAt = 0;
157
+ let lastRejoinAt = 0;
158
+ const REJOIN_COOLDOWN_MS = 5 * 60 * 1000; // a network switch is disruptive; never churn on one
159
+ const REJOIN_SETTLE_MS = 12 * 1000; // DHCP + tailscaled noticing the new endpoint
160
+ /**
161
+ * Last resort when the probe is down: this Mac has no link at all, so move it onto a
162
+ * configured fallback (your phone's hotspot) and re-register Funnel, whose ingress a
163
+ * change of public endpoint invalidates anyway.
164
+ *
165
+ * The guards matter more than the action. Switching Wi-Fi networks can take a working
166
+ * Mac off a working network, so this borrows the shape of `serverWindowCount()`'s veto in
167
+ * writes.ts: **a probe that can't answer must prevent the action, never cause it.**
168
+ * - Opt-in only (`autoRejoin`), and only with somewhere to go.
169
+ * - Only when `hasDefaultRoute()` is definitively false. That probe needs no permission
170
+ * grant, unlike reading the SSID, which macOS refuses without Location Services and
171
+ * which therefore can never gate anything here.
172
+ * - Only into a network macOS already holds credentials for, so no password is stored
173
+ * or passed; an SSID that isn't in the preferred list is named in the log, not tried.
174
+ * - Behind a cooldown, so a Mac that is simply off the air doesn't cycle its Wi-Fi.
175
+ *
176
+ * Returns true if a join reported success, meaning the caller should re-register rather
177
+ * than treat this tick as an ordinary failure.
178
+ */
179
+ async function tryRejoin() {
180
+ const { autoRejoin, fallbackSsids } = readSettings();
181
+ if (!autoRejoin || fallbackSsids.length === 0)
182
+ return false;
183
+ if (Date.now() - lastRejoinAt < REJOIN_COOLDOWN_MS)
184
+ return false;
185
+ if (await hasDefaultRoute())
186
+ return false; // link is up; whatever is broken, it isn't this
187
+ const known = new Set(await preferredNetworks());
188
+ const candidates = fallbackSsids.filter(s => known.has(s));
189
+ const skipped = fallbackSsids.filter(s => !known.has(s));
190
+ if (skipped.length)
191
+ log(`fallback SSID(s) this Mac has no saved credentials for, skipping: ${skipped.join(', ')}`);
192
+ if (candidates.length === 0)
193
+ return false;
194
+ lastRejoinAt = Date.now();
195
+ for (const ssid of candidates) {
196
+ log(`no default route — joining fallback network "${ssid}"`);
197
+ const joined = await joinNetwork(ssid);
198
+ if (!joined.ok) {
199
+ log(`join "${ssid}" failed: ${joined.error}`);
200
+ continue;
201
+ }
202
+ await new Promise(r => setTimeout(r, REJOIN_SETTLE_MS));
203
+ if (await hasDefaultRoute()) {
204
+ log(`joined "${ssid}" and the link is up`);
205
+ return true;
206
+ }
207
+ log(`joined "${ssid}" but no default route appeared after ${REJOIN_SETTLE_MS / 1000}s`);
208
+ }
209
+ return false;
210
+ }
127
211
  function schedule(fn, delayMs) {
128
212
  setTimeout(fn, delayMs).unref();
129
213
  }
@@ -131,9 +215,16 @@ async function tick(host, bin, port, intervalMs) {
131
215
  const again = (delay) => schedule(() => void tick(host, bin, port, intervalMs), delay);
132
216
  const ips = await ingressIps(host);
133
217
  if (ips.length === 0) {
134
- // Can't resolve the ingress at all (DNS down / offline) — can't confirm a fault, so never heal.
218
+ // Can't resolve the ingress at all — can't confirm a *funnel* fault, so never heal.
219
+ // But a dead link looks exactly like this, and that is fixable: tryRejoin decides for
220
+ // itself, starting from whether there is genuinely no route off this Mac. Counted
221
+ // apart from `fails`, which is the evidence a funnel reset spends.
222
+ dnsFails++;
223
+ if (dnsFails >= FAIL_THRESHOLD)
224
+ await rejoinAndReRegister(bin, port);
135
225
  return again(intervalMs);
136
226
  }
227
+ dnsFails = 0;
137
228
  const ip = ips[0];
138
229
  let healthy = false;
139
230
  try {
@@ -157,6 +248,8 @@ async function tick(host, bin, port, intervalMs) {
157
248
  const reachable = await tcpOpen(ip, 443, TCP_TIMEOUT_MS);
158
249
  if (!reachable) {
159
250
  log(`ingress ${ip} unreachable after ${fails} probes — looks offline, not re-registering`);
251
+ // "Offline" is the one funnel-reset can't fix and a rejoin sometimes can.
252
+ await rejoinAndReRegister(bin, port);
160
253
  return again(intervalMs);
161
254
  }
162
255
  if (Date.now() - lastHealAt < HEAL_COOLDOWN_MS)