handmux 0.5.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/README.zh-CN.md +285 -0
  4. package/bin/handmux.js +417 -0
  5. package/hooks/handmux-notify.sh +20 -0
  6. package/hooks/handmux-write.cjs +92 -0
  7. package/package.json +52 -0
  8. package/public/assets/index-BN-IwtP6.css +32 -0
  9. package/public/assets/index-BUQ0R83h.js +157 -0
  10. package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  11. package/public/fonts/TWUnifont.woff2 +0 -0
  12. package/public/icons/apple-touch-icon.png +0 -0
  13. package/public/icons/badge-96.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/logo.svg +32 -0
  17. package/public/index.html +105 -0
  18. package/public/manifest.webmanifest +37 -0
  19. package/public/offline.html +50 -0
  20. package/public/sw.js +117 -0
  21. package/src/.gitkeep +0 -0
  22. package/src/appName.js +23 -0
  23. package/src/asr/iflyConfig.js +10 -0
  24. package/src/asr/iflySign.js +16 -0
  25. package/src/auth.js +30 -0
  26. package/src/claudeEvents.js +212 -0
  27. package/src/cli/cfNamed.js +5 -0
  28. package/src/cli/claudeHooks.js +116 -0
  29. package/src/cli/cloudflareUrl.js +9 -0
  30. package/src/cli/cloudflared.js +53 -0
  31. package/src/cli/drivers.js +59 -0
  32. package/src/cli/options.js +169 -0
  33. package/src/cli/probe.js +16 -0
  34. package/src/cli/qr.js +34 -0
  35. package/src/cli/service.js +98 -0
  36. package/src/cli/setupWizard.js +248 -0
  37. package/src/cli/sshTunnel.js +12 -0
  38. package/src/cli/state.js +42 -0
  39. package/src/cli/supervisor.js +172 -0
  40. package/src/cli/tmuxConf.js +90 -0
  41. package/src/cli/tmuxVersion.js +49 -0
  42. package/src/cli/tunlite.js +22 -0
  43. package/src/config.js +6 -0
  44. package/src/docPath.js +46 -0
  45. package/src/docs.js +222 -0
  46. package/src/git.js +185 -0
  47. package/src/httpApi.js +546 -0
  48. package/src/previewServer.js +182 -0
  49. package/src/previews.js +118 -0
  50. package/src/push.js +121 -0
  51. package/src/server.js +97 -0
  52. package/src/staticCache.js +8 -0
  53. package/src/tmux/commands.js +223 -0
  54. package/src/trimCapture.js +28 -0
  55. package/src/uploadTypes.js +28 -0
  56. package/tmux/README.md +77 -0
  57. package/tmux/claude-tab-seed.py +67 -0
  58. package/tmux/claude-tab-seen.sh +14 -0
