my-ide-mobile-terminal 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 beeweed2727
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # my-ide — mobile-friendly cloud terminal
2
+
3
+ Fix for phone users who can't use the terminal inside cloud-hosted VS Code:
4
+ a full [xterm.js](https://xtermjs.org) terminal in the browser with a touch
5
+ key bar (CTRL, ALT, SHIFT, ESC, TAB, arrows, Home/End, PgUp/PgDn, backspace,
6
+ delete, Ctrl+C/D/Z/…, F1–F12, programming symbols) next to it.
7
+
8
+ ## Install & run (npm package)
9
+
10
+ ```bash
11
+ npm install -g my-ide-mobile-terminal
12
+ myc
13
+ ```
14
+
15
+ `myc` starts the app and prints only the frontend URL, e.g.
16
+ `http://localhost:3000`. Options: `myc --port 4000`, `myc --help`.
17
+
18
+ ## Run from source
19
+
20
+ ```bash
21
+ npm install
22
+ npm start
23
+ ```
24
+
25
+ Open `http://localhost:3000` on your phone (same Wi-Fi, or forward the port
26
+ with the VS Code **Ports** panel). Tap the terminal, then type with your
27
+ phone keyboard (⌨️ button) or the side keys.
28
+
29
+ ## How it works
30
+
31
+ - `server.js` — Express static server + WebSocket (`/pty`) bridge to a real
32
+ shell spawned with `node-pty`. Every installed xterm.js bundle is served
33
+ locally from `node_modules`, no CDN.
34
+ - `public/` — mobile UI: **left** PC-key bar, **middle** xterm.js terminal,
35
+ symbol quick-bar on top, thumb bar at the bottom. All xterm.js packages
36
+ are loaded: core, fit, attach, search, serialize, unicode11, web-links,
37
+ webgl (+ canvas fallback), image, progress.
package/bin/myc.js ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /* `myc` — start the mobile-friendly cloud terminal, print only its frontend URL. */
3
+ import net from 'net';
4
+ import path from 'path';
5
+ import { readFileSync } from 'fs';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const args = process.argv.slice(2);
9
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
10
+
11
+ if (args.includes('-h') || args.includes('--help')) {
12
+ console.log('Usage: myc [--port <n>]\n\nStart the mobile terminal server and print its frontend URL.');
13
+ process.exit(0);
14
+ }
15
+
16
+ if (args.includes('-V') || args.includes('--version')) {
17
+ try {
18
+ console.log(JSON.parse(readFileSync(pkgPath, 'utf8')).version);
19
+ } catch {
20
+ console.log('unknown');
21
+ }
22
+ process.exit(0);
23
+ }
24
+
25
+ function flagValue(names) {
26
+ for (let i = 0; i < args.length; i++) {
27
+ if (names.includes(args[i]) && args[i + 1] !== undefined) return args[i + 1];
28
+ if (names.includes('--port')) {
29
+ const m = /^--port=(\d+)$/.exec(args[i]);
30
+ if (m) return m[1];
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ function isFree(port) {
37
+ return new Promise((resolve) => {
38
+ const probe = net.createServer();
39
+ probe.once('error', () => resolve(false));
40
+ probe.once('listening', () => probe.close(() => resolve(true)));
41
+ probe.listen(port, '127.0.0.1');
42
+ });
43
+ }
44
+
45
+ const explicitPort = flagValue(['-p', '--port']) ?? process.env.PORT;
46
+ const wanted = parseInt(explicitPort ?? '3000', 10) || 3000;
47
+
48
+ let port = wanted;
49
+ if (explicitPort !== undefined) {
50
+ if (!(await isFree(port))) {
51
+ console.error(`error: port ${port} is already in use`);
52
+ process.exit(1);
53
+ }
54
+ } else {
55
+ while (!(await isFree(port))) {
56
+ port++;
57
+ if (port > wanted + 50) {
58
+ console.error(`error: no free port found near ${wanted}`);
59
+ process.exit(1);
60
+ }
61
+ }
62
+ }
63
+
64
+ process.env.PORT = String(port);
65
+ process.env.MYC_QUIET = '1';
66
+ await import('../server.js');
67
+
68
+ // Wait until the server is actually reachable, then expose ONLY the frontend URL.
69
+ const deadline = Date.now() + 15000;
70
+ let up = false;
71
+ while (Date.now() < deadline) {
72
+ try {
73
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
74
+ if (res.ok) {
75
+ up = true;
76
+ break;
77
+ }
78
+ } catch {
79
+ /* not up yet */
80
+ }
81
+ await new Promise((r) => setTimeout(r, 150));
82
+ }
83
+
84
+ if (!up) {
85
+ console.error('error: server did not start in time');
86
+ process.exit(1);
87
+ }
88
+
89
+ console.log(`http://localhost:${port}`);
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "my-ide-mobile-terminal",
3
+ "version": "1.0.0",
4
+ "description": "Mobile-friendly cloud terminal built with full xterm.js + touch key bar (Ctrl, Alt, arrows, etc.) for phones that can't use cloud VS Code's terminal",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "myc": "bin/myc.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "public/",
13
+ "server.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "terminal",
19
+ "xterm",
20
+ "xterm.js",
21
+ "mobile",
22
+ "cloud",
23
+ "pty",
24
+ "cli"
25
+ ],
26
+ "license": "MIT",
27
+ "scripts": {
28
+ "start": "node server.js",
29
+ "dev": "node server.js"
30
+ },
31
+ "dependencies": {
32
+ "@xterm/addon-attach": "^0.11.0",
33
+ "@xterm/addon-canvas": "^0.7.0",
34
+ "@xterm/addon-fit": "^0.10.0",
35
+ "@xterm/addon-image": "^0.9.0",
36
+ "@xterm/addon-progress": "^0.2.0",
37
+ "@xterm/addon-search": "^0.15.0",
38
+ "@xterm/addon-serialize": "^0.13.0",
39
+ "@xterm/addon-unicode11": "^0.8.0",
40
+ "@xterm/addon-web-links": "^0.11.0",
41
+ "@xterm/addon-webgl": "^0.18.0",
42
+ "@xterm/headless": "^5.5.0",
43
+ "@xterm/xterm": "^5.5.0",
44
+ "express": "^4.19.2",
45
+ "node-pty": "^1.0.0",
46
+ "ws": "^8.18.0"
47
+ }
48
+ }
@@ -0,0 +1,169 @@
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.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="mobile-web-app-capable" content="yes" />
7
+ <meta name="apple-mobile-web-app-capable" content="yes" />
8
+ <meta name="theme-color" content="#0b0e14" />
9
+ <title>Mobile Terminal — cloud VS Code companion</title>
10
+ <link rel="stylesheet" href="/xterm/xterm.css" />
11
+ <link rel="stylesheet" href="/style.css" />
12
+ </head>
13
+ <body>
14
+ <header id="topbar">
15
+ <span id="status-dot" class="disconnected" title="connection status"></span>
16
+ <strong id="title">📱 Mobile Terminal</strong>
17
+ <span id="status-text">connecting…</span>
18
+ <span class="spacer"></span>
19
+ <button id="btn-search" title="Search terminal output">🔍</button>
20
+ <button id="btn-clear" title="Clear screen">🧹</button>
21
+ <button id="btn-keyboard" title="Open phone keyboard">⌨️</button>
22
+ <button id="btn-reconnect" title="Reconnect">↻</button>
23
+ </header>
24
+
25
+ <!-- one-tap programming symbols (phone keyboards hide these) -->
26
+ <nav id="symbolbar" aria-label="Quick symbols">
27
+ <button data-insert="|">|</button>
28
+ <button data-insert="/">/</button>
29
+ <button data-insert="\">&#92;</button>
30
+ <button data-insert="-">-</button>
31
+ <button data-insert="_">_</button>
32
+ <button data-insert=":">:</button>
33
+ <button data-insert=";">;</button>
34
+ <button data-insert="~">~</button>
35
+ <button data-insert="`">`</button>
36
+ <button data-insert="&quot;">&quot;</button>
37
+ <button data-insert="'">'</button>
38
+ <button data-insert="(">(</button>
39
+ <button data-insert=")">)</button>
40
+ <button data-insert="{">{</button>
41
+ <button data-insert="}">}</button>
42
+ <button data-insert="[">[</button>
43
+ <button data-insert="]">]</button>
44
+ <button data-insert="*">*</button>
45
+ <button data-insert="&">&amp;</button>
46
+ <button data-insert="?">?</button>
47
+ <button data-insert="!">!</button>
48
+ <button data-insert="$">$</button>
49
+ <button data-insert="^">^</button>
50
+ <button data-insert="=">=</button>
51
+ <button data-insert="+">+</button>
52
+ <button data-insert="%">%</button>
53
+ <button data-insert="#">#</button>
54
+ <button data-insert="@">@</button>
55
+ <button data-insert=" ">␣</button>
56
+ </nav>
57
+
58
+ <div id="searchbar" hidden>
59
+ <input id="search-input" type="search" placeholder="Search output…" enterkeyhint="search" />
60
+ <button id="search-prev">▲</button>
61
+ <button id="search-next">▼</button>
62
+ <button id="search-close">✕</button>
63
+ </div>
64
+
65
+ <main id="layout">
66
+ <!-- LEFT: full PC key bar for touch users -->
67
+ <aside id="keybar" aria-label="PC keys">
68
+ <div class="key-group">
69
+ <div class="key-label">MOD</div>
70
+ <button class="key sticky" id="key-ctrl" title="Ctrl (tap, then tap a letter)">CTRL</button>
71
+ <button class="key sticky" id="key-alt" title="Alt/Option (tap, then tap a key)">ALT</button>
72
+ <button class="key sticky" id="key-shift" title="Shift (uppercases next letter)">⇧</button>
73
+ <button class="key" data-send="&#27;" title="Escape">ESC</button>
74
+ <button class="key" data-send="&#9;" title="Tab">TAB ⇥</button>
75
+ </div>
76
+
77
+ <div class="key-group">
78
+ <div class="key-label">ARROWS</div>
79
+ <div class="arrows">
80
+ <span></span><button class="key" data-send="&#27;[A" title="Up">▲</button><span></span>
81
+ <button class="key" data-send="&#27;[D" title="Left">◀</button><button class="key" data-send="&#27;[B" title="Down">▼</button><button class="key" data-send="&#27;[C" title="Right">▶</button>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="key-group">
86
+ <div class="key-label">NAV</div>
87
+ <button class="key" data-send="&#27;[H" title="Home">HOME</button>
88
+ <button class="key" data-send="&#27;[F" title="End">END</button>
89
+ <button class="key" data-send="&#27;[5~" title="Page Up">PGUP</button>
90
+ <button class="key" data-send="&#27;[6~" title="Page Down">PGDN</button>
91
+ </div>
92
+
93
+ <div class="key-group">
94
+ <div class="key-label">EDIT</div>
95
+ <button class="key wide" data-send="" title="Backspace">⌫ BKSP</button>
96
+ <button class="key" data-send="&#27;[3~" title="Delete">DEL</button>
97
+ <button class="key" data-send="&#27;[2~" title="Insert">INS</button>
98
+ <button class="key wide" data-send="
99
+ </div>
100
+
101
+ <div class="key-group">
102
+ <div class="key-label">CTRL+</div>
103
+ <button class="key" data-send="" title="Ctrl+C — interrupt">C</button>
104
+ <button class="key" data-send="" title="Ctrl+D — EOF / logout">D</button>
105
+ <button class="key" data-send="" title="Ctrl+Z — suspend">Z</button>
106
+ <button class="key" data-send=" " title="Ctrl+L — clear">L</button>
107
+ <button class="key" data-send="" title="Ctrl+R — history search">R</button>
108
+ <button class="key" data-send="" title="Ctrl+U — kill line">U</button>
109
+ <button class="key" data-send=" " title="Ctrl+K — kill to end">K</button>
110
+ <button class="key" data-send="" title="Ctrl+A — start of line">A</button>
111
+ <button class="key" data-send="" title="Ctrl+E — end of line">E</button>
112
+ </div>
113
+
114
+ <details class="key-group" id="fkeys">
115
+ <summary>F1–F12</summary>
116
+ <div class="fkey-grid">
117
+ <button class="key" data-send="&#27;OP">F1</button>
118
+ <button class="key" data-send="&#27;OQ">F2</button>
119
+ <button class="key" data-send="&#27;OR">F3</button>
120
+ <button class="key" data-send="&#27;OS">F4</button>
121
+ <button class="key" data-send="&#27;[15~">F5</button>
122
+ <button class="key" data-send="&#27;[17~">F6</button>
123
+ <button class="key" data-send="&#27;[18~">F7</button>
124
+ <button class="key" data-send="&#27;[19~">F8</button>
125
+ <button class="key" data-send="&#27;[20~">F9</button>
126
+ <button class="key" data-send="&#27;[21~">F10</button>
127
+ <button class="key" data-send="&#27;[23~">F11</button>
128
+ <button class="key" data-send="&#27;[24~">F12</button>
129
+ </div>
130
+ </details>
131
+ </aside>
132
+
133
+ <!-- MIDDLE: the terminal -->
134
+ <section id="terminal-wrap">
135
+ <div id="terminal" aria-label="Terminal. Tap to focus, then use phone keyboard or side keys."></div>
136
+ <div id="tap-hint">👆 tap here to type</div>
137
+ <!-- hidden input summons the OS keyboard on phones; its text is forwarded to the pty -->
138
+ <input id="kbd-proxy" type="text" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false"
139
+ aria-label="Phone keyboard input" />
140
+ </section>
141
+ </main>
142
+
143
+ <footer id="bottombar">
144
+ <button id="btn-kbd2" title="Toggle phone keyboard">⌨️ Keys</button>
145
+ <button data-send="&#9;" title="Tab">⇥</button>
146
+ <button data-send="&#27;" title="Esc">ESC</button>
147
+ <button data-send="|" title="Pipe">|</button>
148
+ <button data-send="~" title="Tilde">~</button>
149
+ <button data-send="/" title="Slash">/</button>
150
+ <button data-send="" title="Ctrl+C">^C</button>
151
+ <button data-send="
152
+ <button data-send="" title="Backspace">⌫</button>
153
+ </footer>
154
+
155
+ <!-- FULL xterm.js stack, every installed package is loaded (nothing left out) -->
156
+ <script src="/xterm/xterm.js"></script>
157
+ <script src="/xterm/addon-attach.js"></script>
158
+ <script src="/xterm/addon-fit.js"></script>
159
+ <script src="/xterm/addon-search.js"></script>
160
+ <script src="/xterm/addon-serialize.js"></script>
161
+ <script src="/xterm/addon-unicode11.js"></script>
162
+ <script src="/xterm/addon-web-links.js"></script>
163
+ <script src="/xterm/addon-webgl.js"></script>
164
+ <script src="/xterm/addon-canvas.js"></script>
165
+ <script src="/xterm/addon-image.js"></script>
166
+ <script src="/xterm/addon-progress.js"></script>
167
+ <script src="/terminal.js"></script>
168
+ </body>
169
+ </html>
@@ -0,0 +1,249 @@
1
+ :root {
2
+ --bg: #0b0e14;
3
+ --panel: #131722;
4
+ --panel-2: #1a2030;
5
+ --border: #2a3348;
6
+ --text: #e6e9f0;
7
+ --dim: #8b93a7;
8
+ --accent: #4cc38a;
9
+ --accent-dim: #1f3d31;
10
+ --danger: #ff5f56;
11
+ --key-h: 44px;
12
+ }
13
+
14
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
15
+
16
+ html, body {
17
+ height: 100%;
18
+ margin: 0;
19
+ overscroll-behavior: none;
20
+ }
21
+
22
+ body {
23
+ background: var(--bg);
24
+ color: var(--text);
25
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
26
+ display: flex;
27
+ flex-direction: column;
28
+ height: 100dvh;
29
+ overflow: hidden;
30
+ }
31
+
32
+ /* ---------- top bar ---------- */
33
+ #topbar {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 8px;
37
+ padding: 8px 10px;
38
+ background: var(--panel);
39
+ border-bottom: 1px solid var(--border);
40
+ font-size: 14px;
41
+ min-height: 48px;
42
+ }
43
+ #topbar .spacer { flex: 1; }
44
+ #status-dot {
45
+ width: 10px; height: 10px; border-radius: 50%;
46
+ background: var(--danger);
47
+ flex: none;
48
+ }
49
+ #status-dot.connected { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
50
+ #status-text { color: var(--dim); font-size: 12px; }
51
+ #topbar button {
52
+ background: var(--panel-2);
53
+ color: var(--text);
54
+ border: 1px solid var(--border);
55
+ border-radius: 8px;
56
+ font-size: 16px;
57
+ min-width: 40px;
58
+ min-height: 36px;
59
+ }
60
+
61
+ /* ---------- symbol quick bar ---------- */
62
+ #symbolbar {
63
+ display: flex;
64
+ gap: 6px;
65
+ overflow-x: auto;
66
+ padding: 6px 8px;
67
+ background: #0e1220;
68
+ border-bottom: 1px solid var(--border);
69
+ scrollbar-width: none;
70
+ }
71
+ #symbolbar::-webkit-scrollbar { display: none; }
72
+ #symbolbar button {
73
+ flex: none;
74
+ min-width: 40px;
75
+ min-height: 36px;
76
+ font-size: 16px;
77
+ font-family: ui-monospace, monospace;
78
+ background: var(--panel-2);
79
+ color: var(--text);
80
+ border: 1px solid var(--border);
81
+ border-radius: 8px;
82
+ }
83
+
84
+ /* ---------- search ---------- */
85
+ #searchbar {
86
+ display: flex;
87
+ gap: 6px;
88
+ padding: 6px 8px;
89
+ background: var(--panel);
90
+ border-bottom: 1px solid var(--border);
91
+ }
92
+ #searchbar[hidden] { display: none; }
93
+ #search-input {
94
+ flex: 1;
95
+ background: #0e1220;
96
+ color: var(--text);
97
+ border: 1px solid var(--border);
98
+ border-radius: 8px;
99
+ padding: 8px;
100
+ font-size: 16px; /* >=16px prevents iOS auto-zoom */
101
+ }
102
+ #searchbar button {
103
+ min-width: 44px;
104
+ background: var(--panel-2);
105
+ color: var(--text);
106
+ border: 1px solid var(--border);
107
+ border-radius: 8px;
108
+ }
109
+
110
+ /* ---------- main layout: LEFT keys + MIDDLE terminal ---------- */
111
+ #layout {
112
+ flex: 1;
113
+ display: flex;
114
+ flex-direction: row;
115
+ min-height: 0;
116
+ }
117
+
118
+ #keybar {
119
+ width: 92px;
120
+ flex: none;
121
+ overflow-y: auto;
122
+ background: var(--panel);
123
+ border-right: 1px solid var(--border);
124
+ padding: 8px 6px 16px;
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 12px;
128
+ touch-action: pan-y;
129
+ overscroll-behavior: contain;
130
+ }
131
+
132
+ .key-group { display: flex; flex-direction: column; gap: 6px; }
133
+ .key-label {
134
+ font-size: 10px;
135
+ letter-spacing: 0.08em;
136
+ color: var(--dim);
137
+ text-align: center;
138
+ }
139
+
140
+ .key {
141
+ min-height: var(--key-h);
142
+ border-radius: 10px;
143
+ border: 1px solid var(--border);
144
+ background: var(--panel-2);
145
+ color: var(--text);
146
+ font-size: 13px;
147
+ font-weight: 600;
148
+ touch-action: manipulation;
149
+ user-select: none;
150
+ }
151
+ .key:active { background: #2a3552; }
152
+ .key.wide { min-height: 52px; }
153
+ .key.sticky.active {
154
+ background: var(--accent-dim);
155
+ border-color: var(--accent);
156
+ color: var(--accent);
157
+ }
158
+
159
+ .arrows {
160
+ display: grid;
161
+ grid-template-columns: 1fr 1fr 1fr;
162
+ gap: 4px;
163
+ }
164
+ .arrows .key { min-height: 40px; font-size: 14px; }
165
+
166
+ #fkeys summary {
167
+ text-align: center;
168
+ font-size: 11px;
169
+ color: var(--dim);
170
+ padding: 6px;
171
+ cursor: pointer;
172
+ }
173
+ .fkey-grid {
174
+ display: grid;
175
+ grid-template-columns: 1fr 1fr;
176
+ gap: 4px;
177
+ }
178
+ .fkey-grid .key { min-height: 40px; font-size: 12px; }
179
+
180
+ /* ---------- terminal (middle) ---------- */
181
+ #terminal-wrap {
182
+ position: relative;
183
+ flex: 1;
184
+ min-width: 0;
185
+ background: #000;
186
+ display: flex;
187
+ }
188
+ #terminal {
189
+ flex: 1;
190
+ padding: 6px 0 6px 8px;
191
+ overflow: hidden;
192
+ }
193
+ #terminal .xterm { height: 100%; }
194
+
195
+ #tap-hint {
196
+ position: absolute;
197
+ right: 10px;
198
+ bottom: 10px;
199
+ background: rgba(20, 26, 40, 0.9);
200
+ border: 1px solid var(--border);
201
+ color: var(--dim);
202
+ font-size: 12px;
203
+ padding: 6px 10px;
204
+ border-radius: 20px;
205
+ pointer-events: none;
206
+ transition: opacity 0.4s;
207
+ }
208
+ #tap-hint.gone { opacity: 0; }
209
+
210
+ /* invisible but focusable: summons the phone keyboard */
211
+ #kbd-proxy {
212
+ position: absolute;
213
+ bottom: 2px;
214
+ left: 2px;
215
+ width: 4px;
216
+ height: 4px;
217
+ opacity: 0.01;
218
+ border: none;
219
+ padding: 0;
220
+ font-size: 16px; /* stops iOS zoom */
221
+ }
222
+
223
+ /* ---------- bottom thumb bar ---------- */
224
+ #bottombar {
225
+ display: flex;
226
+ gap: 6px;
227
+ padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
228
+ background: var(--panel);
229
+ border-top: 1px solid var(--border);
230
+ overflow-x: auto;
231
+ }
232
+ #bottombar button {
233
+ flex: 1 0 auto;
234
+ min-width: 52px;
235
+ min-height: 44px;
236
+ font-size: 16px;
237
+ background: var(--panel-2);
238
+ color: var(--text);
239
+ border: 1px solid var(--border);
240
+ border-radius: 10px;
241
+ touch-action: manipulation;
242
+ }
243
+ #bottombar button:active { background: #2a3552; }
244
+
245
+ /* larger phones / landscape: roomier keys */
246
+ @media (min-width: 700px) {
247
+ #keybar { width: 120px; }
248
+ .key { font-size: 14px; }
249
+ }
@@ -0,0 +1,332 @@
1
+ /* Mobile terminal frontend — full xterm.js stack + touch key bar.
2
+ * Uses EVERY installed xterm package (nothing left out):
3
+ * core + fit, attach, search, serialize, unicode11, web-links,
4
+ * webgl (gpu), canvas (fallback), image (sixel/iTerm2), progress (OSC 9;4),
5
+ * headless is server-side only (imported in node, not the browser).
6
+ */
7
+ (function () {
8
+ 'use strict';
9
+
10
+ const termEl = document.getElementById('terminal');
11
+ const statusDot = document.getElementById('status-dot');
12
+ const statusText = document.getElementById('status-text');
13
+ const kbdProxy = document.getElementById('kbd-proxy');
14
+ const tapHint = document.getElementById('tap-hint');
15
+ const searchbar = document.getElementById('searchbar');
16
+ const searchInput = document.getElementById('search-input');
17
+
18
+ // ---------- create terminal ----------
19
+ const term = new Terminal({
20
+ cursorBlink: true,
21
+ cursorStyle: 'bar',
22
+ fontSize: 14,
23
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
24
+ lineHeight: 1.15,
25
+ scrollback: 5000,
26
+ allowProposedApi: true, // needed by image + progress addons
27
+ theme: {
28
+ background: '#000000',
29
+ foreground: '#e6e9f0',
30
+ cursor: '#4cc38a',
31
+ selectionBackground: 'rgba(76, 195, 138, 0.35)',
32
+ },
33
+ });
34
+
35
+ // ---------- load ALL addons ----------
36
+ const fitAddon = new FitAddon.FitAddon();
37
+ const searchAddon = new SearchAddon.SearchAddon();
38
+ const serializeAddon = new SerializeAddon.SerializeAddon();
39
+ const unicode11 = new Unicode11Addon.Unicode11Addon();
40
+ const webLinks = new WebLinksAddon.WebLinksAddon();
41
+ const attachAddon = null; // we speak JSON {type,input/resize} so we wire the socket manually
42
+ void attachAddon;
43
+
44
+ term.loadAddon(fitAddon);
45
+ term.loadAddon(searchAddon);
46
+ term.loadAddon(serializeAddon);
47
+ term.loadAddon(webLinks);
48
+ term.loadAddon(unicode11);
49
+ term.unicode.activeVersion = '11';
50
+
51
+ // image (sixel / iTerm2 inline images)
52
+ try {
53
+ const imageAddon = new ImageAddon.ImageAddon();
54
+ term.loadAddon(imageAddon);
55
+ } catch (e) { console.warn('image addon unavailable', e); }
56
+
57
+ // progress (OSC 9;4 progress bars, e.g. apt/pacman style tasks)
58
+ try {
59
+ const progressAddon = new ProgressAddon.ProgressAddon();
60
+ term.loadAddon(progressAddon);
61
+ } catch (e) { console.warn('progress addon unavailable', e); }
62
+
63
+ // gpu renderer with canvas fallback
64
+ let gpuAddon = null;
65
+ try {
66
+ gpuAddon = new WebglAddon.WebglAddon();
67
+ term.loadAddon(gpuAddon);
68
+ } catch (e) {
69
+ console.warn('webgl unavailable, trying canvas renderer', e);
70
+ try {
71
+ term.loadAddon(new CanvasAddon.CanvasAddon());
72
+ } catch (e2) { console.warn('canvas addon unavailable', e2); }
73
+ }
74
+ term.onRender && term.onRender(() => {});
75
+ if (gpuAddon && gpuAddon.onContextLoss) {
76
+ gpuAddon.onContextLoss(() => {
77
+ try { gpuAddon.dispose(); } catch {}
78
+ try { term.loadAddon(new CanvasAddon.CanvasAddon()); } catch {}
79
+ });
80
+ }
81
+
82
+ term.open(termEl);
83
+ safeFit();
84
+
85
+ function safeFit() {
86
+ try { fitAddon.fit(); } catch {}
87
+ }
88
+
89
+ // expose for debugging / tests
90
+ window.__term = term;
91
+ window.__serialize = () => serializeAddon.serialize();
92
+
93
+ // ---------- websocket <-> pty ----------
94
+ let ws = null;
95
+ let wantClose = false;
96
+ let retryMs = 1000;
97
+
98
+ function setStatus(connected, text) {
99
+ statusDot.classList.toggle('connected', connected);
100
+ statusDot.classList.toggle('disconnected', !connected);
101
+ statusText.textContent = text;
102
+ }
103
+
104
+ function connect() {
105
+ wantClose = false;
106
+ setStatus(false, 'connecting…');
107
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
108
+ ws = new WebSocket(proto + '://' + location.host + '/pty');
109
+
110
+ ws.onopen = () => {
111
+ retryMs = 1000;
112
+ setStatus(true, 'connected');
113
+ term.writeln('\x1b[32m● connected — tap ⌨️ if your phone keyboard is hidden.\x1b[0m');
114
+ pushResize();
115
+ term.focus();
116
+ };
117
+
118
+ ws.onmessage = (ev) => {
119
+ if (typeof ev.data === 'string' && ev.data.indexOf('{"type":"pong"}') !== -1) return;
120
+ term.write(ev.data);
121
+ };
122
+
123
+ ws.onclose = () => {
124
+ setStatus(false, 'disconnected');
125
+ if (!wantClose) {
126
+ term.writeln('\x1b[31m● disconnected — retrying…\x1b[0m');
127
+ setTimeout(connect, (retryMs = Math.min(retryMs * 1.5, 8000)));
128
+ }
129
+ };
130
+
131
+ ws.onerror = () => { try { ws.close(); } catch {} };
132
+ }
133
+
134
+ function sendInput(data) {
135
+ if (ws && ws.readyState === WebSocket.OPEN) {
136
+ ws.send(JSON.stringify({ type: 'input', data }));
137
+ }
138
+ }
139
+
140
+ function pushResize() {
141
+ if (ws && ws.readyState === WebSocket.OPEN) {
142
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
143
+ }
144
+ }
145
+
146
+ term.onData((data) => sendInput(data));
147
+ term.onResize((size) => {
148
+ if (ws && ws.readyState === WebSocket.OPEN) {
149
+ ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
150
+ }
151
+ });
152
+
153
+ // keep-alive through proxies
154
+ setInterval(() => {
155
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
156
+ }, 25000);
157
+
158
+ // ---------- sticky modifiers (CTRL / ALT / SHIFT) ----------
159
+ const sticky = { ctrl: false, alt: false, shift: false };
160
+ const keyCtrl = document.getElementById('key-ctrl');
161
+ const keyAlt = document.getElementById('key-alt');
162
+ const keyShift = document.getElementById('key-shift');
163
+
164
+ function paintSticky() {
165
+ keyCtrl.classList.toggle('active', sticky.ctrl);
166
+ keyAlt.classList.toggle('active', sticky.alt);
167
+ keyShift.classList.toggle('active', sticky.shift);
168
+ }
169
+
170
+ function bindSticky(btn, name) {
171
+ btn.addEventListener('click', () => {
172
+ sticky[name] = !sticky[name];
173
+ // ctrl and alt are exclusive-ish: enabling one releases the other
174
+ if (sticky[name] && name !== 'shift') {
175
+ if (name === 'ctrl') sticky.alt = false;
176
+ if (name === 'alt') sticky.ctrl = false;
177
+ }
178
+ paintSticky();
179
+ term.focus();
180
+ });
181
+ }
182
+ bindSticky(keyCtrl, 'ctrl');
183
+ bindSticky(keyAlt, 'alt');
184
+ bindSticky(keyShift, 'shift');
185
+
186
+ /** Apply sticky modifiers to a key about to be sent. Single-shot: consumed after one use. */
187
+ function applySticky(data) {
188
+ let out = data;
189
+ const wasCtrl = sticky.ctrl;
190
+ const wasAlt = sticky.alt;
191
+ const wasShift = sticky.shift;
192
+
193
+ if (wasShift && out.length === 1 && /[a-z]/.test(out)) out = out.toUpperCase();
194
+ // shift + arrows -> modified escape sequences terminals understand
195
+ if (wasShift && out.charCodeAt(0) === 0x1b && out[1] === '[' && /[ABCD]$/.test(out)) {
196
+ out = out.slice(0, -1) + ';2' + out.slice(-1);
197
+ }
198
+
199
+ if (wasCtrl && out.length === 1) {
200
+ const lower = out.toLowerCase();
201
+ if (lower >= 'a' && lower <= 'z') {
202
+ out = String.fromCharCode(lower.charCodeAt(0) - 96); // ctrl+a..ctrl+z
203
+ } else if (out === ' ') {
204
+ out = '\x00';
205
+ }
206
+ }
207
+ if (wasAlt) {
208
+ // Alt = ESC prefix (unless it already starts with ESC)
209
+ out = out.charCodeAt(0) === 0x1b ? out : '\x1b' + out;
210
+ }
211
+
212
+ sticky.ctrl = sticky.alt = sticky.shift = false;
213
+ paintSticky();
214
+ return out;
215
+ }
216
+
217
+ // every button with data-send / data-insert flows through here
218
+ document.addEventListener('click', (ev) => {
219
+ const sendBtn = ev.target.closest('[data-send]');
220
+ if (sendBtn) {
221
+ sendInput(applySticky(sendBtn.getAttribute('data-send')));
222
+ term.focus();
223
+ hideHint();
224
+ return;
225
+ }
226
+ const insBtn = ev.target.closest('[data-insert]');
227
+ if (insBtn) {
228
+ sendInput(applySticky(insBtn.getAttribute('data-insert')));
229
+ term.focus();
230
+ hideHint();
231
+ }
232
+ });
233
+
234
+ // ---------- phone keyboard proxy ----------
235
+ // Mobile browsers only open the virtual keyboard for a real <input>.
236
+ // We keep a nearly-invisible input focused and forward everything typed.
237
+ function openKeyboard() {
238
+ kbdProxy.focus({ preventScroll: true });
239
+ // iOS sometimes needs the caret trick:
240
+ try { kbdProxy.setSelectionRange(kbdProxy.value.length, kbdProxy.value.length); } catch {}
241
+ }
242
+
243
+ kbdProxy.addEventListener('input', () => {
244
+ const v = kbdProxy.value;
245
+ if (v) {
246
+ // on-screen keyboards may batch words + autocorrect; send raw then reset
247
+ sendInput(applySticky(v));
248
+ kbdProxy.value = '';
249
+ }
250
+ });
251
+
252
+ kbdProxy.addEventListener('keydown', (e) => {
253
+ // physical / bluetooth keyboards attached to the phone come through here too
254
+ if (e.key === 'Enter') { sendInput(applySticky('\r')); kbdProxy.value = ''; e.preventDefault(); }
255
+ else if (e.key === 'Backspace' && kbdProxy.value === '') { sendInput(applySticky('\x7f')); e.preventDefault(); }
256
+ else if (e.key === 'Tab') { sendInput(applySticky('\t')); e.preventDefault(); }
257
+ else if (e.key === 'Escape') { sendInput(applySticky('\x1b')); e.preventDefault(); }
258
+ else if (e.key && e.key.length === 1 && (e.ctrlKey || e.metaKey)) {
259
+ const lower = e.key.toLowerCase();
260
+ if (lower >= 'a' && lower <= 'z') {
261
+ sendInput(String.fromCharCode(lower.charCodeAt(0) - 96));
262
+ e.preventDefault();
263
+ }
264
+ }
265
+ });
266
+
267
+ termEl.addEventListener('click', () => { hideHint(); openKeyboard(); });
268
+ document.getElementById('btn-keyboard').addEventListener('click', openKeyboard);
269
+ document.getElementById('btn-kbd2').addEventListener('click', () => {
270
+ if (document.activeElement === kbdProxy) kbdProxy.blur();
271
+ else openKeyboard();
272
+ });
273
+
274
+ function hideHint() { tapHint.classList.add('gone'); }
275
+ setTimeout(hideHint, 9000);
276
+
277
+ // ---------- top bar actions ----------
278
+ document.getElementById('btn-clear').addEventListener('click', () => {
279
+ term.clear();
280
+ term.focus();
281
+ });
282
+ document.getElementById('btn-reconnect').addEventListener('click', () => {
283
+ try { wantClose = true; ws && ws.close(); } catch {}
284
+ term.writeln('\x1b[33m● reconnecting…\x1b[0m');
285
+ setTimeout(connect, 300);
286
+ });
287
+
288
+ // ---------- search (addon-search) ----------
289
+ document.getElementById('btn-search').addEventListener('click', () => {
290
+ searchbar.hidden = !searchbar.hidden;
291
+ if (!searchbar.hidden) searchInput.focus();
292
+ else searchAddon.clearDecorations();
293
+ });
294
+ document.getElementById('search-close').addEventListener('click', () => {
295
+ searchbar.hidden = true;
296
+ searchAddon.clearDecorations();
297
+ term.focus();
298
+ });
299
+ function runSearch() {
300
+ const q = searchInput.value;
301
+ if (!q) { searchAddon.clearDecorations(); return; }
302
+ searchAddon.findNext(q, { incremental: true });
303
+ }
304
+ searchInput.addEventListener('input', runSearch);
305
+ searchInput.addEventListener('keydown', (e) => {
306
+ if (e.key === 'Enter') {
307
+ if (e.shiftKey) searchAddon.findPrevious(searchInput.value);
308
+ else searchAddon.findNext(searchInput.value);
309
+ }
310
+ if (e.key === 'Escape') document.getElementById('search-close').click();
311
+ });
312
+ document.getElementById('search-next').addEventListener('click', () => searchAddon.findNext(searchInput.value));
313
+ document.getElementById('search-prev').addEventListener('click', () => searchAddon.findPrevious(searchInput.value));
314
+
315
+ // ---------- resize ----------
316
+ function refit() {
317
+ safeFit();
318
+ pushResize();
319
+ }
320
+ new ResizeObserver(() => refit()).observe(termEl);
321
+ window.addEventListener('orientationchange', () => setTimeout(refit, 250));
322
+ window.addEventListener('resize', () => refit());
323
+ // mobile URL bar show/hide changes dvh; ResizeObserver covers it, this is belt & braces
324
+ if (window.visualViewport) {
325
+ window.visualViewport.addEventListener('resize', () => refit());
326
+ }
327
+ // fit once fonts/layout settle
328
+ setTimeout(refit, 100);
329
+ setTimeout(refit, 600);
330
+
331
+ connect();
332
+ })();
package/server.js ADDED
@@ -0,0 +1,130 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer } from 'ws';
4
+ import * as pty from 'node-pty';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import os from 'os';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ const app = express();
13
+ const server = createServer(app);
14
+ const wss = new WebSocketServer({ server, path: '/pty' });
15
+
16
+ const PORT = process.env.PORT || 3000;
17
+
18
+ // ---- static frontend ----
19
+ app.use(express.static(path.join(__dirname, 'public')));
20
+
21
+ // Serve every installed xterm.js bundle locally (nothing loaded from CDN,
22
+ // so "nothing gets remains" — all packages are installed AND used).
23
+ // core: /xterm/xterm.js + /xterm/xterm.css
24
+ // addons: /xterm/addon-*.js
25
+ app.use(
26
+ '/xterm/xterm.css',
27
+ express.static(path.join(__dirname, 'node_modules/@xterm/xterm/css/xterm.css'))
28
+ );
29
+ for (const addon of [
30
+ 'addon-attach',
31
+ 'addon-canvas',
32
+ 'addon-fit',
33
+ 'addon-image',
34
+ 'addon-progress',
35
+ 'addon-search',
36
+ 'addon-serialize',
37
+ 'addon-unicode11',
38
+ 'addon-web-links',
39
+ 'addon-webgl',
40
+ ]) {
41
+ app.use(
42
+ `/xterm/${addon}.js`,
43
+ express.static(path.join(__dirname, `node_modules/@xterm/${addon}/lib/${addon}.js`))
44
+ );
45
+ }
46
+ app.use(
47
+ '/xterm/xterm.js',
48
+ express.static(path.join(__dirname, 'node_modules/@xterm/xterm/lib/xterm.js'))
49
+ );
50
+
51
+ app.get('/health', (_req, res) => res.json({ ok: true }));
52
+
53
+ // ---- pty handling ----
54
+ function spawnShell(cols = 80, rows = 30) {
55
+ const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : 'bash');
56
+ return pty.spawn(shell, [], {
57
+ name: 'xterm-256color',
58
+ cols,
59
+ rows,
60
+ cwd: process.env.HOME || process.cwd(),
61
+ env: {
62
+ ...process.env,
63
+ TERM: 'xterm-256color',
64
+ COLORTERM: 'truecolor',
65
+ },
66
+ });
67
+ }
68
+
69
+ wss.on('connection', (ws) => {
70
+ const ptyProc = spawnShell();
71
+ console.log(`[+] pty started pid=${ptyProc.pid}`);
72
+
73
+ // pty -> browser
74
+ const onData = (data) => {
75
+ if (ws.readyState === ws.OPEN) ws.send(data);
76
+ };
77
+ ptyProc.onData(onData);
78
+ ptyProc.onExit(({ exitCode, signal }) => {
79
+ if (ws.readyState === ws.OPEN) {
80
+ ws.send(`\r\n\x1b[31m[process exited code=${exitCode} signal=${signal}]\x1b[0m\r\n`);
81
+ ws.close();
82
+ }
83
+ });
84
+
85
+ // browser -> pty. Accepts raw strings (legacy) or JSON:
86
+ // {"type":"input","data":"ls\r"} | {"type":"resize","cols":80,"rows":24}
87
+ ws.on('message', (msg) => {
88
+ const text = msg.toString();
89
+ try {
90
+ const parsed = JSON.parse(text);
91
+ if (parsed && typeof parsed === 'object' && parsed.type) {
92
+ if (parsed.type === 'input' && typeof parsed.data === 'string') {
93
+ ptyProc.write(parsed.data);
94
+ } else if (parsed.type === 'resize') {
95
+ const cols = Math.max(20, Math.min(300, parseInt(parsed.cols, 10) || 80));
96
+ const rows = Math.max(5, Math.min(100, parseInt(parsed.rows, 10) || 30));
97
+ ptyProc.resize(cols, rows);
98
+ } else if (parsed.type === 'ping' && ws.readyState === ws.OPEN) {
99
+ ws.send(JSON.stringify({ type: 'pong' }));
100
+ }
101
+ return;
102
+ }
103
+ } catch {
104
+ // not JSON -> treat as raw input
105
+ }
106
+ ptyProc.write(text);
107
+ });
108
+
109
+ ws.on('close', () => {
110
+ console.log(`[-] ws closed, killing pty pid=${ptyProc.pid}`);
111
+ try {
112
+ ptyProc.kill();
113
+ } catch {
114
+ /* already dead */
115
+ }
116
+ });
117
+ ws.on('error', () => {
118
+ try {
119
+ ptyProc.kill();
120
+ } catch {
121
+ /* noop */
122
+ }
123
+ });
124
+ });
125
+
126
+ server.listen(PORT, () => {
127
+ if (process.env.MYC_QUIET) return; // `myc` CLI prints only the URL itself
128
+ console.log(`Mobile terminal listening on http://localhost:${PORT}`);
129
+ console.log('Open it on your phone (same network / forwarded port) and tap the terminal.');
130
+ });