conductor-remote 1.36.4 → 1.37.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 +8 -0
- package/dist/assets/{index-D_jeEda_.js → index-D3FckDjN.js} +27 -27
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/nosleep.js +154 -5
- package/dist-node/src/server.js +5 -1
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-D3FckDjN.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-BHetu12h.css">
|
|
18
18
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
19
19
|
<body>
|
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 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:"
|
|
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:"13ed6b91304eca7567c470e6f43e4f2b"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-D3FckDjN.js",revision:null},{url:"assets/index-BHetu12h.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\//]}))});
|
package/dist-node/src/nosleep.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* function below degrades to "not available" rather than failing loudly when the rule
|
|
8
8
|
* isn't installed, because that is the normal state for anyone who hasn't opted in.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Three properties matter and none is obvious:
|
|
11
11
|
*
|
|
12
12
|
* - **The armed window must outlive this process.** `autoupdate` deliberately
|
|
13
13
|
* `exit()`s to reload and launchd restarts us; if the helper were an ordinary child
|
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
* `kill(pid, 0)` from this process raises EPERM rather than succeeding. EPERM means
|
|
20
20
|
* it is alive and not ours; ESRCH means it is gone. Treating EPERM as dead would
|
|
21
21
|
* report every armed window as idle.
|
|
22
|
+
* - **Ending a window has to put a shut Mac to sleep itself.** Clamshell sleep is a
|
|
23
|
+
* lid-close *event*, not a state macOS keeps re-checking, and the lid closed while
|
|
24
|
+
* `disablesleep` was up — so the helper's restore re-allows sleep without causing
|
|
25
|
+
* any, and a lid-closed Mac that ended its window (the phone's "Let it sleep", or
|
|
26
|
+
* the timer running out) just sat there awake indefinitely. The relay follows the
|
|
27
|
+
* restore with `pmset sleepnow` — which needs no root (only pmset *settings* do),
|
|
28
|
+
* so it lives here and not in the installed helper, and existing setups need no
|
|
29
|
+
* re-`setup` — gated on ioreg's AppleClamshellState: an open lid, or a desktop Mac
|
|
30
|
+
* that has none, keeps the restore-only behaviour, because forcing sleep on a Mac
|
|
31
|
+
* someone may be sitting at is worse than the bug.
|
|
22
32
|
*
|
|
23
33
|
* Stdlib only, strip-clean — see CLAUDE.md.
|
|
24
34
|
*/
|
|
@@ -84,6 +94,59 @@ export async function nosleepState() {
|
|
|
84
94
|
return armedState(rec);
|
|
85
95
|
return { available: await helperReady(), armed: false, until: null, pid: null };
|
|
86
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether the lid is physically shut. `null` means the probe couldn't say — a desktop Mac
|
|
99
|
+
* has no clamshell and no such key — and callers must read that as "don't force anything",
|
|
100
|
+
* never as either lid state.
|
|
101
|
+
*/
|
|
102
|
+
async function clamshellClosed() {
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execFileP('ioreg', ['-r', '-k', 'AppleClamshellState', '-d', '1'], { timeout: 5000 });
|
|
105
|
+
const m = stdout.match(/"AppleClamshellState"\s*=\s*(Yes|No)/);
|
|
106
|
+
return m ? m[1] === 'Yes' : null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Ask macOS to sleep now. Unprivileged on purpose: `sleepnow` is the one pmset verb that needs no root. */
|
|
113
|
+
async function sleepNow() {
|
|
114
|
+
try {
|
|
115
|
+
await execFileP('pmset', ['sleepnow'], { timeout: 10_000 });
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* How long a disarm answer gets to leave the Mac before the network suspends with it. The
|
|
124
|
+
* phone's tap awaits the DELETE and immediately re-reads /api/settings (~90ms of concurrent
|
|
125
|
+
* subprocesses plus one funnel round trip), so 2s covers both with room, and nobody watches
|
|
126
|
+
* a lid-closed Mac take two extra seconds to sleep.
|
|
127
|
+
*/
|
|
128
|
+
const SLEEP_AFTER_REPLY_MS = 2000;
|
|
129
|
+
/**
|
|
130
|
+
* Sleep the Mac in a moment, unless a new window arms in the meantime — a tap pair of
|
|
131
|
+
* "Let it sleep" then "1h" must end with a Mac that stays awake, not one that sleeps
|
|
132
|
+
* two seconds into its fresh window.
|
|
133
|
+
*/
|
|
134
|
+
function sleepSoon(why) {
|
|
135
|
+
const t = setTimeout(() => {
|
|
136
|
+
void (async () => {
|
|
137
|
+
if (armedRecord()) {
|
|
138
|
+
console.info(`nosleep: not sleeping the Mac (${why}) — a new keep-awake window armed first`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const res = await sleepNow();
|
|
142
|
+
if (res.ok)
|
|
143
|
+
console.info(`nosleep: lid is closed — putting the Mac to sleep (${why})`);
|
|
144
|
+
else
|
|
145
|
+
console.error(`nosleep: pmset sleepnow failed (${why}): ${res.error}`);
|
|
146
|
+
})();
|
|
147
|
+
}, SLEEP_AFTER_REPLY_MS);
|
|
148
|
+
t.unref();
|
|
149
|
+
}
|
|
87
150
|
function unavailable() {
|
|
88
151
|
return {
|
|
89
152
|
ok: false,
|
|
@@ -116,13 +179,23 @@ export async function armNoSleep(seconds) {
|
|
|
116
179
|
const deadline = Date.now() + 10_000;
|
|
117
180
|
while (Date.now() < deadline) {
|
|
118
181
|
const rec = armedRecord();
|
|
119
|
-
if (rec)
|
|
182
|
+
if (rec) {
|
|
183
|
+
// Watch this window's own end: the helper's restore re-allows sleep but causes
|
|
184
|
+
// none, so a lid still shut at expiry needs the nudge below.
|
|
185
|
+
rescanExpiry();
|
|
120
186
|
return { ok: true, state: armedState(rec) };
|
|
187
|
+
}
|
|
121
188
|
await new Promise(r => setTimeout(r, 200));
|
|
122
189
|
}
|
|
123
190
|
return { ok: false, error: 'nosleep did not report itself armed within 10s', state: await nosleepState() };
|
|
124
191
|
}
|
|
125
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Disarm. Goes through the helper because the armed process is root and we can't signal it.
|
|
194
|
+
* A shut lid then gets `pmset sleepnow` a beat later — restoring `disablesleep 0` re-allows
|
|
195
|
+
* sleep without causing any (see the module note), and "Let it sleep" tapped from a phone
|
|
196
|
+
* means exactly that. The caller is answered `willSleep` first, so the phone hears the
|
|
197
|
+
* confirmation before the network goes down with the Mac.
|
|
198
|
+
*/
|
|
126
199
|
export async function disarmNoSleep() {
|
|
127
200
|
if (!(await helperReady()))
|
|
128
201
|
return unavailable();
|
|
@@ -134,9 +207,85 @@ export async function disarmNoSleep() {
|
|
|
134
207
|
}
|
|
135
208
|
const deadline = Date.now() + 8000;
|
|
136
209
|
while (Date.now() < deadline) {
|
|
137
|
-
if (!armedRecord())
|
|
138
|
-
|
|
210
|
+
if (!armedRecord()) {
|
|
211
|
+
cancelExpiryWatch();
|
|
212
|
+
const willSleep = (await clamshellClosed()) === true;
|
|
213
|
+
if (willSleep)
|
|
214
|
+
sleepSoon('the keep-awake window was ended from the phone');
|
|
215
|
+
else
|
|
216
|
+
console.info('nosleep: window ended with the lid open (or unreadable) — sleep re-enabled, not forced');
|
|
217
|
+
return { ok: true, willSleep, state: { available: true, armed: false, until: null, pid: null } };
|
|
218
|
+
}
|
|
139
219
|
await new Promise(r => setTimeout(r, 200));
|
|
140
220
|
}
|
|
141
221
|
return { ok: false, error: 'nosleep is still armed after --stop', state: await nosleepState() };
|
|
142
222
|
}
|
|
223
|
+
// --- The window's own expiry -------------------------------------------------------------
|
|
224
|
+
//
|
|
225
|
+
// "Awake until 12:30" promises a Mac that sleeps at 12:30, and the helper alone cannot keep
|
|
226
|
+
// that promise on a shut lid (its restore re-allows sleep, nothing more). Teaching the
|
|
227
|
+
// *installed* helper to sleepnow would strand every existing setup on the old behaviour —
|
|
228
|
+
// the root copy deliberately never self-updates — so the relay watches the recorded expiry
|
|
229
|
+
// instead, which also covers windows armed from a terminal.
|
|
230
|
+
/** Slack after the recorded expiry for the helper's own restore to land before we look. */
|
|
231
|
+
const EXPIRY_GRACE_MS = 15_000;
|
|
232
|
+
/** A timer this far past its schedule fired on wake-from-sleep, not on schedule. */
|
|
233
|
+
const EXPIRY_LATE_MS = 60_000;
|
|
234
|
+
/** How often to re-read the pidfile for windows this process didn't arm (CLI, pre-restart). */
|
|
235
|
+
const EXPIRY_RESCAN_MS = 60_000;
|
|
236
|
+
let expiryTimer = null;
|
|
237
|
+
/** Identity of the window the timer is armed for — `pid:until`, so a takeover reschedules. */
|
|
238
|
+
let expiryKey = null;
|
|
239
|
+
function cancelExpiryWatch() {
|
|
240
|
+
if (expiryTimer)
|
|
241
|
+
clearTimeout(expiryTimer);
|
|
242
|
+
expiryTimer = null;
|
|
243
|
+
expiryKey = null;
|
|
244
|
+
}
|
|
245
|
+
function rescanExpiry() {
|
|
246
|
+
const rec = armedRecord();
|
|
247
|
+
const key = rec && rec.until !== null ? `${rec.pid}:${rec.until}` : null;
|
|
248
|
+
if (key === expiryKey)
|
|
249
|
+
return;
|
|
250
|
+
cancelExpiryWatch();
|
|
251
|
+
if (!rec || rec.until === null)
|
|
252
|
+
return;
|
|
253
|
+
expiryKey = key;
|
|
254
|
+
const fireAt = rec.until + EXPIRY_GRACE_MS;
|
|
255
|
+
expiryTimer = setTimeout(() => {
|
|
256
|
+
expiryTimer = null;
|
|
257
|
+
expiryKey = null;
|
|
258
|
+
void windowExpired(fireAt);
|
|
259
|
+
}, Math.max(0, fireAt - Date.now()));
|
|
260
|
+
// A 12h timer must not hold the process open, and it survives nothing anyway — a
|
|
261
|
+
// restarted relay rebuilds it from the pidfile in watchNoSleepExpiry's first rescan.
|
|
262
|
+
expiryTimer.unref();
|
|
263
|
+
}
|
|
264
|
+
async function windowExpired(plannedAt) {
|
|
265
|
+
// Node timers don't run while the Mac sleeps; one that fires long after its schedule
|
|
266
|
+
// fired because something woke the Mac, and sleeping a Mac someone just woke is the one
|
|
267
|
+
// wrong move here. On schedule ± a minute is the only firing that means "expiry".
|
|
268
|
+
const lateMs = Date.now() - plannedAt;
|
|
269
|
+
if (lateMs > EXPIRY_LATE_MS) {
|
|
270
|
+
console.info(`nosleep: expiry check ran ${Math.round(lateMs / 1000)}s late (Mac was asleep?) — leaving it awake`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// A record still (or again) present is a takeover or a helper mid-restore — not ours to end.
|
|
274
|
+
if (armedRecord())
|
|
275
|
+
return;
|
|
276
|
+
if ((await clamshellClosed()) !== true) {
|
|
277
|
+
console.info('nosleep: keep-awake window expired with the lid open (or unreadable) — leaving the Mac awake');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const res = await sleepNow();
|
|
281
|
+
if (res.ok)
|
|
282
|
+
console.info('nosleep: keep-awake window expired with the lid closed — putting the Mac to sleep');
|
|
283
|
+
else
|
|
284
|
+
console.error(`nosleep: pmset sleepnow failed at window expiry: ${res.error}`);
|
|
285
|
+
}
|
|
286
|
+
/** Start watching for armed windows reaching their recorded expiry. Called once at relay startup. */
|
|
287
|
+
export function watchNoSleepExpiry() {
|
|
288
|
+
rescanExpiry();
|
|
289
|
+
const iv = setInterval(rescanExpiry, EXPIRY_RESCAN_MS);
|
|
290
|
+
iv.unref();
|
|
291
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { startFunnelWatchdog } from "./funnel-watchdog.js";
|
|
|
11
11
|
import { workspaceDiff } from "./git.js";
|
|
12
12
|
import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
|
|
13
13
|
import { mergePr } from "./merge.js";
|
|
14
|
-
import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState } from "./nosleep.js";
|
|
14
|
+
import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
|
|
15
15
|
import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
16
16
|
import { ParkedPromptQueue } from "./parked.js";
|
|
17
17
|
import { attachPrStatus } from "./pr.js";
|
|
@@ -849,4 +849,8 @@ server.listen(cfg.port, cfg.host, () => {
|
|
|
849
849
|
// Watch for turns ending and push them to subscribed phones. Idle (one small local
|
|
850
850
|
// query per tick) until a device subscribes; see notify.ts.
|
|
851
851
|
startNotifier(reads);
|
|
852
|
+
// Watch armed keep-awake windows for their recorded expiry: the helper's restore only
|
|
853
|
+
// re-allows sleep, so a lid still shut at expiry needs the relay's `pmset sleepnow`
|
|
854
|
+
// (see nosleep.ts). Also picks a window back up after the relay's own restarts.
|
|
855
|
+
watchNoSleepExpiry();
|
|
852
856
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.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.",
|