feat(v0.3): QR connect (M5) + PWA installable (M4)
- 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.
This commit is contained in:
7
public/icon.svg
Normal file
7
public/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="96" fill="#1a1a1a"/>
|
||||
<g fill="none" stroke="#3fb950" stroke-width="34" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="150,196 226,256 150,316"/>
|
||||
<line x1="268" y1="320" x2="372" y2="320"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
@@ -2,8 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Web Terminal</title>
|
||||
<link rel="manifest" href="./manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1a1a1a">
|
||||
<link rel="apple-touch-icon" href="./icon.svg">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<link rel="stylesheet" href="./build/main.css">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
19
public/manifest.webmanifest
Normal file
19
public/manifest.webmanifest
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
public/qr.ts
Normal file
63
public/qr.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
28
public/sw.js
Normal file
28
public/sw.js
Normal file
@@ -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)),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user