From fbc218c57feed2afb220e92841ba4953e0b6580a Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 18:32:57 +0200 Subject: [PATCH] feat(v0.3): QR connect (M5) + PWA installable (M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M5: 📱 toolbar button → modal with a client-side QR of location.origin (shareable when opened via the LAN IP) + localhost tip - M4: manifest.webmanifest + icon.svg + sw.js (network-first, never intercepts /term or /hook) + registration in main.ts; apple-touch + theme-color meta - Browser-verified: QR canvas renders, SW 'controlled', manifest linked, no console errors. 199 tests green, build ok. --- public/icon.svg | 7 +++++ public/index.html | 7 ++++- public/main.ts | 11 +++++++ public/manifest.webmanifest | 19 +++++++++++ public/qr.ts | 63 +++++++++++++++++++++++++++++++++++++ public/style.css | 46 +++++++++++++++++++++++++++ public/sw.js | 28 +++++++++++++++++ 7 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 public/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/qr.ts create mode 100644 public/sw.js diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..d0af5c0 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/index.html b/public/index.html index d896716..df675fe 100644 --- a/public/index.html +++ b/public/index.html @@ -2,8 +2,13 @@ - + Web Terminal + + + + + diff --git a/public/main.ts b/public/main.ts index 748996c..88a0348 100644 --- a/public/main.ts +++ b/public/main.ts @@ -14,6 +14,7 @@ import '@xterm/xterm/css/xterm.css' import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' import { mountSearch } from './search.js' +import { mountQrConnect } from './qr.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -32,3 +33,13 @@ mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), clear: () => app.clearActiveSearch(), }) +mountQrConnect(toolbar) + +// PWA: register the service worker (installable + offline shell, M4). +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + void navigator.serviceWorker.register('./sw.js').catch(() => { + // SW registration is best-effort; the app works without it. + }) + }) +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..d84b8c0 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Web Terminal", + "short_name": "WebTerm", + "description": "Drive your shell / Claude Code from any device on your LAN.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "any", + "background_color": "#1a1a1a", + "theme_color": "#1a1a1a", + "icons": [ + { + "src": "./icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/public/qr.ts b/public/qr.ts new file mode 100644 index 0000000..7995cea --- /dev/null +++ b/public/qr.ts @@ -0,0 +1,63 @@ +/** + * public/qr.ts — "open on another device" QR modal (M5). + * + * Renders a QR of the current origin (client-side, no server call). When the + * page is opened via the host's LAN IP, the QR is directly shareable to a + * phone/tablet on the same network. + */ + +import QRCode from 'qrcode' + +export function mountQrConnect(toolbar: HTMLElement): void { + const modal = document.createElement('div') + modal.id = 'qrmodal' + modal.style.display = 'none' + + const card = document.createElement('div') + card.className = 'qr-card' + + const title = document.createElement('div') + title.className = 'qr-title' + title.textContent = 'Open on another device' + + const canvas = document.createElement('canvas') + const urlEl = document.createElement('div') + urlEl.className = 'qr-url' + const note = document.createElement('div') + note.className = 'qr-note' + + const close = document.createElement('button') + close.className = 'qr-close' + close.textContent = 'Close' + + card.append(title, canvas, urlEl, note, close) + modal.appendChild(card) + document.body.appendChild(modal) + + const open = (): void => { + const origin = location.origin + urlEl.textContent = origin + void QRCode.toCanvas(canvas, origin, { width: 220, margin: 1 }) + const host = location.hostname + note.textContent = + host === 'localhost' || host === '127.0.0.1' + ? 'Tip: open this page via your LAN IP (not localhost) for a shareable code.' + : 'Scan on a device on the same network.' + modal.style.display = 'flex' + } + const hide = (): void => { + modal.style.display = 'none' + } + modal.addEventListener('click', (e) => { + if (e.target === modal) hide() + }) + close.addEventListener('click', hide) + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = '📱' + toggle.title = 'Connect another device (QR)' + toggle.setAttribute('aria-label', 'Connect another device') + toggle.addEventListener('click', open) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index e9389c6..bef601a 100644 --- a/public/style.css +++ b/public/style.css @@ -107,6 +107,52 @@ html, body { background: #555; } +/* QR connect modal (M5) */ +#qrmodal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); +} +.qr-card { + background: #fff; + color: #111; + border-radius: 12px; + padding: 20px; + text-align: center; + max-width: 90vw; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.qr-title { + font-weight: 600; + margin-bottom: 12px; +} +.qr-url { + margin-top: 10px; + font-size: 13px; + color: #333; + word-break: break-all; +} +.qr-note { + margin-top: 8px; + font-size: 12px; + color: #666; + max-width: 240px; +} +.qr-close { + margin-top: 14px; + padding: 6px 18px; + border: none; + border-radius: 6px; + background: #1a1a1a; + color: #fff; + cursor: pointer; + font: inherit; +} + .tab { display: flex; align-items: center; diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d9032cd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,28 @@ +/** + * public/sw.js — minimal service worker (M4: PWA installability + offline shell). + * + * Network-first so we never serve a stale build while online; falls back to the + * cache when offline. Never intercepts the WebSocket (/term) or hook endpoints. + */ + +const CACHE = 'webterm-v1' + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) + +self.addEventListener('fetch', (e) => { + const req = e.request + if (req.method !== 'GET') return + const url = new URL(req.url) + if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks + + e.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone() + caches.open(CACHE).then((c) => c.put(req, copy)) + return res + }) + .catch(() => caches.match(req)), + ) +})