ai-or-die 0.1.67 → 0.1.69
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 +12 -7
- package/bin/ai-or-die.js +13 -6
- package/bin/supervisor.js +11 -1
- package/package.json +1 -1
- package/src/public/app.js +173 -6
- package/src/public/components/bottom-nav.css +1 -1
- package/src/public/components/buttons.css +4 -2
- package/src/public/components/menus.css +1 -1
- package/src/public/components/safe-area.css +68 -0
- package/src/public/extra-keys.js +6 -6
- package/src/public/generic-drop-handler.js +35 -0
- package/src/public/icon-144.png +0 -0
- package/src/public/icon-16.png +0 -0
- package/src/public/icon-180.png +0 -0
- package/src/public/icon-192.png +0 -0
- package/src/public/icon-32.png +0 -0
- package/src/public/icon-512.png +0 -0
- package/src/public/image-handler.js +54 -0
- package/src/public/index.html +26 -3
- package/src/public/manifest.json +1 -1
- package/src/public/mobile.css +26 -4
- package/src/public/tokens.css +11 -0
- package/src/public/voice-handler.js +1 -1
- package/src/server.js +11 -29
- package/src/utils/file-utils.js +16 -2
package/README.md
CHANGED
|
@@ -112,9 +112,12 @@ Download from [Releases](https://github.com/animeshkundu/ai-or-die/releases) —
|
|
|
112
112
|
## Usage
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
# Default —
|
|
115
|
+
# Default — prints the URL (with secure token); does NOT auto-open a browser
|
|
116
116
|
ai-or-die
|
|
117
117
|
|
|
118
|
+
# Open the browser automatically on start
|
|
119
|
+
ai-or-die --open
|
|
120
|
+
|
|
118
121
|
# Custom port
|
|
119
122
|
ai-or-die --port 8080
|
|
120
123
|
|
|
@@ -146,11 +149,11 @@ ai-or-die --stt
|
|
|
146
149
|
| `--disable-auth` | Disable authentication | `false` |
|
|
147
150
|
| `--tunnel` | Enable Microsoft Dev Tunnel | `false` |
|
|
148
151
|
| `--tunnel-allow-anonymous` | Allow anonymous tunnel access | `false` |
|
|
149
|
-
| `--https` | Enable HTTPS | `false` |
|
|
152
|
+
| `--https` | Enable HTTPS (auto-generates a self-signed cert if `--cert`/`--key` not given) | `false` |
|
|
150
153
|
| `--cert <path>` | SSL certificate file | |
|
|
151
154
|
| `--key <path>` | SSL private key file | |
|
|
152
155
|
| `--dev` | Verbose logging | `false` |
|
|
153
|
-
| `--
|
|
156
|
+
| `--open` | Open the browser on start (never on supervised restart) | `false` |
|
|
154
157
|
| `--plan <type>` | Subscription plan (`pro`, `max5`, `max20`) | `max20` |
|
|
155
158
|
| `--stt` | Enable local speech-to-text | `false` |
|
|
156
159
|
| `--stt-endpoint <url>` | External STT endpoint (OpenAI-compatible) | |
|
|
@@ -190,11 +193,13 @@ ai-or-die is an installable progressive web app. Open Settings → Install in th
|
|
|
190
193
|
|
|
191
194
|
**Browser support**: Chrome / Edge / Samsung Internet support one-tap install. Firefox desktop does not have native install — use the browser menu to pin the tab. Safari iOS uses Share → Add to Home Screen.
|
|
192
195
|
|
|
193
|
-
**Installing on LAN devices**: Browsers refuse to install PWAs over an HTTPS origin whose certificate isn't trusted by the device.
|
|
196
|
+
**Installing on LAN devices**: Browsers refuse to install PWAs over an HTTPS origin whose certificate isn't trusted by the device, and public CAs (Let's Encrypt etc.) won't issue certs for a LAN IP or `localhost`. The `--https` self-signed cert therefore triggers a browser warning, and the in-app Install panel will say *"Not available in this browser."* on a LAN IP. Ways to get an installable origin:
|
|
197
|
+
|
|
198
|
+
1. **On the host machine itself** — just open **`http://localhost:<port>`**. `localhost` is a secure context, so the PWA installs with **no cert and no setup** (no `--https` needed).
|
|
199
|
+
2. **On other devices** — use **`--tunnel`** *(recommended)*: Microsoft Dev Tunnels gives a public URL with a real publicly-trusted cert, so any device installs the PWA with **no per-device setup and no admin**.
|
|
200
|
+
3. **Bring your own trusted cert** via `--cert` / `--key` (e.g. a cert for a hostname your devices already trust).
|
|
194
201
|
|
|
195
|
-
|
|
196
|
-
2. **Trust the self-signed cert** on each device — copy `~/.ai-or-die/certs/server.cert` to the device and install it as a trusted root in the OS certificate store.
|
|
197
|
-
3. **Provide a CA-signed cert** via `--cert` / `--key`. [mkcert](https://github.com/FiloSottile/mkcert) is a low-friction option for development.
|
|
202
|
+
See [docs/history/pwa-install-lan-self-signed-cert.md](docs/history/pwa-install-lan-self-signed-cert.md) for the underlying browser-policy reasoning.
|
|
198
203
|
|
|
199
204
|
See [docs/history/pwa-install-lan-self-signed-cert.md](docs/history/pwa-install-lan-self-signed-cert.md) for the device-by-device procedure and the underlying browser-policy reasoning.
|
|
200
205
|
|
package/bin/ai-or-die.js
CHANGED
|
@@ -20,7 +20,7 @@ program
|
|
|
20
20
|
.description('ai-or-die — Universal AI coding terminal')
|
|
21
21
|
.version(require('../package.json').version)
|
|
22
22
|
.option('-p, --port <number>', 'port to run the server on', '7777')
|
|
23
|
-
.option('--
|
|
23
|
+
.option('--open', 'open the browser on start (default: off; never on supervised restart)')
|
|
24
24
|
.option('--auth <token>', 'authentication token for secure access')
|
|
25
25
|
.option('--disable-auth', 'disable authentication (not recommended for production)')
|
|
26
26
|
.option('--https', 'enable HTTPS (auto-generates self-signed cert if --cert/--key not provided)')
|
|
@@ -38,8 +38,11 @@ program
|
|
|
38
38
|
.option('--stt', 'enable local speech-to-text (downloads ~670MB Parakeet V3 model on first use)')
|
|
39
39
|
.option('--stt-endpoint <url>', 'use external STT endpoint (OpenAI-compatible)')
|
|
40
40
|
.option('--stt-model-dir <path>', 'custom directory for STT model files')
|
|
41
|
-
.option('--stt-threads <number>', 'CPU threads for STT inference (default: auto, max 4)')
|
|
42
|
-
|
|
41
|
+
.option('--stt-threads <number>', 'CPU threads for STT inference (default: auto, max 4)');
|
|
42
|
+
|
|
43
|
+
// Auto-open is OFF by default and opt-in via --open. Legacy callers may still pass
|
|
44
|
+
// --no-open (the old opt-out flag); filter it out so it parses harmlessly as a no-op.
|
|
45
|
+
program.parse(process.argv.filter((arg) => arg !== '--no-open'));
|
|
43
46
|
|
|
44
47
|
const options = program.opts();
|
|
45
48
|
|
|
@@ -131,7 +134,11 @@ async function main() {
|
|
|
131
134
|
console.log(' For LAN access, restart with \x1b[1m--https\x1b[0m or \x1b[1m--tunnel\x1b[0m.');
|
|
132
135
|
}
|
|
133
136
|
|
|
134
|
-
// Dev tunnel or browser open
|
|
137
|
+
// Dev tunnel or browser open.
|
|
138
|
+
// Auto-open only when explicitly requested (--open) AND this is the first launch,
|
|
139
|
+
// never on a supervised restart (the supervisor sets AOD_SUPERVISOR_RESTART on respawn),
|
|
140
|
+
// so crash/memory restarts don't spawn a new browser tab each time.
|
|
141
|
+
const shouldOpen = !!options.open && !process.env.AOD_SUPERVISOR_RESTART;
|
|
135
142
|
let tunnel = null;
|
|
136
143
|
if (options.tunnel) {
|
|
137
144
|
const { TunnelManager } = require('../src/tunnel-manager');
|
|
@@ -141,12 +148,12 @@ async function main() {
|
|
|
141
148
|
dev: options.dev,
|
|
142
149
|
onUrl: (tunnelUrl) => {
|
|
143
150
|
console.log(`\n \x1b[1m\x1b[32mTunnel ready:\x1b[0m \x1b[1m\x1b[4m${tunnelUrl}\x1b[0m\n`);
|
|
144
|
-
if (open &&
|
|
151
|
+
if (open && shouldOpen) open(tunnelUrl).catch(() => {});
|
|
145
152
|
}
|
|
146
153
|
});
|
|
147
154
|
app.setTunnelManager(tunnel);
|
|
148
155
|
await tunnel.start();
|
|
149
|
-
} else if (
|
|
156
|
+
} else if (shouldOpen) {
|
|
150
157
|
try { if (open) await open(url); } catch (error) {
|
|
151
158
|
console.warn(' Could not automatically open browser:', error.message);
|
|
152
159
|
}
|
package/bin/supervisor.js
CHANGED
|
@@ -45,6 +45,7 @@ const forwardedArgs = process.argv.slice(2);
|
|
|
45
45
|
|
|
46
46
|
let child = null;
|
|
47
47
|
let shuttingDown = false;
|
|
48
|
+
let spawnCount = 0;
|
|
48
49
|
let crashTimestamps = [];
|
|
49
50
|
let pendingRestartTimer = null;
|
|
50
51
|
|
|
@@ -110,9 +111,18 @@ function startServer() {
|
|
|
110
111
|
pendingRestartTimer = null;
|
|
111
112
|
const nodeArgs = ['--expose-gc', serverScript, ...forwardedArgs];
|
|
112
113
|
|
|
114
|
+
// Mark every spawn after the first as a supervised restart, so the child suppresses
|
|
115
|
+
// browser auto-open (--open) on crash/memory restarts and only opens on first launch.
|
|
116
|
+
const isRestart = spawnCount > 0;
|
|
117
|
+
spawnCount += 1;
|
|
118
|
+
|
|
113
119
|
child = spawn(process.execPath, nodeArgs, {
|
|
114
120
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
115
|
-
env: {
|
|
121
|
+
env: {
|
|
122
|
+
...process.env,
|
|
123
|
+
SUPERVISED: '1',
|
|
124
|
+
...(isRestart ? { AOD_SUPERVISOR_RESTART: '1' } : {})
|
|
125
|
+
}
|
|
116
126
|
});
|
|
117
127
|
|
|
118
128
|
// Flush a queued supervisor_warning into the new child's IPC channel.
|
package/package.json
CHANGED
package/src/public/app.js
CHANGED
|
@@ -194,6 +194,7 @@ class ClaudeCodeWebInterface {
|
|
|
194
194
|
this.setupTerminal();
|
|
195
195
|
this._setupExtraKeys();
|
|
196
196
|
this._setupOrientationHandler();
|
|
197
|
+
this._setupPwaStandaloneListener();
|
|
197
198
|
this.setupUI();
|
|
198
199
|
if (this.voiceInputConfig) this.setupVoiceInput();
|
|
199
200
|
this.setupPlanDetector();
|
|
@@ -691,7 +692,12 @@ class ClaudeCodeWebInterface {
|
|
|
691
692
|
caption: imageData.caption || ''
|
|
692
693
|
});
|
|
693
694
|
}
|
|
694
|
-
}
|
|
695
|
+
},
|
|
696
|
+
// Non-image files pasted from a file manager (clipboardData
|
|
697
|
+
// carries File objects) — route through the generic pipeline.
|
|
698
|
+
// Fires only AFTER the image branch declines, so image paste
|
|
699
|
+
// precedence is unchanged.
|
|
700
|
+
onFilesPaste: (files) => this._attachFiles(files)
|
|
695
701
|
}
|
|
696
702
|
);
|
|
697
703
|
}
|
|
@@ -827,6 +833,7 @@ class ClaudeCodeWebInterface {
|
|
|
827
833
|
const handleOrientationChange = () => {
|
|
828
834
|
setTimeout(() => {
|
|
829
835
|
this.fitTerminal();
|
|
836
|
+
this._polyfillSafeAreaInsets();
|
|
830
837
|
// Re-evaluate keyboard state
|
|
831
838
|
if (window.visualViewport && this._keyboardOpen !== undefined) {
|
|
832
839
|
const heightDiff = window.innerHeight - window.visualViewport.height;
|
|
@@ -1091,9 +1098,20 @@ class ClaudeCodeWebInterface {
|
|
|
1091
1098
|
}, 3000);
|
|
1092
1099
|
};
|
|
1093
1100
|
|
|
1094
|
-
// Attach
|
|
1101
|
+
// Attach File button (id is historical: attachImageBtn). Opens a picker
|
|
1102
|
+
// for ANY file type — images go through the preview flow, other files
|
|
1103
|
+
// upload to .claude-attachments/ and inject `@<path>`.
|
|
1095
1104
|
const attachBtn = document.getElementById('attachImageBtn');
|
|
1096
|
-
if (attachBtn && window.
|
|
1105
|
+
if (attachBtn && window.genericDropHandler
|
|
1106
|
+
&& typeof window.genericDropHandler.triggerFilePicker === 'function') {
|
|
1107
|
+
attachBtn.addEventListener('click', () => {
|
|
1108
|
+
window.genericDropHandler.triggerFilePicker(
|
|
1109
|
+
(files) => this._attachFiles(files),
|
|
1110
|
+
{ multiple: true }
|
|
1111
|
+
);
|
|
1112
|
+
});
|
|
1113
|
+
} else if (attachBtn && window.imageHandler) {
|
|
1114
|
+
// Fallback: generic handler unavailable — keep the legacy image-only picker.
|
|
1097
1115
|
attachBtn.addEventListener('click', () => {
|
|
1098
1116
|
window.imageHandler.triggerFilePicker((imageData) => {
|
|
1099
1117
|
this._pendingImageCaption = imageData.caption;
|
|
@@ -3419,7 +3437,7 @@ class ClaudeCodeWebInterface {
|
|
|
3419
3437
|
}
|
|
3420
3438
|
} else {
|
|
3421
3439
|
if (activeTerminal) {
|
|
3422
|
-
activeTerminal.write('\r\n\x1b[33mImage paste requires HTTPS. Use Attach
|
|
3440
|
+
activeTerminal.write('\r\n\x1b[33mImage paste requires HTTPS. Use Attach File instead.\x1b[0m\r\n');
|
|
3423
3441
|
}
|
|
3424
3442
|
}
|
|
3425
3443
|
} catch (err) {
|
|
@@ -3428,8 +3446,15 @@ class ClaudeCodeWebInterface {
|
|
|
3428
3446
|
break;
|
|
3429
3447
|
}
|
|
3430
3448
|
case 'attachImage': {
|
|
3431
|
-
|
|
3432
|
-
if (window.
|
|
3449
|
+
// Generalized to any file type (action id is historical).
|
|
3450
|
+
if (window.genericDropHandler
|
|
3451
|
+
&& typeof window.genericDropHandler.triggerFilePicker === 'function') {
|
|
3452
|
+
window.genericDropHandler.triggerFilePicker(
|
|
3453
|
+
(files) => this._attachFiles(files),
|
|
3454
|
+
{ multiple: true }
|
|
3455
|
+
);
|
|
3456
|
+
} else if (window.imageHandler) {
|
|
3457
|
+
const attachSocket = activeSocket;
|
|
3433
3458
|
window.imageHandler.triggerFilePicker((imageData) => {
|
|
3434
3459
|
this._pendingImageCaption = imageData.caption;
|
|
3435
3460
|
const msg = JSON.stringify({
|
|
@@ -3660,6 +3685,140 @@ class ClaudeCodeWebInterface {
|
|
|
3660
3685
|
|| navigator.standalone === true;
|
|
3661
3686
|
}
|
|
3662
3687
|
|
|
3688
|
+
// Shared attachment router for the non-drop surfaces (attach button,
|
|
3689
|
+
// context-menu, paste). Partitions the selected files: anything that passes
|
|
3690
|
+
// the image allowlist goes through the EXISTING image preview → image_upload
|
|
3691
|
+
// path (unchanged), everything else is routed to the generic drop pipeline
|
|
3692
|
+
// (upload to .claude-attachments/ + `@<path>` injection). Drag-and-drop does
|
|
3693
|
+
// NOT use this — it already partitions internally in generic-drop-handler.
|
|
3694
|
+
_attachFiles(files) {
|
|
3695
|
+
if (!files) return;
|
|
3696
|
+
const list = Array.prototype.slice.call(files);
|
|
3697
|
+
if (!list.length) return;
|
|
3698
|
+
const ih = window.imageHandler;
|
|
3699
|
+
const isImg = (f) => !!(ih && typeof ih.isAcceptedImageType === 'function'
|
|
3700
|
+
&& ih.isAcceptedImageType(f.type));
|
|
3701
|
+
const images = list.filter(isImg);
|
|
3702
|
+
const others = list.filter((f) => !isImg(f));
|
|
3703
|
+
|
|
3704
|
+
// Capture the socket at attach-initiation time. The image preview modal
|
|
3705
|
+
// is async (user-driven); the active session/socket can change while it
|
|
3706
|
+
// is open. Sending on a captured target avoids the upload landing on a
|
|
3707
|
+
// different session (mirrors the context-menu's existing capture).
|
|
3708
|
+
const targetSocket = this.socket;
|
|
3709
|
+
|
|
3710
|
+
// Images: reuse the existing single-preview flow. The modal handles one
|
|
3711
|
+
// image at a time, so if several images are selected we attach the first
|
|
3712
|
+
// and tell the user rather than silently dropping the rest.
|
|
3713
|
+
if (images.length && ih && typeof ih.showImagePreview === 'function') {
|
|
3714
|
+
if (images.length > 1 && window.feedback && typeof window.feedback.info === 'function') {
|
|
3715
|
+
window.feedback.info('Only the first image is attached — attach images one at a time.');
|
|
3716
|
+
}
|
|
3717
|
+
ih.showImagePreview(images[0], (imageData) => {
|
|
3718
|
+
this._pendingImageCaption = imageData.caption;
|
|
3719
|
+
if (targetSocket && targetSocket.readyState === WebSocket.OPEN) {
|
|
3720
|
+
targetSocket.send(JSON.stringify({
|
|
3721
|
+
type: 'image_upload',
|
|
3722
|
+
base64: imageData.base64,
|
|
3723
|
+
mimeType: imageData.mimeType,
|
|
3724
|
+
fileName: imageData.fileName || 'attached-image.png',
|
|
3725
|
+
caption: imageData.caption || ''
|
|
3726
|
+
}));
|
|
3727
|
+
}
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
// Non-images: shared generic pipeline (upload + @path inject).
|
|
3732
|
+
if (others.length && this._genericDropHandler
|
|
3733
|
+
&& typeof this._genericDropHandler.dispatchFiles === 'function') {
|
|
3734
|
+
this._genericDropHandler.dispatchFiles(others);
|
|
3735
|
+
} else if (others.length && window.feedback && typeof window.feedback.error === 'function') {
|
|
3736
|
+
window.feedback.error('File upload is not available right now.');
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
_setupPwaStandaloneListener() {
|
|
3741
|
+
const apply = () => {
|
|
3742
|
+
const standalone = this._isInstalledPWA();
|
|
3743
|
+
document.documentElement.classList.toggle('pwa-standalone', standalone);
|
|
3744
|
+
if (standalone) this._polyfillSafeAreaInsets();
|
|
3745
|
+
};
|
|
3746
|
+
apply();
|
|
3747
|
+
try {
|
|
3748
|
+
const queries = [
|
|
3749
|
+
'(display-mode: standalone)',
|
|
3750
|
+
'(display-mode: fullscreen)',
|
|
3751
|
+
'(display-mode: minimal-ui)',
|
|
3752
|
+
'(display-mode: window-controls-overlay)',
|
|
3753
|
+
];
|
|
3754
|
+
queries.forEach((q) => {
|
|
3755
|
+
const mql = window.matchMedia(q);
|
|
3756
|
+
if (mql.addEventListener) mql.addEventListener('change', apply);
|
|
3757
|
+
else if (mql.addListener) mql.addListener(apply);
|
|
3758
|
+
});
|
|
3759
|
+
} catch (_) { /* ignore */ }
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
_polyfillSafeAreaInsets() {
|
|
3763
|
+
// WebKit bug: env(safe-area-inset-top|bottom) sometimes returns 0px in
|
|
3764
|
+
// iOS PWA standalone on Dynamic Island / notched devices. Probe the
|
|
3765
|
+
// actual env() value per axis; if zero on a notch-class iOS device in
|
|
3766
|
+
// portrait, substitute the known defaults (59px island, 34px home
|
|
3767
|
+
// indicator). Results are exposed as the --safe-area-inset-* variables
|
|
3768
|
+
// that tokens.css (--sa-top/--sa-bottom) and the gated rules consume.
|
|
3769
|
+
if (!document.body) return;
|
|
3770
|
+
|
|
3771
|
+
const root = document.documentElement;
|
|
3772
|
+
|
|
3773
|
+
// Only ever set fake insets in installed-PWA standalone mode. In a
|
|
3774
|
+
// normal browser tab env() is authoritative (and legitimately 0 at the
|
|
3775
|
+
// top), so forcing a fallback there would push content down for no
|
|
3776
|
+
// reason — and the converted `var(--sa-*)` consumers are ungated, so a
|
|
3777
|
+
// stray value WOULD change non-PWA layout. Clear and bail when not PWA.
|
|
3778
|
+
if (!this._isInstalledPWA()) {
|
|
3779
|
+
root.style.removeProperty('--safe-area-inset-top');
|
|
3780
|
+
root.style.removeProperty('--safe-area-inset-bottom');
|
|
3781
|
+
return;
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
const measure = (axis) => {
|
|
3785
|
+
const probe = document.createElement('div');
|
|
3786
|
+
probe.style.cssText = 'position:fixed;left:0;width:1px;pointer-events:none;visibility:hidden;'
|
|
3787
|
+
+ (axis === 'top' ? 'top:0;' : 'bottom:0;')
|
|
3788
|
+
+ 'height:env(safe-area-inset-' + axis + ');';
|
|
3789
|
+
document.body.appendChild(probe);
|
|
3790
|
+
const px = probe.offsetHeight;
|
|
3791
|
+
document.body.removeChild(probe);
|
|
3792
|
+
return px;
|
|
3793
|
+
};
|
|
3794
|
+
|
|
3795
|
+
const isPortrait = window.matchMedia('(orientation: portrait)').matches;
|
|
3796
|
+
// Notch / Dynamic-Island iPhones are tall (aspect > 2); home-button
|
|
3797
|
+
// iPhones (SE, 8) are ~1.78 and iPads ~1.33. Only those tall devices
|
|
3798
|
+
// have a top cutout + bottom home indicator, so only they get the
|
|
3799
|
+
// fallback when env() reports a (buggy) 0. This keeps the SE/iPad from
|
|
3800
|
+
// gaining a phantom inset.
|
|
3801
|
+
const longSide = Math.max(window.innerWidth, window.innerHeight);
|
|
3802
|
+
const shortSide = Math.min(window.innerWidth, window.innerHeight);
|
|
3803
|
+
const tall = shortSide > 0 && (longSide / shortSide) > 2;
|
|
3804
|
+
const notchClass = isPortrait && this._isIOS() && tall;
|
|
3805
|
+
|
|
3806
|
+
const apply = (axis, fallback) => {
|
|
3807
|
+
const measured = measure(axis);
|
|
3808
|
+
const prop = '--safe-area-inset-' + axis;
|
|
3809
|
+
if (measured > 0) {
|
|
3810
|
+
root.style.setProperty(prop, measured + 'px');
|
|
3811
|
+
} else if (notchClass) {
|
|
3812
|
+
root.style.setProperty(prop, fallback);
|
|
3813
|
+
} else {
|
|
3814
|
+
root.style.removeProperty(prop);
|
|
3815
|
+
}
|
|
3816
|
+
};
|
|
3817
|
+
|
|
3818
|
+
apply('top', '59px');
|
|
3819
|
+
apply('bottom', '34px');
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3663
3822
|
_isIOS() {
|
|
3664
3823
|
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
|
3665
3824
|
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
@@ -3756,6 +3915,14 @@ class ClaudeCodeWebInterface {
|
|
|
3756
3915
|
case 'dismissed':
|
|
3757
3916
|
statusText.textContent = 'Install was cancelled. Reload the page to try again.';
|
|
3758
3917
|
break;
|
|
3918
|
+
case 'unavailable':
|
|
3919
|
+
// Chromium-family browser in a secure context, but beforeinstallprompt
|
|
3920
|
+
// hasn't fired yet (engagement-gated, or already consumed). The app can't
|
|
3921
|
+
// trigger install itself, but the browser's own menu can — and the
|
|
3922
|
+
// beforeinstallprompt listener stays active, so this upgrades to 'available'
|
|
3923
|
+
// if the event fires later.
|
|
3924
|
+
statusText.textContent = 'If no install button appears, use your browser menu → "Install this site as an app".';
|
|
3925
|
+
break;
|
|
3759
3926
|
default:
|
|
3760
3927
|
statusText.textContent = 'Not available in this browser.';
|
|
3761
3928
|
break;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
background: var(--surface-secondary);
|
|
9
9
|
border-top: 1px solid var(--border-default);
|
|
10
10
|
padding: 0 8px;
|
|
11
|
-
padding-bottom:
|
|
11
|
+
padding-bottom: var(--sa-bottom);
|
|
12
12
|
z-index: var(--z-sticky);
|
|
13
13
|
align-items: center;
|
|
14
14
|
justify-content: space-around;
|
|
@@ -145,7 +145,9 @@
|
|
|
145
145
|
position: fixed;
|
|
146
146
|
bottom: 80px;
|
|
147
147
|
right: 20px;
|
|
148
|
-
|
|
148
|
+
/* Below the modal layer (--z-modal=400) so this floating control does not
|
|
149
|
+
paint over open dialogs; still above terminal content. */
|
|
150
|
+
z-index: var(--z-sticky);
|
|
149
151
|
display: none;
|
|
150
152
|
flex-direction: column;
|
|
151
153
|
gap: 10px;
|
|
@@ -264,7 +266,7 @@
|
|
|
264
266
|
|
|
265
267
|
@media (max-width: 820px) {
|
|
266
268
|
.install-btn {
|
|
267
|
-
bottom: calc(52px + 20px +
|
|
269
|
+
bottom: calc(52px + 20px + var(--sa-bottom));
|
|
268
270
|
}
|
|
269
271
|
}
|
|
270
272
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* safe-area.css — PWA standalone safe-area insets for fixed/absolute overlays.
|
|
2
|
+
|
|
3
|
+
The Dynamic Island fix in mobile.css covers the in-flow surfaces (tab bar,
|
|
4
|
+
file-browser header, mobile menu). Out-of-flow overlays (modals, toasts,
|
|
5
|
+
banners, bottom bars) are anchored to physical screen edges and inherit none
|
|
6
|
+
of that compensation, so they collide with the iOS Dynamic Island (top) and
|
|
7
|
+
home indicator (bottom) when launched as an installed PWA. This file applies
|
|
8
|
+
the insets to those surfaces, in ONE place.
|
|
9
|
+
|
|
10
|
+
Everything is gated behind `html.pwa-standalone` (set by the early-paint
|
|
11
|
+
script + matchMedia listener in app.js) so normal browser-tab and
|
|
12
|
+
desktop-non-PWA layouts are completely unaffected. Tokens --sa-top/--sa-bottom
|
|
13
|
+
come from tokens.css and are populated by app.js's _polyfillSafeAreaInsets()
|
|
14
|
+
(which works around the WebKit env()===0 bug), falling back to env() then 0.
|
|
15
|
+
|
|
16
|
+
Loaded LAST so these rules win regardless of source order. */
|
|
17
|
+
|
|
18
|
+
/* --- Full-screen modal overlays ---------------------------------------------
|
|
19
|
+
Pad the overlay itself so the flex-centered .modal-content is inset out of
|
|
20
|
+
both the island and the home indicator. box-sizing keeps the overlay
|
|
21
|
+
full-viewport while the padding shrinks the centering box. */
|
|
22
|
+
html.pwa-standalone .settings-modal,
|
|
23
|
+
html.pwa-standalone .session-modal,
|
|
24
|
+
html.pwa-standalone .plan-modal,
|
|
25
|
+
html.pwa-standalone .folder-browser-modal,
|
|
26
|
+
html.pwa-standalone .shortcuts-modal,
|
|
27
|
+
html.pwa-standalone .image-preview-modal,
|
|
28
|
+
html.pwa-standalone .terminal-overlay {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
padding-top: var(--sa-top);
|
|
31
|
+
padding-bottom: var(--sa-bottom);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Clamp modal content to the padded area so a tall modal (e.g. Settings)
|
|
35
|
+
can't overflow back up into the island. dvh tracks the live viewport. */
|
|
36
|
+
html.pwa-standalone .modal-content {
|
|
37
|
+
max-height: calc(100dvh - var(--sa-top) - var(--sa-bottom) - 40px);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Full-height slide-in side panel (file browser). Its header already gets the
|
|
41
|
+
top inset via mobile.css; pad the bottom so the file list clears the home
|
|
42
|
+
indicator. box-sizing keeps the panel viewport-height. */
|
|
43
|
+
html.pwa-standalone .file-browser-panel {
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
padding-bottom: var(--sa-bottom);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* --- Top-anchored transient surfaces ---------------------------------------- */
|
|
49
|
+
html.pwa-standalone .toast-container {
|
|
50
|
+
top: calc(52px + var(--sa-top));
|
|
51
|
+
}
|
|
52
|
+
html.pwa-standalone .banner-base {
|
|
53
|
+
padding-top: calc(8px + var(--sa-top));
|
|
54
|
+
}
|
|
55
|
+
html.pwa-standalone .notif-permission-prompt {
|
|
56
|
+
top: calc(60px + var(--sa-top));
|
|
57
|
+
}
|
|
58
|
+
html.pwa-standalone .fb-find-panel {
|
|
59
|
+
top: calc(80px + var(--sa-top));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* --- Bottom-anchored bars: lift above the home indicator -------------------- */
|
|
63
|
+
html.pwa-standalone .extra-keys-bar {
|
|
64
|
+
bottom: var(--sa-bottom);
|
|
65
|
+
}
|
|
66
|
+
html.pwa-standalone .input-overlay {
|
|
67
|
+
bottom: calc(16px + var(--sa-bottom));
|
|
68
|
+
}
|
package/src/public/extra-keys.js
CHANGED
|
@@ -28,11 +28,11 @@ class ExtraKeys {
|
|
|
28
28
|
{ label: 'End', data: '\x1b[F' },
|
|
29
29
|
{ label: 'PgUp', data: '\x1b[5~' },
|
|
30
30
|
{ label: 'PgDn', data: '\x1b[6~' },
|
|
31
|
-
{ label: '\u2190', data: '\x1b[D', aria: 'Left arrow' },
|
|
32
|
-
{ label: '\u2192', data: '\x1b[C', aria: 'Right arrow' },
|
|
33
|
-
{ label: '\u2191', data: '\x1b[A', aria: 'Up arrow' },
|
|
34
|
-
{ label: '\u2193', data: '\x1b[B', aria: 'Down arrow' },
|
|
35
|
-
{ label: '\u21E9', dismiss: true, aria: 'Dismiss keyboard' },
|
|
31
|
+
{ label: '\u2190', data: '\x1b[D', aria: 'Left arrow', title: 'Left arrow' },
|
|
32
|
+
{ label: '\u2192', data: '\x1b[C', aria: 'Right arrow', title: 'Right arrow' },
|
|
33
|
+
{ label: '\u2191', data: '\x1b[A', aria: 'Up arrow', title: 'Up arrow' },
|
|
34
|
+
{ label: '\u2193', data: '\x1b[B', aria: 'Down arrow', title: 'Down arrow' },
|
|
35
|
+
{ label: '\u21E9', dismiss: true, aria: 'Dismiss keyboard', title: 'Dismiss keyboard' },
|
|
36
36
|
];
|
|
37
37
|
|
|
38
38
|
const row2Keys = [
|
|
@@ -86,13 +86,13 @@ class ExtraKeys {
|
|
|
86
86
|
btn.className = 'extra-key';
|
|
87
87
|
btn.textContent = key.label;
|
|
88
88
|
btn.setAttribute('aria-label', key.aria || key.label);
|
|
89
|
+
if (key.title) btn.setAttribute('title', key.title);
|
|
89
90
|
|
|
90
91
|
if (key.dismiss) {
|
|
91
92
|
btn.classList.add('extra-key-dismiss');
|
|
92
93
|
btn.addEventListener('click', () => this._dismiss());
|
|
93
94
|
} else if (key.handler) {
|
|
94
95
|
btn.classList.add('extra-key-clipboard');
|
|
95
|
-
if (key.title) btn.setAttribute('title', key.title);
|
|
96
96
|
btn.addEventListener('click', () => {
|
|
97
97
|
if (key.handler === 'copy') {
|
|
98
98
|
this._handleCopy();
|
|
@@ -365,15 +365,50 @@
|
|
|
365
365
|
return {
|
|
366
366
|
cancelInFlight: cancelInFlight,
|
|
367
367
|
destroy: destroy,
|
|
368
|
+
// Public entry point so non-drop surfaces (attach button, paste) can
|
|
369
|
+
// route a FileList/array of File objects through the EXACT same pipeline:
|
|
370
|
+
// image/* → onImageDrop, everything else → upload + @path inject, with the
|
|
371
|
+
// same MAX_FILES_PER_DROP cap and bounded-parallel queue.
|
|
372
|
+
dispatchFiles: dispatchDrop,
|
|
368
373
|
};
|
|
369
374
|
}
|
|
370
375
|
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// triggerFilePicker — open a hidden <input type="file"> with NO accept filter
|
|
378
|
+
// (any file type), used by the attach button / context menu. Mirrors
|
|
379
|
+
// image-handler.js's picker minus the image-only constraint. Resolves the
|
|
380
|
+
// selected files via the onFiles callback. Browser-only.
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
function triggerFilePicker(onFiles, opts) {
|
|
383
|
+
if (typeof document === 'undefined') return;
|
|
384
|
+
opts = opts || {};
|
|
385
|
+
var input = document.createElement('input');
|
|
386
|
+
input.type = 'file';
|
|
387
|
+
if (opts.multiple) input.multiple = true;
|
|
388
|
+
input.style.display = 'none';
|
|
389
|
+
|
|
390
|
+
function cleanup() {
|
|
391
|
+
if (input.parentNode) input.parentNode.removeChild(input);
|
|
392
|
+
}
|
|
393
|
+
input.addEventListener('change', function () {
|
|
394
|
+
var files = input.files ? Array.prototype.slice.call(input.files) : [];
|
|
395
|
+
if (files.length && typeof onFiles === 'function') onFiles(files);
|
|
396
|
+
cleanup();
|
|
397
|
+
});
|
|
398
|
+
// Safety net: if the dialog is cancelled, no change fires — sweep the
|
|
399
|
+
// detached node shortly after to avoid leaking it.
|
|
400
|
+
document.body.appendChild(input);
|
|
401
|
+
input.click();
|
|
402
|
+
setTimeout(cleanup, 60000);
|
|
403
|
+
}
|
|
404
|
+
|
|
371
405
|
// ---------------------------------------------------------------------------
|
|
372
406
|
// Exports
|
|
373
407
|
// ---------------------------------------------------------------------------
|
|
374
408
|
|
|
375
409
|
var exportsObj = {
|
|
376
410
|
attachGenericDropHandler: attachGenericDropHandler,
|
|
411
|
+
triggerFilePicker: triggerFilePicker,
|
|
377
412
|
isImageMime: isImageMime,
|
|
378
413
|
sanitizeBasename: sanitizeBasename,
|
|
379
414
|
buildAtPathInjection: buildAtPathInjection,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -115,6 +115,45 @@ function detectImageInClipboard(clipboardData) {
|
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Collect NON-image File objects from a paste clipboard (files copied from a
|
|
120
|
+
* file manager surface as clipboardData.files / items with kind === 'file').
|
|
121
|
+
* Accepted image types are excluded — those go through the image preview flow.
|
|
122
|
+
* Used by onPaste's fall-through so non-image paste reaches the generic upload
|
|
123
|
+
* pipeline. Returns [] when there are no such files (normal text paste).
|
|
124
|
+
*
|
|
125
|
+
* @param {DataTransfer} clipboardData
|
|
126
|
+
* @returns {File[]}
|
|
127
|
+
*/
|
|
128
|
+
function collectNonImageFiles(clipboardData) {
|
|
129
|
+
var out = [];
|
|
130
|
+
if (!clipboardData) return out;
|
|
131
|
+
|
|
132
|
+
if (clipboardData.files && clipboardData.files.length > 0) {
|
|
133
|
+
for (var i = 0; i < clipboardData.files.length; i++) {
|
|
134
|
+
var f = clipboardData.files[i];
|
|
135
|
+
if (f && !isAcceptedImageType(f.type)) out.push(f);
|
|
136
|
+
}
|
|
137
|
+
// `files` is the authoritative FileList when present; `items` mirrors the
|
|
138
|
+
// same files (plus non-file entries), so scanning both would double-count.
|
|
139
|
+
// Fall back to `items` ONLY when `files` yielded nothing — same precedence
|
|
140
|
+
// as detectImageInClipboard above.
|
|
141
|
+
if (out.length) return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (clipboardData.items && clipboardData.items.length > 0) {
|
|
145
|
+
for (var j = 0; j < clipboardData.items.length; j++) {
|
|
146
|
+
var item = clipboardData.items[j];
|
|
147
|
+
if (item.kind === 'file' && !isAcceptedImageType(item.type)) {
|
|
148
|
+
var asFile = item.getAsFile();
|
|
149
|
+
if (asFile) out.push(asFile);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
118
157
|
// ---------------------------------------------------------------------------
|
|
119
158
|
// Blob → base64
|
|
120
159
|
// ---------------------------------------------------------------------------
|
|
@@ -504,6 +543,20 @@ function attachImageHandler(terminal, containerEl, options) {
|
|
|
504
543
|
e.preventDefault();
|
|
505
544
|
e.stopPropagation();
|
|
506
545
|
showImagePreview(image, options.onImageReady);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// No image — if non-image files were pasted (e.g. copied from a file
|
|
549
|
+
// manager) and a handler is wired, route them to the generic pipeline.
|
|
550
|
+
// Purely additive: when there are no such files this is a no-op and the
|
|
551
|
+
// normal text paste proceeds untouched.
|
|
552
|
+
if (typeof options.onFilesPaste === 'function') {
|
|
553
|
+
var nonImageFiles = collectNonImageFiles(e.clipboardData);
|
|
554
|
+
if (nonImageFiles.length) {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
e.stopPropagation();
|
|
557
|
+
options.onFilesPaste(nonImageFiles);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
507
560
|
}
|
|
508
561
|
// No image found — let the normal text paste proceed
|
|
509
562
|
}
|
|
@@ -636,6 +689,7 @@ var imageHandlerExports = {
|
|
|
636
689
|
mimeToExtension: mimeToExtension,
|
|
637
690
|
formatFileSize: formatFileSize,
|
|
638
691
|
detectImageInClipboard: detectImageInClipboard,
|
|
692
|
+
collectNonImageFiles: collectNonImageFiles,
|
|
639
693
|
blobToBase64: blobToBase64,
|
|
640
694
|
showImagePreview: showImagePreview,
|
|
641
695
|
triggerFilePicker: triggerFilePicker,
|
package/src/public/index.html
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
<meta name="format-detection" content="telephone=no">
|
|
17
17
|
|
|
18
18
|
<!-- Web App Manifest -->
|
|
19
|
-
|
|
19
|
+
<!-- crossorigin=use-credentials: send the session cookie with the manifest (and its icon)
|
|
20
|
+
fetches so they are not redirected to an auth page behind a credentialed proxy/tunnel
|
|
21
|
+
(e.g. Microsoft devtunnel), which otherwise 404s the manifest and breaks installability. -->
|
|
22
|
+
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
|
|
20
23
|
|
|
21
24
|
<!-- Icons for various platforms -->
|
|
22
25
|
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
|
@@ -39,6 +42,24 @@
|
|
|
39
42
|
} catch (_) { /* ignore */ }
|
|
40
43
|
})();
|
|
41
44
|
</script>
|
|
45
|
+
|
|
46
|
+
<!-- Detect PWA standalone mode before first paint so safe-area-top rules can apply
|
|
47
|
+
without a layout-shift flash. Class lives on <html> because <body> isn't parsed yet. -->
|
|
48
|
+
<script>
|
|
49
|
+
(function() {
|
|
50
|
+
try {
|
|
51
|
+
var mm = window.matchMedia;
|
|
52
|
+
var isStandalone = (mm && (
|
|
53
|
+
mm('(display-mode: standalone)').matches
|
|
54
|
+
|| mm('(display-mode: fullscreen)').matches
|
|
55
|
+
|| mm('(display-mode: minimal-ui)').matches
|
|
56
|
+
|| mm('(display-mode: window-controls-overlay)').matches
|
|
57
|
+
))
|
|
58
|
+
|| navigator.standalone === true;
|
|
59
|
+
if (isStandalone) document.documentElement.classList.add('pwa-standalone');
|
|
60
|
+
} catch (_) { /* ignore */ }
|
|
61
|
+
})();
|
|
62
|
+
</script>
|
|
42
63
|
<link rel="preload" href="fonts/MesloLGSNerdFont-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
|
43
64
|
<link rel="preload" href="fonts/MesloLGSNerdFont-Bold.woff2" as="font" type="font/woff2" crossorigin>
|
|
44
65
|
<link rel="stylesheet" href="fonts.css">
|
|
@@ -65,6 +86,8 @@
|
|
|
65
86
|
<link rel="stylesheet" href="components/extra-keys.css">
|
|
66
87
|
<link rel="stylesheet" href="mobile.css">
|
|
67
88
|
<link rel="stylesheet" href="style.css">
|
|
89
|
+
<!-- Loaded last: PWA standalone safe-area overrides win over component CSS. -->
|
|
90
|
+
<link rel="stylesheet" href="components/safe-area.css">
|
|
68
91
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
69
92
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
70
93
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
|
@@ -140,7 +163,7 @@
|
|
|
140
163
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
|
141
164
|
</svg>
|
|
142
165
|
</button>
|
|
143
|
-
<button class="tab-attach-image" id="attachImageBtn" title="Attach
|
|
166
|
+
<button class="tab-attach-image" id="attachImageBtn" title="Attach File" aria-label="Attach file">
|
|
144
167
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
145
168
|
stroke="currentColor" stroke-width="2">
|
|
146
169
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
|
@@ -289,7 +312,7 @@
|
|
|
289
312
|
<div class="ctx-sep" role="separator"></div>
|
|
290
313
|
<div class="ctx-item" data-action="attachImage" role="menuitem" tabindex="-1">
|
|
291
314
|
<span class="ctx-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>
|
|
292
|
-
<span>Attach
|
|
315
|
+
<span>Attach File...</span>
|
|
293
316
|
</div>
|
|
294
317
|
<div class="ctx-sep" role="separator"></div>
|
|
295
318
|
<div class="ctx-item" data-action="selectAll" role="menuitem" tabindex="-1">
|
package/src/public/manifest.json
CHANGED
package/src/public/mobile.css
CHANGED
|
@@ -2,17 +2,39 @@
|
|
|
2
2
|
Component-level responsive styles belong in their respective component CSS files.
|
|
3
3
|
This file contains ONLY cross-cutting mobile concerns that span multiple components. */
|
|
4
4
|
|
|
5
|
-
/* Safe area insets for notched devices (iPhone X+, etc.)
|
|
5
|
+
/* Safe area insets for notched devices (iPhone X+, etc.)
|
|
6
|
+
Top-axis padding is gated to PWA standalone mode (html.pwa-standalone) because
|
|
7
|
+
apple-mobile-web-app-status-bar-style="black-translucent" only causes the
|
|
8
|
+
Dynamic Island / status-bar collision when launched from the home screen.
|
|
9
|
+
In normal mobile-Safari/Edge tab mode the browser chrome already occupies that
|
|
10
|
+
space, so adding padding-top there would visibly push content down for no gain.
|
|
11
|
+
The CSS variable --safe-area-inset-top is populated by a JS polyfill in app.js
|
|
12
|
+
to work around the known WebKit bug where env(safe-area-inset-top) sometimes
|
|
13
|
+
returns 0px in iOS PWA standalone; env() is kept as the secondary fallback. */
|
|
6
14
|
@supports (padding: env(safe-area-inset-top)) {
|
|
7
15
|
.session-tabs-bar {
|
|
8
16
|
padding-left: max(10px, env(safe-area-inset-left));
|
|
9
17
|
padding-right: max(10px, env(safe-area-inset-right));
|
|
10
18
|
}
|
|
19
|
+
html.pwa-standalone .session-tabs-bar {
|
|
20
|
+
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
21
|
+
}
|
|
22
|
+
html.pwa-standalone .file-browser-panel .file-browser-header {
|
|
23
|
+
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
24
|
+
}
|
|
25
|
+
/* Baseline (any env()-capable browser) preserves the long-standing menu
|
|
26
|
+
inset; in tab mode env(safe-area-inset-top) is 0 so this is a no-op,
|
|
27
|
+
but it guards against any non-PWA notched case the standalone rule
|
|
28
|
+
below wouldn't cover. The standalone rule wins by specificity and
|
|
29
|
+
routes through the JS-populated --safe-area-inset-top variable. */
|
|
11
30
|
.mobile-menu {
|
|
12
31
|
padding-top: env(safe-area-inset-top);
|
|
13
32
|
}
|
|
33
|
+
html.pwa-standalone .mobile-menu {
|
|
34
|
+
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
35
|
+
}
|
|
14
36
|
.mode-switcher {
|
|
15
|
-
bottom: max(80px, calc(80px +
|
|
37
|
+
bottom: max(80px, calc(80px + var(--sa-bottom)));
|
|
16
38
|
right: max(20px, env(safe-area-inset-right));
|
|
17
39
|
}
|
|
18
40
|
}
|
|
@@ -183,12 +205,12 @@
|
|
|
183
205
|
|
|
184
206
|
/* Account for bottom nav height */
|
|
185
207
|
#app {
|
|
186
|
-
padding-bottom: calc(52px +
|
|
208
|
+
padding-bottom: calc(52px + var(--sa-bottom));
|
|
187
209
|
}
|
|
188
210
|
|
|
189
211
|
/* Move mode switcher above bottom nav */
|
|
190
212
|
.mode-switcher {
|
|
191
|
-
bottom: calc(62px +
|
|
213
|
+
bottom: calc(62px + var(--sa-bottom));
|
|
192
214
|
}
|
|
193
215
|
}
|
|
194
216
|
|
package/src/public/tokens.css
CHANGED
|
@@ -127,6 +127,17 @@
|
|
|
127
127
|
--z-input-overlay: 550;
|
|
128
128
|
--z-auth: 9999;
|
|
129
129
|
--z-max: 9999;
|
|
130
|
+
|
|
131
|
+
/* --- Safe-area insets (PWA standalone / notched devices) ---
|
|
132
|
+
Consumed by fixed/absolute overlays so they clear the iOS Dynamic Island
|
|
133
|
+
(top) and home indicator (bottom). The JS polyfill in app.js sets
|
|
134
|
+
--safe-area-inset-top/-bottom as VARIABLES to work around the WebKit bug
|
|
135
|
+
where env(safe-area-inset-*) returns 0px in iOS standalone; we fall back to
|
|
136
|
+
env() then 0. In normal (non-standalone) mode these resolve to 0, and the
|
|
137
|
+
consuming rules are gated behind html.pwa-standalone anyway, so there is no
|
|
138
|
+
effect on browser-tab or desktop-non-PWA layouts. */
|
|
139
|
+
--sa-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
|
140
|
+
--sa-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
/* ============================================================
|
|
@@ -135,7 +135,7 @@ SpeechRecognitionRecorder.prototype.start = function () {
|
|
|
135
135
|
var recognition = new SpeechRecognitionCtor();
|
|
136
136
|
recognition.continuous = true;
|
|
137
137
|
recognition.interimResults = false;
|
|
138
|
-
recognition.lang = 'en-
|
|
138
|
+
recognition.lang = 'en-IN';
|
|
139
139
|
|
|
140
140
|
self._recognition = recognition;
|
|
141
141
|
self._resultText = '';
|
package/src/server.js
CHANGED
|
@@ -913,34 +913,11 @@ class ClaudeCodeWebServer {
|
|
|
913
913
|
fallthrough: false,
|
|
914
914
|
}));
|
|
915
915
|
|
|
916
|
-
// PWA
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
const r = s * 0.1;
|
|
922
|
-
const svg = `
|
|
923
|
-
<svg width="${s}" height="${s}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
924
|
-
<rect width="100" height="100" fill="#1a1a1a" rx="${r > 1 ? 10 : 0}"/>
|
|
925
|
-
<path d="M50 18 C28 18 18 32 18 48 C18 58 24 66 32 70 L32 74 C32 78 36 80 40 78 L44 76"
|
|
926
|
-
fill="none" stroke="#ff6b00" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
|
927
|
-
<path d="M50 18 C72 18 82 32 82 48 C82 58 76 66 68 70 L68 74 C68 78 64 80 60 78 L56 76"
|
|
928
|
-
fill="none" stroke="#ff6b00" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
|
929
|
-
<circle cx="38" cy="38" r="3" fill="#ff6b00" opacity="0.5"/>
|
|
930
|
-
<circle cx="62" cy="38" r="3" fill="#ff6b00" opacity="0.5"/>
|
|
931
|
-
<circle cx="50" cy="28" r="2.5" fill="#ff6b00" opacity="0.4"/>
|
|
932
|
-
<text x="50" y="62" text-anchor="middle" dominant-baseline="middle"
|
|
933
|
-
font-family="'JetBrains Mono',monospace" font-size="28" font-weight="700" fill="#ff6b00">
|
|
934
|
-
>_
|
|
935
|
-
</text>
|
|
936
|
-
</svg>
|
|
937
|
-
`;
|
|
938
|
-
const svgBuffer = Buffer.from(svg);
|
|
939
|
-
res.setHeader('Content-Type', 'image/svg+xml');
|
|
940
|
-
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
941
|
-
res.send(svgBuffer);
|
|
942
|
-
});
|
|
943
|
-
});
|
|
916
|
+
// PWA icons are static PNG files in src/public/ (icon-<size>.png), served by
|
|
917
|
+
// express.static above (or the SEA asset middleware). They are real PNGs so the
|
|
918
|
+
// served Content-Type matches the manifest's declared `image/png` — required for
|
|
919
|
+
// Chromium installability and iOS apple-touch-icon. Regenerate with
|
|
920
|
+
// `node scripts/generate-pwa-icons.js`.
|
|
944
921
|
|
|
945
922
|
// PWA Screenshot routes - serve pre-built branded screenshots
|
|
946
923
|
this.app.get('/screenshot-wide.png', (req, res) => {
|
|
@@ -3022,7 +2999,11 @@ class ClaudeCodeWebServer {
|
|
|
3022
2999
|
cert = fs.readFileSync(this.certFile);
|
|
3023
3000
|
key = fs.readFileSync(this.keyFile);
|
|
3024
3001
|
} else {
|
|
3025
|
-
// Auto-generate self-signed cert for LAN use
|
|
3002
|
+
// Auto-generate self-signed cert for LAN use.
|
|
3003
|
+
// Note: a self-signed cert is not a trusted "secure context", so on a LAN IP the
|
|
3004
|
+
// browser warns and the PWA can't be installed. For an installable trusted origin
|
|
3005
|
+
// use --tunnel (public cert, no admin) or access the app on http://localhost (a
|
|
3006
|
+
// secure context with no cert needed).
|
|
3026
3007
|
const { ensureCert } = require('./utils/self-signed-cert');
|
|
3027
3008
|
const certInfo = ensureCert();
|
|
3028
3009
|
cert = certInfo.cert;
|
|
@@ -3034,6 +3015,7 @@ class ClaudeCodeWebServer {
|
|
|
3034
3015
|
}
|
|
3035
3016
|
console.log(` Cached at: ${certInfo.certPath}`);
|
|
3036
3017
|
console.log(' Browsers will show a security warning on first visit.');
|
|
3018
|
+
console.log(' For a trusted, installable origin use \x1b[1m--tunnel\x1b[0m.');
|
|
3037
3019
|
}
|
|
3038
3020
|
server = https.createServer({ cert, key }, this.app);
|
|
3039
3021
|
} else {
|
package/src/utils/file-utils.js
CHANGED
|
@@ -132,19 +132,33 @@ function sanitizeFileName(name) {
|
|
|
132
132
|
throw new Error('File name is required');
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// Strip path separators, null bytes, and control characters
|
|
135
|
+
// Strip path separators, null bytes, and control characters. Windows-first
|
|
136
|
+
// (primary deployment target): also strip the characters NTFS forbids in a
|
|
137
|
+
// file name — `< > : " | ? *`. Stripping ':' additionally neutralizes NTFS
|
|
138
|
+
// Alternate Data Streams (`name:stream`) and any stray drive-letter colon.
|
|
136
139
|
let sanitized = name
|
|
137
140
|
.replace(/[/\\]/g, '')
|
|
141
|
+
.replace(/[<>:"|?*]/g, '')
|
|
138
142
|
.replace(/\0/g, '')
|
|
139
143
|
.replace(/[\x01-\x1f\x7f]/g, '');
|
|
140
144
|
|
|
141
|
-
// Trim whitespace and dots from start/end
|
|
145
|
+
// Trim whitespace and dots from start/end (also covers Windows' rule that a
|
|
146
|
+
// file name may not end in a dot or space).
|
|
142
147
|
sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, '');
|
|
143
148
|
|
|
144
149
|
if (!sanitized) {
|
|
145
150
|
throw new Error('File name is empty after sanitization');
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
// Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) are
|
|
154
|
+
// illegal as a file's base name regardless of extension — `COM1.tar.gz` is
|
|
155
|
+
// still the COM1 device. Windows matches on the stem before the first dot,
|
|
156
|
+
// case-insensitively. Prefix with '_' to neutralize while staying readable.
|
|
157
|
+
const stem = sanitized.split('.')[0];
|
|
158
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) {
|
|
159
|
+
sanitized = '_' + sanitized;
|
|
160
|
+
}
|
|
161
|
+
|
|
148
162
|
if (sanitized.length > 255) {
|
|
149
163
|
const ext = path.extname(sanitized);
|
|
150
164
|
sanitized = sanitized.slice(0, 255 - ext.length) + ext;
|