@@ -0,0 +1,182 @@
1
+ // server/src/previewServer.js
2
+ // Serves registered static-preview directories under /preview, reusing the system token via a cookie
3
+ // (a browser opening a URL can't send a Bearer header). Absolute-rooted assets (/assets/...) are
4
+ // served from the right preview dir via a Referer fallback (design's "方案 A").
5
+ // Also handles dynamic preview: Host-based dispatch to loopback ports (HTTP + WS upgrade).
6
+ import net from 'node:net';
7
+ import http from 'node:http';
8
+ import express from 'express';
9
+ import { tokenEquals } from './auth.js';
10
+ import { safePreviewName } from './previews.js';
11
+
12
+ const COOKIE = 'tw_preview';
13
+
14
+ // Read one cookie value from a raw Cookie header, URL-decoded. No cookie-parser dep (zero-dep house style).
15
+ export function parseCookie(header, name) {
16
+ if (!header) return null;
17
+ const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(header);
18
+ return m ? decodeURIComponent(m[1]) : null;
19
+ }
20
+
21
+ // Credential check shared by the /preview gate AND the referer fallback. Accepts the token via
22
+ // ?token= (first visit) or the tw_preview cookie (subsequent). timing-safe via tokenEquals.
23
+ export function credOk(req, token) {
24
+ const q = req.query?.token;
25
+ const provided = (typeof q === 'string' && q) ? q : parseCookie(req.headers?.cookie, COOKIE);
26
+ if (!provided) return false;
27
+ try { return tokenEquals(provided, token); } catch { return false; }
28
+ }
29
+
30
+ export { COOKIE };
31
+
32
+ // A preview host is exactly `<name>.<domain>` where <name> is one safe label. Anything deeper, the
33
+ // base domain itself, or a foreign domain → not ours (null). domain unset → dynamic disabled → null.
34
+ // The configured domain may carry a :port (the edge runs on a non-standard port, e.g. :39999) — that
35
+ // port belongs in the browser URL, but Host matching is hostname-only, so strip it here. The incoming
36
+ // `host` already has its port stripped by the caller.
37
+ export function isPreviewHost(host, domain) {
38
+ if (!domain || !host) return null;
39
+ const base = domain.split(':')[0];
40
+ const suffix = `.${base}`;
41
+ if (!host.endsWith(suffix)) return null;
42
+ const label = host.slice(0, -suffix.length);
43
+ if (!label || label.includes('.')) return null; // single label only
44
+ return safePreviewName(label);
45
+ }
46
+
47
+ // Cookie scope = base domain minus its first label (preview.example.com → example.com) so the token
48
+ // cookie set on a preview subdomain is also sent to the main app and every sibling preview. A cookie
49
+ // Domain attribute can't carry a port, so strip any :port from the configured domain first.
50
+ function cookieScope(domain) {
51
+ if (!domain) return null;
52
+ const base = domain.split(':')[0];
53
+ const dot = base.indexOf('.');
54
+ return dot === -1 ? base : base.slice(dot + 1);
55
+ }
56
+
57
+ // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
58
+ function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
59
+
60
+ export function createPreview({ previews, token, domain = null }) {
61
+ const router = express.Router();
62
+ const cookieDomain = cookieScope(domain);
63
+
64
+ // Gate: ?token= (set cookie + 302 strip) OR a valid cookie; else 401.
65
+ router.use((req, res, next) => {
66
+ const q = req.query?.token;
67
+ if (typeof q === 'string' && q && credOk(req, token)) {
68
+ res.setHeader('Set-Cookie', `${COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`);
69
+ const u = new URL(req.originalUrl, 'http://x');
70
+ u.searchParams.delete('token');
71
+ return res.redirect(302, u.pathname + u.search);
72
+ }
73
+ if (!credOk(req, token)) return res.status(401).send('unauthorized');
74
+ next();
75
+ });
76
+
77
+ function serve(name, rest, res) {
78
+ const { state, entry } = previews.get(name);
79
+ if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
80
+ if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
81
+ res.setHeader('Cache-Control', 'no-store');
82
+ res.sendFile(fileFor(rest), { root: entry.dir, dotfiles: 'deny' }, (err) => {
83
+ if (err && !res.headersSent) res.status(err.statusCode || 404).end();
84
+ });
85
+ }
86
+
87
+ // /:name catches both '/live' (no trailing slash → redirect) and '/live/' (trailing slash → serve root).
88
+ // /:name/* catches '/live/index.html', '/live/assets/x.js', etc.
89
+ router.get('/:name', (req, res, next) => {
90
+ if (req.url.endsWith('/')) return serve(req.params.name, '', res);
91
+ res.redirect(301, `/preview/${encodeURIComponent(req.params.name)}/`);
92
+ });
93
+ router.get('/:name/*', (req, res) => serve(req.params.name, req.params[0], res));
94
+
95
+ // Referer fallback (mount AFTER /preview, BEFORE express.static): an absolute /assets/... request
96
+ // whose Referer is a preview page is served from that preview's dir. Reuses credOk so it can never
97
+ // read a dir without a valid token/cookie. Misses fall through to the normal static/SPA layer.
98
+ function refererFallback(req, res, next) {
99
+ if (req.method !== 'GET') return next();
100
+ if (req.path.startsWith('/api') || req.path.startsWith('/preview')) return next();
101
+ const ref = req.headers.referer;
102
+ if (!ref) return next();
103
+ let refPath;
104
+ try { refPath = new URL(ref).pathname; } catch { return next(); }
105
+ const m = /^\/preview\/([^/]+)\//.exec(refPath);
106
+ if (!m) return next();
107
+ if (!credOk(req, token)) return next();
108
+ const { state, entry } = previews.get(decodeURIComponent(m[1]));
109
+ if (state !== 'active') return next();
110
+ res.setHeader('Cache-Control', 'no-store');
111
+ res.sendFile(req.path, { root: entry.dir, dotfiles: 'deny' }, (err) => {
112
+ if (err && !res.headersSent) next();
113
+ });
114
+ }
115
+
116
+ // Reverse-proxy one request to the dynamic preview's loopback port. No prefix strip — the app owns
117
+ // its own root, so path/method/headers/body forward as-is. `host` is the loopback family the app was
118
+ // found on at register time ('127.0.0.1' or '::1'); the Host header stays 127.0.0.1:<port> so a dev
119
+ // server's host check (e.g. Vite, whose allowedHosts include localhost/127.0.0.1) is satisfied.
120
+ function proxyHttp(port, host, req, res) {
121
+ const headers = { ...req.headers, host: `127.0.0.1:${port}` };
122
+ const up = http.request({ host: host || '127.0.0.1', port, method: req.method, path: req.url, headers }, (upRes) => {
123
+ res.writeHead(upRes.statusCode, upRes.headers);
124
+ upRes.on('error', () => res.destroy()); // mid-stream upstream reset (after headers) → tear down the client socket
125
+ upRes.pipe(res);
126
+ });
127
+ up.on('error', () => { if (!res.headersSent) res.status(502).type('text').end('preview upstream error'); });
128
+ req.pipe(up);
129
+ }
130
+
131
+ // Host-based dispatch middleware. Mount FIRST (before /api). A non-preview Host just calls next() →
132
+ // the request falls through to the existing app, zero impact.
133
+ function dynamicProxy(req, res, next) {
134
+ const host = (req.headers.host || '').split(':')[0];
135
+ const name = isPreviewHost(host, domain);
136
+ if (!name) return next();
137
+ const q = req.query?.token;
138
+ if (typeof q === 'string' && q && credOk(req, token)) {
139
+ const scope = cookieDomain ? `; Domain=${cookieDomain}` : '';
140
+ res.setHeader('Set-Cookie', `${COOKIE}=${encodeURIComponent(token)}${scope}; Path=/; HttpOnly; SameSite=Lax`);
141
+ const u = new URL(req.originalUrl, 'http://x');
142
+ u.searchParams.delete('token');
143
+ return res.redirect(302, u.pathname + u.search);
144
+ }
145
+ if (!credOk(req, token)) return res.status(401).send('unauthorized');
146
+ const { state, entry } = previews.get(name);
147
+ if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
148
+ if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
149
+ if (entry.kind !== 'dynamic') return res.status(404).end(); // a static name reached via subdomain — not served here
150
+ proxyHttp(entry.port, entry.host, req, res);
151
+ }
152
+
153
+ // WebSocket (and any raw Upgrade) for a dynamic preview: same cookie auth, then a bare TCP pipe to
154
+ // the loopback port — covers HMR, SSE-over-ws, and an app's own websockets. Wired via
155
+ // server.on('upgrade'). A non-preview host or failed auth just destroys the socket (the app has no
156
+ // other ws endpoints).
157
+ function onUpgrade(req, socket, head) {
158
+ const host = (req.headers.host || '').split(':')[0];
159
+ const name = isPreviewHost(host, domain);
160
+ if (!name) return socket.destroy();
161
+ // ws handshakes rarely carry ?token=; the Domain-scoped cookie set on the first HTTP load is what
162
+ // authorizes them. Build a minimal req-shape for credOk (query parsed from the URL for parity).
163
+ let query = {};
164
+ try { query = Object.fromEntries(new URL(req.url, 'http://x').searchParams); } catch { /* none */ }
165
+ if (!credOk({ query, headers: req.headers }, token)) return socket.destroy();
166
+ const { state, entry } = previews.get(name);
167
+ if (state !== 'active' || entry.kind !== 'dynamic') return socket.destroy();
168
+ const up = net.connect(entry.port, entry.host || '127.0.0.1', () => {
169
+ const fwd = { ...req.headers, host: `127.0.0.1:${entry.port}` };
170
+ up.write(`GET ${req.url} HTTP/1.1\r\n`);
171
+ for (const [k, v] of Object.entries(fwd)) up.write(`${k}: ${v}\r\n`);
172
+ up.write('\r\n');
173
+ if (head && head.length) up.write(head);
174
+ up.pipe(socket);
175
+ socket.pipe(up);
176
+ });
177
+ up.on('error', () => socket.destroy());
178
+ socket.on('error', () => up.destroy());
179
+ }
180
+
181
+ return { router, refererFallback, dynamicProxy, onUpgrade };
182
+ }
@@ -0,0 +1,118 @@
1
+ // server/src/previews.js
2
+ // Preview registry. Maps a safe single-segment name → either an on-disk directory under $HOME
3
+ // (kind:'static') or a local port (kind:'dynamic'), with a TTL. Persistence mirrors push.js: a JSON
4
+ // array at server/data/previews.json, read fresh on each op. Pure-ish: home/now/store/ttl plus the
5
+ // dynamic switch and port probe are injected so it unit-tests on its own.
6
+ import fs from 'node:fs';
7
+ import net from 'node:net';
8
+ import path from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { isUnder } from './docPath.js';
12
+
13
+ export function safePreviewName(raw) {
14
+ if (typeof raw !== 'string') return null;
15
+ if (!/^[A-Za-z0-9._-]+$/.test(raw)) return null;
16
+ if (raw === '.' || raw === '..' || raw[0] === '.') return null;
17
+ // Normalize to lowercase: a dynamic preview is reached via a subdomain, and browsers lowercase the
18
+ // hostname — so a stored name with uppercase (from a tmux window name) could never be matched. Keep
19
+ // register/get/subdomain all on the same lowercased key.
20
+ return raw.toLowerCase();
21
+ }
22
+
23
+ // Is something listening on a loopback `host:port`? A quick TCP connect with a short timeout.
24
+ function probeHost(port, host, timeout) {
25
+ return new Promise((resolve) => {
26
+ const s = net.connect({ port, host });
27
+ const finish = (ok) => { s.destroy(); resolve(ok); };
28
+ s.setTimeout(timeout, () => finish(false));
29
+ s.once('connect', () => finish(true));
30
+ s.once('error', () => finish(false));
31
+ });
32
+ }
33
+
34
+ // Which loopback host answers on `port` — '127.0.0.1', '::1', or null if neither. macOS dev servers
35
+ // often bind ONLY IPv6 localhost (::1), so a 127.0.0.1-only probe wrongly reports "not listening".
36
+ // The answering host is stored on the entry so the proxy connects to the same family the app is on.
37
+ async function probeListening(port, timeout = 300) {
38
+ if (await probeHost(port, '127.0.0.1', timeout)) return '127.0.0.1';
39
+ if (await probeHost(port, '::1', timeout)) return '::1';
40
+ return null;
41
+ }
42
+
43
+ export function createPreviews({
44
+ home = homedir(),
45
+ store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'),
46
+ now = () => Date.now(),
47
+ ttlMs = Number(process.env.HANDMUX_PREVIEW_TTL) || 3_600_000,
48
+ dynamicEnabled = false,
49
+ probePort = probeListening,
50
+ } = {}) {
51
+ let realHome;
52
+ try { realHome = fs.realpathSync(home); } catch { realHome = home; }
53
+
54
+ const loadStore = () => {
55
+ try { return JSON.parse(fs.readFileSync(store, 'utf8')) || []; } catch { return []; }
56
+ };
57
+ const saveStore = (arr) => {
58
+ try { fs.mkdirSync(path.dirname(store), { recursive: true }); fs.writeFileSync(store, JSON.stringify(arr)); }
59
+ catch { /* best effort: a lost registry just means previews must be re-started */ }
60
+ };
61
+
62
+ // Common upsert: drop any prior entry with this name (so static↔dynamic switching just replaces),
63
+ // stamp a single now() into createdAt/expiresAt, persist.
64
+ const upsert = (fields) => {
65
+ const all = loadStore().filter((e) => e && e.name !== fields.name);
66
+ const ts = now();
67
+ const entry = { ...fields, createdAt: ts, expiresAt: ts + ttlMs };
68
+ all.push(entry);
69
+ saveStore(all);
70
+ return { name: entry.name, kind: entry.kind, expiresAt: entry.expiresAt };
71
+ };
72
+
73
+ async function register({ name, dir, port }) {
74
+ const nm = safePreviewName(name);
75
+ if (!nm) return { error: 'bad name', status: 400 };
76
+ if (port !== undefined && port !== null && port !== '') {
77
+ if (!dynamicEnabled) return { error: 'dynamic disabled', status: 400 };
78
+ const p = Number(port);
79
+ if (!Number.isInteger(p) || p < 1 || p > 65535) return { error: 'bad port', status: 400 };
80
+ const host = await probePort(p); // '127.0.0.1' | '::1' | null
81
+ if (!host) return { error: 'port not listening', status: 400 };
82
+ return upsert({ name: nm, kind: 'dynamic', port: p, host });
83
+ }
84
+ if (typeof dir !== 'string' || dir[0] !== '/') return { error: 'not absolute', status: 400 };
85
+ let real;
86
+ try { real = fs.realpathSync(dir); } catch { return { error: 'not found', status: 404 }; }
87
+ if (!isUnder(real, realHome)) return { error: 'outside home', status: 400 };
88
+ let st;
89
+ try { st = fs.statSync(real); } catch { return { error: 'not accessible', status: 404 }; }
90
+ if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
91
+ return upsert({ name: nm, kind: 'static', dir: real });
92
+ }
93
+
94
+ function get(name) {
95
+ const all = loadStore();
96
+ const entry = all.find((e) => e && e.name === name);
97
+ if (!entry) return { state: 'missing' };
98
+ if (entry.expiresAt <= now()) { saveStore(all.filter((e) => e.name !== name)); return { state: 'expired' }; }
99
+ return { state: 'active', entry: { kind: 'static', ...entry } }; // legacy rows (no kind) → static
100
+ }
101
+
102
+ function list() {
103
+ const all = loadStore();
104
+ const active = all.filter((e) => e && e.expiresAt > now());
105
+ if (active.length !== all.length) saveStore(active);
106
+ return active.map((e) => (e.kind === 'dynamic'
107
+ ? { name: e.name, kind: 'dynamic', port: e.port, expiresAt: e.expiresAt }
108
+ : { name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
109
+ }
110
+
111
+ function remove(name) {
112
+ const all = loadStore();
113
+ const next = all.filter((e) => e && e.name !== name);
114
+ if (next.length !== all.length) saveStore(next);
115
+ }
116
+
117
+ return { register, get, list, remove };
118
+ }
package/src/push.js ADDED
@@ -0,0 +1,121 @@
1
+ // Web Push (VAPID) — minimal delivery layer. Sends notifications to subscribed devices via the
2
+ // browser/OS push services (Android=FCM, iOS=APNs); the server only talks to those services, never
3
+ // to the phone directly, so device reachability/queueing is their problem (see TTL/topic below).
4
+ //
5
+ // Subscriptions are stored as records: { subscription, boundSessions }. The subscription field is
6
+ // the raw PushSubscription the browser hands us ({endpoint, keys:{p256dh, auth}}); boundSessions is
7
+ // the list of session names this device cares about (used by sendToSession for targeted delivery). A
8
+ // dead subscription (404/410 from the push service) is pruned on the next send.
9
+ import webpush from 'web-push';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ const STORE = process.env.PUSH_STORE || path.resolve(here, '../data/push-subs.json');
16
+
17
+ // VAPID is optional: without keys every send is a no-op, so the server still boots in an
18
+ // environment that hasn't generated keys (configured=false surfaces as a 503 on /vapid). Push delivers
19
+ // whenever VAPID is configured — there is no separate "dev vs prod server" anymore (one config file, one
20
+ // `handmux start`). If you run a second instance on the same host and don't want it to deliver, leave
21
+ // `vapid` out of that instance's config. Init is LAZY (first use) so the module is import-safe.
22
+ let inited = false;
23
+ let configured = false;
24
+ function ensureInit() {
25
+ if (inited) return;
26
+ inited = true;
27
+ configured = !!(process.env.VAPID_PUBLIC && process.env.VAPID_PRIVATE);
28
+ if (configured) {
29
+ webpush.setVapidDetails(
30
+ // Apple (APNs) rejects a VAPID subject on a fake/.local domain with BadJwtToken — it must be a
31
+ // valid mailto:/https: with a real-looking domain. example.com is reserved and accepted by both.
32
+ process.env.VAPID_SUBJECT || 'mailto:admin@example.com',
33
+ process.env.VAPID_PUBLIC,
34
+ process.env.VAPID_PRIVATE,
35
+ );
36
+ }
37
+ }
38
+
39
+ let subs = load();
40
+
41
+ function load() {
42
+ try {
43
+ const raw = JSON.parse(fs.readFileSync(STORE, 'utf8')) || [];
44
+ return raw.map((e) => (e && e.subscription)
45
+ ? { subscription: e.subscription, boundSessions: e.boundSessions || [] }
46
+ : { subscription: e, boundSessions: [] }); // migrate old bare-subscription entries
47
+ } catch { return []; }
48
+ }
49
+ function persist() {
50
+ try {
51
+ fs.mkdirSync(path.dirname(STORE), { recursive: true });
52
+ fs.writeFileSync(STORE, JSON.stringify(subs));
53
+ } catch { /* best effort — a lost subscription just means the client re-subscribes next launch */ }
54
+ }
55
+
56
+ export function isConfigured() { ensureInit(); return configured; }
57
+ export function publicKey() { ensureInit(); return process.env.VAPID_PUBLIC || null; }
58
+ export function count() { return subs.length; }
59
+
60
+ export function addSubscription(sub, boundSessions = []) {
61
+ if (!sub || typeof sub.endpoint !== 'string') return false;
62
+ const i = subs.findIndex((s) => s.subscription.endpoint === sub.endpoint);
63
+ if (i === -1) subs.push({ subscription: sub, boundSessions });
64
+ else subs[i] = { subscription: sub, boundSessions };
65
+ persist();
66
+ return true;
67
+ }
68
+
69
+ export function updateBound(endpoint, boundSessions = []) {
70
+ const rec = subs.find((s) => s.subscription.endpoint === endpoint);
71
+ if (rec) { rec.boundSessions = boundSessions; persist(); }
72
+ }
73
+
74
+ export function removeSubscription(endpoint) {
75
+ const before = subs.length;
76
+ subs = subs.filter((s) => s.subscription.endpoint !== endpoint);
77
+ if (subs.length !== before) persist();
78
+ }
79
+
80
+ // The push-service Topic header (RFC 8030) must be ≤32 URL/filename-safe base64 chars [A-Za-z0-9_-].
81
+ // An invalid topic makes the service reject the ENTIRE send with a non-404/410 error that deliver()
82
+ // swallows — a silent zero-delivery, no log, no prune. tmux pane ids carry a '%' (e.g. %4), so a
83
+ // pane-derived topic ("pane-%4") breaks every per-pane push (需要你 / 已完成). Sanitize here, the layer
84
+ // that owns the web-push contract, so no caller has to know the rule.
85
+ function safeTopic(t) {
86
+ if (!t) return undefined;
87
+ const s = t.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32);
88
+ return s || undefined;
89
+ }
90
+
91
+ // TTL bounds staleness (a phone offline longer than this drops the push instead of getting it
92
+ // hours later); topic collapses older undelivered messages with the same key so a device coming
93
+ // back online sees only the latest per topic. urgency hints the OS how aggressively to wake.
94
+ function options(opts = {}) {
95
+ return { TTL: opts.ttl ?? 90, urgency: opts.urgency || 'normal', topic: safeTopic(opts.topic) };
96
+ }
97
+
98
+ async function deliver(records, payload, opts = {}) {
99
+ ensureInit();
100
+ if (!configured) return { sent: 0, configured: false };
101
+ const data = JSON.stringify(payload);
102
+ const dead = [];
103
+ let sent = 0;
104
+ await Promise.all(records.map(async (rec) => {
105
+ try { await webpush.sendNotification(rec.subscription, data, options(opts)); sent += 1; }
106
+ catch (e) { if (e?.statusCode === 404 || e?.statusCode === 410) dead.push(rec.subscription.endpoint); }
107
+ }));
108
+ if (dead.length) { subs = subs.filter((s) => !dead.includes(s.subscription.endpoint)); persist(); }
109
+ return { sent, configured: true };
110
+ }
111
+
112
+ export const sendToAll = (payload, opts = {}) => deliver(subs, payload, opts);
113
+ export const sendToSession = (session, payload, opts = {}) =>
114
+ deliver(subs.filter((s) => s.boundSessions.includes(session)), payload, opts);
115
+
116
+ // Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
117
+ export async function sendToOne(sub, payload, opts = {}) {
118
+ const rec = subs.find((s) => s.subscription.endpoint === sub.endpoint)
119
+ ?? { subscription: sub, boundSessions: [] };
120
+ return deliver([rec], payload, opts);
121
+ }
package/src/server.js ADDED
@@ -0,0 +1,97 @@
1
+ import express from 'express';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadConfig } from './config.js';
6
+ import { loadToken } from './auth.js';
7
+ import { createApiRouter } from './httpApi.js';
8
+ import { loadUploadExts } from './uploadTypes.js';
9
+ import { createClaudeEvents } from './claudeEvents.js';
10
+ import * as commands from './tmux/commands.js';
11
+ import * as push from './push.js';
12
+ import { cacheControlFor } from './staticCache.js';
13
+ import { applyAppName, applyManifestName } from './appName.js';
14
+ import { homedir } from 'node:os';
15
+ import { createPreviews } from './previews.js';
16
+ import { createPreview } from './previewServer.js';
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ // There is ONE config source — ~/.handmux/config.json — and ONE way to run handmux: `handmux start`
21
+ // (the CLI; `node bin/handmux.js start` from a source checkout is the same thing). The CLI resolves
22
+ // the config and spawns this server with everything it needs in the environment, so the server reads
23
+ // only process.env here (no .env files, no NODE_ENV branching). Running this file directly is not a
24
+ // supported entry point — go through the CLI.
25
+ const cfg = loadConfig();
26
+ const token = loadToken();
27
+ const uploadExts = loadUploadExts();
28
+
29
+ // The inbox is driven by a JSON state file the Claude hooks maintain (server/hooks/handmux-notify.sh →
30
+ // handmux-write.js). We only READ it. start() watches it so idle/permission push fires even when no
31
+ // client is polling; getStates reads it fresh on each /states.
32
+ const events = createClaudeEvents({ commands, push });
33
+ events.start();
34
+
35
+ // Static-site + dynamic preview. The dynamic side is enabled by HANDMUX_PREVIEW_DOMAIN (the wildcard
36
+ // base domain, e.g. preview.example.com); unset → static only. One registry instance is shared by the
37
+ // API (register/list/remove), the /preview static layer, and the Host-based dynamic proxy.
38
+ const previewDomain = process.env.HANDMUX_PREVIEW_DOMAIN || null;
39
+ const previews = createPreviews({ home: homedir(), dynamicEnabled: !!previewDomain });
40
+ const preview = createPreview({ previews, token, domain: previewDomain });
41
+
42
+ const app = express();
43
+ // Host-based dispatch FIRST: a request to <name>.<domain> is reverse-proxied to its dynamic preview;
44
+ // every other Host falls straight through (next()) to the app below, unaffected.
45
+ app.use(preview.dynamicProxy);
46
+ app.use('/api', createApiRouter({ token, events, uploadExts, previews, previewDomain }));
47
+ app.use('/preview', preview.router);
48
+ app.use(preview.refererFallback);
49
+
50
+ // Serve the built web client so one process hosts both the API and the frontend (single origin, no dev
51
+ // proxy). Prefer the bundled copy inside the package (server/public — what `npm publish` ships); fall back
52
+ // to the sibling web/dist of a source checkout. Override either with HANDMUX_STATIC_DIR.
53
+ const bundledDir = path.resolve(here, '../public');
54
+ const sourceDir = path.resolve(here, '../../web/dist');
55
+ const staticDir = process.env.HANDMUX_STATIC_DIR || (fs.existsSync(bundledDir) ? bundledDir : sourceDir);
56
+ const indexPath = path.join(staticDir, 'index.html');
57
+
58
+ // Optional custom instance name (handmux start --name). When set, the prebuilt shell + manifest are
59
+ // rewritten on the way out so the browser-tab title and "Add to Home Screen" label match the user's
60
+ // name — the bundle ships generic and is renamed at serve time, never rebuilt. Unset → serve as-is.
61
+ const appName = process.env.HANDMUX_APP_NAME || null;
62
+ let renamedIndex = null; // computed once; the name is fixed for the process lifetime
63
+ if (appName) {
64
+ app.get('/manifest.webmanifest', (req, res, next) => {
65
+ try {
66
+ const raw = fs.readFileSync(path.join(staticDir, 'manifest.webmanifest'), 'utf8');
67
+ res.type('application/manifest+json').send(JSON.stringify(applyManifestName(JSON.parse(raw), appName)));
68
+ } catch { next(); }
69
+ });
70
+ }
71
+
72
+ // index:false so the renamed shell below owns "/" too (otherwise static would serve the generic one).
73
+ app.use(express.static(staticDir, {
74
+ index: false,
75
+ // Cache-Control policy lives in staticCache.js (unit-tested): index.html + sw.js are never cached
76
+ // (stale-shell / stale-SW trap), content-hashed assets cache forever.
77
+ setHeaders: (res, filePath) => res.setHeader('Cache-Control', cacheControlFor(filePath)),
78
+ }));
79
+ // SPA fallback: serve index.html for any non-API GET (client routing lives in the URL hash, so
80
+ // the server only ever needs to hand back the one HTML shell). API 404s pass through untouched.
81
+ app.get('*', (req, res, next) => {
82
+ if (req.path.startsWith('/api/')) return next();
83
+ res.setHeader('Cache-Control', 'no-store');
84
+ if (appName) {
85
+ try {
86
+ if (renamedIndex == null) renamedIndex = applyAppName(fs.readFileSync(indexPath, 'utf8'), appName);
87
+ return res.type('html').send(renamedIndex);
88
+ } catch { /* fall through to sendFile */ }
89
+ }
90
+ res.sendFile(indexPath);
91
+ });
92
+
93
+ const server = app.listen(cfg.port, cfg.host, () => {
94
+ console.log(`[handmux] listening on http://${cfg.host}:${cfg.port} (serving ${staticDir})`);
95
+ });
96
+ // WebSocket/HMR for dynamic previews: route raw Upgrade by Host to the right loopback port.
97
+ server.on('upgrade', preview.onUpgrade);
@@ -0,0 +1,8 @@
1
+ // Cache-Control policy for the static web bundle. index.html and the service worker must NEVER be
2
+ // HTTP-cached — an installed PWA would otherwise keep loading a stale shell (pointing at missing
3
+ // hashed assets) or a stale SW. Everything else under dist carries a content hash in its name, so
4
+ // it's safe to cache forever. Pure so it unit-tests on its own.
5
+ export function cacheControlFor(filePath) {
6
+ if (filePath.endsWith('.html') || filePath.endsWith('/sw.js')) return 'no-store';
7
+ return 'public, max-age=31536000, immutable';
8
+ }