handmux 0.20.3 → 0.22.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 (41) hide show
  1. package/README.md +10 -10
  2. package/README.zh-CN.md +10 -10
  3. package/package.json +7 -5
  4. package/public/assets/index-CRAlQZDz.css +32 -0
  5. package/public/assets/index-CzlitgRX.js +335 -0
  6. package/public/index.html +2 -2
  7. package/src/browser/bootstrap.js +68 -0
  8. package/src/browser/cookieProfiles.js +403 -0
  9. package/src/browser/coordinator.js +47 -0
  10. package/src/browser/hammerheadCompat.js +86 -0
  11. package/src/browser/hammerheadRedirectCompat.js +38 -0
  12. package/src/browser/manager.js +530 -0
  13. package/src/browser/originLabel.js +17 -0
  14. package/src/browser/profilePersistence.js +241 -0
  15. package/src/browser/protocol.js +1 -0
  16. package/src/browser/publicProxy.js +153 -0
  17. package/src/browser/routes.js +145 -0
  18. package/src/browser/sessionStore.js +78 -0
  19. package/src/browser/targetPolicy.js +137 -0
  20. package/src/browser/worker.js +32 -0
  21. package/src/browser/workerClient.js +253 -0
  22. package/src/browser/workerServer.js +98 -0
  23. package/src/cli/i18n/en.js +6 -11
  24. package/src/cli/i18n/zh.js +6 -11
  25. package/src/cli/options.js +3 -4
  26. package/src/cli/setupModel.js +3 -3
  27. package/src/cli/setupWizard.js +5 -9
  28. package/src/cli/supervisor.js +8 -3
  29. package/src/httpApi.js +10 -2
  30. package/src/previewServer.js +123 -151
  31. package/src/previews.js +54 -56
  32. package/src/push.js +16 -7
  33. package/src/routes/previews.js +9 -16
  34. package/src/routes/push.js +5 -4
  35. package/src/routes/system.js +7 -2
  36. package/src/routes/terminal.js +12 -0
  37. package/src/server.js +24 -14
  38. package/src/workspace/checkpointer.js +6 -2
  39. package/public/assets/index-C8Bg8Ecf.css +0 -32
  40. package/public/assets/index-CYZJ6vVb.js +0 -335
  41. package/src/internalPreviewGateway.js +0 -487
