handmux 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -14
- package/README.zh-CN.md +16 -12
- package/package.json +3 -3
- package/public/assets/index-BD9dS7RY.css +32 -0
- package/public/assets/index-Bfjbl7JM.js +159 -0
- package/public/index.html +2 -2
- package/src/cli/updateCheck.js +19 -1
- package/src/git.js +27 -4
- package/src/httpApi.js +77 -16
- package/src/uploadTypes.js +2 -0
- package/public/assets/index-CCvTPJwI.js +0 -157
- package/public/assets/index-CYEn5HNU.css +0 -32
package/public/index.html
CHANGED
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
43
43
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
44
44
|
</style>
|
|
45
|
-
<script type="module" crossorigin src="/assets/index-
|
|
46
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
45
|
+
<script type="module" crossorigin src="/assets/index-Bfjbl7JM.js"></script>
|
|
46
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BD9dS7RY.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BD9dS7RY.css"></noscript>
|
|
47
47
|
</head>
|
|
48
48
|
<body>
|
|
49
49
|
<div id="boot-splash" aria-hidden="true">
|
package/src/cli/updateCheck.js
CHANGED
|
@@ -14,7 +14,7 @@ import { pocketHome } from './state.js';
|
|
|
14
14
|
import { t } from './i18n/index.js';
|
|
15
15
|
|
|
16
16
|
export const PKG_NAME = 'handmux';
|
|
17
|
-
export const CHECK_INTERVAL_MS =
|
|
17
|
+
export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // refresh the cached "latest" at most once an hour
|
|
18
18
|
|
|
19
19
|
export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
|
|
20
20
|
|
|
@@ -60,6 +60,24 @@ export function fetchLatestVersion({ timeoutMs = 4000, run = spawnSync } = {}) {
|
|
|
60
60
|
} catch { return null; }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Non-blocking refresh for the long-running server: query npm asynchronously (never stalls the event loop
|
|
64
|
+
// the way the CLI's spawnSync path would) and persist the same {checkedAt, latest} cache the CLI reads. The
|
|
65
|
+
// /api/version route calls this when the cache is stale, so the phone opening the app keeps `latest` current
|
|
66
|
+
// without the user re-running the CLI. Best-effort: npm missing/offline/blocked leaves the prior latest.
|
|
67
|
+
export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000 } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
const child = spawnFn('npm', ['view', PKG_NAME, 'version'], { timeout: timeoutMs });
|
|
70
|
+
let out = '';
|
|
71
|
+
child.stdout?.on('data', (d) => { out += d; });
|
|
72
|
+
child.on('close', (code) => {
|
|
73
|
+
const v = String(out).trim();
|
|
74
|
+
const latest = (code === 0 && parts(v)) ? v : (readCache(home)?.latest ?? null);
|
|
75
|
+
writeCache(home, { checkedAt: now, latest });
|
|
76
|
+
});
|
|
77
|
+
child.on('error', () => { /* npm missing/offline — leave the cache untouched */ });
|
|
78
|
+
} catch { /* best effort */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
// The hidden `__update-check` worker (runs detached, prints nothing): refresh the cache. On a failed fetch
|
|
64
82
|
// keep the previously-known latest but still stamp checkedAt, so a flaky network doesn't re-spawn every run.
|
|
65
83
|
export function runUpdateCheck(home, { now = Date.now(), ...opts } = {}) {
|
package/src/git.js
CHANGED
|
@@ -3,13 +3,37 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join, basename, isAbsolute } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { isUnder } from './docPath.js';
|
|
6
|
+
import { defaultExtraRoots } from './docs.js';
|
|
6
7
|
|
|
7
8
|
// 只读子命令白名单:命令层硬过滤,杜绝任何写操作混入。
|
|
8
9
|
const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
|
|
9
10
|
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
10
11
|
|
|
11
|
-
export function createGit({ home } = {}) {
|
|
12
|
+
export function createGit({ home, extraRoots = [] } = {}) {
|
|
12
13
|
const realHomeP = fs.realpath(home);
|
|
14
|
+
// Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
|
|
15
|
+
// an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
|
|
16
|
+
// missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
|
|
17
|
+
// lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
|
|
18
|
+
// /tmp with a red "outside home".
|
|
19
|
+
const rootsP = (async () => {
|
|
20
|
+
const rh = await realHomeP;
|
|
21
|
+
const out = [rh];
|
|
22
|
+
for (const r of extraRoots) {
|
|
23
|
+
if (typeof r !== 'string' || !r) continue;
|
|
24
|
+
let real;
|
|
25
|
+
try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
|
|
26
|
+
if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
|
|
27
|
+
out.push(real);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
})();
|
|
31
|
+
// The allowed root that contains `real` (longest match wins should roots ever nest), or null.
|
|
32
|
+
const rootOf = (real, roots) => {
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
|
|
35
|
+
return best;
|
|
36
|
+
};
|
|
13
37
|
|
|
14
38
|
function git(cwd, args) {
|
|
15
39
|
const sub = args[0];
|
|
@@ -23,10 +47,9 @@ export function createGit({ home } = {}) {
|
|
|
23
47
|
|
|
24
48
|
async function resolveRepo(rawPath) {
|
|
25
49
|
if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) return { error: 'not absolute', status: 400 };
|
|
26
|
-
const rh = await realHomeP;
|
|
27
50
|
let real;
|
|
28
51
|
try { real = await fs.realpath(rawPath); } catch { return { error: 'not found', status: 404 }; }
|
|
29
|
-
if (!
|
|
52
|
+
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
30
53
|
return { real };
|
|
31
54
|
}
|
|
32
55
|
|
|
@@ -182,4 +205,4 @@ export function createGit({ home } = {}) {
|
|
|
182
205
|
return { resolveRepo, isRepo, detectRepos, status, log, branches, diff, commit };
|
|
183
206
|
}
|
|
184
207
|
|
|
185
|
-
export const defaultGit = createGit({ home: homedir() });
|
|
208
|
+
export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
|
package/src/httpApi.js
CHANGED
|
@@ -25,10 +25,19 @@ import { codexHooksStatus, installCodexHooks } from './cli/codexHooks.js';
|
|
|
25
25
|
import { claudeStatePath } from './cli/state.js';
|
|
26
26
|
import { scanOrphans, takeoverOrphan, defaultProjectsDir } from './orphans.js';
|
|
27
27
|
import { getUsageCached } from './usage.js';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { readCache, isNewer, shouldRefresh, refreshLatestAsync } from './cli/updateCheck.js';
|
|
28
30
|
|
|
29
31
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
32
|
const HOOKS_SRC = resolvePath(here, '../hooks'); // server/hooks (bundled scripts)
|
|
31
33
|
|
|
34
|
+
// The installed CLI version (server/package.json) — read once. The phone compares this against the cached
|
|
35
|
+
// npm "latest" to surface an update hint ("run `handmux update` on your computer"); see the /version route.
|
|
36
|
+
const PKG_VERSION = (() => {
|
|
37
|
+
try { return JSON.parse(readFileSync(resolvePath(here, '../package.json'), 'utf8')).version || null; }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
})();
|
|
40
|
+
|
|
32
41
|
// Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
|
|
33
42
|
// 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
|
|
34
43
|
// agent at all (→ hide the prompt).
|
|
@@ -40,10 +49,24 @@ function combinedHooksStatus(home) {
|
|
|
40
49
|
return 'no-claude';
|
|
41
50
|
}
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
// Keys the mobile keyboard may send via /keys. A controlled vocabulary of named tmux keys, PLUS
|
|
53
|
+
// live-modifier combinations (Ctrl/Alt + a single letter or digit) so the keyboard's Ctrl modifier
|
|
54
|
+
// can compose any readline/tmux binding (C-r, C-w, C-a, the tmux prefix, …) without enumerating each
|
|
55
|
+
// one here. tmux send-keys key names are themselves a closed set, so this stays a strict allowlist
|
|
56
|
+
// (never a passthrough): a key either names an approved token or matches the modifier shape, or it's
|
|
57
|
+
// rejected. The old fixed C-c/C-d/C-z/C-l/C-r/C-o/C-e all still match `C-[a-z0-9]`.
|
|
58
|
+
const NAMED = 'Up|Down|Left|Right|Space|Enter|Escape|Tab|BTab|BSpace|Home|End|PageUp|PageDown';
|
|
59
|
+
// A named tmux key with any (optional) modifier prefixes, in the canonical C- M- S- order buildChord emits.
|
|
60
|
+
// Covers the plain keys (Up, Tab, …) AND modifier combos on them — C-Up, M-Up, S-Left, C-Tab, C-S-Up, … —
|
|
61
|
+
// so the 按键 editor can bind e.g. Ctrl+Arrow or Ctrl+Tab, not just Ctrl + a letter.
|
|
62
|
+
const NAMED_KEY = new RegExp(`^(?:C-)?(?:M-)?(?:S-)?(?:${NAMED})$`);
|
|
63
|
+
// Ctrl and/or Alt + one letter or digit — C-r, C-a, M-b, C-M-k. Must carry a modifier (never a bare char;
|
|
64
|
+
// Shift+letter folds to the uppercase character, so no S- here).
|
|
65
|
+
const CHAR_KEY = /^(?:C-)?(?:M-)?[a-z0-9]$/;
|
|
66
|
+
export function isAllowedKey(k) {
|
|
67
|
+
if (typeof k !== 'string') return false;
|
|
68
|
+
return NAMED_KEY.test(k) || (CHAR_KEY.test(k) && k.includes('-'));
|
|
69
|
+
}
|
|
47
70
|
|
|
48
71
|
// Pause between typing the text and pressing Enter on a /send. A TUI like Claude Code needs a
|
|
49
72
|
// beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
|
|
@@ -271,11 +294,26 @@ export function createApiRouter({
|
|
|
271
294
|
} catch (e) { next(e); }
|
|
272
295
|
});
|
|
273
296
|
|
|
297
|
+
// First free filename in `dir`: the name as-is, else Finder-style "base (1).ext", "base (2).ext", …
|
|
298
|
+
// (the suffix goes before the extension). Bounded; a pathological fallback keeps it from looping.
|
|
299
|
+
async function freeUploadName(dir, name) {
|
|
300
|
+
const dot = name.lastIndexOf('.');
|
|
301
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
302
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
303
|
+
let cand = name;
|
|
304
|
+
for (let n = 1; n <= 999; n++) {
|
|
305
|
+
try { await fsp.access(joinPath(dir, cand)); } // exists → try the next suffix
|
|
306
|
+
catch { return cand; } // ENOENT → free
|
|
307
|
+
cand = `${base} (${n})${ext}`;
|
|
308
|
+
}
|
|
309
|
+
return `${base} (${randomBytes(3).toString('hex')})${ext}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
274
312
|
// Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
|
|
275
313
|
// buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
|
|
276
314
|
// non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
|
|
277
|
-
// in the allow-list
|
|
278
|
-
// part, so the field is known by the time the file event fires.
|
|
315
|
+
// in the allow-list. A name clash auto-suffixes (never overwrites). The client appends `dir` BEFORE
|
|
316
|
+
// the file part, so the field is known by the time the file event fires.
|
|
279
317
|
r.post('/upload', (req, res) => {
|
|
280
318
|
let bb;
|
|
281
319
|
// defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
|
|
@@ -307,11 +345,13 @@ export function createApiRouter({
|
|
|
307
345
|
const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
|
|
308
346
|
if (target.error) { file.resume(); return done(target.status, { error: target.error }); }
|
|
309
347
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
348
|
+
// Finder-style de-dup: never 409 / overwrite on a name clash — pick the first free "base (n).ext".
|
|
349
|
+
// Resolved up front for the response; re-resolved at link time if the race is lost (see below).
|
|
350
|
+
const origName = name;
|
|
351
|
+
let finalName = await freeUploadName(target.real, origName);
|
|
352
|
+
let dest = joinPath(target.real, finalName);
|
|
313
353
|
|
|
314
|
-
tmp = joinPath(target.real, `.${
|
|
354
|
+
tmp = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
|
|
315
355
|
ws = createWriteStream(tmp);
|
|
316
356
|
ws.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
|
|
317
357
|
ws.on('finish', async () => {
|
|
@@ -320,13 +360,22 @@ export function createApiRouter({
|
|
|
320
360
|
// maxUploadBytes is allowed; only strictly-larger trips it.)
|
|
321
361
|
if (file.truncated) { await cleanup(); return done(413, { error: 'too large' }); }
|
|
322
362
|
try {
|
|
323
|
-
// link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race)
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
363
|
+
// link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race) link
|
|
364
|
+
// throws EEXIST → we pick the NEXT free suffix and retry, so we still never overwrite another
|
|
365
|
+
// file, and a clash auto-suffixes rather than 409s.
|
|
366
|
+
let linked = false;
|
|
367
|
+
for (let attempt = 0; attempt < 6 && !linked; attempt++) {
|
|
368
|
+
try { await fsp.link(tmp, dest); linked = true; }
|
|
369
|
+
catch (e) {
|
|
370
|
+
if (e.code !== 'EEXIST') throw e;
|
|
371
|
+
finalName = await freeUploadName(target.real, origName);
|
|
372
|
+
dest = joinPath(target.real, finalName);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!linked) { await cleanup(); return done(409, { error: 'exists' }); }
|
|
327
376
|
await cleanup(); // link made dest a second name for the data; drop the temp name
|
|
328
377
|
const st = await fsp.stat(dest);
|
|
329
|
-
done(201, { name, size: st.size, path: dest }); // absolute path: the dock pastes it
|
|
378
|
+
done(201, { name: finalName, size: st.size, path: dest }); // absolute path: the dock pastes it in
|
|
330
379
|
} catch { await cleanup(); done(500, { error: 'finalize failed' }); }
|
|
331
380
|
});
|
|
332
381
|
file.pipe(ws);
|
|
@@ -437,7 +486,7 @@ export function createApiRouter({
|
|
|
437
486
|
r.post('/keys', async (req, res, next) => {
|
|
438
487
|
const { pane, keys } = req.body || {};
|
|
439
488
|
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
440
|
-
if (!Array.isArray(keys) || keys.some((k) => !
|
|
489
|
+
if (!Array.isArray(keys) || keys.some((k) => !isAllowedKey(k))) {
|
|
441
490
|
return res.status(400).json({ error: 'disallowed key' });
|
|
442
491
|
}
|
|
443
492
|
try {
|
|
@@ -457,6 +506,18 @@ export function createApiRouter({
|
|
|
457
506
|
res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
|
|
458
507
|
});
|
|
459
508
|
|
|
509
|
+
// Update hint for the phone: is the globally-installed CLI behind the latest npm release? `current` is
|
|
510
|
+
// this server's version; `latest` comes from the same cache the CLI maintains (~/.handmux/update-check.json).
|
|
511
|
+
// We never block on the network here — if the cache is stale we kick a best-effort async refresh (throttled
|
|
512
|
+
// to once an hour, like the CLI) and return the currently-known value. The upgrade itself is a computer-side
|
|
513
|
+
// `handmux update`; the phone only shows the notice.
|
|
514
|
+
r.get('/version', (req, res) => {
|
|
515
|
+
const cache = readCache(home);
|
|
516
|
+
if (shouldRefresh(cache)) refreshLatestAsync(home);
|
|
517
|
+
const latest = cache?.latest ?? null;
|
|
518
|
+
res.json({ current: PKG_VERSION, latest, updateAvailable: !!(latest && PKG_VERSION && isNewer(latest, PKG_VERSION)) });
|
|
519
|
+
});
|
|
520
|
+
|
|
460
521
|
// One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
|
|
461
522
|
// host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
|
|
462
523
|
// Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
|
package/src/uploadTypes.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export const DEFAULT_UPLOAD_EXTS = new Set([
|
|
5
5
|
// images
|
|
6
6
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'heif', 'avif', 'ico', 'tiff',
|
|
7
|
+
// video
|
|
8
|
+
'mp4', 'm4v', 'mov', 'webm', 'mkv', 'avi', 'wmv', 'flv', '3gp', 'ogv', 'mpeg', 'mpg',
|
|
7
9
|
// text / code
|
|
8
10
|
'txt', 'md', 'markdown', 'rst', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini',
|
|
9
11
|
'conf', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go',
|