feat(v0.4): multi-device discovery + share link (frontend)
- tabs.ts: on open, syncLiveSessions() fetches /live-sessions and adds a tab for each host session not already open → any device sees & mirrors everything running on the host. Empty storage + no live sessions → one fresh tab. activeSessionId() + openSession(id) helpers. - share.ts: 🔗 toolbar button → QR + <origin>/?join=<id> for the active session. - main.ts: ?join=<id> on load opens/focuses that shared session, then strips the param. Mount share button (toolbar: 🔍 ⚙ ▦ 🕘 ⌨ 🔗 📱). - style.css: #sharemodal reuses the QR card/backdrop. Verified in-browser: fresh device auto-discovered the host session and replayed its scrollback; two concurrent clients (browser + ws) both received live output (clientCount=2); share modal shows the join URL/QR.
This commit is contained in:
@@ -19,6 +19,7 @@ import { mountSettings, loadSettings, type Settings } from './settings.js'
|
|||||||
import { mountDashboard } from './dashboard.js'
|
import { mountDashboard } from './dashboard.js'
|
||||||
import { mountHistory } from './history.js'
|
import { mountHistory } from './history.js'
|
||||||
import { mountShortcuts } from './shortcuts.js'
|
import { mountShortcuts } from './shortcuts.js'
|
||||||
|
import { mountShareSession } from './share.js'
|
||||||
|
|
||||||
const paneHost = document.getElementById('term')
|
const paneHost = document.getElementById('term')
|
||||||
const tabs = document.getElementById('tabs')
|
const tabs = document.getElementById('tabs')
|
||||||
@@ -29,6 +30,14 @@ if (!toolbar) throw new Error('#toolbar element not found in DOM')
|
|||||||
|
|
||||||
const app = new TabApp(paneHost, tabs)
|
const app = new TabApp(paneHost, tabs)
|
||||||
|
|
||||||
|
// v0.4: a share link (?join=<id>) opens/focuses that shared session, then we
|
||||||
|
// strip the param so a refresh doesn't keep re-opening it.
|
||||||
|
const joinId = new URLSearchParams(location.search).get('join')
|
||||||
|
if (joinId) {
|
||||||
|
app.openSession(joinId)
|
||||||
|
history.replaceState(null, '', location.pathname)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply saved theme/font (M3) to the restored tabs.
|
// Apply saved theme/font (M3) to the restored tabs.
|
||||||
let settings: Settings = loadSettings()
|
let settings: Settings = loadSettings()
|
||||||
app.applySettings(settings)
|
app.applySettings(settings)
|
||||||
@@ -57,6 +66,7 @@ mountHistory(toolbar, {
|
|||||||
resume: (cwd, id) => app.newTabForResume(cwd, id),
|
resume: (cwd, id) => app.newTabForResume(cwd, id),
|
||||||
})
|
})
|
||||||
mountShortcuts(toolbar)
|
mountShortcuts(toolbar)
|
||||||
|
mountShareSession(toolbar, () => app.activeSessionId())
|
||||||
mountQrConnect(toolbar)
|
mountQrConnect(toolbar)
|
||||||
|
|
||||||
// PWA: register the service worker (installable + offline shell, M4).
|
// PWA: register the service worker (installable + offline shell, M4).
|
||||||
|
|||||||
72
public/share.ts
Normal file
72
public/share.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* public/share.ts — "share this session" (v0.4 multi-device mirror).
|
||||||
|
*
|
||||||
|
* A 🔗 toolbar button shows a QR + URL of the form `<origin>/?join=<sessionId>`
|
||||||
|
* for the ACTIVE tab. Opening that link on another device on the same network
|
||||||
|
* joins the same live terminal (mirror: both see output, either can type).
|
||||||
|
* Reuses the .qr-* card styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
export function mountShareSession(toolbar: HTMLElement, getActiveId: () => string | null): void {
|
||||||
|
const modal = document.createElement('div')
|
||||||
|
modal.id = 'sharemodal'
|
||||||
|
modal.className = '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 = 'Share this session'
|
||||||
|
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 hide = (): void => {
|
||||||
|
modal.style.display = 'none'
|
||||||
|
}
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) hide()
|
||||||
|
})
|
||||||
|
close.addEventListener('click', hide)
|
||||||
|
|
||||||
|
const open = (): void => {
|
||||||
|
const id = getActiveId()
|
||||||
|
if (!id) {
|
||||||
|
// The active tab hasn't attached yet — no id to share.
|
||||||
|
urlEl.textContent = ''
|
||||||
|
note.textContent = 'This tab is still connecting — try again in a second.'
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
modal.style.display = 'flex'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = `${location.origin}/?join=${id}`
|
||||||
|
urlEl.textContent = url
|
||||||
|
void QRCode.toCanvas(canvas, url, { 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) so the code works on other devices.'
|
||||||
|
: 'Scan on a device on the same network to mirror this session.'
|
||||||
|
modal.style.display = 'flex'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = document.createElement('button')
|
||||||
|
toggle.className = 'toolbtn'
|
||||||
|
toggle.textContent = '🔗'
|
||||||
|
toggle.title = 'Share this session (mirror it on another device)'
|
||||||
|
toggle.setAttribute('aria-label', 'Share this session')
|
||||||
|
toggle.addEventListener('click', open)
|
||||||
|
toolbar.appendChild(toggle)
|
||||||
|
}
|
||||||
@@ -368,8 +368,9 @@ body {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* QR modal */
|
/* QR + share modals */
|
||||||
#qrmodal {
|
#qrmodal,
|
||||||
|
#sharemodal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ export class TabApp {
|
|||||||
} catch {
|
} catch {
|
||||||
legacy = null
|
legacy = null
|
||||||
}
|
}
|
||||||
stored = [{ id: legacy, title: null }]
|
// v0.2.x single-session migration only; otherwise stay empty and let
|
||||||
|
// syncLiveSessions() decide (host sessions, or a fresh tab as fallback).
|
||||||
|
if (legacy) stored = [{ id: legacy, title: null }]
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of stored) this.addEntry(s.id, s.title)
|
for (const s of stored) this.addEntry(s.id, s.title)
|
||||||
@@ -146,7 +148,71 @@ export class TabApp {
|
|||||||
}
|
}
|
||||||
if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) active = 0
|
if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) active = 0
|
||||||
this.rebuild()
|
this.rebuild()
|
||||||
this.activate(active)
|
if (this.tabs.length > 0) this.activate(active)
|
||||||
|
|
||||||
|
// v0.4: discover the host's running sessions and show them as tabs too.
|
||||||
|
void this.syncLiveSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.4 multi-device: fetch the host's currently-running sessions and add a tab
|
||||||
|
* for each one not already open, so opening the app on any device shows (and
|
||||||
|
* joins/mirrors) everything running on the host. Falls back to one fresh tab
|
||||||
|
* when nothing is stored and nothing is live.
|
||||||
|
*/
|
||||||
|
private async syncLiveSessions(): Promise<void> {
|
||||||
|
let live: Array<{ id: string }> = []
|
||||||
|
try {
|
||||||
|
const res = await fetch('/live-sessions')
|
||||||
|
const data: unknown = await res.json()
|
||||||
|
if (Array.isArray(data)) live = data as Array<{ id: string }>
|
||||||
|
} catch {
|
||||||
|
live = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = new Set(
|
||||||
|
this.tabs.map((t) => t.session.id).filter((x): x is string => typeof x === 'string'),
|
||||||
|
)
|
||||||
|
let added = false
|
||||||
|
for (const info of live) {
|
||||||
|
if (typeof info.id === 'string' && !open.has(info.id)) {
|
||||||
|
this.addEntry(info.id, null)
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing restored and nothing live → start one fresh session.
|
||||||
|
if (this.tabs.length === 0) {
|
||||||
|
this.addEntry(null, null)
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
if (!added) return
|
||||||
|
|
||||||
|
this.rebuild()
|
||||||
|
const idx =
|
||||||
|
this.activeIndex >= 0 && this.activeIndex < this.tabs.length ? this.activeIndex : 0
|
||||||
|
this.activeIndex = idx
|
||||||
|
this.tabs.forEach((t, i) => (i === idx ? t.session.show() : t.session.hide()))
|
||||||
|
this.tabs.forEach((t) => this.refreshTab(t))
|
||||||
|
this.updateApprovalBar()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active tab's server session id (null until it has attached). */
|
||||||
|
activeSessionId(): string | null {
|
||||||
|
return this.tabs[this.activeIndex]?.session.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus the tab for `sessionId`, or open one that joins it (share-link target). */
|
||||||
|
openSession(sessionId: string): void {
|
||||||
|
const existing = this.tabs.findIndex((t) => t.session.id === sessionId)
|
||||||
|
if (existing >= 0) {
|
||||||
|
this.activate(existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.addEntry(sessionId, null)
|
||||||
|
this.persist()
|
||||||
|
this.rebuild()
|
||||||
|
this.activate(this.tabs.length - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── tab lifecycle ───────────────────────────────────────────── */
|
/* ── tab lifecycle ───────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user