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/README.md +53 -7
- package/bin/cli.js +6 -2
- package/dist/assets/{index--7Dp2aq8.css → index-BnXNOJmN.css} +1 -1
- package/dist/assets/{index-vRxdHNVK.js → index-qYuCQlG_.js} +31 -31
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +186 -0
- package/dist-node/scripts/nosleep.js +61 -56
- package/dist-node/src/funnel-watchdog.js +94 -1
- package/dist-node/src/nosleep-helper.js +249 -0
- package/dist-node/src/nosleep.js +142 -0
- package/dist-node/src/server.js +65 -0
- package/dist-node/src/settings.js +63 -0
- package/dist-node/src/wifi.js +173 -0
- package/package.json +4 -3
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-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index
|
|
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
|
|
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
|
|
2
|
-
// the lid closed, so the relay stays reachable and
|
|
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.
|
|
8
|
-
// FOREGROUND
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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:
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// the
|
|
18
|
-
//
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
console.info('Note: sleep only — lid-closed *sending*
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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)
|