conductor-remote 1.46.2 → 1.46.3
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 +34 -11
- package/bin/cli.js +7 -7
- package/dist/assets/{index-Cox9xTfw.js → index-DeCCNN08.js} +23 -23
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep.js +14 -13
- package/dist-node/scripts/service.js +60 -20
- package/dist-node/src/config.js +34 -0
- package/dist-node/src/nosleep-helper.js +27 -4
- package/dist-node/src/nosleep.js +33 -12
- package/dist-node/src/server.js +1 -1
- package/package.json +3 -2
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-DeCCNN08.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-DGZkOBT-.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
|
|
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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"7981a45fcfdd796b99d3615870365723"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DeCCNN08.js",revision:null},{url:"assets/index-DGZkOBT-.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,5 +1,5 @@
|
|
|
1
1
|
// `conductor-remote nosleep [duration | setup | status]` — keep this Mac fully
|
|
2
|
-
// awake
|
|
2
|
+
// awake and block its automatic screen lock, so the relay stays reachable and
|
|
3
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
|
|
@@ -11,14 +11,13 @@
|
|
|
11
11
|
// root shell whose EXIT trap restores the captured values, so Ctrl-C, a timeout, or
|
|
12
12
|
// a crash can't leave the Mac unable to sleep.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
14
|
+
// The helper also owns a process-scoped `caffeinate -d` assertion by default.
|
|
15
|
+
// ScreenSaverDaemon checks that assertion before starting the idle screen saver
|
|
16
|
+
// that locks the session. An explicit lock or lid close can still lock macOS;
|
|
17
|
+
// those sends park in `src/parked.ts` until the next unlock. Strip-clean
|
|
20
18
|
// (plain-node type-stripping), stdlib-only — see CLAUDE.md.
|
|
21
19
|
import { spawn } from 'node:child_process';
|
|
20
|
+
import { installedServiceEnvironment, preventScreenLockEnabled } from "../src/config.js";
|
|
22
21
|
import { HELPER_PATH, helperFile, helperReady, installedHelper, NOSLEEP_BODY } from "../src/nosleep-helper.js";
|
|
23
22
|
import { setup, status } from "./nosleep-setup.js";
|
|
24
23
|
/** Parse `90m` / `2h` / `30s` / bare seconds into seconds; null = run until Ctrl-C. */
|
|
@@ -53,24 +52,26 @@ async function main() {
|
|
|
53
52
|
return;
|
|
54
53
|
}
|
|
55
54
|
const seconds = parseDuration(arg);
|
|
55
|
+
const configuredMode = process.env.PREVENT_SCREEN_LOCK ?? installedServiceEnvironment().PREVENT_SCREEN_LOCK;
|
|
56
|
+
const preventScreenLock = preventScreenLockEnabled(configuredMode);
|
|
56
57
|
// The shared body reads its window from an argument (0 = until killed) rather than
|
|
57
58
|
// having one interpolated in, because the installed helper is a fixed file the
|
|
58
59
|
// sudoers rule names — see nosleep-helper.ts. `label` is only echoed back, and the
|
|
59
60
|
// script re-validates it, so the two paths print the same confirmation.
|
|
60
|
-
const args = [String(seconds ?? 0), arg ?? ''];
|
|
61
|
+
const args = [String(seconds ?? 0), arg ?? '', preventScreenLock ? '1' : '0'];
|
|
61
62
|
console.info('conductor-remote nosleep — keeping this Mac awake (incl. lid-closed system sleep).');
|
|
62
63
|
console.info(seconds !== null ? `Duration: ${arg} — Ctrl-C to stop early.` : 'Runs until you press Ctrl-C.');
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
console.info('Note: sleep only — lid-closed *sending* still parks until you unlock.');
|
|
64
|
+
console.info(preventScreenLock
|
|
65
|
+
? 'Automatic screen lock: blocked for this window.'
|
|
66
|
+
: 'Automatic screen lock: allowed by config (prevent-screen-lock=off).');
|
|
67
67
|
// Prefer the root-owned helper `nosleep setup` installs: same script, no prompt, and
|
|
68
68
|
// it is the only path a TTY-less caller (the LaunchAgent, and so the phone) can take.
|
|
69
69
|
// Fall back to piping the body through `sudo sh -c`, which is the un-installed
|
|
70
70
|
// experience and asks for a password.
|
|
71
71
|
const viaHelper = await helperReady();
|
|
72
72
|
if (viaHelper && installedHelper() !== helperFile()) {
|
|
73
|
-
console.
|
|
73
|
+
console.error(`✗ ${HELPER_PATH} is out of date — run \`conductor-remote nosleep setup\`, then try again.`);
|
|
74
|
+
process.exit(1);
|
|
74
75
|
}
|
|
75
76
|
if (!viaHelper) {
|
|
76
77
|
console.info('Tip: `conductor-remote nosleep setup` installs this once so it stops asking.');
|
|
@@ -12,6 +12,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import { installedServiceEnvironment, serviceEnvironmentWithSetting } from "../src/config.js";
|
|
15
16
|
import { packageRoot } from "../src/pkg-root.js";
|
|
16
17
|
import { driftWarningLines, exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin, writeUrlHost } from "../src/tailscale.js";
|
|
17
18
|
import { qrLines } from "./qr.js";
|
|
@@ -33,6 +34,7 @@ const FLAG_ENV = {
|
|
|
33
34
|
'--hostname': 'RELAY_HOSTNAME',
|
|
34
35
|
'--token': 'RELAY_TOKEN',
|
|
35
36
|
'--write-strategy': 'WRITE_STRATEGY',
|
|
37
|
+
'--prevent-screen-lock': 'PREVENT_SCREEN_LOCK',
|
|
36
38
|
'--auto-update': 'AUTO_UPDATE',
|
|
37
39
|
'--auto-update-interval': 'AUTO_UPDATE_INTERVAL_MINUTES',
|
|
38
40
|
'--funnel-watchdog': 'FUNNEL_WATCHDOG',
|
|
@@ -57,6 +59,10 @@ function applyFlags(argv) {
|
|
|
57
59
|
console.error(`flag ${name} needs a value (e.g. ${name} <value>)`);
|
|
58
60
|
process.exit(1);
|
|
59
61
|
}
|
|
62
|
+
if (name === '--prevent-screen-lock' && value !== 'on' && value !== 'off') {
|
|
63
|
+
console.error(`flag ${name} must be on or off`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
60
66
|
process.env[envKey] = value;
|
|
61
67
|
}
|
|
62
68
|
}
|
|
@@ -143,6 +149,8 @@ function buildPlist() {
|
|
|
143
149
|
envEntries.push(['EXPOSE', process.env.EXPOSE]);
|
|
144
150
|
if (process.env.WRITE_STRATEGY)
|
|
145
151
|
envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY]);
|
|
152
|
+
if (process.env.PREVENT_SCREEN_LOCK)
|
|
153
|
+
envEntries.push(['PREVENT_SCREEN_LOCK', process.env.PREVENT_SCREEN_LOCK]);
|
|
146
154
|
if (process.env.RELAY_HOST)
|
|
147
155
|
envEntries.push(['RELAY_HOST', process.env.RELAY_HOST]);
|
|
148
156
|
if (process.env.RELAY_PORT)
|
|
@@ -436,6 +444,14 @@ function install() {
|
|
|
436
444
|
fs.mkdirSync(logDir, { recursive: true });
|
|
437
445
|
fs.writeFileSync(plistPath, buildPlist());
|
|
438
446
|
reloadAgent();
|
|
447
|
+
const changedSetting = process.env.CONDUCTOR_REMOTE_CONFIG_SET;
|
|
448
|
+
if (changedSetting) {
|
|
449
|
+
if (changedSetting === 'expose' || changedSetting === 'port' || changedSetting === 'hostname')
|
|
450
|
+
ensureTailscale();
|
|
451
|
+
console.info(`✓ set ${changedSetting}; the relay restarted with the new value.`);
|
|
452
|
+
console.info(' Check it with: conductor-remote config');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
439
455
|
console.info(`✓ installed LaunchAgent ${LABEL}`);
|
|
440
456
|
console.info(` plist: ${plistPath}`);
|
|
441
457
|
console.info(` logs: ${logDir}/relay.log`);
|
|
@@ -469,18 +485,9 @@ function restart() {
|
|
|
469
485
|
* believes something else — which is exactly the bug that made EXPOSE a plist entry.
|
|
470
486
|
*/
|
|
471
487
|
function readPlistEnv() {
|
|
472
|
-
|
|
473
|
-
const out = execFileSync('plutil', ['-convert', 'json', '-o', '-', plistPath], {
|
|
474
|
-
encoding: 'utf8',
|
|
475
|
-
stdio: 'pipe'
|
|
476
|
-
});
|
|
477
|
-
const env = JSON.parse(out)?.EnvironmentVariables;
|
|
478
|
-
return env && typeof env === 'object' ? env : {};
|
|
479
|
-
}
|
|
480
|
-
catch {
|
|
481
|
-
return {};
|
|
482
|
-
}
|
|
488
|
+
return installedServiceEnvironment();
|
|
483
489
|
}
|
|
490
|
+
const CONFIG_KEYS = new Map(Object.entries(FLAG_ENV).map(([flag, env]) => [flag.slice(2), env]));
|
|
484
491
|
const HOME = os.homedir();
|
|
485
492
|
const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
|
|
486
493
|
const KNOBS = [
|
|
@@ -503,6 +510,7 @@ const KNOBS = [
|
|
|
503
510
|
{ name: 'port', env: 'RELAY_PORT', fallback: () => '8787' },
|
|
504
511
|
{ name: 'host', env: 'RELAY_HOST', fallback: () => '127.0.0.1' },
|
|
505
512
|
{ name: 'write-strategy', env: 'WRITE_STRATEGY', fallback: () => 'applescript' },
|
|
513
|
+
{ name: 'prevent-screen-lock', env: 'PREVENT_SCREEN_LOCK', fallback: () => 'on' },
|
|
506
514
|
{ name: 'auto-update', env: 'AUTO_UPDATE', fallback: () => 'auto' },
|
|
507
515
|
{ name: 'auto-update-interval', env: 'AUTO_UPDATE_INTERVAL_MINUTES', fallback: () => '5 (minutes)' },
|
|
508
516
|
{
|
|
@@ -524,15 +532,47 @@ const KNOBS = [
|
|
|
524
532
|
fallback: () => tilde(path.join(HOME, 'conductor', 'workspaces'))
|
|
525
533
|
}
|
|
526
534
|
];
|
|
527
|
-
/**
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
+
/** Set one daemon knob while preserving every other value already baked into the plist. */
|
|
536
|
+
function setConfig(args) {
|
|
537
|
+
const [name, value, ...extra] = args;
|
|
538
|
+
const envKey = name ? CONFIG_KEYS.get(name) : undefined;
|
|
539
|
+
if (!name || value === undefined || extra.length > 0) {
|
|
540
|
+
console.error('usage: conductor-remote config set <setting> <value>');
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
if (!envKey) {
|
|
544
|
+
console.error(`config set: unknown setting "${name}"\n known: ${[...CONFIG_KEYS.keys()].join(', ')}`);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
if (name === 'prevent-screen-lock' && value !== 'on' && value !== 'off') {
|
|
548
|
+
console.error('config set: prevent-screen-lock must be on or off');
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
if (!fs.existsSync(plistPath)) {
|
|
552
|
+
console.error('config set: the service is not installed. Run `conductor-remote service install` first.');
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
// Re-run install in a fresh process so module-level values such as RELAY_PORT see
|
|
556
|
+
// the new environment. Start from the plist, which protects unrelated settings
|
|
557
|
+
// from ambient shell variables and from being reset to defaults.
|
|
558
|
+
const childEnv = serviceEnvironmentWithSetting(process.env, readPlistEnv(), CONFIG_KEYS.values(), envKey, value);
|
|
559
|
+
childEnv.CONDUCTOR_REMOTE_CONFIG_SET = name;
|
|
560
|
+
const cli = path.join(projectDir, 'bin', 'cli.js');
|
|
561
|
+
const result = spawnSync(process.execPath, [cli, 'service', 'install'], { env: childEnv, stdio: 'inherit' });
|
|
562
|
+
if (result.error)
|
|
563
|
+
console.error(`config set: could not restart the service (${result.error.message})`);
|
|
564
|
+
process.exit(result.status ?? 1);
|
|
565
|
+
}
|
|
566
|
+
/** What the daemon is configured with, where values came from, and an optional setter. */
|
|
535
567
|
function config() {
|
|
568
|
+
if (process.argv[3] === 'set') {
|
|
569
|
+
setConfig(process.argv.slice(4));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (process.argv[3] !== undefined) {
|
|
573
|
+
console.error('usage: conductor-remote config [set <setting> <value>]');
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
536
576
|
if (!fs.existsSync(plistPath)) {
|
|
537
577
|
console.info(`plist: (not installed)\n\nRun \`conductor-remote service install\` first.`);
|
|
538
578
|
return;
|
|
@@ -574,7 +614,7 @@ function config() {
|
|
|
574
614
|
console.info(` ${r.name.padEnd(width)} ${r.value.padEnd(valueWidth)} ${r.source}`);
|
|
575
615
|
}
|
|
576
616
|
console.info('\n (values come from the daemon\u2019s own plist environment, not this shell\u2019s)');
|
|
577
|
-
console.info(' change one with: conductor-remote
|
|
617
|
+
console.info(' change one with: conductor-remote config set <setting> <value>');
|
|
578
618
|
// The cross-check worth having. A posture the relay believes and a Tailscale that is doing something
|
|
579
619
|
// else is invisible in every other command, and it is self-healing in the wrong direction: the funnel
|
|
580
620
|
// watchdog only runs for `public`, so a relay that thinks it is public will re-register Funnel.
|
package/dist-node/src/config.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { packageRoot } from "./pkg-root.js";
|
|
6
7
|
const home = os.homedir();
|
|
8
|
+
/** The CLI and daemon share one on/off setting. Only `off` opts out. */
|
|
9
|
+
export function preventScreenLockEnabled(raw = process.env.PREVENT_SCREEN_LOCK) {
|
|
10
|
+
return raw?.trim().toLowerCase() !== 'off';
|
|
11
|
+
}
|
|
7
12
|
/**
|
|
8
13
|
* The relay's own state directory — its token, its Funnel posture, its undelivered
|
|
9
14
|
* first prompts. Never Conductor's: everything about Conductor is read from the DB.
|
|
@@ -11,6 +16,34 @@ const home = os.homedir();
|
|
|
11
16
|
export function stateDir() {
|
|
12
17
|
return path.join(home, 'Library', 'Application Support', 'conductor-remote');
|
|
13
18
|
}
|
|
19
|
+
/** The runtime configuration baked into the installed LaunchAgent, or an empty object. */
|
|
20
|
+
export function installedServiceEnvironment() {
|
|
21
|
+
const plist = path.join(home, 'Library', 'LaunchAgents', 'no.adluna.conductor-remote.plist');
|
|
22
|
+
try {
|
|
23
|
+
const out = execFileSync('plutil', ['-convert', 'json', '-o', '-', plist], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: 'pipe'
|
|
26
|
+
});
|
|
27
|
+
const env = JSON.parse(out)?.EnvironmentVariables;
|
|
28
|
+
return env && typeof env === 'object' ? env : {};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Build the environment for a one-setting service update without changing its other knobs. */
|
|
35
|
+
export function serviceEnvironmentWithSetting(ambient, installed, configKeys, key, value) {
|
|
36
|
+
const next = { ...ambient };
|
|
37
|
+
const known = new Set(configKeys);
|
|
38
|
+
for (const configKey of known)
|
|
39
|
+
delete next[configKey];
|
|
40
|
+
for (const [installedKey, installedValue] of Object.entries(installed)) {
|
|
41
|
+
if (known.has(installedKey))
|
|
42
|
+
next[installedKey] = installedValue;
|
|
43
|
+
}
|
|
44
|
+
next[key] = value;
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
14
47
|
/** Where a generated token is persisted so a phone's saved URL stays valid across relay restarts. */
|
|
15
48
|
function tokenStorePath() {
|
|
16
49
|
return path.join(stateDir(), 'token');
|
|
@@ -62,6 +95,7 @@ export function loadConfig() {
|
|
|
62
95
|
host,
|
|
63
96
|
token: resolveToken(),
|
|
64
97
|
writeStrategy,
|
|
98
|
+
preventScreenLock: preventScreenLockEnabled(),
|
|
65
99
|
publicDir: resolvePublicDir()
|
|
66
100
|
};
|
|
67
101
|
}
|
|
@@ -20,8 +20,8 @@ const execFileP = promisify(execFile);
|
|
|
20
20
|
/** Absolute path of the root-owned helper. Must sit in a directory only root can write. */
|
|
21
21
|
export const HELPER_PATH = '/usr/local/libexec/conductor-remote-nosleep';
|
|
22
22
|
/**
|
|
23
|
-
* Where the armed window records itself: `<pid> <expiry-epoch> <start-token>`
|
|
24
|
-
* 0 = until killed). Written by root, mode 0644, because the relay has to *read* it on
|
|
23
|
+
* Where the armed window records itself: `<pid> <expiry-epoch> <start-token> <prevent-lock>`
|
|
24
|
+
* (expiry 0 = until killed). Written by root, mode 0644, because the relay has to *read* it on
|
|
25
25
|
* every status poll and a sudo round-trip per poll would be absurd. It is the lock as
|
|
26
26
|
* well as the record — see the one-owner note on NOSLEEP_BODY.
|
|
27
27
|
*
|
|
@@ -31,7 +31,8 @@ export const HELPER_PATH = '/usr/local/libexec/conductor-remote-nosleep';
|
|
|
31
31
|
* as root — so `kill -0` succeeds against whatever inherited the number, and `--stop`
|
|
32
32
|
* would then SIGTERM an unrelated root process. Readers that only want the pid and the
|
|
33
33
|
* expiry (`src/nosleep.ts` ▸ readPidfile) can ignore it; a two-field file from an older
|
|
34
|
-
* version still parses, and skips the check.
|
|
34
|
+
* version still parses, and skips the check. The fourth field records whether this
|
|
35
|
+
* window also holds the display assertion that blocks the automatic screen lock.
|
|
35
36
|
*/
|
|
36
37
|
export const PIDFILE_PATH = '/var/run/conductor-remote-nosleep.pid';
|
|
37
38
|
/**
|
|
@@ -40,10 +41,13 @@ export const PIDFILE_PATH = '/var/run/conductor-remote-nosleep.pid';
|
|
|
40
41
|
* file that reads fine and never takes effect.
|
|
41
42
|
*/
|
|
42
43
|
export const SUDOERS_PATH = '/etc/sudoers.d/conductor-remote';
|
|
44
|
+
/** Fixed absolute path because this body runs through a passwordless root helper. */
|
|
45
|
+
export const CAFFEINATE_PATH = '/usr/bin/caffeinate';
|
|
43
46
|
/**
|
|
44
47
|
* `$1` = seconds to stay awake, 0 = until killed, or one of `--check` / `--stop`.
|
|
45
48
|
* `$2` = optional display label ("90m") echoed back in the confirmation, validated to
|
|
46
49
|
* the same charset the CLI accepts so nothing arbitrary reaches the terminal.
|
|
50
|
+
* `$3` = 1 to block the automatic screen lock for the window, 0 to allow it.
|
|
47
51
|
*
|
|
48
52
|
* Restore is on the EXIT trap only; INT/TERM/HUP just `exit`, so a signal can't run
|
|
49
53
|
* the restore twice. The captured values are read *before* anything changes and put
|
|
@@ -107,8 +111,10 @@ export const NOSLEEP_BODY = [
|
|
|
107
111
|
'fi',
|
|
108
112
|
'secs=$arg',
|
|
109
113
|
'label=${2:-}',
|
|
114
|
+
'preventlock=${3:-1}',
|
|
110
115
|
"case \"$secs\" in '' | *[!0-9]*) echo 'nosleep: seconds must be digits (0 = until killed)' >&2; exit 64 ;; esac",
|
|
111
116
|
'case "$label" in *[!0-9smh]*) label=\'\' ;; esac',
|
|
117
|
+
'case "$preventlock" in 0 | 1) ;; *) echo \'nosleep: prevent-screen-lock must be 0 or 1\' >&2; exit 64 ;; esac',
|
|
112
118
|
'',
|
|
113
119
|
'# Take over from any incumbent BEFORE capturing, or we would capture its flipped',
|
|
114
120
|
'# values and restore those — the one way this leaves a Mac unable to sleep.',
|
|
@@ -132,8 +138,10 @@ export const NOSLEEP_BODY = [
|
|
|
132
138
|
"ds=$(pmset -g | awk '/SleepDisabled/{print $2;exit}')",
|
|
133
139
|
'',
|
|
134
140
|
"sleeper=''",
|
|
141
|
+
"displaykeeper=''",
|
|
135
142
|
'cleanup() {',
|
|
136
143
|
'\tif [ -n "$sleeper" ]; then kill "$sleeper" 2>/dev/null || true; fi',
|
|
144
|
+
'\tif [ -n "$displaykeeper" ]; then kill "$displaykeeper" 2>/dev/null || true; wait "$displaykeeper" 2>/dev/null || true; fi',
|
|
137
145
|
'\tpmset -b standby "${sb:-1}" powernap "${pn:-1}"',
|
|
138
146
|
'\tpmset -a disablesleep "${ds:-0}"',
|
|
139
147
|
// Only clear the record if it is still ours: a successor that already took over
|
|
@@ -148,10 +156,20 @@ export const NOSLEEP_BODY = [
|
|
|
148
156
|
'pmset -b standby 0 powernap 0',
|
|
149
157
|
'pmset -a disablesleep 1',
|
|
150
158
|
'',
|
|
159
|
+
'# ScreenSaverDaemon consults PreventUserIdleDisplaySleep before starting the idle',
|
|
160
|
+
'# screen saver that locks the session. The assertion is process-scoped, so macOS',
|
|
161
|
+
'# drops it even if the helper is killed before its EXIT trap can run.',
|
|
162
|
+
'if [ "$preventlock" = 1 ]; then',
|
|
163
|
+
`\t${CAFFEINATE_PATH} -d -w "$$" &`,
|
|
164
|
+
'\tdisplaykeeper=$!',
|
|
165
|
+
'\tsleep 0.1',
|
|
166
|
+
'\tif ! kill -0 "$displaykeeper" 2>/dev/null; then echo "nosleep: could not block the automatic screen lock" >&2; exit 69; fi',
|
|
167
|
+
'fi',
|
|
168
|
+
'',
|
|
151
169
|
'until=0',
|
|
152
170
|
'if [ "$secs" -gt 0 ]; then until=$(( $(date +%s) + secs )); fi',
|
|
153
171
|
// 0644 so the relay can poll the state without a sudo round-trip per tick.
|
|
154
|
-
'printf \'%s %s %s\\n\' "$$" "$until" "$(procStart $$)" > "$pidfile" && chmod 644 "$pidfile"',
|
|
172
|
+
'printf \'%s %s %s %s\\n\' "$$" "$until" "$(procStart $$)" "$preventlock" > "$pidfile" && chmod 644 "$pidfile"',
|
|
155
173
|
'',
|
|
156
174
|
'# A long window is ambiguous without the weekday: "until 15:45" reads as today.',
|
|
157
175
|
'clock() {',
|
|
@@ -171,6 +189,11 @@ export const NOSLEEP_BODY = [
|
|
|
171
189
|
'\techo "✓ Sleep disabled at $(date \'+%H:%M\') (incl. lid closed) — until you press Ctrl-C."',
|
|
172
190
|
'\tsleep 2147483647 &',
|
|
173
191
|
'fi',
|
|
192
|
+
'if [ "$preventlock" = 1 ]; then',
|
|
193
|
+
'\techo "⚠ Automatic screen lock is disabled for this window. Anyone with physical access can use this Mac."',
|
|
194
|
+
'else',
|
|
195
|
+
'\techo "Automatic screen lock remains enabled (prevent-screen-lock=off)."',
|
|
196
|
+
'fi',
|
|
174
197
|
'sleeper=$!',
|
|
175
198
|
'wait "$sleeper"'
|
|
176
199
|
].join('\n');
|
package/dist-node/src/nosleep.js
CHANGED
|
@@ -29,13 +29,17 @@
|
|
|
29
29
|
* re-`setup` — gated on ioreg's AppleClamshellState: an open lid, or a desktop Mac
|
|
30
30
|
* that has none, keeps the restore-only behaviour, because forcing sleep on a Mac
|
|
31
31
|
* someone may be sitting at is worse than the bug.
|
|
32
|
+
* - **The same window blocks the idle screen lock by default.** The root helper owns
|
|
33
|
+
* a `PreventUserIdleDisplaySleep` assertion through `caffeinate -d`; macOS drops it
|
|
34
|
+
* with the process even if the EXIT trap never runs. The pidfile records that mode
|
|
35
|
+
* so the phone can warn accurately, and the persisted setting can opt out.
|
|
32
36
|
*
|
|
33
37
|
* Stdlib only, strip-clean — see CLAUDE.md.
|
|
34
38
|
*/
|
|
35
39
|
import { execFile, spawn } from 'node:child_process';
|
|
36
40
|
import fs from 'node:fs';
|
|
37
41
|
import { promisify } from 'node:util';
|
|
38
|
-
import { HELPER_PATH, helperReady, PIDFILE_PATH } from "./nosleep-helper.js";
|
|
42
|
+
import { HELPER_PATH, helperFile, helperReady, installedHelper, PIDFILE_PATH } from "./nosleep-helper.js";
|
|
39
43
|
const execFileP = promisify(execFile);
|
|
40
44
|
/** Longest window the API will arm. A phone tap should never be able to disable sleep forever. */
|
|
41
45
|
export const MAX_SECONDS = 12 * 3600;
|
|
@@ -52,7 +56,7 @@ function alive(pid) {
|
|
|
52
56
|
return err.code === 'EPERM';
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
|
-
/** Parse `<pid> <expiry-epoch-seconds>`; expiry 0 means "until stopped". */
|
|
59
|
+
/** Parse `<pid> <expiry-epoch-seconds> <start-token> <prevent-lock>`; expiry 0 means "until stopped". */
|
|
56
60
|
function readPidfile() {
|
|
57
61
|
let raw;
|
|
58
62
|
try {
|
|
@@ -61,12 +65,17 @@ function readPidfile() {
|
|
|
61
65
|
catch {
|
|
62
66
|
return null;
|
|
63
67
|
}
|
|
64
|
-
const [pidRaw, untilRaw] = raw.trim().split(/\s+/);
|
|
68
|
+
const [pidRaw, untilRaw, , preventLockRaw] = raw.trim().split(/\s+/);
|
|
65
69
|
const pid = Number(pidRaw);
|
|
66
70
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
67
71
|
return null;
|
|
68
72
|
const untilSec = Number(untilRaw);
|
|
69
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
pid,
|
|
75
|
+
until: Number.isFinite(untilSec) && untilSec > 0 ? untilSec * 1000 : null,
|
|
76
|
+
// Three-field records came from the old helper, which held no display assertion.
|
|
77
|
+
preventsScreenLock: preventLockRaw === '1'
|
|
78
|
+
};
|
|
70
79
|
}
|
|
71
80
|
/**
|
|
72
81
|
* Armed-ness on its own: a local file read plus a signal probe, no subprocess at all.
|
|
@@ -82,7 +91,13 @@ function armedRecord() {
|
|
|
82
91
|
return rec && alive(rec.pid) ? rec : null;
|
|
83
92
|
}
|
|
84
93
|
function armedState(rec) {
|
|
85
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
available: true,
|
|
96
|
+
armed: true,
|
|
97
|
+
until: rec.until,
|
|
98
|
+
pid: rec.pid,
|
|
99
|
+
preventsScreenLock: rec.preventsScreenLock
|
|
100
|
+
};
|
|
86
101
|
}
|
|
87
102
|
/**
|
|
88
103
|
* Current state. `helperReady()` shells out to sudo, so it is only consulted when nothing
|
|
@@ -92,7 +107,9 @@ export async function nosleepState() {
|
|
|
92
107
|
const rec = armedRecord();
|
|
93
108
|
if (rec)
|
|
94
109
|
return armedState(rec);
|
|
95
|
-
|
|
110
|
+
const ready = await helperReady();
|
|
111
|
+
const current = installedHelper() === helperFile();
|
|
112
|
+
return { available: ready && current, armed: false, until: null, pid: null, preventsScreenLock: false };
|
|
96
113
|
}
|
|
97
114
|
/**
|
|
98
115
|
* Whether the lid is physically shut. `null` means the probe couldn't say — a desktop Mac
|
|
@@ -150,8 +167,8 @@ function sleepSoon(why) {
|
|
|
150
167
|
function unavailable() {
|
|
151
168
|
return {
|
|
152
169
|
ok: false,
|
|
153
|
-
error: 'Passwordless nosleep
|
|
154
|
-
state: { available: false, armed: false, until: null, pid: null }
|
|
170
|
+
error: 'Passwordless nosleep is missing or out of date. Run `conductor-remote nosleep setup` on the Mac.',
|
|
171
|
+
state: { available: false, armed: false, until: null, pid: null, preventsScreenLock: false }
|
|
155
172
|
};
|
|
156
173
|
}
|
|
157
174
|
/**
|
|
@@ -159,8 +176,8 @@ function unavailable() {
|
|
|
159
176
|
* rather than stacking — the helper enforces that, and it has to, since two owners would
|
|
160
177
|
* restore each other's flipped values and leave sleep disabled for good.
|
|
161
178
|
*/
|
|
162
|
-
export async function armNoSleep(seconds) {
|
|
163
|
-
if (!(await helperReady()))
|
|
179
|
+
export async function armNoSleep(seconds, preventScreenLock = true) {
|
|
180
|
+
if (!(await helperReady()) || installedHelper() !== helperFile())
|
|
164
181
|
return unavailable();
|
|
165
182
|
// Floor of 1, not 0. The helper reads 0 as "until killed", so anything under a second
|
|
166
183
|
// truncates straight past MAX_SECONDS into a window nothing ever closes — which is the
|
|
@@ -168,7 +185,7 @@ export async function armNoSleep(seconds) {
|
|
|
168
185
|
const secs = Math.min(MAX_SECONDS, Math.max(1, Math.trunc(seconds)));
|
|
169
186
|
// Detached, own session, no stdio: it has to survive this relay's own restarts,
|
|
170
187
|
// which autoupdate performs routinely and without warning.
|
|
171
|
-
const child = spawn('sudo', ['-n', HELPER_PATH, String(secs), ''], {
|
|
188
|
+
const child = spawn('sudo', ['-n', HELPER_PATH, String(secs), '', preventScreenLock ? '1' : '0'], {
|
|
172
189
|
detached: true,
|
|
173
190
|
stdio: 'ignore'
|
|
174
191
|
});
|
|
@@ -214,7 +231,11 @@ export async function disarmNoSleep() {
|
|
|
214
231
|
sleepSoon('the keep-awake window was ended from the phone');
|
|
215
232
|
else
|
|
216
233
|
console.info('nosleep: window ended with the lid open (or unreadable) — sleep re-enabled, not forced');
|
|
217
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
willSleep,
|
|
237
|
+
state: { available: true, armed: false, until: null, pid: null, preventsScreenLock: false }
|
|
238
|
+
};
|
|
218
239
|
}
|
|
219
240
|
await new Promise(r => setTimeout(r, 200));
|
|
220
241
|
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -671,7 +671,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
671
671
|
// truncates to 0 — an unbounded window from a request that looked bounded.
|
|
672
672
|
if (!Number.isInteger(seconds) || seconds < 1)
|
|
673
673
|
return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
|
|
674
|
-
const result = await armNoSleep(seconds);
|
|
674
|
+
const result = await armNoSleep(seconds, cfg.preventScreenLock);
|
|
675
675
|
return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
|
|
676
676
|
}
|
|
677
677
|
// DELETE /api/nosleep — let it sleep again now, rather than at the window's end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.46.
|
|
3
|
+
"version": "1.46.3",
|
|
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,12 +50,13 @@
|
|
|
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:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
|
|
53
|
+
"verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:config && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
|
|
54
54
|
"release": "semantic-release",
|
|
55
55
|
"prepack": "yarn build && yarn build:node",
|
|
56
56
|
"postinstall": "husky || true",
|
|
57
57
|
"check:attachments": "node scripts/check-attachments.ts",
|
|
58
58
|
"check:applescript": "node scripts/check-applescript.ts",
|
|
59
|
+
"check:config": "node scripts/check-config.ts",
|
|
59
60
|
"check:firstprompt": "node scripts/check-firstprompt.ts",
|
|
60
61
|
"check:model-labels": "node scripts/check-model-labels.ts",
|
|
61
62
|
"check:sendonce": "node scripts/check-sendonce.ts",
|