@@ -0,0 +1,137 @@
1
+ import dns from 'node:dns/promises';
2
+ import net from 'node:net';
3
+
4
+ function ipv4Parts(address) {
5
+ const parts = address.split('.').map(Number);
6
+ return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
7
+ ? parts
8
+ : null;
9
+ }
10
+
11
+ function ipv6Words(address) {
12
+ const halves = address.split('::');
13
+ if (halves.length > 2) return null;
14
+ const parseHalf = (half) => {
15
+ if (!half) return [];
16
+ const words = [];
17
+ for (const token of half.split(':')) {
18
+ if (token.includes('.')) {
19
+ const parts = ipv4Parts(token);
20
+ if (!parts) return null;
21
+ words.push((parts[0] << 8) | parts[1], (parts[2] << 8) | parts[3]);
22
+ } else {
23
+ if (!/^[0-9a-f]{1,4}$/.test(token)) return null;
24
+ words.push(Number.parseInt(token, 16));
25
+ }
26
+ }
27
+ return words;
28
+ };
29
+ const left = parseHalf(halves[0]);
30
+ const right = parseHalf(halves[1] || '');
31
+ if (!left || !right) return null;
32
+ if (halves.length === 1) return left.length === 8 ? left : null;
33
+ const omitted = 8 - left.length - right.length;
34
+ if (omitted < 1) return null;
35
+ return [...left, ...Array(omitted).fill(0), ...right];
36
+ }
37
+
38
+ function mappedIpv4Parts(address) {
39
+ const words = ipv6Words(address);
40
+ if (!words
41
+ || words.slice(0, 5).some((word) => word !== 0)
42
+ || words[5] !== 0xffff) return null;
43
+ return [words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff];
44
+ }
45
+
46
+ function classifyIpv4(parts) {
47
+ if (parts[0] === 0) return 'unspecified';
48
+ if (parts[0] === 127) return 'loopback';
49
+ if (parts[0] === 169 && parts[1] === 254) return 'link-local';
50
+ if (parts[0] >= 224) return 'multicast';
51
+ return null;
52
+ }
53
+
54
+ export function classifyIp(raw) {
55
+ const address = String(raw || '').replace(/^\[|\]$/g, '').toLowerCase();
56
+ if (net.isIP(address) === 4) {
57
+ const parts = ipv4Parts(address);
58
+ return classifyIpv4(parts);
59
+ }
60
+ if (net.isIP(address) === 6) {
61
+ const mapped = mappedIpv4Parts(address);
62
+ if (mapped) return classifyIpv4(mapped);
63
+ if (address === '::') return 'unspecified';
64
+ if (address === '::1') return 'loopback';
65
+ if (/^fe[89ab]/.test(address)) return 'link-local';
66
+ if (address.startsWith('ff')) return 'multicast';
67
+ return null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function effectivePort(url) {
73
+ if (url.port) return url.port;
74
+ return url.protocol === 'https:' ? '443' : '80';
75
+ }
76
+
77
+ export function createBrowserTargetPolicy({
78
+ topLevelUrl,
79
+ handmuxOrigin,
80
+ lookup = dns.lookup,
81
+ } = {}) {
82
+ let topOrigin = new URL(topLevelUrl).origin;
83
+ let topAddressClass = null;
84
+ const appUrl = new URL(handmuxOrigin);
85
+ const appOrigin = appUrl.origin;
86
+ const appControlPort = effectivePort(appUrl);
87
+
88
+ return {
89
+ authorizeTopLevel(raw) {
90
+ const url = new URL(raw);
91
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('browser URL must use http or https');
92
+ topOrigin = url.origin;
93
+ topAddressClass = null;
94
+ },
95
+
96
+ async check(raw) {
97
+ let url;
98
+ try { url = new URL(raw); } catch { return { allowed: false, reason: 'unsupported-protocol' }; }
99
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
100
+ return { allowed: false, reason: 'unsupported-protocol' };
101
+ }
102
+ if (url.origin === appOrigin) return { allowed: false, reason: 'handmux-origin' };
103
+
104
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
105
+ let addresses;
106
+ if (net.isIP(hostname)) addresses = [{ address: hostname }];
107
+ else {
108
+ try { addresses = await lookup(hostname, { all: true, verbatim: true }); }
109
+ catch { return { allowed: false, reason: 'dns-failed' }; }
110
+ }
111
+ if (!addresses?.length) return { allowed: false, reason: 'dns-failed' };
112
+
113
+ const classifications = addresses.map((item) => classifyIp(item.address));
114
+ if (effectivePort(url) === appControlPort && classifications.some((reason) => reason === 'loopback')) {
115
+ return { allowed: false, reason: 'handmux-origin' };
116
+ }
117
+ if (url.origin === topOrigin && topAddressClass == null) {
118
+ if (classifications.every((reason) => reason === 'loopback')) topAddressClass = 'loopback';
119
+ else if (classifications.every((reason) => reason == null)) topAddressClass = 'ordinary';
120
+ }
121
+ const loopbackNavigationAllowed = url.origin === topOrigin
122
+ && topAddressClass === 'loopback'
123
+ && classifications.every((reason) => reason === 'loopback');
124
+ for (const reason of classifications) {
125
+ if (reason === 'loopback' && loopbackNavigationAllowed) continue;
126
+ if (reason) return { allowed: false, reason: reason === 'loopback' ? 'loopback-not-authorized' : reason };
127
+ }
128
+ return {
129
+ allowed: true,
130
+ addresses: addresses.map((item) => ({
131
+ address: item.address,
132
+ family: item.family || net.isIP(item.address),
133
+ })),
134
+ };
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,32 @@
1
+ import { createBrowserWorkerServer } from './workerServer.js';
2
+ import { installHammerheadWebSocketUpgradeCompat } from './hammerheadCompat.js';
3
+
4
+ const internalToken = process.env.HANDMUX_BROWSER_INTERNAL_TOKEN;
5
+ const previewDomain = process.env.HANDMUX_PREVIEW_DOMAIN || null;
6
+ const handmuxOrigin = process.env.HANDMUX_BROWSER_CONTROL_ORIGIN || 'http://127.0.0.1';
7
+
8
+ let worker = null;
9
+ let shutdownPromise = null;
10
+
11
+ async function shutdown(code = 0) {
12
+ if (!shutdownPromise) {
13
+ shutdownPromise = (async () => {
14
+ await worker?.close();
15
+ process.exit(code);
16
+ })();
17
+ }
18
+ return shutdownPromise;
19
+ }
20
+
21
+ process.once('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
22
+ process.once('SIGTERM', () => { shutdown().catch(() => process.exit(1)); });
23
+ process.once('disconnect', () => { shutdown().catch(() => process.exit(1)); });
24
+
25
+ try {
26
+ installHammerheadWebSocketUpgradeCompat();
27
+ worker = await createBrowserWorkerServer({ internalToken, previewDomain, handmuxOrigin });
28
+ process.send?.({ type: 'handmux-browser-ready', port: worker.port });
29
+ } catch (error) {
30
+ console.error(`[handmux] browser worker failed to start: ${error?.message || error}`);
31
+ process.exit(1);
32
+ }
@@ -0,0 +1,253 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { fork } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { claimedBrowserRequest } from './publicProxy.js';
7
+ import { BROWSER_INTERNAL_HEADER } from './protocol.js';
8
+ import { createBrowserCoordinator } from './coordinator.js';
9
+
10
+ const WORKER_FILE = fileURLToPath(new URL('./worker.js', import.meta.url));
11
+
12
+ function forwardedHeaders(headers, internalToken, appToken, api) {
13
+ const out = { ...headers, [BROWSER_INTERNAL_HEADER]: internalToken };
14
+ if (api || out.authorization === `Bearer ${appToken}`) delete out.authorization;
15
+ delete out['proxy-authorization'];
16
+ return out;
17
+ }
18
+
19
+ export function createBrowserWorkerClient({
20
+ appToken,
21
+ previewDomain = null,
22
+ handmuxOrigin = 'http://127.0.0.1',
23
+ forkWorker = fork,
24
+ request = http.request,
25
+ connect = net.connect,
26
+ randomToken = () => randomBytes(32).toString('base64url'),
27
+ parentEnv = process.env,
28
+ setTimer = setTimeout,
29
+ clearTimer = clearTimeout,
30
+ readyTimeoutMs = 10_000,
31
+ stableAfterMs = 30_000,
32
+ requestTimeoutMs = 15_000,
33
+ stopTimeoutMs = 3_000,
34
+ } = {}) {
35
+ const internalToken = randomToken();
36
+ let child = null;
37
+ let port = null;
38
+ let generation = 0;
39
+ let stopping = false;
40
+ let restartDelay = 250;
41
+ let restartTimer = null;
42
+ let readyTimer = null;
43
+ let stableTimer = null;
44
+ const activeSockets = new Set();
45
+ const workerEnv = {};
46
+ for (const name of [
47
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TMP', 'TEMP',
48
+ 'LANG', 'LC_ALL', 'TZ', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
49
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
50
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
51
+ ]) {
52
+ if (parentEnv[name] != null) workerEnv[name] = parentEnv[name];
53
+ }
54
+
55
+ const clearReadyTimer = () => {
56
+ if (readyTimer != null) clearTimer(readyTimer);
57
+ readyTimer = null;
58
+ };
59
+ const clearStableTimer = () => {
60
+ if (stableTimer != null) clearTimer(stableTimer);
61
+ stableTimer = null;
62
+ };
63
+ const scheduleRestart = () => {
64
+ if (stopping || restartTimer != null) return;
65
+ const delay = restartDelay;
66
+ restartDelay = Math.min(restartDelay * 2, 5_000);
67
+ restartTimer = setTimer(() => {
68
+ restartTimer = null;
69
+ if (!stopping) start();
70
+ }, delay);
71
+ };
72
+ const start = () => {
73
+ let spawned;
74
+ try {
75
+ spawned = forkWorker(WORKER_FILE, [], {
76
+ env: {
77
+ ...workerEnv,
78
+ HANDMUX_BROWSER_INTERNAL_TOKEN: internalToken,
79
+ HANDMUX_BROWSER_CONTROL_ORIGIN: handmuxOrigin,
80
+ ...(previewDomain ? { HANDMUX_PREVIEW_DOMAIN: previewDomain } : {}),
81
+ },
82
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
83
+ });
84
+ } catch {
85
+ child = null;
86
+ port = null;
87
+ scheduleRestart();
88
+ return;
89
+ }
90
+ child = spawned;
91
+ port = null;
92
+ readyTimer = setTimer(() => {
93
+ if (child === spawned && port == null) spawned.kill('SIGTERM');
94
+ }, readyTimeoutMs);
95
+ spawned.on('message', (message) => {
96
+ if (child !== spawned || message?.type !== 'handmux-browser-ready' || !Number.isInteger(message.port)) return;
97
+ port = message.port;
98
+ generation += 1;
99
+ clearReadyTimer();
100
+ clearStableTimer();
101
+ stableTimer = setTimer(() => {
102
+ stableTimer = null;
103
+ if (child === spawned && port != null) restartDelay = 250;
104
+ }, stableAfterMs);
105
+ });
106
+ const finalize = () => {
107
+ if (child !== spawned) return;
108
+ child = null;
109
+ port = null;
110
+ generation += 1;
111
+ clearReadyTimer();
112
+ clearStableTimer();
113
+ for (const socket of activeSockets) socket.destroy();
114
+ activeSockets.clear();
115
+ scheduleRestart();
116
+ };
117
+ spawned.once('error', () => {
118
+ if (child !== spawned) return;
119
+ try { spawned.kill('SIGTERM'); } catch { /* already gone */ }
120
+ finalize();
121
+ });
122
+ spawned.once('exit', finalize);
123
+ spawned.once('close', finalize);
124
+ };
125
+
126
+ const unavailable = (res, status) => res.status(status).json({ error: 'browser unavailable' });
127
+ const proxyHttp = (api) => (req, res, next) => {
128
+ if (!api && !claimedBrowserRequest(req)) return next();
129
+ const targetPort = port;
130
+ if (!targetPort) return unavailable(res, api ? 503 : 502);
131
+ const upstream = request({
132
+ hostname: '127.0.0.1',
133
+ port: targetPort,
134
+ method: req.method,
135
+ path: req.originalUrl || req.url,
136
+ headers: forwardedHeaders(req.headers, internalToken, appToken, api),
137
+ }, (incoming) => {
138
+ res.writeHead(incoming.statusCode || 502, incoming.headers);
139
+ incoming.pipe(res);
140
+ res.once('close', () => { if (!res.writableEnded) incoming.destroy(); });
141
+ });
142
+ if (api) upstream.setTimeout?.(requestTimeoutMs, () => upstream.destroy(new Error('browser worker request timeout')));
143
+ const abort = () => upstream.destroy();
144
+ req.once('aborted', abort);
145
+ res.once('close', () => { if (!res.writableEnded) abort(); });
146
+ upstream.once('error', () => {
147
+ if (!res.headersSent) unavailable(res, api ? 503 : 502);
148
+ else res.destroy();
149
+ });
150
+ req.pipe(upstream);
151
+ };
152
+
153
+ const onUpgrade = (req, socket, head) => {
154
+ if (!claimedBrowserRequest(req)) return false;
155
+ const targetPort = port;
156
+ if (!targetPort) { socket.destroy(); return true; }
157
+ const upstream = connect({ host: '127.0.0.1', port: targetPort });
158
+ activeSockets.add(socket);
159
+ activeSockets.add(upstream);
160
+ const cleanup = () => {
161
+ activeSockets.delete(socket);
162
+ activeSockets.delete(upstream);
163
+ };
164
+ upstream.once('connect', () => {
165
+ const headers = forwardedHeaders(req.headers, internalToken, appToken, false);
166
+ const lines = [`${req.method || 'GET'} ${req.url} HTTP/${req.httpVersion || '1.1'}`];
167
+ for (const [name, value] of Object.entries(headers)) {
168
+ if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
169
+ else if (value != null) lines.push(`${name}: ${value}`);
170
+ }
171
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
172
+ if (head?.length) upstream.write(head);
173
+ socket.pipe(upstream).pipe(socket);
174
+ });
175
+ upstream.once('error', () => socket.destroy());
176
+ socket.once('error', () => upstream.destroy());
177
+ upstream.once('close', cleanup);
178
+ socket.once('close', cleanup);
179
+ return true;
180
+ };
181
+
182
+ const proxyRequest = ({ req, method, path, body }) => new Promise((resolve) => {
183
+ const targetPort = port;
184
+ if (!targetPort) return resolve(null);
185
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
186
+ const headers = forwardedHeaders(req.headers, internalToken, appToken, true);
187
+ if (payload) {
188
+ headers['content-type'] = 'application/json';
189
+ headers['content-length'] = String(payload.length);
190
+ } else {
191
+ delete headers['content-length'];
192
+ delete headers['content-type'];
193
+ }
194
+ const upstream = request({ hostname: '127.0.0.1', port: targetPort, method, path, headers }, (incoming) => {
195
+ const chunks = [];
196
+ incoming.on('data', (chunk) => chunks.push(chunk));
197
+ incoming.on('end', () => resolve({
198
+ status: incoming.statusCode || 502,
199
+ headers: incoming.headers,
200
+ body: Buffer.concat(chunks),
201
+ }));
202
+ });
203
+ upstream.setTimeout?.(requestTimeoutMs, () => upstream.destroy(new Error('browser worker request timeout')));
204
+ upstream.once('error', () => resolve(null));
205
+ req.once('aborted', () => upstream.destroy());
206
+ upstream.end(payload || undefined);
207
+ });
208
+
209
+ const apiHandler = createBrowserCoordinator({
210
+ proxyRequest,
211
+ getStatus: () => ({ ready: port != null, generation }),
212
+ });
213
+
214
+ if (previewDomain) start();
215
+ let closePromise = null;
216
+ return {
217
+ apiHandler,
218
+ publicHandler: proxyHttp(false),
219
+ onUpgrade,
220
+ close() {
221
+ if (!closePromise) {
222
+ stopping = true;
223
+ apiHandler.close();
224
+ if (restartTimer != null) clearTimer(restartTimer);
225
+ restartTimer = null;
226
+ clearReadyTimer();
227
+ clearStableTimer();
228
+ for (const socket of activeSockets) socket.destroy();
229
+ activeSockets.clear();
230
+ const current = child;
231
+ if (!current) closePromise = Promise.resolve();
232
+ else closePromise = new Promise((resolve) => {
233
+ let finished = false;
234
+ let forceTimer = setTimer(() => {
235
+ forceTimer = null;
236
+ current.kill('SIGKILL');
237
+ }, stopTimeoutMs);
238
+ const finish = () => {
239
+ if (finished) return;
240
+ finished = true;
241
+ if (forceTimer != null) clearTimer(forceTimer);
242
+ resolve();
243
+ };
244
+ current.once('error', finish);
245
+ current.once('exit', finish);
246
+ current.once('close', finish);
247
+ try { current.kill('SIGTERM'); } catch { finish(); }
248
+ });
249
+ }
250
+ return closePromise;
251
+ },
252
+ };
253
+ }
@@ -0,0 +1,98 @@
1
+ import http from 'node:http';
2
+ import express from 'express';
3
+ import { tokenEquals } from '../auth.js';
4
+ import { createBrowserPreviewManager } from './manager.js';
5
+ import { createBrowserBootstrapStore } from './bootstrap.js';
6
+ import { createBrowserPublicProxy } from './publicProxy.js';
7
+ import { browserRoutes } from './routes.js';
8
+ import { BROWSER_INTERNAL_HEADER } from './protocol.js';
9
+
10
+ function isLoopback(address) {
11
+ return address === '127.0.0.1' || address === '::1' || String(address || '').startsWith('::ffff:127.');
12
+ }
13
+
14
+ function authenticated(req, token) {
15
+ const provided = req.headers[BROWSER_INTERNAL_HEADER];
16
+ return isLoopback(req.socket?.remoteAddress)
17
+ && typeof provided === 'string'
18
+ && tokenEquals(provided, token);
19
+ }
20
+
21
+ export async function createBrowserWorkerServer({
22
+ internalToken,
23
+ previewDomain = null,
24
+ handmuxOrigin = 'http://127.0.0.1',
25
+ browser: suppliedBrowser = null,
26
+ managerFactory = createBrowserPreviewManager,
27
+ browserPublicFactory = createBrowserPublicProxy,
28
+ host = '127.0.0.1',
29
+ port = 0,
30
+ } = {}) {
31
+ if (!internalToken) throw new Error('browser worker internal token required');
32
+ const browserBootstrap = createBrowserBootstrapStore();
33
+ const browser = suppliedBrowser || await managerFactory({
34
+ handmuxOrigin,
35
+ previewDomain,
36
+ browserBootstrap,
37
+ });
38
+ const browserPublic = browserPublicFactory({ browser, browserBootstrap });
39
+ const app = express();
40
+
41
+ app.use((req, res, next) => {
42
+ if (!authenticated(req, internalToken)) return res.status(401).json({ error: 'browser worker unauthorized' });
43
+ delete req.headers[BROWSER_INTERNAL_HEADER];
44
+ next();
45
+ });
46
+ app.get('/_browser-worker/health', (_req, res) => res.json({ ok: true }));
47
+ app.use(
48
+ '/api/browser-proxy',
49
+ express.json(),
50
+ browserRoutes({ browser, previewDomain, browserBootstrap }),
51
+ );
52
+ app.use(browserPublic.handler);
53
+
54
+ const server = http.createServer(app);
55
+ const sockets = new Set();
56
+ server.on('connection', (socket) => {
57
+ sockets.add(socket);
58
+ socket.once('close', () => sockets.delete(socket));
59
+ });
60
+ server.on('upgrade', (req, socket, head) => {
61
+ if (!authenticated(req, internalToken)) {
62
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
63
+ return;
64
+ }
65
+ delete req.headers[BROWSER_INTERNAL_HEADER];
66
+ if (!browserPublic.onUpgrade(req, socket, head)) socket.destroy();
67
+ });
68
+
69
+ await new Promise((resolve, reject) => {
70
+ const onError = (error) => { server.off('listening', onListening); reject(error); };
71
+ const onListening = () => { server.off('error', onError); resolve(); };
72
+ server.once('error', onError);
73
+ server.once('listening', onListening);
74
+ server.listen(port, host);
75
+ });
76
+
77
+ let closePromise = null;
78
+ return {
79
+ host,
80
+ port: server.address().port,
81
+ server,
82
+ browser,
83
+ close() {
84
+ if (!closePromise) {
85
+ closePromise = (async () => {
86
+ const stopped = new Promise((resolve) => server.close(resolve));
87
+ server.closeIdleConnections?.();
88
+ for (const socket of sockets) socket.destroy();
89
+ try { await browser.close?.(); } finally {
90
+ server.closeAllConnections?.();
91
+ await stopped;
92
+ }
93
+ })();
94
+ }
95
+ return closePromise;
96
+ },
97
+ };
98
+ }
@@ -171,7 +171,6 @@ export default {
171
171
  'setup.choose': 'choose 1-6',
172
172
  'setup.invalid': 'invalid choice',
173
173
  'setup.askPort': 'server port',
174
- 'setup.askPreviewDomain': 'preview domain (no http://, https://, or *.)',
175
174
  'setup.askHostname': 'public hostname (e.g. handmux.example.com)',
176
175
  'setup.askTunnelName': 'tunnel name',
177
176
  'setup.askSshHost': 'ssh host (user@host[:port])',
@@ -193,7 +192,10 @@ export default {
193
192
  'setup.secName': 'Name',
194
193
  'setup.secPort': 'Port',
195
194
  'setup.secToken': 'Token',
196
- 'setup.secPreview': 'Dynamic preview',
195
+ 'setup.secBrowser': 'Web preview',
196
+ 'setup.browserOff': 'not configured · Direct mode only',
197
+ 'setup.askBrowserDomain': 'Web preview proxy domain (blank = Direct mode only)',
198
+ 'setup.browserAbout': 'Enter a proxy domain (for example, preview.example.com) and route its wildcard subdomains to Handmux over HTTPS; leave it blank for Direct mode only.',
197
199
  'setup.tokenAuto': 'auto · new each start',
198
200
  'setup.tokenCustom': 'Set a custom token…',
199
201
  'setup.tokenRandom': 'Generate a random one',
@@ -206,7 +208,6 @@ export default {
206
208
  'setup.secPush': 'Push',
207
209
  'setup.secVoice': 'Voice',
208
210
  'setup.default': '(default)',
209
- 'setup.previewOff': 'Not configured',
210
211
  'setup.on': 'on',
211
212
  'setup.off': 'off',
212
213
  'setup.yes': 'Yes',
@@ -287,11 +288,6 @@ export default {
287
288
  'setup.sshHelp1': 'Server side (one-time): point a reverse proxy at the forwarded loopback port.',
288
289
  'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; also set proxy_http_version 1.1, the Upgrade/Connection WebSocket headers, client_max_body_size 60m, and proxy_read_timeout 90s;',
289
290
  'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
290
- 'setup.previewAbout': 'Open a development service running on this machine by port from your phone. Static directory preview still works without this setting.',
291
- 'setup.previewRoute': 'Enter only the preview domain (for example, preview.example.com). handmux uses <project-name>.preview.example.com; route *.preview.example.com to the handmux gateway.',
292
- 'setup.previewHttps': 'The preview domain itself must support HTTPS. The local development service may use HTTP or HTTPS.',
293
- 'setup.previewTlsCf': " TLS: Cloudflare's free cert covers ONE level (*.example.com); deeper (*.preview.example.com) needs Advanced Certificate Manager.",
294
- 'setup.previewTlsEdge': " TLS: your edge serves the wildcard cert (e.g. a Let's Encrypt *.preview.your.domain).",
295
291
 
296
292
  // workspace restore
297
293
  'restore.usage': 'Usage: handmux restore [--dry-run] [--checkpoint <id>] [--session <name>] | handmux restore --list',
@@ -381,12 +377,11 @@ start flags (matching env var in parens):
381
377
  --cf-tunnel-name N tunnel name for cloudflare-named (HANDMUX_CF_TUNNEL_NAME, default: handmux)
382
378
  --authtoken T authtoken for natapp / cpolar (HANDMUX_AUTHTOKEN)
383
379
  --cpolar-region R cpolar edge region, e.g. cn (HANDMUX_CPOLAR_REGION)
384
- --preview-domain D enable dynamic previews, needs wildcard subdomain (HANDMUX_PREVIEW_DOMAIN)
385
380
  --foreground, -f run in the foreground (don't daemonize)
386
381
  --no-qr don't render the QR code
387
382
 
388
- rarely needed (env or flag): --static-dir / --upload-exts / --preview-ttl
389
- (HANDMUX_STATIC_DIR / HANDMUX_UPLOAD_EXTS / HANDMUX_PREVIEW_TTL)
383
+ rarely needed (env or flag): --static-dir / --upload-exts
384
+ (HANDMUX_STATIC_DIR / HANDMUX_UPLOAD_EXTS)
390
385
 
391
386
  'handmux service install' accepts these same start flags — they're baked into the autostart entry.
392
387
  `,
@@ -170,7 +170,6 @@ export default {
170
170
  'setup.choose': '选择 1-6',
171
171
  'setup.invalid': '无效选择',
172
172
  'setup.askPort': '服务端口',
173
- 'setup.askPreviewDomain': '预览域名(不要填 http://、https:// 或 *.)',
174
173
  'setup.askHostname': '公网域名(例如 handmux.example.com)',
175
174
  'setup.askTunnelName': '隧道名称',
176
175
  'setup.askSshHost': 'ssh 主机(user@host[:port])',
@@ -192,7 +191,10 @@ export default {
192
191
  'setup.secName': '名称',
193
192
  'setup.secPort': '端口',
194
193
  'setup.secToken': '令牌',
195
- 'setup.secPreview': '动态预览',
194
+ 'setup.secBrowser': '网页预览器',
195
+ 'setup.browserOff': '未配置 · 仅手机直连',
196
+ 'setup.askBrowserDomain': '网页预览器代理域名(留空 = 仅手机直连)',
197
+ 'setup.browserAbout': '填写代理域名(如 preview.example.com),并将其通配子域以 HTTPS 路由到 Handmux;留空则仅使用手机直连。',
196
198
  'setup.tokenAuto': '自动 · 每次启动新生成',
197
199
  'setup.tokenCustom': '自定义令牌…',
198
200
  'setup.tokenRandom': '随机生成一个',
@@ -205,7 +207,6 @@ export default {
205
207
  'setup.secPush': '推送',
206
208
  'setup.secVoice': '语音',
207
209
  'setup.default': '(默认)',
208
- 'setup.previewOff': '未配置',
209
210
  'setup.on': '开',
210
211
  'setup.off': '关',
211
212
  'setup.yes': '是',
@@ -286,11 +287,6 @@ export default {
286
287
  'setup.sshHelp1': '服务端(一次性):把反向代理指向被转发的 loopback 端口。',
287
288
  'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; 并设置 proxy_http_version 1.1、Upgrade/Connection WebSocket 头、client_max_body_size 60m、proxy_read_timeout 90s;',
288
289
  'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
289
- 'setup.previewAbout': '用于在手机上按端口打开本机正在运行的开发服务;不配置时,静态目录预览仍可使用。',
290
- 'setup.previewRoute': '只填预览域名(如 preview.example.com)。handmux 会使用 <项目名>.preview.example.com;请将 *.preview.example.com 路由到 handmux 网关。',
291
- 'setup.previewHttps': '预览域名本身必须支持 HTTPS;本机开发服务可使用 HTTP 或 HTTPS。',
292
- 'setup.previewTlsCf': ' TLS:Cloudflare 的免费证书只覆盖一级(*.example.com);更深一层(*.preview.example.com)需要 Advanced Certificate Manager。',
293
- 'setup.previewTlsEdge': ' TLS:由你自己的边缘提供通配证书(例如 Let\'s Encrypt 的 *.preview.your.domain)。',
294
290
 
295
291
  // 工作区恢复
296
292
  'restore.usage': '用法: handmux restore [--dry-run] [--checkpoint <id>] [--session <name>] | handmux restore --list',
@@ -380,12 +376,11 @@ start flag(括号内为对应环境变量):
380
376
  --cf-tunnel-name N cloudflare-named 的隧道名(HANDMUX_CF_TUNNEL_NAME,默认:handmux)
381
377
  --authtoken T natapp / cpolar 的 authtoken(HANDMUX_AUTHTOKEN)
382
378
  --cpolar-region R cpolar 边缘区域,如 cn(HANDMUX_CPOLAR_REGION)
383
- --preview-domain D 启用动态预览,需要通配子域名(HANDMUX_PREVIEW_DOMAIN)
384
379
  --foreground, -f 前台运行(不后台化)
385
380
  --no-qr 不渲染二维码
386
381
 
387
- 很少用到(环境变量或 flag):--static-dir / --upload-exts / --preview-ttl
388
- (HANDMUX_STATIC_DIR / HANDMUX_UPLOAD_EXTS / HANDMUX_PREVIEW_TTL)
382
+ 很少用到(环境变量或 flag):--static-dir / --upload-exts
383
+ (HANDMUX_STATIC_DIR / HANDMUX_UPLOAD_EXTS)
389
384
 
390
385
  'handmux service install' 也接受上面这些 start flag —— 它们会被烘进自启项。
391
386
  `,
@@ -65,14 +65,13 @@ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen =
65
65
  name: pick('name', env.HANDMUX_APP_NAME) || null,
66
66
  host: pick('host', env.HANDMUX_HOST, '0.0.0.0'),
67
67
  token: pick('token', env.HANDMUX_TOKEN) || gen(),
68
- previewDomain: pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN) || null,
69
68
  foreground: !!pick('foreground', false),
70
69
  qr: pick('qr', true) !== false,
71
70
  // Unified config — what used to live in .env. The supervisor injects these into the server child's
72
71
  // environment (HANDMUX_STATIC_DIR / VAPID_* / XFYUN_* …), which is exactly where the server reads them.
73
72
  staticDir: pick('staticDir', env.HANDMUX_STATIC_DIR) || null,
74
73
  uploadExts: pick('uploadExts', env.HANDMUX_UPLOAD_EXTS) || null,
75
- previewTtl: pick('previewTtl', env.HANDMUX_PREVIEW_TTL) || null,
74
+ previewDomain: pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN) || null,
76
75
  vapid: fileCfg.vapid || null, // { public, private, subject } — push notifications
77
76
  xfyun: fileCfg.xfyun || null, // { appId, apiKey, apiSecret } — voice input
78
77
  shortcuts: normalizeShortcuts(fileCfg.shortcuts),
@@ -183,8 +182,8 @@ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = pr
183
182
  }
184
183
  add('publicUrl', pub, pub.value == null ? '(none — derived from tunnel if any)' : String(pub.value));
185
184
 
186
- const preview = trace(flags, fileCfg, env, cfgPath, 'previewDomain', 'HANDMUX_PREVIEW_DOMAIN', null);
187
- add('previewDomain', preview, preview.value == null ? '(off)' : String(preview.value));
185
+ const previewDomain = trace(flags, fileCfg, env, cfgPath, 'previewDomain', 'HANDMUX_PREVIEW_DOMAIN', null);
186
+ add('previewDomain', previewDomain, previewDomain.value == null ? '(none)' : String(previewDomain.value));
188
187
 
189
188
  if (t === 'ssh') {
190
189
  add('sshHost', trace(flags, fileCfg, env, cfgPath, 'sshHost', 'HANDMUX_SSH_HOST', null));