- Colors: #1a1a1a bg, #c4c4c4 fg, gray muted, white-10% borders - Fonts: IBM Plex Sans body, Inconsolata mono - No rounded corners, no shadows, no styled buttons - Underline as interaction indicator (decoration-transparent → currentColor) - Bottom status bar with » separator (vim/gitdot style) - Setup screen: centered, minimal, monospace - Actions as text links: host · connect · unregister - Side panel for device info (# section headers) - Full-viewport video, minimal top bar
- commit
- e0af5fd3eed43df8c68c44f4f74b9dc5e9e7227f
- parent
- 1091f68d6e32a5041b1a6684eb83c5ce0765eb2e
- author
- dami etoile <dami.etoile@gmail.com>
| M | src/ | 870 | +++++++++++++++++++----------- |
@@ -3,222 +3,454 @@
33 <head>44 <meta charset="UTF-8" />55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />6- <title>Keyhome</title>6+ <title>keyhome</title>7+ <link rel="preconnect" href="https://fonts.googleapis.com" />8+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />9+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inconsolata:wght@400;500;600&display=swap" rel="stylesheet" />710 <style>811 :root {9- --bg: #0d1117;10- --surface: #161b22;11- --border: #30363d;12- --text: #c9d1d9;13- --text-dim: #8b949e;14- --accent: #58a6ff;15- --green: #3fb950;16- --red: #f85149;17- --yellow: #d29922;18- --mono: "SF Mono", "Cascadia Code", "Fira Code", monospace;12+ --background: #1a1a1a;13+ --foreground: #c4c4c4;14+ --muted: #2a2a2a;15+ --muted-fg: #808080;16+ --accent: #2a2a2a;17+ --border: rgba(255,255,255,0.1);18+ --border-strong: rgba(255,255,255,0.15);19+ --destructive: #ff6568;20+ --green: #4ade80;21+ --yellow: #fbbf24;22+ --mono: "Inconsolata", "SF Mono", ui-monospace, monospace;23+ --sans: "IBM Plex Sans", -apple-system, system-ui, sans-serif;1924 }2025 * { margin: 0; padding: 0; box-sizing: border-box; }26+ html, body { height: 100%; }2127 body {22- background: var(--bg);23- color: var(--text);24- font-family: -apple-system, system-ui, sans-serif;25- font-size: 14px;26- padding: 24px;27- }28- h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; }29- .subtitle { color: var(--text-dim); font-size: 13px; margin-bottom: 24px; }30- .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; max-width: 1100px; }31- .card {32- background: var(--surface);33- border: 1px solid var(--border);34- border-radius: 8px;35- padding: 16px;36- }37- .card h2 {38- font-size: 14px; font-weight: 600; color: var(--text-dim);39- text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px;40- }41- .row {42- display: flex; justify-content: space-between; align-items: center;43- padding: 6px 0; border-bottom: 1px solid var(--border);44- }45- .row:last-child { border-bottom: none; }46- .row .label { color: var(--text-dim); }47- .row .value { font-family: var(--mono); font-size: 13px; }48- .status-badge {49- display: inline-block; padding: 2px 8px; border-radius: 12px;50- font-size: 12px; font-weight: 500;51- }52- .status-ok { background: rgba(63,185,80,0.15); color: var(--green); }53- .status-err { background: rgba(248,81,73,0.15); color: var(--red); }54- .status-pending { background: rgba(210,153,34,0.15); color: var(--yellow); }55- .btn {56- background: var(--surface); border: 1px solid var(--border);57- color: var(--text); padding: 8px 16px; border-radius: 6px;58- font-size: 13px; cursor: pointer; transition: border-color 0.2s;59- }60- .btn:hover { border-color: var(--accent); }61- .btn-primary {62- background: rgba(88,166,255,0.1); border-color: rgba(88,166,255,0.3);63- color: var(--accent);64- }65- .btn-bar { display: flex; gap: 8px; margin-top: 12px; }66- .input {67- width: 100%; background: var(--bg); border: 1px solid var(--border);68- color: var(--text); padding: 8px 12px; border-radius: 6px;69- font-family: var(--mono); font-size: 12px; margin-top: 8px;70- }71- .input:focus { outline: none; border-color: var(--accent); }72- .error-msg { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }73- .error-msg.visible { display: block; }74- .spinner {75- display: inline-block; width: 14px; height: 14px;76- border: 2px solid var(--border); border-top-color: var(--accent);77- border-radius: 50%; animation: spin 0.8s linear infinite;78- }79- @keyframes spin { to { transform: rotate(360deg); } }80-81- /* Frame viewer */82- .viewer-card { grid-column: 1 / -1; }83- .viewer-wrap {84- margin-top: 12px; border: 1px solid var(--border); border-radius: 6px;85- overflow: hidden; background: #000; position: relative;86- min-height: 300px; display: flex; align-items: center; justify-content: center;28+ background: var(--background);29+ color: var(--foreground);30+ font-family: var(--sans);31+ font-size: 13px;32+ display: flex;33+ flex-direction: column;34+ overflow: hidden;35+ -webkit-font-smoothing: antialiased;36+ }37+38+ /* ─── Top bar (minimal, no navbar feel) ────────────────────────────── */39+ .topbar {40+ display: flex;41+ align-items: center;42+ gap: 12px;43+ padding: 8px 16px;44+ border-bottom: 1px solid var(--border);45+ flex-shrink: 0;46+ }47+ .brand {48+ font-family: var(--mono);49+ font-size: 13px;50+ font-weight: 500;51+ color: var(--foreground);52+ }53+ .sep { color: var(--muted-fg); }54+ .status-line {55+ font-family: var(--mono);56+ font-size: 12px;57+ color: var(--muted-fg);58+ display: flex;59+ align-items: center;60+ gap: 6px;61+ }62+ .status-line .dot {63+ width: 6px; height: 6px; border-radius: 50%;64+ background: var(--muted-fg);65+ }66+ .status-line .dot.ok { background: var(--green); }67+ .status-line .dot.err { background: var(--destructive); }68+ .status-line .dot.pending { background: var(--yellow); }69+ .status-line.ok { color: var(--green); }70+ .status-line.err { color: var(--destructive); }71+ .status-line.pending { color: var(--yellow); }72+ .topbar-right {73+ margin-left: auto;74+ display: flex;75+ align-items: center;76+ gap: 12px;77+ }78+ .node-id {79+ font-family: var(--mono);80+ font-size: 11px;81+ color: var(--muted-fg);82+ max-width: 180px;83+ overflow: hidden;84+ text-overflow: ellipsis;85+ white-space: nowrap;86+ }87+ .link-btn {88+ font-family: var(--mono);89+ font-size: 12px;90+ color: var(--muted-fg);91+ cursor: pointer;92+ text-decoration: underline;93+ text-decoration-color: transparent;94+ background: none;95+ border: none;96+ padding: 0;97+ transition: color 0.1s, text-decoration-color 0.1s;98+ }99+ .link-btn:hover {100+ color: var(--foreground);101+ text-decoration-color: currentColor;102+ }103+104+ /* ─── Main (video) ────────────────────────────────────────────────── */105+ .main {106+ flex: 1;107+ display: flex;108+ align-items: center;109+ justify-content: center;110+ position: relative;111+ min-height: 0;112+ background: #0d0d0d;87113 }88114 #frame-canvas {89- max-width: 100%; max-height: 500px; display: none;90- cursor: crosshair;115+ max-width: 100%;116+ max-height: 100%;117+ object-fit: contain;118+ display: none;91119 }92- #frame-canvas.control-active { cursor: default; }93- .viewer-placeholder {94- color: var(--text-dim); font-size: 13px; padding: 60px 20px; text-align: center;120+ .placeholder {121+ text-align: center;122+ color: var(--muted-fg);123+ font-family: var(--mono);124+ font-size: 13px;125+ padding: 40px;95126 }96- .viewer-stats {97- position: absolute; top: 8px; right: 8px;98- background: rgba(0,0,0,0.7); color: var(--green);99- font-family: var(--mono); font-size: 11px; padding: 4px 8px;100- border-radius: 4px; display: none;127+ .placeholder.hidden { display: none; }128+129+ /* Stats overlay */130+ .stats {131+ position: absolute;132+ top: 8px; right: 8px;133+ font-family: var(--mono);134+ font-size: 11px;135+ color: var(--green);136+ background: rgba(0,0,0,0.5);137+ padding: 2px 8px;138+ display: none;139+ pointer-events: none;101140 }102- .viewer-stats.visible { display: block; }141+ .stats.visible { display: block; }142+143+ /* Control overlay */103144 .control-bar {104- display: flex; align-items: center; gap: 12px; margin-top: 12px;145+ position: absolute;146+ bottom: 8px; left: 50%;147+ transform: translateX(-50%);148+ display: none;149+ gap: 4px;150+ background: rgba(0,0,0,0.5);151+ padding: 4px;105152 }153+ .control-bar.visible { display: flex; }106154 .control-toggle {107- display: flex; align-items: center; gap: 8px; cursor: pointer;108- padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border);109- background: var(--surface); font-size: 13px;110- transition: all 0.2s;155+ font-family: var(--mono);156+ font-size: 12px;157+ color: var(--muted-fg);158+ cursor: pointer;159+ padding: 4px 10px;160+ text-decoration: underline;161+ text-decoration-color: transparent;162+ transition: color 0.1s, text-decoration-color 0.1s;163+ }164+ .control-toggle:hover {165+ color: var(--foreground);166+ text-decoration-color: currentColor;111167 }112- .control-toggle:hover { border-color: var(--accent); }113168 .control-toggle.active {114- background: rgba(63,185,80,0.1); border-color: rgba(63,185,80,0.3);115169 color: var(--green);170+ text-decoration-color: currentColor;171+ }172+173+ /* ─── Bottom bar (status bar — vim/gitdot style) ───────────────────── */174+ .bottombar {175+ display: flex;176+ align-items: center;177+ gap: 8px;178+ padding: 6px 16px;179+ border-top: 1px solid var(--border);180+ background: var(--background);181+ flex-shrink: 0;182+ font-family: var(--mono);183+ font-size: 12px;184+ }185+ .bottombar .prompt { color: var(--foreground); }186+ .bottombar .sep { color: var(--muted-fg); }187+ .bottombar .action {188+ color: var(--muted-fg);189+ cursor: pointer;190+ text-decoration: underline;191+ text-decoration-color: transparent;192+ transition: color 0.1s, text-decoration-color 0.1s;193+ }194+ .bottombar .action:hover {195+ color: var(--foreground);196+ text-decoration-color: currentColor;197+ }198+ .bottombar .action.primary { color: var(--foreground); }199+ .bottombar .spacer { flex: 1; }200+ .pin-group {201+ display: flex;202+ align-items: center;203+ gap: 6px;204+ }205+ .pin-input {206+ width: 80px;207+ background: transparent;208+ border: none;209+ border-bottom: 1px solid var(--border-strong);210+ color: var(--foreground);211+ padding: 2px 4px;212+ font-family: var(--mono);213+ font-size: 12px;214+ text-align: center;215+ }216+ .pin-input:focus {217+ outline: none;218+ border-bottom-color: var(--foreground);219+ }220+ .pin-label {221+ font-size: 11px;222+ color: var(--muted-fg);223+ }224+225+ /* ─── Setup screen ────────────────────────────────────────────────── */226+ .setup {227+ position: fixed;228+ inset: 0;229+ z-index: 100;230+ display: flex;231+ align-items: center;232+ justify-content: center;233+ background: var(--background);234+ }235+ .setup.hidden { display: none; }236+ .setup-content {237+ text-align: center;238+ max-width: 360px;239+ padding: 40px;240+ }241+ .setup-content h1 {242+ font-family: var(--mono);243+ font-size: 16px;244+ font-weight: 500;245+ color: var(--foreground);246+ margin-bottom: 12px;247+ }248+ .setup-content p {249+ font-size: 13px;250+ color: var(--muted-fg);251+ line-height: 1.6;252+ margin-bottom: 24px;253+ }254+ .setup-form {255+ display: flex;256+ gap: 8px;257+ justify-content: center;258+ align-items: center;259+ }260+ .setup-input {261+ width: 120px;262+ text-align: center;263+ background: transparent;264+ border: none;265+ border-bottom: 1px solid var(--border-strong);266+ color: var(--foreground);267+ padding: 6px 8px;268+ font-family: var(--mono);269+ font-size: 14px;270+ }271+ .setup-input:focus {272+ outline: none;273+ border-bottom-color: var(--foreground);116274 }117- .control-toggle .dot {118- width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);275+ .setup-btn {276+ font-family: var(--mono);277+ font-size: 13px;278+ color: var(--muted-fg);279+ cursor: pointer;280+ background: none;281+ border: none;282+ padding: 6px 12px;283+ text-decoration: underline;284+ text-decoration-color: transparent;285+ transition: color 0.1s, text-decoration-color 0.1s;119286 }120- .control-toggle.active .dot { background: var(--green); }121- .control-hint {122- color: var(--text-dim); font-size: 11px;287+ .setup-btn:hover {288+ color: var(--foreground);289+ text-decoration-color: currentColor;123290 }124- .titan-hint {125- color: var(--text-dim); font-size: 11px; margin-top: 8px;291+ .setup-btn:disabled {292+ opacity: 0.4;293+ cursor: not-allowed;294+ }295+ .setup-status {296+ font-family: var(--mono);297+ font-size: 12px;298+ color: var(--muted-fg);299+ margin-top: 16px;300+ min-height: 16px;301+ }302+ .setup-status.pending { color: var(--yellow); }303+ .setup-status.err { color: var(--destructive); }304+ .setup-status.ok { color: var(--green); }305+306+ /* ─── Side panel ──────────────────────────────────────────────────── */307+ .panel-overlay {308+ position: fixed;309+ inset: 0;310+ background: rgba(0,0,0,0.4);311+ z-index: 90;312+ display: none;313+ }314+ .panel-overlay.visible { display: block; }315+ .panel {316+ position: fixed;317+ top: 0; right: 0; bottom: 0;318+ width: 300px;319+ background: var(--background);320+ border-left: 1px solid var(--border);321+ transform: translateX(100%);322+ transition: transform 0.15s;323+ z-index: 91;324+ display: flex;325+ flex-direction: column;326+ }327+ .panel.visible { transform: translateX(0); }328+ .panel-header {329+ display: flex;330+ align-items: center;331+ justify-content: space-between;332+ padding: 10px 16px;333+ border-bottom: 1px solid var(--border);334+ }335+ .panel-header h2 {336+ font-family: var(--mono);337+ font-size: 13px;338+ font-weight: 500;339+ }340+ .panel-body {341+ flex: 1;342+ overflow-y: auto;343+ padding: 12px 16px;344+ }345+ .panel-section {346+ margin-bottom: 24px;347+ }348+ .panel-section h3 {349+ font-family: var(--mono);350+ font-size: 11px;351+ font-weight: 500;352+ color: var(--muted-fg);353+ margin-bottom: 8px;354+ }355+ .info-row {356+ display: flex;357+ justify-content: space-between;358+ align-items: center;359+ padding: 4px 0;360+ border-bottom: 1px solid var(--border);361+ }362+ .info-row:last-child { border-bottom: none; }363+ .info-label {364+ font-size: 12px;365+ color: var(--muted-fg);366+ }367+ .info-value {368+ font-family: var(--mono);369+ font-size: 11px;370+ color: var(--foreground);126371 }127372 </style>128373 </head>129374 <body>130- <h1>Keyhome</h1>131- <p class="subtitle">FIDO2-secured remote desktop via Iroh — no address sharing, just tap your key</p>132-133- <div class="grid">134- <!-- FIDO2 Device Card -->135- <div class="card">136- <h2>Security Key</h2>137- <div class="row">138- <span class="label">Status</span>139- <span id="fido-status" class="status-badge status-pending">Not scanned</span>140- </div>141- <div class="row">142- <span class="label">Device</span>143- <span id="fido-product" class="value">—</span>144- </div>145- <div class="row">146- <span class="label">VID:PID</span>147- <span id="fido-vidpid" class="value">—</span>148- </div>149- <div class="row">150- <span class="label">CTAP versions</span>151- <span id="fido-versions" class="value">—</span>152- </div>153- <div class="row">154- <span class="label">Extensions</span>155- <span id="fido-extensions" class="value">—</span>156- </div>157- <div class="row">158- <span class="label">PIN retries</span>159- <span id="fido-pin" class="value">—</span>160- </div>161- <div class="btn-bar">162- <button class="btn btn-primary" onclick="scanFido()">Scan for key</button>375+ <!-- Setup screen -->376+ <div class="setup hidden" id="setup">377+ <div class="setup-content">378+ <h1>register key</h1>379+ <p>Plug in your Titan key. This one-time step derives your machine's identity and stores it in the OS keyring. After this, hosting starts without the key.</p>380+ <div class="setup-form">381+ <input class="setup-input" id="setup-pin" type="password" placeholder="PIN" autocomplete="off" />382+ <button class="setup-btn" id="setup-btn" onclick="setupRegister()">register</button>163383 </div>384+ <div class="setup-status" id="setup-status"></div>164385 </div>386+ </div>165387 166- <!-- Host Card -->167- <div class="card">168- <h2>Host (Server)</h2>169- <div class="row">170- <span class="label">Registration</span>171- <span id="reg-status" class="status-badge status-pending">Not registered</span>172- </div>173- <div class="row">174- <span class="label">Status</span>175- <span id="host-status" class="status-badge status-pending">Not started</span>176- </div>177- <div class="row">178- <span class="label">Node ID</span>179- <span id="host-id" class="value">—</span>180- </div>181- <div class="btn-bar">182- <button class="btn btn-primary" onclick="registerKey()">Register Key</button>183- <button class="btn" onclick="startHost()">Start Hosting</button>184- <button class="btn" onclick="unregister()" style="margin-left:auto; font-size:11px; opacity:0.6;">Unregister</button>185- </div>186- <div class="titan-hint">187- Register: one-time Titan tap, stores identity in OS keyring.188- Start Hosting: no key needed.189- </div>388+ <!-- Top bar -->389+ <div class="topbar">390+ <span class="brand">keyhome</span>391+ <span class="sep">·</span>392+ <div class="status-line" id="status-line">393+ <span class="dot" id="status-dot"></span>394+ <span id="status-text">idle</span>395+ </div>396+ <div class="topbar-right">397+ <span class="node-id" id="node-id-text"></span>398+ <button class="link-btn" onclick="togglePanel()">info</button>190399 </div>400+ </div>191401 192- <!-- Client Card -->193- <div class="card" style="grid-column: 1 / -1;">194- <h2>Connect to Host (Client)</h2>195- <p style="color: var(--text-dim); font-size: 12px; margin-bottom: 8px;">196- Enter your Titan PIN and tap the key. The host's identity is derived from197- the same key — no address needed.198- </p>199- <div class="btn-bar" style="margin-top: 0;">200- <input class="input" id="pin-input" type="password" placeholder="Titan PIN" style="flex: 1; margin-top: 0;" />201- <button class="btn btn-primary" onclick="connectHost()">Connect via Titan</button>402+ <!-- Main / Video -->403+ <div class="main">404+ <canvas id="frame-canvas"></canvas>405+ <div class="placeholder" id="placeholder">406+ no stream407+ </div>408+ <div class="stats" id="stats"></div>409+ <div class="control-bar" id="control-bar">410+ <div class="control-toggle" id="control-toggle" onclick="toggleControl()">411+ take control202412 </div>203- <div id="connect-error" class="error-msg"></div>204413 </div>414+ </div>415+416+ <!-- Bottom bar (status bar) -->417+ <div class="bottombar">418+ <span class="prompt" id="bottom-action">keyhome</span>419+ <span class="sep">»</span>420+ <span class="action primary" id="action-host" onclick="startHost()">host</span>421+ <span class="sep">·</span>422+ <span class="action" id="action-unreg" onclick="unregister()" style="color: var(--destructive); opacity: 0.6;">unregister</span>423+ <div class="spacer"></div>424+ <div class="pin-group">425+ <span class="pin-label">pin</span>426+ <input class="pin-input" id="pin-input" type="password" placeholder="····" autocomplete="off" />427+ </div>428+ <span class="sep">·</span>429+ <span class="action primary" onclick="connectHost()">connect</span>430+ </div>205431 206- <!-- Frame Viewer + Input Control -->207- <div class="card viewer-card">208- <h2>Remote Screen <span id="viewer-stats" class="viewer-stats"></span></h2>209- <div class="viewer-wrap">210- <canvas id="frame-canvas"></canvas>211- <div id="viewer-placeholder" class="viewer-placeholder">212- No stream connected. Start hosting on one machine, then connect from another213- with the same Titan key.214- </div>432+ <!-- Side panel -->433+ <div class="panel-overlay" id="panel-overlay" onclick="togglePanel()"></div>434+ <div class="panel" id="panel">435+ <div class="panel-header">436+ <h2>device</h2>437+ <button class="link-btn" onclick="togglePanel()">close</button>438+ </div>439+ <div class="panel-body">440+ <div class="panel-section">441+ <h3># security key</h3>442+ <div class="info-row"><span class="info-label">status</span><span class="info-value" id="fido-status">—</span></div>443+ <div class="info-row"><span class="info-label">product</span><span class="info-value" id="fido-product">—</span></div>444+ <div class="info-row"><span class="info-label">vid:pid</span><span class="info-value" id="fido-vidpid">—</span></div>445+ <div class="info-row"><span class="info-label">ctap</span><span class="info-value" id="fido-versions">—</span></div>446+ <div class="info-row"><span class="info-label">extensions</span><span class="info-value" id="fido-extensions">—</span></div>447+ <div class="info-row"><span class="info-label">pin retries</span><span class="info-value" id="fido-pin">—</span></div>448+ <button class="link-btn" style="margin-top: 8px;" onclick="scanFido()">scan</button>215449 </div>216- <div class="control-bar">217- <div class="control-toggle" id="control-toggle" onclick="toggleControl()">218- <span class="dot"></span>219- <span id="control-label">Take Control</span>220- </div>221- <span class="control-hint" id="control-hint">Connect to a host first</span>450+ <div class="panel-section">451+ <h3># registration</h3>452+ <div class="info-row"><span class="info-label">status</span><span class="info-value" id="reg-status">—</span></div>453+ <div class="info-row"><span class="info-label">node id</span><span class="info-value" id="reg-node-id">—</span></div>222454 </div>223455 </div>224456 </div>@@ -226,168 +458,188 @@
226458 <script>227459 let controlMode = false;228460 let connected = false;461+ let hosting = false;462+ let registered = false;463+ let frameWidth = 0;464+ let frameHeight = 0;229465 230466 const invoke = (...args) => window.__TAURI__.core.invoke(...args);231467 const listen = (...args) => window.__TAURI__.event.listen(...args);232468 233- // ─── FIDO2 ─────────────────────────────────────────────────────────234- async function scanFido() {235- const status = document.getElementById('fido-status');236- status.className = 'status-badge status-pending';237- status.innerHTML = '<span class="spinner"></span> Scanning...';238- try {239- const info = await invoke('fido_device_info');240- if (info.found && !info.error) {241- status.className = 'status-badge status-ok';242- status.textContent = 'Connected';243- document.getElementById('fido-product').textContent = info.product;244- document.getElementById('fido-vidpid').textContent =245- `0x${info.vid.toString(16)}:0x${info.pid.toString(16)}`;246- document.getElementById('fido-versions').textContent = info.versions.join(', ');247- document.getElementById('fido-extensions').textContent = info.extensions.join(', ');248- document.getElementById('fido-pin').textContent = info.pin_retries;249- } else if (info.found) {250- status.className = 'status-badge status-err';251- status.textContent = 'Error';252- document.getElementById('fido-product').textContent = info.error;253- } else {254- status.className = 'status-badge status-err';255- status.textContent = 'No device';256- }257- } catch (e) {258- status.className = 'status-badge status-err';259- status.textContent = 'Error';260- console.error(e);261- }469+ // ─── Status ──────────────────────────────────────────────────────────470+ function setStatus(state, text) {471+ const line = document.getElementById('status-line');472+ const dot = document.getElementById('status-dot');473+ const txt = document.getElementById('status-text');474+ line.className = 'status-line ' + state;475+ dot.className = 'dot ' + state;476+ txt.textContent = text;262477 }263478 264- // ─── Host Registration ─────────────────────────────────────────────479+ // ─── Registration ───────────────────────────────────────────────────265480 async function checkRegistration() {266481 try {267482 const reg = await invoke('host_registration_status');268- const badge = document.getElementById('reg-status');483+ registered = reg.registered;269484 if (reg.registered) {270- badge.className = 'status-badge status-ok';271- badge.textContent = 'Registered';272- document.getElementById('host-id').textContent =485+ document.getElementById('setup').classList.add('hidden');486+ document.getElementById('node-id-text').textContent =273487 reg.node_id.substring(0, 16) + '...';274- } else if (reg.error) {275- badge.className = 'status-badge status-err';276- badge.textContent = 'Error';488+ document.getElementById('reg-status').textContent = 'registered';489+ document.getElementById('reg-node-id').textContent =490+ reg.node_id.substring(0, 24) + '...';277491 } else {278- badge.className = 'status-badge status-pending';279- badge.textContent = 'Not registered';492+ document.getElementById('setup').classList.remove('hidden');493+ document.getElementById('reg-status').textContent = 'not registered';280494 }281495 } catch (e) {282496 console.error('registration check failed:', e);283497 }284498 }285499 286- async function registerKey() {287- const pin = document.getElementById('pin-input').value.trim();500+ // ─── Setup ──────────────────────────────────────────────────────────501+ async function setupRegister() {502+ const pin = document.getElementById('setup-pin').value.trim();503+ const btn = document.getElementById('setup-btn');504+ const status = document.getElementById('setup-status');288505 if (!pin) {289- alert('Enter Titan PIN first');506+ status.className = 'setup-status err';507+ status.textContent = 'enter PIN';290508 return;291509 }292-293- const badge = document.getElementById('reg-status');294- badge.className = 'status-badge status-pending';295- badge.innerHTML = '<span class="spinner"></span> Tap Titan...';510+ btn.disabled = true;511+ status.className = 'setup-status pending';512+ status.textContent = 'tap titan...';296513 try {297514 const result = await invoke('titan_register_host', { pin });298515 if (result.registered) {299- badge.className = 'status-badge status-ok';300- badge.textContent = 'Registered';301- document.getElementById('host-id').textContent =302- result.node_id.substring(0, 16) + '...';516+ status.className = 'setup-status ok';517+ status.textContent = 'registered';518+ setTimeout(() => {519+ document.getElementById('setup').classList.add('hidden');520+ document.getElementById('node-id-text').textContent =521+ result.node_id.substring(0, 16) + '...';522+ registered = true;523+ checkRegistration();524+ }, 600);303525 } else {304- badge.className = 'status-badge status-err';305- badge.textContent = 'Failed';526+ status.className = 'setup-status err';527+ status.textContent = 'failed';528+ btn.disabled = false;306529 }307530 } catch (e) {308- badge.className = 'status-badge status-err';309- badge.textContent = 'Error: ' + e;310- }311- }312-313- async function unregister() {314- if (!confirm('Remove registered identity from keyring?')) return;315- try {316- await invoke('host_unregister');317- const badge = document.getElementById('reg-status');318- badge.className = 'status-badge status-pending';319- badge.textContent = 'Not registered';320- document.getElementById('host-id').textContent = '—';321- } catch (e) {322- alert('Unregister failed: ' + e);531+ status.className = 'setup-status err';532+ status.textContent = 'error: ' + e;533+ btn.disabled = false;323534 }324535 }325536 326- // ─── Host ──────────────────────────────────────────────────────────537+ // ─── Host ───────────────────────────────────────────────────────────327538 async function startHost() {328- const status = document.getElementById('host-status');329- status.className = 'status-badge status-pending';330- status.innerHTML = '<span class="spinner"></span> Starting...';539+ const action = document.getElementById('action-host');540+ if (hosting) {541+ // TODO: wire iroh_host_stop542+ hosting = false;543+ setStatus('', 'idle');544+ action.textContent = 'host';545+ return;546+ }547+ action.style.opacity = '0.4';548+ setStatus('pending', 'starting...');331549 try {332550 const result = await invoke('iroh_host_start');333551 if (result.running) {334- status.className = 'status-badge status-ok';335- status.textContent = 'Hosting';336- document.getElementById('host-id').textContent =552+ hosting = true;553+ setStatus('ok', 'hosting');554+ document.getElementById('node-id-text').textContent =337555 result.node_id.substring(0, 16) + '...';556+ action.textContent = 'stop';557+ action.style.opacity = '1';338558 } else {339- status.className = 'status-badge status-err';340- status.textContent = 'Failed';559+ setStatus('err', 'failed');560+ action.style.opacity = '1';341561 }342562 } catch (e) {343- status.className = 'status-badge status-err';344- status.textContent = 'Error: ' + e;563+ setStatus('err', 'error');564+ action.style.opacity = '1';345565 }346566 }347567 348- // ─── Client ────────────────────────────────────────────────────────568+ // ─── Client ─────────────────────────────────────────────────────────349569 async function connectHost() {350570 const pin = document.getElementById('pin-input').value.trim();351- const errBox = document.getElementById('connect-error');352- errBox.classList.remove('visible');353-354571 if (!pin) {355- errBox.textContent = 'Enter Titan PIN';356- errBox.classList.add('visible');572+ setStatus('err', 'enter pin');357573 return;358574 }359-360- errBox.style.color = 'var(--yellow)';361- errBox.textContent = 'Tap Titan when it blinks...';362- errBox.classList.add('visible');363-575+ setStatus('pending', 'tap titan...');364576 try {365577 const result = await invoke('iroh_client_connect', { pin });366578 if (result.connected) {367579 connected = true;368- errBox.style.color = 'var(--green)';369- errBox.textContent = 'Connected to host ' + result.host_node_id.substring(0, 16) + '...';370- errBox.classList.add('visible');580+ setStatus('ok', 'connected');581+ document.getElementById('node-id-text').textContent =582+ result.host_node_id.substring(0, 16) + '...';371583 document.getElementById('frame-canvas').style.display = 'block';372- document.getElementById('viewer-placeholder').style.display = 'none';373- document.getElementById('control-hint').textContent = 'Click "Take Control" to send input';584+ document.getElementById('placeholder').classList.add('hidden');585+ document.getElementById('control-bar').classList.add('visible');586+ } else {587+ setStatus('err', 'failed');588+ }589+ } catch (e) {590+ setStatus('err', 'error');591+ }592+ }593+594+ // ─── Unregister ─────────────────────────────────────────────────────595+ async function unregister() {596+ if (!confirm('Remove identity from keyring?')) return;597+ try {598+ await invoke('host_unregister');599+ registered = false;600+ setStatus('', 'idle');601+ document.getElementById('node-id-text').textContent = '';602+ document.getElementById('reg-status').textContent = 'not registered';603+ document.getElementById('reg-node-id').textContent = '—';604+ document.getElementById('setup').classList.remove('hidden');605+ } catch (e) {606+ alert('unregister failed: ' + e);607+ }608+ }609+610+ // ─── FIDO scan ──────────────────────────────────────────────────────611+ async function scanFido() {612+ try {613+ const info = await invoke('fido_device_info');614+ if (info.found && !info.error) {615+ document.getElementById('fido-status').textContent = 'connected';616+ document.getElementById('fido-status').style.color = 'var(--green)';617+ document.getElementById('fido-product').textContent = info.product;618+ document.getElementById('fido-vidpid').textContent =619+ `0x${info.vid.toString(16)}:0x${info.pid.toString(16)}`;620+ document.getElementById('fido-versions').textContent = info.versions.join(', ');621+ document.getElementById('fido-extensions').textContent = info.extensions.join(', ');622+ document.getElementById('fido-pin').textContent = info.pin_retries;374623 } else {375- errBox.style.color = 'var(--red)';376- errBox.textContent = result.error || 'Connection failed';377- errBox.classList.add('visible');624+ document.getElementById('fido-status').textContent = info.error || 'no device';625+ document.getElementById('fido-status').style.color = 'var(--destructive)';378626 }379627 } catch (e) {380- errBox.style.color = 'var(--red)';381- errBox.textContent = 'Error: ' + e;382- errBox.classList.add('visible');628+ console.error(e);383629 }384630 }385631 386- // ─── Frame rendering ───────────────────────────────────────────────632+ // ─── Panel ──────────────────────────────────────────────────────────633+ function togglePanel() {634+ const panel = document.getElementById('panel');635+ const overlay = document.getElementById('panel-overlay');636+ panel.classList.toggle('visible');637+ overlay.classList.toggle('visible');638+ }639+640+ // ─── Frames ─────────────────────────────────────────────────────────387641 const canvas = document.getElementById('frame-canvas');388642 const ctx = canvas.getContext('2d');389- let frameWidth = 0;390- let frameHeight = 0;391643 392644 listen('frame', (event) => {393645 const { width, height, data } = event.payload;@@ -395,7 +647,6 @@
395647 frameHeight = height;396648 if (canvas.width !== width) canvas.width = width;397649 if (canvas.height !== height) canvas.height = height;398-399650 const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));400651 const blob = new Blob([bytes], { type: 'image/jpeg' });401652 const url = URL.createObjectURL(blob);@@ -408,26 +659,24 @@
408659 });409660 410661 listen('frame-stats', (event) => {411- const stats = document.getElementById('viewer-stats');412- stats.textContent = `${event.payload.fps.toFixed(1)} fps | frame ${event.payload.count}`;662+ const stats = document.getElementById('stats');663+ stats.textContent = `${event.payload.fps.toFixed(1)} fps · ${event.payload.count}`;413664 stats.classList.add('visible');414665 });415666 416667 listen('frame-error', (event) => {417- const errBox = document.getElementById('connect-error');418- errBox.style.color = 'var(--red)';419- errBox.textContent = event.payload;420- errBox.classList.add('visible');668+ setStatus('err', 'disconnected');421669 document.getElementById('frame-canvas').style.display = 'none';422- document.getElementById('viewer-placeholder').style.display = 'block';423- document.getElementById('viewer-placeholder').textContent = 'Disconnected: ' + event.payload;670+ document.getElementById('placeholder').classList.remove('hidden');671+ document.getElementById('placeholder').textContent = event.payload;672+ document.getElementById('stats').classList.remove('visible');673+ document.getElementById('control-bar').classList.remove('visible');424674 connected = false;425675 controlMode = false;426676 updateControlUI();427677 });428678 429- // ─── Input Control ─────────────────────────────────────────────────430-679+ // ─── Input ──────────────────────────────────────────────────────────431680 function toggleControl() {432681 if (!connected) return;433682 controlMode = !controlMode;@@ -435,26 +684,9 @@
435684 }436685 437686 function updateControlUI() {438- const toggle = document.getElementById('control-toggle');439- const label = document.getElementById('control-label');440- const hint = document.getElementById('control-hint');441- const canvas = document.getElementById('frame-canvas');442-443- if (controlMode) {444- toggle.classList.add('active');445- label.textContent = 'Controlling';446- hint.textContent = 'Mouse and keyboard sent to host. Click to toggle off.';447- canvas.classList.add('control-active');448- } else {449- toggle.classList.remove('active');450- label.textContent = 'Take Control';451- if (connected) {452- hint.textContent = 'Click "Take Control" to send input';453- } else {454- hint.textContent = 'Connect to a host first';455- }456- canvas.classList.remove('control-active');457- }687+ const el = document.getElementById('control-toggle');688+ el.textContent = controlMode ? 'controlling' : 'take control';689+ el.classList.toggle('active', controlMode);458690 }459691 460692 function scaleCoords(clientX, clientY) {@@ -540,7 +772,7 @@
540772 }541773 });542774 543- // ─── Init ──────────────────────────────────────────────────────────775+ // ─── Init ───────────────────────────────────────────────────────────544776 checkRegistration();545777 </script>546778 </body>