agentbox-app 0.1.12 → 0.1.13
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/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -22
- package/main/analytics.mjs +30 -0
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +16 -16
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -249
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +26 -4
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +4 -15
- package/main/feedback.mjs +11 -6
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +38 -0
- package/main/ipc.mjs +165 -31
- package/main/local-app-frames.mjs +82 -0
- package/main/main.mjs +100 -4
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +47 -4
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +6 -6
- package/main/runs-on.mjs +32 -35
- package/main/serve.mjs +113 -9
- package/main/settings.mjs +148 -10
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +24 -6
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store.mjs +1 -1
- package/main/supervisor.mjs +832 -428
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +490 -98
- package/main/task-terminals.mjs +37 -7
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +2 -0
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +18 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/engines.mjs +20 -5
- package/shared/harness-definitions.mjs +103 -0
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +0 -10
- package/shared/runs-on.mjs +25 -49
- package/shared/shortcuts.mjs +10 -4
- package/shared/spawn-trouble.mjs +57 -6
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +25 -1
- package/shared/work-lines.mjs +1 -1
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
- package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// THE PHONE DOOR: the desktop app's own inbox, answering a phone.
|
|
2
|
+
//
|
|
3
|
+
// main/serve.mjs is a door for a browser tab on this computer: it boots a
|
|
4
|
+
// second copy of the doing half and listens on the loopback address only. A
|
|
5
|
+
// phone needs the opposite on both counts. It must reach the inbox the desktop
|
|
6
|
+
// window is showing, not a second copy of it, and it must reach it from off
|
|
7
|
+
// this computer.
|
|
8
|
+
//
|
|
9
|
+
// So this takes the handlers the desktop app already registered (main.mjs
|
|
10
|
+
// collects them as `registerIpc` files them) and the pushes the desktop window
|
|
11
|
+
// already receives, and puts serve.mjs's request handler behind a server that
|
|
12
|
+
// listens on every address, once it is turned on in Settings.
|
|
13
|
+
//
|
|
14
|
+
// THE KEY. Whoever holds it can run agents on this computer, so it is long,
|
|
15
|
+
// random, kept in a file only this user can read, and shown only as the QR
|
|
16
|
+
// code. It survives a restart, because a phone's home-screen icon carries it
|
|
17
|
+
// and a key that changed on every launch would break that icon every launch.
|
|
18
|
+
// Reset makes a new one and closes every open phone at once.
|
|
19
|
+
//
|
|
20
|
+
// THE DEVICES. Each phone's page makes up an id for itself and sends it along
|
|
21
|
+
// with the key. The id is the phone's own word and proves nothing; the list is
|
|
22
|
+
// there so you can see who is connected, and Reset is what actually locks
|
|
23
|
+
// somebody out.
|
|
24
|
+
|
|
25
|
+
import http from 'node:http';
|
|
26
|
+
import https from 'node:https';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import QRCode from 'qrcode';
|
|
34
|
+
import webpush from 'web-push';
|
|
35
|
+
import { banner, shouldSpeak } from '../shared/notify-rules.mjs';
|
|
36
|
+
import { createHandler, sameKey } from './serve.mjs';
|
|
37
|
+
import { createRelayClient } from './relay-client.mjs';
|
|
38
|
+
import { Name } from '../shared/product-name.mjs';
|
|
39
|
+
|
|
40
|
+
export const PHONE_PORT = 41745;
|
|
41
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const ICON = path.join(HERE, 'phone-icon.png');
|
|
43
|
+
const WORKER = path.join(HERE, 'phone-sw.js');
|
|
44
|
+
|
|
45
|
+
// ALERTS ON THE PHONE. A phone app on the home screen can get
|
|
46
|
+
// Web Push on iOS 16.4 and later, over https only, so over the Tailscale
|
|
47
|
+
// address. The computer signs each alert with its own key pair (VAPID) and
|
|
48
|
+
// hands it, encrypted for that one phone, to the phone's push service (Apple's,
|
|
49
|
+
// for an iPhone). The subject names who is sending; Apple wants a URL or a
|
|
50
|
+
// mailto, and the project's page is the honest one.
|
|
51
|
+
const PUSH_SUBJECT = 'https://github.com/savannahfeder/agentbox';
|
|
52
|
+
const COALESCE_MS = 1500;
|
|
53
|
+
// THE CONNECTION TO APPLE. Node tries each of a host's addresses for 250ms
|
|
54
|
+
// before the next, and web.push.apple.com has eight, so over a slow link
|
|
55
|
+
// (measured on this Mac, 2026-10-09: three connects in four failed with
|
|
56
|
+
// ETIMEDOUT after 2s) every alert was dropped. Two and a half seconds an
|
|
57
|
+
// address connected four in four, in about half a second each.
|
|
58
|
+
const PUSH_AGENT = new https.Agent({ keepAlive: true, autoSelectFamilyAttemptTimeout: 2500 });
|
|
59
|
+
const DEVICE_HEADER = 'x-agentbox-device';
|
|
60
|
+
// A phone that has asked nothing for this long, and has no live event stream,
|
|
61
|
+
// is listed as last seen rather than connected.
|
|
62
|
+
const QUIET_MS = 30_000;
|
|
63
|
+
|
|
64
|
+
const newKey = () => crypto.randomBytes(24).toString('base64url');
|
|
65
|
+
|
|
66
|
+
// THE SECURE ADDRESS. Over plain http a phone's browser says "Not Secure" in
|
|
67
|
+
// the address bar (measured on an iPhone, 2026-10-09). Tailscale can put a real
|
|
68
|
+
// certificate in front of this server: `tailscale serve` answers
|
|
69
|
+
// https://<this computer>.<tailnet>.ts.net:8443 inside the tailnet only, and
|
|
70
|
+
// passes each request on to the loopback port. So picking Tailscale picks that.
|
|
71
|
+
// Wi-Fi has no such certificate and stays plain http.
|
|
72
|
+
export const SECURE_PORT = 8443;
|
|
73
|
+
const TAILSCALE_BINS = ['/Applications/Tailscale.app/Contents/MacOS/tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale', '/usr/bin/tailscale'];
|
|
74
|
+
|
|
75
|
+
/** The `tailscale` command, as three questions. Null when it is not installed. */
|
|
76
|
+
export function tailscaleCli(bins = TAILSCALE_BINS) {
|
|
77
|
+
const bin = bins.find((b) => { try { fs.accessSync(b, fs.constants.X_OK); return true; } catch { return false; } });
|
|
78
|
+
if (!bin) return null;
|
|
79
|
+
const run = (args) => new Promise((resolve, reject) => {
|
|
80
|
+
execFile(bin, args, { timeout: 20_000 }, (err, stdout, stderr) => (err ? reject(new Error(String(stderr || err.message).trim())) : resolve(String(stdout))));
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
/** This computer's name in the tailnet, if it can have a certificate. */
|
|
84
|
+
async name() {
|
|
85
|
+
const d = JSON.parse(await run(['status', '--json']));
|
|
86
|
+
const host = String(d.Self?.DNSName ?? '').replace(/\.$/, '');
|
|
87
|
+
return host && (d.CertDomains ?? []).includes(host) ? host : null;
|
|
88
|
+
},
|
|
89
|
+
serve: (port) => run(['serve', '--bg', '--yes', `--https=${SECURE_PORT}`, `http://127.0.0.1:${port}`]),
|
|
90
|
+
unserve: () => run(['serve', `--https=${SECURE_PORT}`, 'off']),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Wi-Fi and Tailscale addresses on this computer, in that order. */
|
|
95
|
+
export function phoneAddresses(interfaces = os.networkInterfaces()) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const [name, list] of Object.entries(interfaces)) {
|
|
98
|
+
for (const a of list ?? []) {
|
|
99
|
+
if (a.family !== 'IPv4' || a.internal) continue;
|
|
100
|
+
const [x, y] = a.address.split('.').map(Number);
|
|
101
|
+
// Tailscale hands out addresses from 100.64.0.0/10.
|
|
102
|
+
if (x === 100 && y >= 64 && y <= 127) out.push({ via: 'tailscale', host: a.address, iface: name });
|
|
103
|
+
else if (x === 10 || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168)) {
|
|
104
|
+
out.push({ via: 'wifi', host: a.address, iface: name });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// en0 is the built-in Wi-Fi or Ethernet; prefer it over a VM bridge.
|
|
109
|
+
const rank = (a) => (a.via === 'wifi' ? (a.iface === 'en0' ? 0 : 1) : 2);
|
|
110
|
+
return out.sort((a, b) => rank(a) - rank(b));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* WHO MAY KNOCK. This computer, a private network (a home or office Wi-Fi,
|
|
115
|
+
* an iPhone's hotspot at 172.20.10.x) and Tailscale's own range. Never a
|
|
116
|
+
* public address: the key is the whole computer, and a Mac on a network that
|
|
117
|
+
* hands it a public address must not answer the world with it.
|
|
118
|
+
*/
|
|
119
|
+
export function fromThisNetwork(address) {
|
|
120
|
+
const a = String(address ?? '').replace(/^::ffff:/, '');
|
|
121
|
+
if (a === '::1') return true;
|
|
122
|
+
const m = a.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
|
|
123
|
+
if (!m) return false;
|
|
124
|
+
const [x, y] = [Number(m[1]), Number(m[2])];
|
|
125
|
+
return x === 127 || x === 10 || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 100 && y >= 64 && y <= 127);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** "iPhone · Safari" from a user agent string. */
|
|
129
|
+
export function deviceName(ua = '') {
|
|
130
|
+
const what = /iPhone/.test(ua) ? 'iPhone'
|
|
131
|
+
: /iPad/.test(ua) ? 'iPad'
|
|
132
|
+
: /Android/.test(ua) ? 'Android phone'
|
|
133
|
+
: /Macintosh/.test(ua) ? 'Mac'
|
|
134
|
+
: /Windows/.test(ua) ? 'Windows PC'
|
|
135
|
+
: /Linux/.test(ua) ? 'Linux computer'
|
|
136
|
+
: 'Device';
|
|
137
|
+
const browser = /EdgA?\//.test(ua) ? 'Edge'
|
|
138
|
+
: /CriOS|Chrome\//.test(ua) ? 'Chrome'
|
|
139
|
+
: /FxiOS|Firefox\//.test(ua) ? 'Firefox'
|
|
140
|
+
: /Safari\//.test(ua) ? 'Safari'
|
|
141
|
+
: /AppleWebKit/.test(ua) ? 'home-screen app'
|
|
142
|
+
: null;
|
|
143
|
+
return browser ? `${what} · ${browser}` : what;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {{ file: string, channels: Map<string, Function>, listeners: Set<Function>,
|
|
148
|
+
* dist?: string, port?: number, onChange?: () => void,
|
|
149
|
+
* interfaces?: () => object, log?: { warn?: Function } }} options
|
|
150
|
+
*/
|
|
151
|
+
export function createPhoneLink({ file, channels, listeners, dist, files = null, port = PHONE_PORT, onChange = () => {}, interfaces = () => os.networkInterfaces(), log = console, tailscale = tailscaleCli(), warm = (url) => fetch(url).catch(() => {}), send = (sub, body, opts) => webpush.sendNotification(sub, body, opts), atDesk = () => false, coalesceMs = COALESCE_MS, relayUrl = '', createRelay = (opts) => createRelayClient(opts) }) {
|
|
152
|
+
let saved = read(file);
|
|
153
|
+
// ANYWHERE, THROUGH THE RELAY (main/relay-client.mjs). With a relay to use it
|
|
154
|
+
// is where a person starts, because it needs nothing on the phone; a route
|
|
155
|
+
// someone picked stays picked. Before the relay every file said "wifi"
|
|
156
|
+
// whether or not anybody picked it, so only a pick marked as one counts.
|
|
157
|
+
const relay = relayUrl ? createRelay({ relayUrl, key: () => saved.key, port: () => port, log, onChange: () => onChange() }) : null;
|
|
158
|
+
const via = () => {
|
|
159
|
+
const picked = saved.viaChosen || saved.via === 'tailscale' ? saved.via : null;
|
|
160
|
+
if (picked === 'anywhere' && !relay) return 'wifi';
|
|
161
|
+
return picked ?? (relay ? 'anywhere' : 'wifi');
|
|
162
|
+
};
|
|
163
|
+
function settleRelay() {
|
|
164
|
+
if (!relay) return;
|
|
165
|
+
if (saved.on && via() === 'anywhere') relay.start();
|
|
166
|
+
else relay.stop();
|
|
167
|
+
}
|
|
168
|
+
let server = null;
|
|
169
|
+
let error = null;
|
|
170
|
+
/** The https name `tailscale serve` is answering on, or why it is not. */
|
|
171
|
+
let secure = { host: null, error: null, busy: null };
|
|
172
|
+
/** id -> { id, name, ip, firstSeen, lastSeen, streams: Set<ServerResponse> } */
|
|
173
|
+
const devices = new Map();
|
|
174
|
+
|
|
175
|
+
const key = () => saved.key;
|
|
176
|
+
// What the alerts know: the inbox count the desktop last drew, the arrivals
|
|
177
|
+
// not yet told, and whether a burst has been told already (the desktop's
|
|
178
|
+
// own rule, shared/notify-rules.mjs: once, then again only for a card).
|
|
179
|
+
let count = 0;
|
|
180
|
+
const unseen = new Map();
|
|
181
|
+
let timer = null;
|
|
182
|
+
let spoke = false;
|
|
183
|
+
let spokeAsk = false;
|
|
184
|
+
const inner = createHandler({ channels, token: key, listeners, dist, files });
|
|
185
|
+
|
|
186
|
+
function save() {
|
|
187
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
188
|
+
fs.writeFileSync(file, JSON.stringify(saved, null, 2), { mode: 0o600 });
|
|
189
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function seen(req, url) {
|
|
193
|
+
const id = String(req.headers[DEVICE_HEADER] || url.searchParams.get('device') || '').slice(0, 64);
|
|
194
|
+
if (!id) return null;
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
let d = devices.get(id);
|
|
197
|
+
if (!d) {
|
|
198
|
+
d = { id, name: deviceName(req.headers['user-agent']), ip: null, firstSeen: now, lastSeen: now, streams: new Set() };
|
|
199
|
+
devices.set(id, d);
|
|
200
|
+
onChange();
|
|
201
|
+
}
|
|
202
|
+
d.lastSeen = now;
|
|
203
|
+
// Behind `tailscale serve` every request arrives from loopback; the phone's
|
|
204
|
+
// own address is in the header it adds.
|
|
205
|
+
const from = String(req.socket.remoteAddress ?? '').replace(/^::ffff:/, '');
|
|
206
|
+
const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim();
|
|
207
|
+
d.ip = (from === '127.0.0.1' || from === '::1') && forwarded ? forwarded : from;
|
|
208
|
+
return d;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handle(req, res) {
|
|
212
|
+
if (!fromThisNetwork(req.socket?.remoteAddress)) { res.writeHead(403).end(); return; }
|
|
213
|
+
const url = new URL(req.url, 'http://phone');
|
|
214
|
+
const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/') || url.pathname === '/manifest.webmanifest';
|
|
215
|
+
const given = req.headers['x-agentbox-token'] || url.searchParams.get('token');
|
|
216
|
+
const ok = sameKey(given, saved.key);
|
|
217
|
+
|
|
218
|
+
// THE HOME-SCREEN ICON. Its start address carries the key, so the icon
|
|
219
|
+
// opens straight into the inbox. Asked for with the key, like the api.
|
|
220
|
+
if (url.pathname === '/manifest.webmanifest') {
|
|
221
|
+
if (!ok) { res.writeHead(403).end(); return; }
|
|
222
|
+
res.writeHead(200, { 'content-type': 'application/manifest+json', 'cache-control': 'no-store' });
|
|
223
|
+
res.end(JSON.stringify({
|
|
224
|
+
name: Name,
|
|
225
|
+
short_name: Name,
|
|
226
|
+
// Saved through the relay, the icon opens with the key after the #, so
|
|
227
|
+
// a cold start (before the phone's worker is back) never hands the key
|
|
228
|
+
// to the relay in an address it could log.
|
|
229
|
+
start_url: req.headers['x-agentbox-relay'] ? `/#k=${encodeURIComponent(given)}` : `/?token=${encodeURIComponent(given)}`,
|
|
230
|
+
scope: '/',
|
|
231
|
+
display: 'standalone',
|
|
232
|
+
// The app's own warm dark. A cool grey here was what Safari painted
|
|
233
|
+
// above and below a warm page (2026-10-11); the page sets the exact
|
|
234
|
+
// colour of its look once it has drawn (renderer/src/phone-screen.ts).
|
|
235
|
+
background_color: '#1e1b1a',
|
|
236
|
+
theme_color: '#1e1b1a',
|
|
237
|
+
icons: [{ src: '/phone-icon.png', sizes: '512x512', type: 'image/png', purpose: 'any' }],
|
|
238
|
+
}));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The alert worker is the page's own script, so it is as public as the page.
|
|
243
|
+
if (url.pathname === '/phone-sw.js') {
|
|
244
|
+
fs.readFile(WORKER, (err, buf) => {
|
|
245
|
+
if (err) { res.writeHead(404).end(); return; }
|
|
246
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache', 'service-worker-allowed': '/' });
|
|
247
|
+
res.end(String(buf).replaceAll('__APP_NAME__', Name));
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A phone turning its alerts on or off, asking for the key to sign up
|
|
253
|
+
// with, or asking for a test. With the key, like the api.
|
|
254
|
+
if (url.pathname === '/phone/alerts') {
|
|
255
|
+
if (!ok || req.method !== 'POST') { res.writeHead(403).end(); return; }
|
|
256
|
+
const d = seen(req, url);
|
|
257
|
+
let body = {};
|
|
258
|
+
try { body = JSON.parse((await readJson(req)) || '{}'); } catch { res.writeHead(400).end(); return; }
|
|
259
|
+
try {
|
|
260
|
+
const answer = await alertsFor(d, body);
|
|
261
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
262
|
+
res.end(JSON.stringify(answer));
|
|
263
|
+
} catch (err) {
|
|
264
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
265
|
+
res.end(JSON.stringify({ error: String(err.message ?? err) }));
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The icon lives beside this file, not in the built screen.
|
|
271
|
+
if (url.pathname === '/phone-icon.png') {
|
|
272
|
+
fs.readFile(ICON, (err, buf) => {
|
|
273
|
+
if (err) { res.writeHead(404).end(); return; }
|
|
274
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'max-age=86400' });
|
|
275
|
+
res.end(buf);
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (guarded && ok) {
|
|
281
|
+
const d = seen(req, url);
|
|
282
|
+
if (d && url.pathname === '/events') {
|
|
283
|
+
// A phone that opens the inbox has seen what was waiting.
|
|
284
|
+
seenAll();
|
|
285
|
+
d.streams.add(res);
|
|
286
|
+
onChange();
|
|
287
|
+
req.on('close', () => { d.streams.delete(res); d.lastSeen = Date.now(); onChange(); });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return inner(req, res);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// WHERE IT LISTENS. Over Tailscale the phone arrives through `tailscale
|
|
294
|
+
// serve`, which hands each request on to 127.0.0.1, so the door listens on
|
|
295
|
+
// this computer only and no network the Mac joins can reach it in plain
|
|
296
|
+
// http. Over Wi-Fi the phone is on the network, so it listens on every
|
|
297
|
+
// address and `fromThisNetwork` turns away anyone who is not.
|
|
298
|
+
const wantedHost = () => (secureWanted() || via() === 'anywhere' ? '127.0.0.1' : '0.0.0.0');
|
|
299
|
+
let host = null;
|
|
300
|
+
|
|
301
|
+
function start() {
|
|
302
|
+
if (server) return;
|
|
303
|
+
error = null;
|
|
304
|
+
host = wantedHost();
|
|
305
|
+
server = http.createServer(handle);
|
|
306
|
+
server.on('error', (err) => {
|
|
307
|
+
error = err.code === 'EADDRINUSE'
|
|
308
|
+
? `Port ${port} is taken by another program, so phones cannot reach this app.`
|
|
309
|
+
: String(err.message ?? err);
|
|
310
|
+
log.warn?.('[phone-link]', error);
|
|
311
|
+
server = null;
|
|
312
|
+
onChange();
|
|
313
|
+
});
|
|
314
|
+
server.listen(port, host, () => onChange());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Listening on the wrong addresses for the route picked: listen again. */
|
|
318
|
+
function rebind() {
|
|
319
|
+
if (!server || host === wantedHost()) return;
|
|
320
|
+
stop();
|
|
321
|
+
start();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ON WHEN PHONES ARE LET IN OVER TAILSCALE, OFF OTHERWISE. Only the one
|
|
325
|
+
// https port is touched, so anything else this computer serves is left alone.
|
|
326
|
+
function secureWanted() { return saved.on && saved.via === 'tailscale'; }
|
|
327
|
+
function settleSecure() {
|
|
328
|
+
const run = (secure.busy ?? Promise.resolve()).then(async () => {
|
|
329
|
+
if (secureWanted() && !secure.host) {
|
|
330
|
+
if (!tailscale) { secure = { ...secure, error: 'Tailscale is not installed here, so there is no secure address.' }; return; }
|
|
331
|
+
try {
|
|
332
|
+
const host = await tailscale.name();
|
|
333
|
+
if (!host) throw new Error('This tailnet has HTTPS certificates turned off. Turn them on in the Tailscale admin console, under DNS.');
|
|
334
|
+
await tailscale.serve(port);
|
|
335
|
+
secure = { ...secure, host, error: null };
|
|
336
|
+
// The first request makes Tailscale fetch the certificate, which took
|
|
337
|
+
// 40 seconds when measured. Make it now, not on the phone.
|
|
338
|
+
warm(`https://${host}:${SECURE_PORT}/`);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
secure = { ...secure, host: null, error: `No secure address: ${err.message}` };
|
|
341
|
+
log.warn?.('[phone-link]', secure.error);
|
|
342
|
+
}
|
|
343
|
+
} else if (!secureWanted() && secure.host) {
|
|
344
|
+
try { await tailscale?.unserve(); } catch (err) { log.warn?.('[phone-link] tailscale serve off:', err.message); }
|
|
345
|
+
secure = { ...secure, host: null, error: null };
|
|
346
|
+
} else if (!secureWanted()) {
|
|
347
|
+
secure = { ...secure, error: null };
|
|
348
|
+
}
|
|
349
|
+
}).finally(() => { if (secure.busy === run) secure.busy = null; onChange(); });
|
|
350
|
+
secure.busy = run;
|
|
351
|
+
return run;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function stop() {
|
|
355
|
+
if (!server) return;
|
|
356
|
+
for (const d of devices.values()) for (const r of d.streams) r.destroy();
|
|
357
|
+
server.close();
|
|
358
|
+
// A phone's kept-alive connection would otherwise go on being answered
|
|
359
|
+
// by a door that is shut.
|
|
360
|
+
server.closeAllConnections?.();
|
|
361
|
+
server = null;
|
|
362
|
+
host = null;
|
|
363
|
+
devices.clear();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function dropEveryPhone() {
|
|
367
|
+
for (const d of devices.values()) for (const r of d.streams) r.destroy();
|
|
368
|
+
devices.clear();
|
|
369
|
+
// A phone locked out by a new key must not keep getting alerts either.
|
|
370
|
+
saved.alerts = {};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* ------------------------------- alerts -------------------------------- */
|
|
374
|
+
|
|
375
|
+
function vapid() {
|
|
376
|
+
if (!saved.vapid) { saved.vapid = webpush.generateVAPIDKeys(); save(); }
|
|
377
|
+
return saved.vapid;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function alertsFor(d, { action, subscription } = {}) {
|
|
381
|
+
if (!d) throw new Error('This phone did not say which phone it is.');
|
|
382
|
+
if (action === 'key') return { publicKey: vapid().publicKey, on: !!saved.alerts[d.id] };
|
|
383
|
+
if (action === 'state') return { on: !!saved.alerts[d.id] };
|
|
384
|
+
if (action === 'subscribe') {
|
|
385
|
+
const endpoint = String(subscription?.endpoint ?? '');
|
|
386
|
+
if (!/^https:\/\//.test(endpoint) || !subscription?.keys?.p256dh || !subscription?.keys?.auth) throw new Error('That is not a push subscription.');
|
|
387
|
+
saved.alerts[d.id] = { sub: { endpoint, keys: { p256dh: String(subscription.keys.p256dh), auth: String(subscription.keys.auth) } }, name: d.name, since: Date.now() };
|
|
388
|
+
save();
|
|
389
|
+
onChange();
|
|
390
|
+
return { on: true };
|
|
391
|
+
}
|
|
392
|
+
if (action === 'unsubscribe') { delete saved.alerts[d.id]; save(); onChange(); return { on: false }; }
|
|
393
|
+
if (action === 'test') {
|
|
394
|
+
if (!saved.alerts[d.id]) throw new Error('Alerts are off on this phone.');
|
|
395
|
+
const sent = await push(d.id, { title: 'Alerts are on', body: `This is how ${Name} tells you an agent needs you.`, count, open: null });
|
|
396
|
+
if (!sent && saved.alerts[d.id]) throw new Error(`The computer could not send it (${saved.alerts[d.id].lastError}).`);
|
|
397
|
+
return { on: true, sent };
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`Unknown alerts action ${action}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** One alert to one phone. A phone whose subscription is gone is forgotten. */
|
|
403
|
+
async function push(id, payload) {
|
|
404
|
+
const entry = saved.alerts[id];
|
|
405
|
+
if (!entry) return false;
|
|
406
|
+
try {
|
|
407
|
+
await send(entry.sub, JSON.stringify({ ...payload, tag: 'agentbox' }), {
|
|
408
|
+
vapidDetails: { subject: PUSH_SUBJECT, publicKey: vapid().publicKey, privateKey: vapid().privateKey },
|
|
409
|
+
TTL: 6 * 3600,
|
|
410
|
+
urgency: 'high',
|
|
411
|
+
agent: PUSH_AGENT,
|
|
412
|
+
});
|
|
413
|
+
entry.lastSent = Date.now();
|
|
414
|
+
entry.lastError = null;
|
|
415
|
+
return true;
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (err?.statusCode === 404 || err?.statusCode === 410) {
|
|
418
|
+
delete saved.alerts[id];
|
|
419
|
+
save();
|
|
420
|
+
onChange();
|
|
421
|
+
} else {
|
|
422
|
+
// Said on the Phone page too, not only in a log nobody reads.
|
|
423
|
+
entry.lastError = `${err?.statusCode ?? err?.code ?? 'error'} ${String(err?.body || err?.message || err).trim()}`.trim();
|
|
424
|
+
entry.lastErrorAt = Date.now();
|
|
425
|
+
log.warn?.('[phone-link] alert not sent:', entry.lastError);
|
|
426
|
+
onChange();
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function seenAll() {
|
|
433
|
+
unseen.clear();
|
|
434
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
435
|
+
spoke = false;
|
|
436
|
+
spokeAsk = false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function speak() {
|
|
440
|
+
timer = null;
|
|
441
|
+
if (atDesk()) return seenAll();
|
|
442
|
+
const items = [...unseen.values()];
|
|
443
|
+
if (!shouldSpeak({ spoke, spokeAsk, waiting: items })) return;
|
|
444
|
+
const say = banner(items);
|
|
445
|
+
if (!say) return;
|
|
446
|
+
spoke = true;
|
|
447
|
+
if (items.some((i) => i.kind === 'approval')) spokeAsk = true;
|
|
448
|
+
// A phone with the inbox open on its screen is looking already.
|
|
449
|
+
const away = Object.keys(saved.alerts).filter((id) => !(devices.get(id)?.streams.size > 0));
|
|
450
|
+
await Promise.all(away.map((id) => push(id, { title: say.title, body: say.body, open: say.open, count })));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** What the desktop's notifier was told arrived (main/notify.mjs). */
|
|
454
|
+
function arrivals(list) {
|
|
455
|
+
if (!saved.on || !Object.keys(saved.alerts).length) return;
|
|
456
|
+
if (!Array.isArray(list) || !list.length) return;
|
|
457
|
+
if (atDesk()) return seenAll();
|
|
458
|
+
for (const a of list) if (a?.id && !unseen.has(a.id)) unseen.set(a.id, a);
|
|
459
|
+
if (!unseen.size || timer) return;
|
|
460
|
+
timer = setTimeout(() => { void speak(); }, coalesceMs);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function status({ via: asked } = {}) {
|
|
464
|
+
const addrs = phoneAddresses(interfaces());
|
|
465
|
+
const want = asked ?? via();
|
|
466
|
+
// THROUGH THE RELAY the code is its address with the key after the #,
|
|
467
|
+
// which a browser never sends: the relay sees a room, never the key.
|
|
468
|
+
const anywhere = want === 'anywhere' && relay;
|
|
469
|
+
const pick = anywhere ? { via: 'anywhere', host: new URL(relayUrl).host } : addrs.find((a) => a.via === want) ?? addrs[0] ?? null;
|
|
470
|
+
const safe = anywhere || (pick?.via === 'tailscale' && secure.host);
|
|
471
|
+
const url = !pick ? null
|
|
472
|
+
: anywhere ? `${relayUrl.replace(/\/+$/, '')}/#k=${encodeURIComponent(saved.key)}`
|
|
473
|
+
: safe ? `https://${secure.host}:${SECURE_PORT}/?token=${encodeURIComponent(saved.key)}`
|
|
474
|
+
: `http://${pick.host}:${port}/?token=${encodeURIComponent(saved.key)}`;
|
|
475
|
+
const relayState = anywhere ? relay.state() : null;
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
return {
|
|
478
|
+
on: !!saved.on,
|
|
479
|
+
listening: !!server?.listening,
|
|
480
|
+
/** 127.0.0.1 over Tailscale and the relay, 0.0.0.0 over Wi-Fi. */
|
|
481
|
+
host: server?.listening ? host : null,
|
|
482
|
+
error: error ?? (pick?.via === 'tailscale' ? secure.error : null) ?? relayState?.error ?? null,
|
|
483
|
+
port,
|
|
484
|
+
secure: !!safe,
|
|
485
|
+
settingUp: !!secure.busy,
|
|
486
|
+
/** Through the relay: whether this Mac is connected to it now. */
|
|
487
|
+
relay: relayState ? { connected: !!relayState.connected, host: pick.host } : null,
|
|
488
|
+
via: pick?.via ?? null,
|
|
489
|
+
routes: [
|
|
490
|
+
...(relay ? [{ via: 'anywhere', host: new URL(relayUrl).host }] : []),
|
|
491
|
+
...[...new Set(addrs.map((a) => a.via))].map((v) => ({ via: v, host: addrs.find((a) => a.via === v).host })),
|
|
492
|
+
],
|
|
493
|
+
url: saved.on ? url : null,
|
|
494
|
+
qr: saved.on && url ? await QRCode.toString(url, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }) : null,
|
|
495
|
+
// The phones seen since the app started, and the phones with alerts on,
|
|
496
|
+
// which may not have opened the inbox since.
|
|
497
|
+
devices: [
|
|
498
|
+
...[...devices.values()].map((d) => ({ id: d.id, name: d.name, ip: d.ip, firstSeen: d.firstSeen, lastSeen: d.lastSeen, live: d.streams.size > 0 || now - d.lastSeen < QUIET_MS, alerts: !!saved.alerts[d.id], alertError: saved.alerts[d.id]?.lastError ?? null })),
|
|
499
|
+
...Object.entries(saved.alerts).filter(([id]) => !devices.has(id)).map(([id, a]) => ({ id, name: a.name, ip: null, firstSeen: a.since, lastSeen: a.lastSent ?? a.since, live: false, alerts: true, alertError: a.lastError ?? null })),
|
|
500
|
+
].sort((a, b) => b.lastSeen - a.lastSeen),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The Settings page's one channel. */
|
|
505
|
+
async function act({ action, via, device } = {}) {
|
|
506
|
+
if (action === 'on') { saved.on = true; save(); start(); await settleSecure(); settleRelay(); }
|
|
507
|
+
else if (action === 'off') { saved.on = false; save(); stop(); await settleSecure(); settleRelay(); }
|
|
508
|
+
else if (action === 'reset') { saved.key = newKey(); save(); dropEveryPhone(); relay?.restart(); }
|
|
509
|
+
else if (action === 'via' && (via === 'wifi' || via === 'tailscale' || (via === 'anywhere' && relay))) {
|
|
510
|
+
saved.via = via; saved.viaChosen = true; save(); await settleSecure(); settleRelay(); rebind();
|
|
511
|
+
} else if (action === 'via') { /* a route this Mac does not have: nothing to change */ }
|
|
512
|
+
else if (action === 'stop-alerts' && typeof device === 'string') { delete saved.alerts[device]; save(); }
|
|
513
|
+
else if (action !== 'status') throw new Error(`Unknown phone action ${action}`);
|
|
514
|
+
onChange();
|
|
515
|
+
return status();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (saved.on) { start(); void settleSecure(); settleRelay(); }
|
|
519
|
+
return {
|
|
520
|
+
act, status, start, stop, handle, arrivals, seen: seenAll,
|
|
521
|
+
/** The inbox count the desktop last drew, which each alert carries for the icon. */
|
|
522
|
+
count(n) { if (Number.isFinite(n)) count = Math.max(0, Math.floor(n)); },
|
|
523
|
+
settled: () => secure.busy ?? Promise.resolve(),
|
|
524
|
+
get key() { return saved.key; },
|
|
525
|
+
close() { if (timer) clearTimeout(timer); stop(); relay?.stop(); },
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function read(file) {
|
|
530
|
+
let saved = {};
|
|
531
|
+
try { saved = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* first run */ }
|
|
532
|
+
if (typeof saved.key !== 'string' || saved.key.length < 24) saved.key = newKey();
|
|
533
|
+
return {
|
|
534
|
+
on: !!saved.on,
|
|
535
|
+
key: saved.key,
|
|
536
|
+
via: ['tailscale', 'wifi', 'anywhere'].includes(saved.via) ? saved.via : 'wifi',
|
|
537
|
+
// A route somebody picked, not one written down because it was the only one.
|
|
538
|
+
viaChosen: saved.viaChosen === true,
|
|
539
|
+
vapid: saved.vapid?.publicKey && saved.vapid?.privateKey ? saved.vapid : null,
|
|
540
|
+
alerts: saved.alerts && typeof saved.alerts === 'object' ? saved.alerts : {},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function readJson(req) {
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
let body = '';
|
|
547
|
+
req.on('data', (c) => { body += c; if (body.length > 64 * 1024) reject(new Error('Body too large')); });
|
|
548
|
+
req.on('end', () => resolve(body));
|
|
549
|
+
req.on('error', reject);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// WHICH RELAY THIS MAC USES (tests/the-mac-finds-the-relay-the-deploy-wrote-down).
|
|
2
|
+
// AGENTBOX_RELAY_URL first, for trying one by hand; then the address
|
|
3
|
+
// `npm run relay:deploy` wrote to relay/relay.local.json; then the one built
|
|
4
|
+
// into the app, which is empty until a deployed address is committed.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { RELAY_URL } from '../shared/relay-address.mjs';
|
|
8
|
+
|
|
9
|
+
function usable(url) {
|
|
10
|
+
if (typeof url !== 'string' || !url) return null;
|
|
11
|
+
let u;
|
|
12
|
+
try { u = new URL(url); } catch { return null; }
|
|
13
|
+
const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
|
|
14
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
|
|
15
|
+
return url.replace(/\/+$/, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function relayAddress({ env = process.env, appDir } = {}) {
|
|
19
|
+
const byHand = usable(env.AGENTBOX_RELAY_URL);
|
|
20
|
+
if (byHand) return byHand;
|
|
21
|
+
if (appDir) {
|
|
22
|
+
try {
|
|
23
|
+
const deployed = usable(JSON.parse(fs.readFileSync(path.join(appDir, 'relay', 'relay.local.json'), 'utf8'))?.url);
|
|
24
|
+
if (deployed) return deployed;
|
|
25
|
+
} catch { /* not deployed from this folder */ }
|
|
26
|
+
}
|
|
27
|
+
return usable(RELAY_URL) ?? RELAY_URL;
|
|
28
|
+
}
|