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.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/README.zh-CN.md +285 -0
- package/bin/handmux.js +417 -0
- package/hooks/handmux-notify.sh +20 -0
- package/hooks/handmux-write.cjs +92 -0
- package/package.json +52 -0
- package/public/assets/index-BN-IwtP6.css +32 -0
- package/public/assets/index-BUQ0R83h.js +157 -0
- package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/public/fonts/TWUnifont.woff2 +0 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/badge-96.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/logo.svg +32 -0
- package/public/index.html +105 -0
- package/public/manifest.webmanifest +37 -0
- package/public/offline.html +50 -0
- package/public/sw.js +117 -0
- package/src/.gitkeep +0 -0
- package/src/appName.js +23 -0
- package/src/asr/iflyConfig.js +10 -0
- package/src/asr/iflySign.js +16 -0
- package/src/auth.js +30 -0
- package/src/claudeEvents.js +212 -0
- package/src/cli/cfNamed.js +5 -0
- package/src/cli/claudeHooks.js +116 -0
- package/src/cli/cloudflareUrl.js +9 -0
- package/src/cli/cloudflared.js +53 -0
- package/src/cli/drivers.js +59 -0
- package/src/cli/options.js +169 -0
- package/src/cli/probe.js +16 -0
- package/src/cli/qr.js +34 -0
- package/src/cli/service.js +98 -0
- package/src/cli/setupWizard.js +248 -0
- package/src/cli/sshTunnel.js +12 -0
- package/src/cli/state.js +42 -0
- package/src/cli/supervisor.js +172 -0
- package/src/cli/tmuxConf.js +90 -0
- package/src/cli/tmuxVersion.js +49 -0
- package/src/cli/tunlite.js +22 -0
- package/src/config.js +6 -0
- package/src/docPath.js +46 -0
- package/src/docs.js +222 -0
- package/src/git.js +185 -0
- package/src/httpApi.js +546 -0
- package/src/previewServer.js +182 -0
- package/src/previews.js +118 -0
- package/src/push.js +121 -0
- package/src/server.js +97 -0
- package/src/staticCache.js +8 -0
- package/src/tmux/commands.js +223 -0
- package/src/trimCapture.js +28 -0
- package/src/uploadTypes.js +28 -0
- package/tmux/README.md +77 -0
- package/tmux/claude-tab-seed.py +67 -0
- package/tmux/claude-tab-seen.sh +14 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="512" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#16180f"/><stop offset="1" stop-color="#080907"/></linearGradient>
|
|
5
|
+
<radialGradient id="glow" cx="230" cy="262" r="250" gradientUnits="userSpaceOnUse">
|
|
6
|
+
<stop offset="0" stop-color="#74e6a0" stop-opacity="0.26"/><stop offset="1" stop-color="#74e6a0" stop-opacity="0"/></radialGradient>
|
|
7
|
+
<linearGradient id="body" x1="150" y1="120" x2="300" y2="420" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop offset="0" stop-color="#aef9c8"/><stop offset="1" stop-color="#46d6a2"/></linearGradient>
|
|
9
|
+
<linearGradient id="a1" x1="300" y1="190" x2="372" y2="262" gradientUnits="userSpaceOnUse">
|
|
10
|
+
<stop offset="0" stop-color="#7fe9b8" stop-opacity="0.95"/><stop offset="1" stop-color="#7fe9b8" stop-opacity="0.12"/></linearGradient>
|
|
11
|
+
<linearGradient id="a2" x1="296" y1="150" x2="406" y2="262" gradientUnits="userSpaceOnUse">
|
|
12
|
+
<stop offset="0" stop-color="#6fe2ac" stop-opacity="0.7"/><stop offset="1" stop-color="#6fe2ac" stop-opacity="0.05"/></linearGradient>
|
|
13
|
+
<linearGradient id="a3" x1="292" y1="112" x2="446" y2="262" gradientUnits="userSpaceOnUse">
|
|
14
|
+
<stop offset="0" stop-color="#62dca4" stop-opacity="0.45"/><stop offset="1" stop-color="#62dca4" stop-opacity="0"/></linearGradient>
|
|
15
|
+
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="7" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
|
16
|
+
</defs>
|
|
17
|
+
<rect width="512" height="512" rx="116" fill="url(#bg)"/>
|
|
18
|
+
<rect width="512" height="512" rx="116" fill="url(#glow)"/>
|
|
19
|
+
<g transform="rotate(-7 221 281)">
|
|
20
|
+
<g fill="none" stroke-linecap="round">
|
|
21
|
+
<path d="M300 190 a74 74 0 0 1 70 70" stroke="url(#a1)" stroke-width="15"/>
|
|
22
|
+
<path d="M296 148 a118 118 0 0 1 112 112" stroke="url(#a2)" stroke-width="14"/>
|
|
23
|
+
<path d="M292 108 a160 160 0 0 1 154 154" stroke="url(#a3)" stroke-width="13"/>
|
|
24
|
+
</g>
|
|
25
|
+
<g filter="url(#soft)"><rect x="146" y="150" width="150" height="262" rx="36" fill="url(#body)"/></g>
|
|
26
|
+
<rect x="163" y="184" width="116" height="194" rx="18" fill="#0a0b09"/>
|
|
27
|
+
<rect x="194" y="146" width="30" height="6" rx="3" fill="#0a0b09" opacity="0.45"/>
|
|
28
|
+
<rect x="206" y="396" width="30" height="6" rx="3" fill="#0a0b09" opacity="0.45"/>
|
|
29
|
+
<polyline points="188,236 212,266 188,296" fill="none" stroke="#aef9c8" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
|
30
|
+
<rect x="224" y="252" width="15" height="32" rx="3" fill="#aef9c8"/>
|
|
31
|
+
</g>
|
|
32
|
+
</svg>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
6
|
+
<title>handmux</title>
|
|
7
|
+
|
|
8
|
+
<!-- Installable PWA: "Add to Home Screen" + standalone (no browser address bar). -->
|
|
9
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
10
|
+
<meta name="theme-color" content="#1a1b1e" />
|
|
11
|
+
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
|
12
|
+
<!-- iOS Safari ignores the manifest's display mode, so these drive standalone + the
|
|
13
|
+
home-screen icon/label. "black" keeps the white status-bar text readable on the dark topbar. -->
|
|
14
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
15
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
16
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
|
17
|
+
<meta name="apple-mobile-web-app-title" content="handmux" />
|
|
18
|
+
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
|
19
|
+
|
|
20
|
+
<!-- Inline boot splash: paints instantly (before the JS bundle loads) so a cold launch shows the
|
|
21
|
+
brand, not a blank dark screen. main.jsx calls window.__hideBootSplash() once React has painted;
|
|
22
|
+
a safety timer hides it even if the bundle never boots. Self-contained here — no app code. -->
|
|
23
|
+
<style>
|
|
24
|
+
html, body { margin: 0; background: #1a1b1e; } /* opaque dark from frame one — no wallpaper showing through */
|
|
25
|
+
#boot-splash { position: fixed; inset: 0; z-index: 9999; display: flex; flex-direction: column;
|
|
26
|
+
align-items: center; justify-content: center; gap: 22px; background: #1a1b1e;
|
|
27
|
+
transition: opacity .45s ease; }
|
|
28
|
+
#boot-splash.boot-hide { opacity: 0; pointer-events: none; }
|
|
29
|
+
.boot-mark { position: relative; width: 108px; height: 108px; display: flex;
|
|
30
|
+
align-items: center; justify-content: center; }
|
|
31
|
+
.boot-mark svg { width: 108px; height: 108px; border-radius: 24px; position: relative; z-index: 1; }
|
|
32
|
+
.boot-glow { position: absolute; inset: -42%; border-radius: 50%;
|
|
33
|
+
background: radial-gradient(circle, rgba(116,230,160,.42) 0%, rgba(116,230,160,0) 68%);
|
|
34
|
+
animation: bootGlow 2.4s ease-in-out infinite; }
|
|
35
|
+
@keyframes bootGlow { 0%,100% { transform: scale(.9); opacity: .5; } 50% { transform: scale(1.14); opacity: .95; } }
|
|
36
|
+
.boot-word { font: 600 22px/1 -apple-system, system-ui, sans-serif; letter-spacing: .5px; color: #e9efe9; }
|
|
37
|
+
.boot-dots { display: flex; gap: 7px; }
|
|
38
|
+
.boot-dots i { width: 6px; height: 6px; border-radius: 50%; background: #6fe2ac; opacity: .22;
|
|
39
|
+
animation: bootDot 1.3s ease-in-out infinite; }
|
|
40
|
+
.boot-dots i:nth-child(2) { animation-delay: .18s; }
|
|
41
|
+
.boot-dots i:nth-child(3) { animation-delay: .36s; }
|
|
42
|
+
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
43
|
+
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
44
|
+
</style>
|
|
45
|
+
<script type="module" crossorigin src="/assets/index-BUQ0R83h.js"></script>
|
|
46
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BN-IwtP6.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BN-IwtP6.css"></noscript>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div id="boot-splash" aria-hidden="true">
|
|
50
|
+
<div class="boot-mark">
|
|
51
|
+
<span class="boot-glow"></span>
|
|
52
|
+
<svg viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
53
|
+
<defs>
|
|
54
|
+
<linearGradient id="bsbg" x1="0" y1="0" x2="0" y2="512" gradientUnits="userSpaceOnUse">
|
|
55
|
+
<stop offset="0" stop-color="#16180f"/><stop offset="1" stop-color="#080907"/></linearGradient>
|
|
56
|
+
<radialGradient id="bsglow" cx="230" cy="262" r="250" gradientUnits="userSpaceOnUse">
|
|
57
|
+
<stop offset="0" stop-color="#74e6a0" stop-opacity="0.26"/><stop offset="1" stop-color="#74e6a0" stop-opacity="0"/></radialGradient>
|
|
58
|
+
<linearGradient id="bsbody" x1="150" y1="120" x2="300" y2="420" gradientUnits="userSpaceOnUse">
|
|
59
|
+
<stop offset="0" stop-color="#aef9c8"/><stop offset="1" stop-color="#46d6a2"/></linearGradient>
|
|
60
|
+
<linearGradient id="bsa1" x1="300" y1="190" x2="372" y2="262" gradientUnits="userSpaceOnUse">
|
|
61
|
+
<stop offset="0" stop-color="#7fe9b8" stop-opacity="0.95"/><stop offset="1" stop-color="#7fe9b8" stop-opacity="0.12"/></linearGradient>
|
|
62
|
+
<linearGradient id="bsa2" x1="296" y1="150" x2="406" y2="262" gradientUnits="userSpaceOnUse">
|
|
63
|
+
<stop offset="0" stop-color="#6fe2ac" stop-opacity="0.7"/><stop offset="1" stop-color="#6fe2ac" stop-opacity="0.05"/></linearGradient>
|
|
64
|
+
<linearGradient id="bsa3" x1="292" y1="112" x2="446" y2="262" gradientUnits="userSpaceOnUse">
|
|
65
|
+
<stop offset="0" stop-color="#62dca4" stop-opacity="0.45"/><stop offset="1" stop-color="#62dca4" stop-opacity="0"/></linearGradient>
|
|
66
|
+
<filter id="bssoft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="7" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
|
67
|
+
</defs>
|
|
68
|
+
<rect width="512" height="512" rx="116" fill="url(#bsbg)"/>
|
|
69
|
+
<rect width="512" height="512" rx="116" fill="url(#bsglow)"/>
|
|
70
|
+
<g transform="rotate(-7 221 281)">
|
|
71
|
+
<g fill="none" stroke-linecap="round">
|
|
72
|
+
<path d="M300 190 a74 74 0 0 1 70 70" stroke="url(#bsa1)" stroke-width="15"/>
|
|
73
|
+
<path d="M296 148 a118 118 0 0 1 112 112" stroke="url(#bsa2)" stroke-width="14"/>
|
|
74
|
+
<path d="M292 108 a160 160 0 0 1 154 154" stroke="url(#bsa3)" stroke-width="13"/>
|
|
75
|
+
</g>
|
|
76
|
+
<g filter="url(#bssoft)"><rect x="146" y="150" width="150" height="262" rx="36" fill="url(#bsbody)"/></g>
|
|
77
|
+
<rect x="163" y="184" width="116" height="194" rx="18" fill="#0a0b09"/>
|
|
78
|
+
<rect x="194" y="146" width="30" height="6" rx="3" fill="#0a0b09" opacity="0.45"/>
|
|
79
|
+
<rect x="206" y="396" width="30" height="6" rx="3" fill="#0a0b09" opacity="0.45"/>
|
|
80
|
+
<polyline points="188,236 212,266 188,296" fill="none" stroke="#aef9c8" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
|
81
|
+
<rect x="224" y="252" width="15" height="32" rx="3" fill="#aef9c8"/>
|
|
82
|
+
</g>
|
|
83
|
+
</svg>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="boot-word">handmux</div>
|
|
86
|
+
<div class="boot-dots"><i></i><i></i><i></i></div>
|
|
87
|
+
</div>
|
|
88
|
+
<div id="root"></div>
|
|
89
|
+
<script>
|
|
90
|
+
// Hide the splash once the app has painted (main.jsx), but keep it up at least ~650ms so the glow
|
|
91
|
+
// is actually seen, not a flicker. Safety: hide after 8s no matter what, so a failed boot never traps it.
|
|
92
|
+
window.__bootStart = Date.now();
|
|
93
|
+
window.__hideBootSplash = function () {
|
|
94
|
+
var el = document.getElementById('boot-splash');
|
|
95
|
+
if (!el || el.classList.contains('boot-hide')) return;
|
|
96
|
+
var wait = Math.max(0, 650 - (Date.now() - (window.__bootStart || 0)));
|
|
97
|
+
setTimeout(function () {
|
|
98
|
+
el.classList.add('boot-hide');
|
|
99
|
+
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 500);
|
|
100
|
+
}, wait);
|
|
101
|
+
};
|
|
102
|
+
setTimeout(function () { window.__hideBootSplash(); }, 8000);
|
|
103
|
+
</script>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "handmux",
|
|
3
|
+
"short_name": "handmux",
|
|
4
|
+
"description": "Mobile vibe coding — the same live tmux session on your computer and your phone.",
|
|
5
|
+
"lang": "en",
|
|
6
|
+
"start_url": ".",
|
|
7
|
+
"scope": ".",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"background_color": "#1a1b1e",
|
|
10
|
+
"theme_color": "#1a1b1e",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
|
13
|
+
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
|
14
|
+
],
|
|
15
|
+
"share_target": {
|
|
16
|
+
"action": "/share-target",
|
|
17
|
+
"method": "POST",
|
|
18
|
+
"enctype": "multipart/form-data",
|
|
19
|
+
"params": {
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"name": "file",
|
|
23
|
+
"accept": [
|
|
24
|
+
"image/*", "text/*", "application/pdf", "application/json",
|
|
25
|
+
"application/msword",
|
|
26
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
27
|
+
"application/vnd.ms-excel",
|
|
28
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
29
|
+
"application/vnd.ms-powerpoint",
|
|
30
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
31
|
+
".md", ".markdown", ".csv", ".log"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
6
|
+
<title>tmux web</title>
|
|
7
|
+
<!-- Self-contained: inline CSS + inline JS, NO external resources, so this page is immutable and
|
|
8
|
+
works with no network. The service worker caches it once and serves it when a navigation
|
|
9
|
+
can't reach the network (replacing the browser's own ERR_NETWORK_CHANGED error page). -->
|
|
10
|
+
<style>
|
|
11
|
+
html, body {
|
|
12
|
+
margin: 0; height: 100%; background: #1a1b1e; color: #9aa0a6;
|
|
13
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
|
14
|
+
display: flex; align-items: center; justify-content: center;
|
|
15
|
+
}
|
|
16
|
+
.box { text-align: center; }
|
|
17
|
+
.dots { display: flex; gap: 6px; justify-content: center; }
|
|
18
|
+
.dot {
|
|
19
|
+
width: 8px; height: 8px; border-radius: 50%; background: #4a7cff;
|
|
20
|
+
opacity: 0.3; animation: blink 1.2s infinite;
|
|
21
|
+
}
|
|
22
|
+
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
23
|
+
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
24
|
+
@keyframes blink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
25
|
+
p { margin-top: 16px; font-size: 14px; letter-spacing: 0.05em; }
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<div class="box">
|
|
30
|
+
<div class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
|
|
31
|
+
<p>连接中…</p>
|
|
32
|
+
</div>
|
|
33
|
+
<script>
|
|
34
|
+
// We are the SW's offline fallback. Probe connectivity and reload into the real app the moment
|
|
35
|
+
// the network is back. The probe is a normal fetch (mode !== 'navigate'), so the SW won't
|
|
36
|
+
// intercept it — it goes straight to the network. Reloading while still offline just re-shows
|
|
37
|
+
// this page (the browser never gets to show its own error page).
|
|
38
|
+
(function () {
|
|
39
|
+
function probe() {
|
|
40
|
+
fetch('/', { cache: 'no-store' })
|
|
41
|
+
.then(function (r) { if (r && r.ok) location.reload(); })
|
|
42
|
+
.catch(function () { /* still offline — keep waiting */ });
|
|
43
|
+
}
|
|
44
|
+
setInterval(probe, 2000);
|
|
45
|
+
addEventListener('online', probe);
|
|
46
|
+
probe();
|
|
47
|
+
})();
|
|
48
|
+
</script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
package/public/sw.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// handmux service worker — single purpose: replace the browser's own cold-launch network-error
|
|
2
|
+
// page (e.g. Chromium's ERR_NETWORK_CHANGED white screen) with our offline fallback, then let the
|
|
3
|
+
// page auto-retry into the real app once the network is ready. It caches EXACTLY one immutable file
|
|
4
|
+
// (offline.html) and NEVER caches app code or /api — every launch fetches the latest app from the
|
|
5
|
+
// network, so deploys always take effect (no stale-shell trap).
|
|
6
|
+
// See docs/superpowers/specs/2026-06-04-cold-launch-offline-shell-design.md
|
|
7
|
+
const CACHE = 'tmw-offline-v1';
|
|
8
|
+
const OFFLINE_URL = '/offline.html';
|
|
9
|
+
// Web Share Target intake: a shared file arrives as a POST navigation to /share-target. We can't hand
|
|
10
|
+
// a File to the page across the redirect, so we stash it in this cache; the page consumes it on boot
|
|
11
|
+
// (see src/shareIntake.js — these two constants MUST match its SHARE_CACHE / SHARE_PREFIX).
|
|
12
|
+
const SHARE_CACHE = 'tmw-share-v1';
|
|
13
|
+
const SHARE_PREFIX = '/__share__/';
|
|
14
|
+
const SHARE_ACTION = '/share-target';
|
|
15
|
+
|
|
16
|
+
self.addEventListener('install', (event) => {
|
|
17
|
+
// skipWaiting is chained AFTER the cache write so we never activate before offline.html is cached.
|
|
18
|
+
event.waitUntil(
|
|
19
|
+
caches.open(CACHE).then((c) => c.add(OFFLINE_URL)).then(() => self.skipWaiting()),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
self.addEventListener('activate', (event) => {
|
|
24
|
+
// Keep both our caches: the offline shell AND any pending share (a file shared just before an SW
|
|
25
|
+
// update must survive until the page consumes it). Drop everything else.
|
|
26
|
+
const KEEP = new Set([CACHE, SHARE_CACHE]);
|
|
27
|
+
event.waitUntil(
|
|
28
|
+
caches.keys()
|
|
29
|
+
.then((keys) => Promise.all(keys.filter((k) => !KEEP.has(k)).map((k) => caches.delete(k))))
|
|
30
|
+
.then(() => self.clients.claim()),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Stash a shared file, then redirect into the app with ?share so it knows to pick it up. The File
|
|
35
|
+
// can't ride the redirect, so we cache it under SHARE_PREFIX+name (the name rides the key; the type
|
|
36
|
+
// rides the cached Response's Content-Type). Failures still redirect — the page just finds nothing.
|
|
37
|
+
async function handleShareTarget(request) {
|
|
38
|
+
try {
|
|
39
|
+
const form = await request.formData();
|
|
40
|
+
const files = form.getAll('file').filter((f) => f && typeof f.name === 'string');
|
|
41
|
+
if (files.length) {
|
|
42
|
+
const cache = await caches.open(SHARE_CACHE);
|
|
43
|
+
for (const k of await cache.keys()) await cache.delete(k); // keep only the latest share
|
|
44
|
+
const f = files[0];
|
|
45
|
+
await cache.put(
|
|
46
|
+
SHARE_PREFIX + encodeURIComponent(f.name || 'shared'),
|
|
47
|
+
new Response(f, { headers: { 'Content-Type': f.type || 'application/octet-stream' } }),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
} catch { /* fall through to the redirect; the page finds no pending file and carries on */ }
|
|
51
|
+
return Response.redirect('/?share=1', 303);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
self.addEventListener('fetch', (event) => {
|
|
55
|
+
const req = event.request;
|
|
56
|
+
// Web Share Target POST — intercept before the navigation guard below (a share POST IS a navigate).
|
|
57
|
+
if (req.method === 'POST' && new URL(req.url).pathname === SHARE_ACTION) {
|
|
58
|
+
event.respondWith(handleShareTarget(req));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Only guard top-level navigations. Everything else (JS/CSS/fonts/icons and /api) falls through
|
|
62
|
+
// to the browser's default network handling — the SW neither caches nor intercepts it.
|
|
63
|
+
if (req.mode !== 'navigate') return;
|
|
64
|
+
// Network-first: a normal launch gets the freshest index.html; only when the fetch REJECTS (the
|
|
65
|
+
// radio-wakeup transient that makes the browser show its error page) do we serve the offline page.
|
|
66
|
+
event.respondWith(fetch(req).catch(() => caches.match(OFFLINE_URL)));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// --- Web Push ---------------------------------------------------------------------------------
|
|
70
|
+
// Show a notification for each push. `tag` collapses repeats: a second push with the same tag
|
|
71
|
+
// REPLACES the visible notification instead of stacking, so a given pane never shows a pile of
|
|
72
|
+
// stale alerts. iOS requires us to actually show something on every push, which we always do.
|
|
73
|
+
self.addEventListener('push', (event) => {
|
|
74
|
+
let d = {};
|
|
75
|
+
try { d = event.data ? event.data.json() : {}; }
|
|
76
|
+
catch { d = { body: event.data ? event.data.text() : '' }; }
|
|
77
|
+
event.waitUntil(self.registration.showNotification(d.title || 'handmux', {
|
|
78
|
+
body: d.body || '',
|
|
79
|
+
tag: d.tag || 'handmux',
|
|
80
|
+
renotify: false,
|
|
81
|
+
// icon = the large right-side logo. We MUST set it: HyperOS/MIUI always reserves that slot, and an
|
|
82
|
+
// empty `icon` makes it back-fill the slot with the PWA's own icon at a stray size (the `>` reads as
|
|
83
|
+
// a lone "v") plus leaves a gap. An explicit app icon fills the slot cleanly.
|
|
84
|
+
icon: '/icons/icon-192.png',
|
|
85
|
+
// badge = the small monochrome status-bar icon. Android reads only its ALPHA and tints the
|
|
86
|
+
// silhouette; an opaque image (icon-192 has no alpha) can't be used, so it falls back to the
|
|
87
|
+
// browser's own Chrome logo. badge-96.png is a white `>▮` silhouette on transparent → our mark.
|
|
88
|
+
badge: '/icons/badge-96.png',
|
|
89
|
+
data: d.data || {},
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Tapping the notification deep-links to the pane: focus an open client (and tell it where to go),
|
|
94
|
+
// or open a new window at the deep-link hash.
|
|
95
|
+
self.addEventListener('notificationclick', (event) => {
|
|
96
|
+
event.notification.close();
|
|
97
|
+
const d = event.notification.data || {};
|
|
98
|
+
const e = encodeURIComponent;
|
|
99
|
+
const url = d.session
|
|
100
|
+
? `/#/s/${e(d.session)}/w/${e(d.window || '')}/p/${e(d.pane || '')}`
|
|
101
|
+
: '/';
|
|
102
|
+
event.waitUntil((async () => {
|
|
103
|
+
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
104
|
+
const open = all.find((c) => 'focus' in c);
|
|
105
|
+
if (open) {
|
|
106
|
+
await open.focus();
|
|
107
|
+
// Prefer navigate(): it changes the client URL, so the target survives even a backgrounded tab
|
|
108
|
+
// the browser discarded (it reloads into the deep-link hash, which the boot effect reads).
|
|
109
|
+
// postMessage is a fallback for engines without WindowClient.navigate. URL format MUST match
|
|
110
|
+
// hashRoute.readRoute()/buildDeepLink.
|
|
111
|
+
if ('navigate' in open) { try { await open.navigate(url); return; } catch { /* fall back ↓ */ } }
|
|
112
|
+
open.postMessage({ type: 'navigate', session: d.session, window: d.window, pane: d.pane });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return self.clients.openWindow(url);
|
|
116
|
+
})());
|
|
117
|
+
});
|
package/src/.gitkeep
ADDED
|
File without changes
|
package/src/appName.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Runtime app-name injection. The web bundle ships prebuilt, so a user's custom name (set at
|
|
2
|
+
// `handmux start --name`) can't be baked at build time — instead the server rewrites the name into
|
|
3
|
+
// the shell HTML and the PWA manifest as it serves them. Pure string/object transforms so they're
|
|
4
|
+
// unit-testable; the server side-effects (read file / send) live in server.js.
|
|
5
|
+
|
|
6
|
+
const escAttr = (s) => String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
8
|
+
|
|
9
|
+
// Replace the browser-tab title and the iOS home-screen label in index.html. Matches by tag/attr,
|
|
10
|
+
// not by the default text, so it survives a changed default. No-op when a tag is absent.
|
|
11
|
+
export function applyAppName(html, name) {
|
|
12
|
+
if (!name) return html;
|
|
13
|
+
const e = escAttr(name);
|
|
14
|
+
return html
|
|
15
|
+
.replace(/<title>[^<]*<\/title>/, `<title>${e}</title>`)
|
|
16
|
+
.replace(/(<meta name="apple-mobile-web-app-title" content=")[^"]*(")/, `$1${e}$2`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Override the PWA install name (Android uses short_name; both set so home-screen + app list agree).
|
|
20
|
+
export function applyManifestName(manifest, name) {
|
|
21
|
+
if (!name) return manifest;
|
|
22
|
+
return { ...manifest, name, short_name: name };
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// server/src/asr/iflyConfig.js
|
|
2
|
+
// Read iFlytek IAT credentials lazily from env (default process.env) so it's injectable in tests.
|
|
3
|
+
// app_id is a public identifier (safe to hand the browser); apiKey/apiSecret never leave the server.
|
|
4
|
+
export function asrConfig(env = process.env) {
|
|
5
|
+
return { appId: env.XFYUN_APPID || '', apiKey: env.XFYUN_APIKEY || '', apiSecret: env.XFYUN_APISECRET || '' };
|
|
6
|
+
}
|
|
7
|
+
export function isAsrConfigured(env = process.env) {
|
|
8
|
+
const c = asrConfig(env);
|
|
9
|
+
return !!(c.appId && c.apiKey && c.apiSecret);
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Build a fully-signed iFlytek IAT v2 WebSocket URL. The secret (apiSecret) is used only here on the
|
|
4
|
+
// server — the browser receives only the resulting URL (HMAC output, not the key). `host`/`date` are
|
|
5
|
+
// injectable so this is deterministic and unit-testable; production passes host='iat-api.xfyun.cn'
|
|
6
|
+
// and date=new Date().toUTCString() (RFC1123 GMT, iFlytek allows ±300s skew).
|
|
7
|
+
export function buildIatSignedUrl({ appId, apiKey, apiSecret, host = 'iat-api.xfyun.cn', date }) {
|
|
8
|
+
const requestLine = 'GET /v2/iat HTTP/1.1';
|
|
9
|
+
const signatureOrigin = `host: ${host}\ndate: ${date}\n${requestLine}`;
|
|
10
|
+
const signature = createHmac('sha256', apiSecret).update(signatureOrigin).digest('base64');
|
|
11
|
+
const authorizationOrigin =
|
|
12
|
+
`api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
|
|
13
|
+
const authorization = Buffer.from(authorizationOrigin, 'utf8').toString('base64');
|
|
14
|
+
const qs = new URLSearchParams({ authorization, date, host });
|
|
15
|
+
return { url: `wss://${host}/v2/iat?${qs.toString()}`, appId };
|
|
16
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function loadToken(env = process.env) {
|
|
4
|
+
let token = env.HANDMUX_TOKEN;
|
|
5
|
+
if (!token) {
|
|
6
|
+
token = crypto.randomBytes(24).toString('base64url');
|
|
7
|
+
console.log(`[handmux] no HANDMUX_TOKEN set; generated token: ${token}`);
|
|
8
|
+
}
|
|
9
|
+
return token;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const sha = (s) => crypto.createHash('sha256').update(String(s)).digest();
|
|
13
|
+
|
|
14
|
+
export function tokenEquals(a, b) {
|
|
15
|
+
return crypto.timingSafeEqual(sha(a), sha(b));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function bearerFrom(header) {
|
|
19
|
+
if (!header) return null;
|
|
20
|
+
const m = /^Bearer (.+)$/.exec(header);
|
|
21
|
+
return m ? m[1] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function expressAuth(token) {
|
|
25
|
+
return (req, res, next) => {
|
|
26
|
+
const provided = bearerFrom(req.get('authorization'));
|
|
27
|
+
if (provided && tokenEquals(provided, token)) return next();
|
|
28
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
29
|
+
};
|
|
30
|
+
}
|