@wendongfly/myhi 1.0.1

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.
@@ -0,0 +1,445 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <link rel="manifest" href="/manifest.json">
10
+ <link rel="apple-touch-icon" href="/icon.png">
11
+ <title>myhi 终端</title>
12
+ <link rel="stylesheet" href="/lib/xterm/css/xterm.css">
13
+ <script src="/lib/xterm/lib/xterm.js"></script>
14
+ <script src="/lib/xterm-fit/lib/xterm-addon-fit.js"></script>
15
+ <script src="/lib/xterm-links/lib/xterm-addon-web-links.js"></script>
16
+ <script src="/socket.io/socket.io.js"></script>
17
+ <style>
18
+ * { box-sizing: border-box; margin: 0; padding: 0; }
19
+
20
+ html, body {
21
+ height: 100%;
22
+ background: #0d1117;
23
+ color: #e6edf3;
24
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
25
+ overflow: hidden;
26
+ }
27
+
28
+ #top-bar {
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: space-between;
32
+ padding: 0.5rem 0.75rem;
33
+ background: #161b22;
34
+ border-bottom: 1px solid #30363d;
35
+ flex-shrink: 0;
36
+ gap: 0.5rem;
37
+ padding-top: max(0.5rem, env(safe-area-inset-top));
38
+ }
39
+
40
+ #session-title {
41
+ font-size: 0.9rem;
42
+ font-weight: 600;
43
+ color: #58a6ff;
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ flex: 1;
48
+ }
49
+
50
+ #viewer-count {
51
+ font-size: 0.75rem;
52
+ color: #8b949e;
53
+ white-space: nowrap;
54
+ }
55
+
56
+ .top-btn {
57
+ background: #21262d;
58
+ color: #e6edf3;
59
+ border: 1px solid #30363d;
60
+ border-radius: 6px;
61
+ padding: 0.3rem 0.6rem;
62
+ font-size: 0.75rem;
63
+ cursor: pointer;
64
+ white-space: nowrap;
65
+ }
66
+ .top-btn:hover { background: #30363d; }
67
+
68
+ #terminal-wrapper {
69
+ flex: 1;
70
+ overflow: hidden;
71
+ padding: 4px;
72
+ }
73
+
74
+ #terminal {
75
+ width: 100%;
76
+ height: 100%;
77
+ }
78
+
79
+ /* Mobile shortcut bar */
80
+ #shortcut-bar {
81
+ display: flex;
82
+ align-items: center;
83
+ background: #161b22;
84
+ border-top: 1px solid #30363d;
85
+ padding: 0.4rem 0.5rem;
86
+ padding-bottom: max(0.4rem, env(safe-area-inset-bottom));
87
+ gap: 0.4rem;
88
+ overflow-x: auto;
89
+ flex-shrink: 0;
90
+ -webkit-overflow-scrolling: touch;
91
+ scrollbar-width: none;
92
+ }
93
+ #shortcut-bar::-webkit-scrollbar { display: none; }
94
+
95
+ .sk {
96
+ background: #21262d;
97
+ color: #e6edf3;
98
+ border: 1px solid #30363d;
99
+ border-radius: 6px;
100
+ padding: 0.35rem 0.65rem;
101
+ font-size: 0.8rem;
102
+ font-family: 'SF Mono', 'Consolas', monospace;
103
+ cursor: pointer;
104
+ white-space: nowrap;
105
+ flex-shrink: 0;
106
+ user-select: none;
107
+ -webkit-user-select: none;
108
+ touch-action: manipulation;
109
+ }
110
+ .sk:active { background: #30363d; transform: scale(0.95); }
111
+
112
+ /* Layout */
113
+ body {
114
+ display: flex;
115
+ flex-direction: column;
116
+ }
117
+
118
+ /* QR modal */
119
+ #qr-modal {
120
+ display: none;
121
+ position: fixed;
122
+ inset: 0;
123
+ background: rgba(0,0,0,0.7);
124
+ z-index: 100;
125
+ align-items: center;
126
+ justify-content: center;
127
+ }
128
+ #qr-modal.open { display: flex; }
129
+ #qr-box {
130
+ background: #161b22;
131
+ border: 1px solid #30363d;
132
+ border-radius: 12px;
133
+ padding: 1.5rem;
134
+ text-align: center;
135
+ max-width: 320px;
136
+ width: 90%;
137
+ }
138
+ #qr-box h3 { margin-bottom: 1rem; font-size: 0.95rem; }
139
+ #qr-box img { width: 100%; max-width: 240px; border-radius: 8px; background: #fff; padding: 8px; }
140
+ #qr-box .close-btn {
141
+ margin-top: 1rem;
142
+ background: #21262d;
143
+ color: #e6edf3;
144
+ border: 1px solid #30363d;
145
+ border-radius: 6px;
146
+ padding: 0.5rem 1.5rem;
147
+ cursor: pointer;
148
+ font-size: 0.9rem;
149
+ }
150
+
151
+ #status-overlay {
152
+ position: absolute;
153
+ inset: 0;
154
+ display: flex;
155
+ flex-direction: column;
156
+ align-items: center;
157
+ justify-content: center;
158
+ background: rgba(13,17,23,0.85);
159
+ color: #8b949e;
160
+ font-size: 0.9rem;
161
+ z-index: 10;
162
+ }
163
+ #status-overlay.hidden { display: none; }
164
+ </style>
165
+ </head>
166
+ <body>
167
+
168
+ <div id="top-bar">
169
+ <div id="session-title">加载中...</div>
170
+ <span id="viewer-count"></span>
171
+ <button class="top-btn" onclick="showQR()">扫码分享</button>
172
+ <button class="top-btn" onclick="goBack()">← 返回</button>
173
+ </div>
174
+
175
+ <div id="terminal-wrapper">
176
+ <div id="terminal"></div>
177
+ <div id="status-overlay">连接中...</div>
178
+ </div>
179
+
180
+ <!-- Mobile shortcut bar -->
181
+ <div id="shortcut-bar">
182
+ <button class="sk" id="btn-photo" onclick="openCamera()" style="color:#7c3aed;border-color:#7c3aed33;background:#7c3aed11">📷</button>
183
+ <button class="sk" data-send="ctrl-c">Ctrl+C</button>
184
+ <button class="sk" data-send="ctrl-d">Ctrl+D</button>
185
+ <button class="sk" data-send="tab">Tab</button>
186
+ <button class="sk" data-send="up">↑</button>
187
+ <button class="sk" data-send="down">↓</button>
188
+ <button class="sk" data-send="right">→</button>
189
+ <button class="sk" data-send="left">←</button>
190
+ <button class="sk" data-send="esc">Esc</button>
191
+ <button class="sk" data-send="ctrl-a">Ctrl+A</button>
192
+ <button class="sk" data-send="ctrl-e">Ctrl+E</button>
193
+ <button class="sk" data-send="ctrl-k">Ctrl+K</button>
194
+ <button class="sk" data-send="ctrl-u">Ctrl+U</button>
195
+ <button class="sk" data-send="ctrl-r">Ctrl+R</button>
196
+ <button class="sk" data-send="alt-.">Alt+.</button>
197
+ </div>
198
+
199
+ <!-- QR modal -->
200
+ <div id="qr-modal">
201
+ <div id="qr-box">
202
+ <h3>扫码在其他设备打开此终端</h3>
203
+ <img id="qr-img" src="" alt="QR Code">
204
+ <br>
205
+ <button class="close-btn" onclick="closeQR()">关闭</button>
206
+ </div>
207
+ </div>
208
+
209
+ <script>
210
+ // Parse URL params
211
+ const pathParts = location.pathname.split('/');
212
+ const SESSION_ID = pathParts[pathParts.length - 1];
213
+
214
+ // ── xterm.js setup ─────────────────────────────────────────
215
+ const term = new Terminal({
216
+ theme: {
217
+ background: '#0d1117',
218
+ foreground: '#e6edf3',
219
+ cursor: '#58a6ff',
220
+ selectionBackground: '#264f78',
221
+ black: '#0d1117',
222
+ red: '#ff7b72',
223
+ green: '#3fb950',
224
+ yellow: '#d29922',
225
+ blue: '#58a6ff',
226
+ magenta: '#bc8cff',
227
+ cyan: '#39c5cf',
228
+ white: '#e6edf3',
229
+ brightBlack: '#6e7681',
230
+ brightRed: '#ffa198',
231
+ brightGreen: '#56d364',
232
+ brightYellow: '#e3b341',
233
+ brightBlue: '#79c0ff',
234
+ brightMagenta: '#d2a8ff',
235
+ brightCyan: '#56d4dd',
236
+ brightWhite: '#f0f6fc',
237
+ },
238
+ fontFamily: "'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace",
239
+ fontSize: 14,
240
+ lineHeight: 1.2,
241
+ cursorBlink: true,
242
+ allowTransparency: false,
243
+ scrollback: 5000,
244
+ });
245
+
246
+ const fitAddon = new FitAddon.FitAddon();
247
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
248
+ term.loadAddon(fitAddon);
249
+ term.loadAddon(webLinksAddon);
250
+ term.open(document.getElementById('terminal'));
251
+
252
+ function fitTerm() {
253
+ try { fitAddon.fit(); } catch {}
254
+ if (typeof socket !== 'undefined' && socket.connected && currentSession) {
255
+ socket.emit('resize', { cols: term.cols, rows: term.rows });
256
+ }
257
+ }
258
+
259
+ // Defer initial fit until after layout, socket setup, and joined
260
+ window.addEventListener('resize', fitTerm);
261
+
262
+ // ── Socket.IO ──────────────────────────────────────────────
263
+ const socket = io({ transports: ['websocket'] });
264
+ let currentSession = null;
265
+ const overlay = document.getElementById('status-overlay');
266
+
267
+ function showOverlay(text, showBack = false) {
268
+ overlay.innerHTML = '';
269
+ overlay.classList.remove('hidden');
270
+ const msg = document.createElement('div');
271
+ msg.textContent = text;
272
+ overlay.appendChild(msg);
273
+ if (showBack) {
274
+ const btn = document.createElement('button');
275
+ btn.textContent = '返回首页';
276
+ btn.style.cssText = 'margin-top:1rem;padding:0.5rem 1.5rem;border:1px solid #8b949e;border-radius:8px;background:transparent;color:#c9d1d9;cursor:pointer;font-size:0.9rem';
277
+ btn.onclick = () => window.location.href = '/';
278
+ overlay.appendChild(btn);
279
+ }
280
+ }
281
+
282
+ socket.on('connect', () => {
283
+ showOverlay('正在加入会话...');
284
+ socket.emit('join', SESSION_ID);
285
+ });
286
+
287
+ socket.on('connect_error', (err) => {
288
+ showOverlay('连接失败: ' + err.message, true);
289
+ });
290
+
291
+ socket.on('disconnect', () => {
292
+ showOverlay('已断开连接,正在重连...');
293
+ });
294
+
295
+ socket.on('joined', (session) => {
296
+ currentSession = session;
297
+ overlay.classList.add('hidden');
298
+ document.title = session.title + ' — myhi';
299
+ document.getElementById('session-title').textContent = session.title;
300
+ updateViewers(session.viewers);
301
+ fitTerm();
302
+ });
303
+
304
+ socket.on('output', (data) => {
305
+ term.write(data);
306
+ });
307
+
308
+ socket.on('session-exit', ({ code }) => {
309
+ overlay.classList.remove('hidden');
310
+ overlay.style.pointerEvents = 'auto';
311
+ overlay.textContent = `会话已退出 (code ${code}) — 点击返回`;
312
+ overlay.onclick = () => history.back();
313
+ });
314
+
315
+ socket.on('sessions', (sessions) => {
316
+ const s = sessions.find(x => x.id === SESSION_ID);
317
+ if (s) updateViewers(s.viewers);
318
+ });
319
+
320
+ socket.on('error', ({ message }) => {
321
+ showOverlay('错误: ' + message, true);
322
+ });
323
+
324
+ // ── Input ──────────────────────────────────────────────────
325
+ term.onData((data) => {
326
+ socket.emit('input', data);
327
+ });
328
+
329
+ // ── Shortcut bar ───────────────────────────────────────────
330
+ // Map data-send keys to actual byte sequences
331
+ const SEQ = {
332
+ 'ctrl-c': '\x03', 'ctrl-d': '\x04', 'tab': '\t',
333
+ 'up': '\x1b[A', 'down': '\x1b[B', 'right': '\x1b[C', 'left': '\x1b[D',
334
+ 'esc': '\x1b',
335
+ 'ctrl-a': '\x01', 'ctrl-e': '\x05', 'ctrl-k': '\x0b',
336
+ 'ctrl-u': '\x15', 'ctrl-r': '\x12', 'alt-.': '\x1b.',
337
+ };
338
+
339
+ document.querySelectorAll('.sk').forEach(btn => {
340
+ const send = () => {
341
+ const seq = SEQ[btn.dataset.send] || btn.dataset.send;
342
+ socket.emit('input', seq);
343
+ term.focus();
344
+ };
345
+
346
+ // Mobile: fire on touchend, prevent synthetic click + double-tap zoom
347
+ btn.addEventListener('touchend', (e) => { e.preventDefault(); send(); });
348
+ // Desktop: fire on click
349
+ btn.addEventListener('click', send);
350
+ });
351
+
352
+ // ── QR modal ───────────────────────────────────────────────
353
+ function showQR() {
354
+ const img = document.getElementById('qr-img');
355
+ img.src = `/qr/${SESSION_ID}`;
356
+ document.getElementById('qr-modal').classList.add('open');
357
+ }
358
+ function closeQR() {
359
+ document.getElementById('qr-modal').classList.remove('open');
360
+ }
361
+ document.getElementById('qr-modal').addEventListener('click', (e) => {
362
+ if (e.target === e.currentTarget) closeQR();
363
+ });
364
+
365
+ // ── Helpers ────────────────────────────────────────────────
366
+ function updateViewers(count) {
367
+ document.getElementById('viewer-count').textContent =
368
+ count > 1 ? `👁 ${count}` : '';
369
+ }
370
+
371
+ function goBack() {
372
+ window.location.href = '/';
373
+ }
374
+
375
+ // ── Photo upload ───────────────────────────────────────────
376
+ const MAX_PX = 1920; // long side max pixels
377
+ const QUALITY = 0.85; // JPEG quality
378
+
379
+ function compressImage(file) {
380
+ return new Promise((resolve, reject) => {
381
+ const img = new Image();
382
+ const url = URL.createObjectURL(file);
383
+ img.onload = () => {
384
+ URL.revokeObjectURL(url);
385
+ let { width: w, height: h } = img;
386
+ if (w > MAX_PX || h > MAX_PX) {
387
+ if (w >= h) { h = Math.round(h * MAX_PX / w); w = MAX_PX; }
388
+ else { w = Math.round(w * MAX_PX / h); h = MAX_PX; }
389
+ }
390
+ const canvas = document.createElement('canvas');
391
+ canvas.width = w; canvas.height = h;
392
+ canvas.getContext('2d').drawImage(img, 0, 0, w, h);
393
+ canvas.toBlob(
394
+ blob => blob ? resolve(blob) : reject(new Error('canvas toBlob failed')),
395
+ 'image/jpeg', QUALITY
396
+ );
397
+ };
398
+ img.onerror = reject;
399
+ img.src = url;
400
+ });
401
+ }
402
+
403
+ function openCamera() {
404
+ const input = document.createElement('input');
405
+ input.type = 'file';
406
+ input.accept = 'image/*';
407
+ input.capture = 'environment';
408
+ input.onchange = async () => {
409
+ const file = input.files?.[0];
410
+ if (!file) return;
411
+
412
+ const btn = document.getElementById('btn-photo');
413
+ btn.textContent = '⏳';
414
+ btn.disabled = true;
415
+
416
+ try {
417
+ const compressed = await compressImage(file);
418
+ const kb = (compressed.size / 1024).toFixed(0);
419
+ console.log(`[photo] ${(file.size/1024).toFixed(0)}KB → ${kb}KB`);
420
+
421
+ const form = new FormData();
422
+ form.append('image', compressed, 'photo.jpg');
423
+ const res = await fetch('/upload', { method: 'POST', body: form });
424
+ const data = await res.json();
425
+ if (data.path) {
426
+ socket.emit('input', data.path);
427
+ term.focus();
428
+ } else {
429
+ alert('上传失败: ' + (data.error || '未知'));
430
+ }
431
+ } catch (e) {
432
+ alert('出错: ' + e.message);
433
+ } finally {
434
+ btn.textContent = '📷';
435
+ btn.disabled = false;
436
+ }
437
+ };
438
+ input.click();
439
+ }
440
+
441
+ // Focus terminal on tap (mobile)
442
+ document.getElementById('terminal-wrapper').addEventListener('click', () => term.focus());
443
+ </script>
444
+ </body>
445
+ </html>
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@wendongfly/myhi",
3
+ "version": "1.0.1",
4
+ "description": "Web-based terminal sharing with chat UI — control your terminal from phone via LAN/Tailscale",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "myhi": "bin/myhi.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "dist/"
13
+ ],
14
+ "scripts": {
15
+ "start": "node dist/index.js",
16
+ "dev": "node --watch src/server.js",
17
+ "build": "node scripts/build.js"
18
+ },
19
+ "keywords": [
20
+ "terminal",
21
+ "web-terminal",
22
+ "claude",
23
+ "claude-code",
24
+ "tailscale",
25
+ "pty",
26
+ "remote-terminal",
27
+ "chat-ui"
28
+ ],
29
+ "author": "wendongfly",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": ""
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "dependencies": {
39
+ "node-pty": "^1.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@vercel/ncc": "^0.38.4",
43
+ "esbuild": "^0.27.4",
44
+ "express": "^4.18.2",
45
+ "multer": "^2.1.1",
46
+ "qrcode": "^1.5.4",
47
+ "qrcode-terminal": "^0.12.0",
48
+ "socket.io": "^4.8.1",
49
+ "socket.io-client": "^4.8.3",
50
+ "terser": "^5.46.1",
51
+ "xterm": "^4.19.0",
52
+ "xterm-addon-fit": "^0.5.0",
53
+ "xterm-addon-web-links": "^0.6.0"
54
+ }
55
+ }