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)),
+ )
+})