Backend: - iroh_client_disconnect: closes client endpoint, clears input channel - iroh_host_stop: closes host endpoint, clears host state - Connection counting: AtomicU32 in handlers, emits 'host-connections' event - --daemon flag: auto-starts hosting from keyring on launch - is_daemon_mode command for frontend check Frontend: - Hosting view: shows node ID + connection count instead of video placeholder - Disconnect button appears when connected as client - 'client-only' instead of 'unregistered' (gray, not red) - Bottom bar 'host' becomes 'register' when not registered → opens intro - FIDO key info auto-scans on page load - Pin + connect hidden when hosting - Main action button context-sensitive: host/stop/register
- commit
- 43300e5e9c5c374f368d7cc818899b5afa266cbb
- parent
- 4250baa31d5ff11b35b3ebef09a4bb692edb2417
- author
- dami etoile <dami.etoile@gmail.com>
| M | src-tauri/ | 95 | +++++++- |
| M | src-tauri/ | 3 | + |
| M | src/ | 365 | ++++++++++++++++++------------ |
@@ -29,6 +29,7 @@ use openh264::encoder::Encoder;
2929 use openh264::formats::{RgbSliceU8, YUVBuffer, YUVSource};3030 use openh264::nal_units;3131 use std::io::Cursor;32+use std::sync::atomic::{AtomicU32, Ordering};3233 use std::sync::{Arc, Mutex};3334 use std::time::{Duration, Instant};3435 use tauri::{AppHandle, Emitter, State};@@ -300,7 +301,11 @@ fn key_from_str(s: &str) -> Option<Key> {
300301 301302 pub struct AppState {302303 pub host: Mutex<Option<HostState>>,304+ pub host_connections: Arc<AtomicU32>,305+ pub host_endpoint: TokioMutex<Option<Endpoint>>,303306 pub input_send: TokioMutex<Option<tokio::sync::mpsc::UnboundedSender<InputEvent>>>,307+ pub client_endpoint: TokioMutex<Option<Endpoint>>,308+ pub daemon: bool,304309 }305310 306311 pub struct HostState {@@ -309,9 +314,14 @@ pub struct HostState {
309314 310315 impl Default for AppState {311316 fn default() -> Self {317+ let daemon = std::env::args().any(|a| a == "--daemon");312318 Self {313319 host: Mutex::new(None),320+ host_connections: Arc::new(AtomicU32::new(0)),321+ host_endpoint: TokioMutex::new(None),314322 input_send: TokioMutex::new(None),323+ client_endpoint: TokioMutex::new(None),324+ daemon,315325 }316326 }317327 }@@ -456,6 +466,13 @@ pub fn titan_derive_identity(pin: String) -> TitanIdentity {
456466 }457467 }458468 469+// ─── Daemon mode ─────────────────────────────────────────────────────────────470+471+472+pub fn is_daemon_mode(state: State<'_, AppState>) -> bool {473+ state.daemon474+}475+459476 // ─── Host Registration (keyring) ─────────────────────────────────────────────460477 461478 @@ -523,11 +540,14 @@ pub struct HostStatus {
523540 }524541 525542 526-pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, String> {543+pub async fn iroh_host_start(544+ app: AppHandle,545+ state: State<'_, AppState>,546+) -> Result<HostStatus, String> {527547 // Read identity from keyring — no Titan needed528548 let secret_bytes = load_identity_from_keyring()529549 .map_err(|e| format!("Keyring read failed: {:?}", e))?530- .ok_or_else(|| "Not registered. Click 'Register Key' first.".to_string())?;550+ .ok_or_else(|| "Not registered. Click 'Register' first.".to_string())?;531551 532552 let secret = SecretKey::from_bytes(&secret_bytes);533553 let node_id = secret.public().to_string();@@ -540,15 +560,26 @@ pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, S
540560 541561 let _ = tokio::time::timeout(Duration::from_secs(5), endpoint.online()).await;542562 543- let frame_handler = Arc::new(FrameStreamHandler);544- let input_handler = Arc::new(InputStreamHandler);563+ let connections = state.host_connections.clone();564+ let frame_handler = Arc::new(FrameStreamHandler {565+ connections: connections.clone(),566+ app: app.clone(),567+ });568+ let input_handler = Arc::new(InputStreamHandler {569+ connections: connections.clone(),570+ app: app.clone(),571+ });545572 546573 let router = Router::builder(endpoint.clone())547574 .accept(FRAME_ALPN, frame_handler)548575 .accept(INPUT_ALPN, input_handler)549576 .spawn();550577 551- std::mem::forget(endpoint);578+ // Store endpoint and router for clean shutdown579+ {580+ let mut he = state.host_endpoint.lock().await;581+ *he = Some(endpoint);582+ }552583 std::mem::forget(router);553584 554585 let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;@@ -563,6 +594,21 @@ pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, S
563594 })564595 }565596 597+598+pub async fn iroh_host_stop(state: State<'_, AppState>) -> Result<bool, String> {599+ {600+ let mut he = state.host_endpoint.lock().await;601+ if let Some(endpoint) = he.take() {602+ endpoint.close().await;603+ }604+ }605+ {606+ let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;607+ *host = None;608+ }609+ Ok(true)610+}611+566612 567613 pub fn iroh_host_status(state: State<'_, AppState>) -> HostStatus {568614 let host = state.host.lock().ok();@@ -590,13 +636,22 @@ pub fn iroh_host_status(state: State<'_, AppState>) -> HostStatus {
590636 // ─── Frame Stream Handler (host side) ────────────────────────────────────────591637 592638 593-struct FrameStreamHandler;639+struct FrameStreamHandler {640+ connections: Arc<AtomicU32>,641+ app: AppHandle,642+}594643 595644 impl ProtocolHandler for FrameStreamHandler {596645 async fn accept(&self, conn: Connection) -> Result<(), iroh::protocol::AcceptError> {646+ let count = self.connections.fetch_add(1, Ordering::SeqCst) + 1;647+ let _ = self.app.emit("host-connections", count);648+ eprintln!("[host] client connected: {} (total: {})", conn.remote_id(), count);597649 if let Err(e) = stream_frames(conn).await {598650 eprintln!("[host] stream error: {}", e);599651 }652+ let count = self.connections.fetch_sub(1, Ordering::SeqCst) - 1;653+ let _ = self.app.emit("host-connections", count);654+ eprintln!("[host] client disconnected (total: {})", count);600655 Ok(())601656 }602657 }@@ -674,7 +729,10 @@ async fn stream_frames(conn: Connection) -> anyhow::Result<()> {
674729 // ─── Input Stream Handler (host side) ─────────────────────────────────────────675730 676731 677-struct InputStreamHandler;732+struct InputStreamHandler {733+ connections: Arc<AtomicU32>,734+ app: AppHandle,735+}678736 679737 impl ProtocolHandler for InputStreamHandler {680738 async fn accept(&self, conn: Connection) -> Result<(), iroh::protocol::AcceptError> {@@ -840,6 +898,12 @@ pub async fn iroh_client_connect(
840898 *input_send_guard = Some(tx);841899 }842900 901+ // Store endpoint for disconnect902+ {903+ let mut ce = state.client_endpoint.lock().await;904+ *ce = Some(endpoint.clone());905+ }906+843907 // Spawn input forwarder844908 let mut input_stream = input_send;845909 tokio::spawn(async move {@@ -966,3 +1030,20 @@ pub async fn iroh_client_send_input(
9661030 None => Err("Not connected to host".into()),9671031 }9681032 }1033+1034+1035+pub async fn iroh_client_disconnect(state: State<'_, AppState>) -> Result<bool, String> {1036+ // Close endpoint — kills all streams1037+ {1038+ let mut ce = state.client_endpoint.lock().await;1039+ if let Some(endpoint) = ce.take() {1040+ endpoint.close().await;1041+ }1042+ }1043+ // Clear input channel1044+ {1045+ let mut input_send = state.input_send.lock().await;1046+ *input_send = None;1047+ }1048+ Ok(true)1049+}@@ -13,12 +13,15 @@ fn main() {
1313 commands::fido_device_info,1414 commands::fido_pin_retries,1515 commands::titan_derive_identity,16+ commands::is_daemon_mode,1617 commands::host_registration_status,1718 commands::titan_register_host,1819 commands::host_unregister,1920 commands::iroh_host_start,21+ commands::iroh_host_stop,2022 commands::iroh_host_status,2123 commands::iroh_client_connect,24+ commands::iroh_client_disconnect,2225 commands::iroh_client_send_input,2326 ])2427 .run(tauri::generate_context!())@@ -35,7 +35,7 @@
3535 -webkit-font-smoothing: antialiased;3636 }3737 38- /* ─── Top bar (minimal, no navbar feel) ────────────────────────────── */38+ /* ─── Top bar ─────────────────────────────────────────────────────── */3939 .topbar {4040 display: flex;4141 align-items: center;@@ -101,7 +101,7 @@
101101 text-decoration-color: currentColor;102102 }103103 104- /* ─── Main (video) ────────────────────────────────────────────────── */104+ /* ─── Main area ───────────────────────────────────────────────────── */105105 .main {106106 flex: 1;107107 display: flex;@@ -126,6 +126,18 @@
126126 }127127 .placeholder.hidden { display: none; }128128 129+ /* Hosting info panel */130+ .host-info {131+ text-align: center;132+ color: var(--muted-fg);133+ font-family: var(--mono);134+ padding: 40px;135+ }136+ .host-info.hidden { display: none; }137+ .host-info .label { font-size: 11px; color: var(--muted-fg); margin-bottom: 4px; }138+ .host-info .value { font-size: 16px; color: var(--foreground); margin-bottom: 20px; }139+ .host-info .conn-count { font-size: 24px; color: var(--green); }140+129141 /* Stats overlay */130142 .stats {131143 position: absolute;@@ -170,7 +182,7 @@
170182 text-decoration-color: currentColor;171183 }172184 173- /* ─── Bottom bar (status bar — vim/gitdot style) ───────────────────── */185+ /* ─── Bottom bar ──────────────────────────────────────────────────── */174186 .bottombar {175187 display: flex;176188 align-items: center;@@ -196,6 +208,8 @@
196208 text-decoration-color: currentColor;197209 }198210 .bottombar .action.primary { color: var(--foreground); }211+ .bottombar .action.danger { color: var(--destructive); opacity: 0.7; }212+ .bottombar .action.danger:hover { opacity: 1; }199213 .bottombar .spacer { flex: 1; }200214 .pin-group {201215 display: flex;@@ -217,13 +231,15 @@
217231 outline: none;218232 border-bottom-color: var(--foreground);219233 }234+ .pin-input.hidden { display: none; }220235 .pin-label {221236 font-size: 11px;222237 color: var(--muted-fg);223238 }239+ .pin-label.hidden { display: none; }224240 225- /* ─── Setup screen ────────────────────────────────────────────────── */226- .setup {241+ /* ─── Intro / overlay screen ──────────────────────────────────────── */242+ .overlay-screen {227243 position: fixed;228244 inset: 0;229245 z-index: 100;@@ -232,32 +248,32 @@
232248 justify-content: center;233249 background: var(--background);234250 }235- .setup.hidden { display: none; }236- .setup-content {251+ .overlay-screen.hidden { display: none; }252+ .overlay-content {237253 text-align: center;238254 max-width: 360px;239255 padding: 40px;240256 }241- .setup-content h1 {257+ .overlay-content h1 {242258 font-family: var(--mono);243259 font-size: 16px;244260 font-weight: 500;245261 color: var(--foreground);246262 margin-bottom: 12px;247263 }248- .setup-content p {264+ .overlay-content p {249265 font-size: 13px;250266 color: var(--muted-fg);251267 line-height: 1.6;252268 margin-bottom: 24px;253269 }254- .setup-form {270+ .overlay-form {255271 display: flex;256272 gap: 8px;257273 justify-content: center;258274 align-items: center;259275 }260- .setup-input {276+ .overlay-input {261277 width: 120px;262278 text-align: center;263279 background: transparent;@@ -268,11 +284,11 @@
268284 font-family: var(--mono);269285 font-size: 14px;270286 }271- .setup-input:focus {287+ .overlay-input:focus {272288 outline: none;273289 border-bottom-color: var(--foreground);274290 }275- .setup-btn {291+ .overlay-btn {276292 font-family: var(--mono);277293 font-size: 13px;278294 color: var(--muted-fg);@@ -284,24 +300,24 @@
284300 text-decoration-color: transparent;285301 transition: color 0.1s, text-decoration-color 0.1s;286302 }287- .setup-btn:hover {303+ .overlay-btn:hover {288304 color: var(--foreground);289305 text-decoration-color: currentColor;290306 }291- .setup-btn:disabled {307+ .overlay-btn:disabled {292308 opacity: 0.4;293309 cursor: not-allowed;294310 }295- .setup-status {311+ .overlay-status {296312 font-family: var(--mono);297313 font-size: 12px;298314 color: var(--muted-fg);299315 margin-top: 16px;300316 min-height: 16px;301317 }302- .setup-status.pending { color: var(--yellow); }303- .setup-status.err { color: var(--destructive); }304- .setup-status.ok { color: var(--green); }318+ .overlay-status.pending { color: var(--yellow); }319+ .overlay-status.err { color: var(--destructive); }320+ .overlay-status.ok { color: var(--green); }305321 306322 /* ─── Side panel ──────────────────────────────────────────────────── */307323 .panel-overlay {@@ -372,45 +388,45 @@
372388 </style>373389 </head>374390 <body>375- <!-- Titan tap overlay -->376- <div class="setup hidden" id="tap-overlay" style="z-index: 200;">377- <div class="setup-content">391+ <!-- Tap titan overlay -->392+ <div class="overlay-screen hidden" id="tap-overlay" style="z-index: 200;">393+ <div class="overlay-content">378394 <h1>tap titan</h1>379395 <p>Touch the sensor on your key when it blinks.</p>380- <div class="setup-status pending" id="tap-status">waiting...</div>396+ <div class="overlay-status pending" id="tap-status">waiting...</div>381397 </div>382398 </div>383399 384- <!-- Intro screen (adaptive — shows both workflows) -->385- <div class="setup hidden" id="setup">386- <div class="setup-content">387- <h1 id="intro-title">keyhome</h1>400+ <!-- Intro screen -->401+ <div class="overlay-screen hidden" id="intro">402+ <div class="overlay-content">403+ <h1>keyhome</h1>388404 <p id="intro-desc">Plug in your Titan key and choose an action.</p>389405 390406 <!-- Unregistered: register or connect -->391407 <div id="intro-unregistered">392- <div class="setup-form">393- <input class="setup-input" id="setup-pin" type="password" placeholder="PIN" autocomplete="off" />408+ <div class="overlay-form">409+ <input class="overlay-input" id="intro-pin-unreg" type="password" placeholder="PIN" autocomplete="off" />394410 </div>395411 <div style="display: flex; gap: 16px; justify-content: center; margin-top: 12px;">396- <button class="setup-btn" id="setup-btn" onclick="setupRegister()">register</button>397- <button class="setup-btn" onclick="introConnect()">connect</button>412+ <button class="overlay-btn" onclick="introRegister()">register</button>413+ <button class="overlay-btn" onclick="introConnectUnreg()">connect</button>398414 </div>399- <div class="setup-status" id="setup-status"></div>415+ <div class="overlay-status" id="intro-status-unreg"></div>400416 </div>401417 402418 <!-- Registered: host or connect -->403419 <div id="intro-registered" style="display: none;">404420 <div style="display: flex; gap: 16px; justify-content: center; margin-bottom: 16px;">405- <button class="setup-btn" onclick="introHost()">host</button>421+ <button class="overlay-btn" onclick="introHost()">host</button>406422 </div>407- <div class="setup-form">408- <input class="setup-input" id="intro-pin" type="password" placeholder="PIN" autocomplete="off" />423+ <div class="overlay-form">424+ <input class="overlay-input" id="intro-pin-reg" type="password" placeholder="PIN" autocomplete="off" />409425 </div>410426 <div style="display: flex; gap: 16px; justify-content: center; margin-top: 12px;">411- <button class="setup-btn" onclick="introConnectRegistered()">connect</button>427+ <button class="overlay-btn" onclick="introConnectReg()">connect</button>412428 </div>413- <div class="setup-status" id="setup-status-reg"></div>429+ <div class="overlay-status" id="intro-status-reg"></div>414430 </div>415431 </div>416432 </div>@@ -429,11 +445,15 @@
429445 </div>430446 </div>431447 432- <!-- Main / Video -->448+ <!-- Main area -->433449 <div class="main">434450 <canvas id="frame-canvas"></canvas>435- <div class="placeholder" id="placeholder">436- no stream451+ <div class="placeholder" id="placeholder">no stream</div>452+ <div class="host-info hidden" id="host-info">453+ <div class="label">hosting</div>454+ <div class="value" id="host-node-id"></div>455+ <div class="label">connections</div>456+ <div class="conn-count" id="host-conn-count">0</div>437457 </div>438458 <div class="stats" id="stats"></div>439459 <div class="control-bar" id="control-bar">@@ -443,20 +463,20 @@
443463 </div>444464 </div>445465 446- <!-- Bottom bar (status bar) -->466+ <!-- Bottom bar -->447467 <div class="bottombar">448- <span class="prompt" id="bottom-action">keyhome</span>468+ <span class="prompt">keyhome</span>449469 <span class="sep">»</span>450- <span class="action primary" id="action-host" onclick="startHost()">host</span>470+ <span class="action primary" id="action-main" onclick="mainAction()">host</span>451471 <span class="sep">·</span>452- <span class="action" id="action-unreg" onclick="unregister()" style="color: var(--destructive); opacity: 0.6;">unregister</span>472+ <span class="action danger hidden" id="action-disconnect" onclick="disconnect()">disconnect</span>473+ <span class="sep hidden" id="sep-disconnect">·</span>474+ <span class="action" id="action-unreg" onclick="unregister()">unregister</span>453475 <div class="spacer"></div>454- <div class="pin-group">455- <span class="pin-label">pin</span>456- <input class="pin-input" id="pin-input" type="password" placeholder="····" autocomplete="off" />457- </div>476+ <span class="pin-label" id="pin-label">pin</span>477+ <input class="pin-input" id="pin-input" type="password" placeholder="····" autocomplete="off" />458478 <span class="sep">·</span>459- <span class="action primary" onclick="connectHost()">connect</span>479+ <span class="action primary" id="action-connect" onclick="connectHost()">connect</span>460480 </div>461481 462482 <!-- Side panel -->@@ -475,7 +495,7 @@
475495 <div class="info-row"><span class="info-label">ctap</span><span class="info-value" id="fido-versions">—</span></div>476496 <div class="info-row"><span class="info-label">extensions</span><span class="info-value" id="fido-extensions">—</span></div>477497 <div class="info-row"><span class="info-label">pin retries</span><span class="info-value" id="fido-pin">—</span></div>478- <button class="link-btn" style="margin-top: 8px;" onclick="scanFido()">scan</button>498+ <button class="link-btn" style="margin-top: 8px;" onclick="scanFido()">rescan</button>479499 </div>480500 <div class="panel-section">481501 <h3># registration</h3>@@ -506,6 +526,11 @@
506526 txt.textContent = text;507527 }508528 529+ function showIntro() { document.getElementById('intro').classList.remove('hidden'); }530+ function hideIntro() { document.getElementById('intro').classList.add('hidden'); }531+ function showTap() { document.getElementById('tap-overlay').classList.remove('hidden'); }532+ function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }533+509534 // ─── Registration ───────────────────────────────────────────────────510535 async function checkRegistration() {511536 try {@@ -514,7 +539,6 @@
514539 const unregDiv = document.getElementById('intro-unregistered');515540 const regDiv = document.getElementById('intro-registered');516541 if (reg.registered) {517- document.getElementById('setup').classList.remove('hidden');518542 unregDiv.style.display = 'none';519543 regDiv.style.display = 'block';520544 document.getElementById('node-id-text').textContent =@@ -522,36 +546,74 @@
522546 document.getElementById('reg-status').textContent = 'registered';523547 document.getElementById('reg-node-id').textContent =524548 reg.node_id.substring(0, 24) + '...';549+ // Bottom bar: show host + unregister550+ document.getElementById('action-main').textContent = 'host';551+ document.getElementById('action-unreg').style.display = '';525552 } else {526- document.getElementById('setup').classList.remove('hidden');527553 unregDiv.style.display = 'block';528554 regDiv.style.display = 'none';529- document.getElementById('reg-status').textContent = 'not registered';555+ document.getElementById('reg-status').textContent = 'client-only';556+ // Bottom bar: show register, hide unregister557+ document.getElementById('action-main').textContent = 'register';558+ document.getElementById('action-unreg').style.display = 'none';559+ }560+561+ // Check daemon mode — auto-host if --daemon flag562+ const daemon = await invoke('is_daemon_mode');563+ if (daemon && reg.registered) {564+ hideIntro();565+ await startHost();566+ } else {567+ showIntro();530568 }531569 } catch (e) {532570 console.error('registration check failed:', e);571+ showIntro();533572 }534573 }535574 536- // ─── Tap overlay ───────────────────────────────────────────────────537- function showTap() {538- document.getElementById('tap-overlay').classList.remove('hidden');539- }540- function hideTap() {541- document.getElementById('tap-overlay').classList.add('hidden');542- }543-544- // ─── Intro actions ─────────────────────────────────────────────────545- function hideIntro() {546- document.getElementById('setup').classList.add('hidden');575+ // ─── Intro actions ──────────────────────────────────────────────────576+ async function introRegister() {577+ const pin = document.getElementById('intro-pin-unreg').value.trim();578+ const status = document.getElementById('intro-status-unreg');579+ if (!pin) {580+ status.className = 'overlay-status err';581+ status.textContent = 'enter pin';582+ return;583+ }584+ hideIntro();585+ showTap();586+ try {587+ const result = await invoke('titan_register_host', { pin });588+ if (result.registered) {589+ hideTap();590+ registered = true;591+ document.getElementById('node-id-text').textContent =592+ result.node_id.substring(0, 16) + '...';593+ document.getElementById('action-main').textContent = 'host';594+ document.getElementById('action-unreg').style.display = '';595+ // Show intro again with registered state596+ await checkRegistration();597+ } else {598+ hideTap();599+ showIntro();600+ status.className = 'overlay-status err';601+ status.textContent = 'failed';602+ }603+ } catch (e) {604+ hideTap();605+ showIntro();606+ status.className = 'overlay-status err';607+ status.textContent = 'error: ' + e;608+ }547609 }548610 549- async function introConnect() {550- const pin = document.getElementById('setup-pin').value.trim();611+ async function introConnectUnreg() {612+ const pin = document.getElementById('intro-pin-unreg').value.trim();613+ const status = document.getElementById('intro-status-unreg');551614 if (!pin) {552- const s = document.getElementById('setup-status');553- s.className = 'setup-status err';554- s.textContent = 'enter pin';615+ status.className = 'overlay-status err';616+ status.textContent = 'enter pin';555617 return;556618 }557619 hideIntro();@@ -561,12 +623,12 @@
561623 hideTap();562624 }563625 564- async function introConnectRegistered() {565- const pin = document.getElementById('intro-pin').value.trim();626+ async function introConnectReg() {627+ const pin = document.getElementById('intro-pin-reg').value.trim();628+ const status = document.getElementById('intro-status-reg');566629 if (!pin) {567- const s = document.getElementById('setup-status-reg');568- s.className = 'setup-status err';569- s.textContent = 'enter pin';630+ status.className = 'overlay-status err';631+ status.textContent = 'enter pin';570632 return;571633 }572634 hideIntro();@@ -581,54 +643,8 @@
581643 await startHost();582644 }583645 584- // ─── Setup (register from intro) ────────────────────────────────────585- async function setupRegister() {586- const pin = document.getElementById('setup-pin').value.trim();587- const btn = document.getElementById('setup-btn');588- const status = document.getElementById('setup-status');589- if (!pin) {590- status.className = 'setup-status err';591- status.textContent = 'enter pin';592- return;593- }594- btn.disabled = true;595- hideIntro();596- showTap();597- try {598- const result = await invoke('titan_register_host', { pin });599- if (result.registered) {600- hideTap();601- registered = true;602- document.getElementById('node-id-text').textContent =603- result.node_id.substring(0, 16) + '...';604- checkRegistration();605- } else {606- hideTap();607- document.getElementById('setup').classList.remove('hidden');608- status.className = 'setup-status err';609- status.textContent = 'failed';610- btn.disabled = false;611- }612- } catch (e) {613- hideTap();614- document.getElementById('setup').classList.remove('hidden');615- status.className = 'setup-status err';616- status.textContent = 'error: ' + e;617- btn.disabled = false;618- }619- }620-621646 // ─── Host ───────────────────────────────────────────────────────────622647 async function startHost() {623- const action = document.getElementById('action-host');624- if (hosting) {625- // TODO: wire iroh_host_stop626- hosting = false;627- setStatus('', 'idle');628- action.textContent = 'host';629- return;630- }631- action.style.opacity = '0.4';632648 setStatus('pending', 'starting...');633649 try {634650 const result = await invoke('iroh_host_start');@@ -637,15 +653,49 @@
637653 setStatus('ok', 'hosting');638654 document.getElementById('node-id-text').textContent =639655 result.node_id.substring(0, 16) + '...';640- action.textContent = 'stop';641- action.style.opacity = '1';656+ document.getElementById('action-main').textContent = 'stop';657+ document.getElementById('action-unreg').style.display = 'none';658+ // Show host info panel, hide placeholder659+ document.getElementById('placeholder').classList.add('hidden');660+ document.getElementById('host-info').classList.remove('hidden');661+ document.getElementById('host-node-id').textContent =662+ result.node_id.substring(0, 24) + '...';663+ // Hide pin + connect when hosting664+ document.getElementById('pin-label').classList.add('hidden');665+ document.getElementById('pin-input').classList.add('hidden');666+ document.getElementById('action-connect').style.display = 'none';642667 } else {643668 setStatus('err', 'failed');644- action.style.opacity = '1';645669 }646670 } catch (e) {647671 setStatus('err', 'error');648- action.style.opacity = '1';672+ }673+ }674+675+ async function stopHost() {676+ try {677+ await invoke('iroh_host_stop');678+ } catch (e) { console.error(e); }679+ hosting = false;680+ setStatus('', 'idle');681+ document.getElementById('action-main').textContent = registered ? 'host' : 'register';682+ document.getElementById('action-unreg').style.display = registered ? '' : 'none';683+ document.getElementById('host-info').classList.add('hidden');684+ document.getElementById('placeholder').classList.remove('hidden');685+ document.getElementById('pin-label').classList.remove('hidden');686+ document.getElementById('pin-input').classList.remove('hidden');687+ document.getElementById('action-connect').style.display = '';688+ }689+690+ // ─── Main action (host/stop/register) ───────────────────────────────691+ async function mainAction() {692+ if (hosting) {693+ await stopHost();694+ } else if (registered) {695+ await startHost();696+ } else {697+ // Not registered — show intro for registration698+ showIntro();649699 }650700 }651701 @@ -656,7 +706,7 @@
656706 setStatus('err', 'enter pin');657707 return;658708 }659- setStatus('pending', 'tap titan...');709+ setStatus('pending', 'connecting...');660710 try {661711 const result = await invoke('iroh_client_connect', { pin });662712 if (result.connected) {@@ -667,6 +717,11 @@
667717 document.getElementById('frame-canvas').style.display = 'block';668718 document.getElementById('placeholder').classList.add('hidden');669719 document.getElementById('control-bar').classList.add('visible');720+ // Show disconnect, hide connect721+ document.getElementById('action-connect').style.display = 'none';722+ document.getElementById('action-disconnect').classList.remove('hidden');723+ document.getElementById('sep-disconnect').classList.remove('hidden');724+ document.getElementById('action-main').style.display = 'none';670725 } else {671726 setStatus('err', 'failed');672727 }@@ -675,6 +730,25 @@
675730 }676731 }677732 733+ async function disconnect() {734+ try {735+ await invoke('iroh_client_disconnect');736+ } catch (e) { console.error(e); }737+ connected = false;738+ controlMode = false;739+ updateControlUI();740+ setStatus('', 'idle');741+ document.getElementById('frame-canvas').style.display = 'none';742+ document.getElementById('placeholder').classList.remove('hidden');743+ document.getElementById('placeholder').textContent = 'no stream';744+ document.getElementById('stats').classList.remove('visible');745+ document.getElementById('control-bar').classList.remove('visible');746+ document.getElementById('action-connect').style.display = '';747+ document.getElementById('action-disconnect').classList.add('hidden');748+ document.getElementById('sep-disconnect').classList.add('hidden');749+ document.getElementById('action-main').style.display = '';750+ }751+678752 // ─── Unregister ─────────────────────────────────────────────────────679753 async function unregister() {680754 if (!confirm('Remove identity from keyring?')) return;@@ -683,9 +757,11 @@
683757 registered = false;684758 setStatus('', 'idle');685759 document.getElementById('node-id-text').textContent = '';686- document.getElementById('reg-status').textContent = 'not registered';760+ document.getElementById('reg-status').textContent = 'client-only';687761 document.getElementById('reg-node-id').textContent = '—';688- document.getElementById('setup').classList.remove('hidden');762+ document.getElementById('action-main').textContent = 'register';763+ document.getElementById('action-unreg').style.display = 'none';764+ showIntro();689765 } catch (e) {690766 alert('unregister failed: ' + e);691767 }@@ -695,9 +771,10 @@
695771 async function scanFido() {696772 try {697773 const info = await invoke('fido_device_info');774+ const el = document.getElementById('fido-status');698775 if (info.found && !info.error) {699- document.getElementById('fido-status').textContent = 'connected';700- document.getElementById('fido-status').style.color = 'var(--green)';776+ el.textContent = 'connected';777+ el.style.color = 'var(--green)';701778 document.getElementById('fido-product').textContent = info.product;702779 document.getElementById('fido-vidpid').textContent =703780 `0x${info.vid.toString(16)}:0x${info.pid.toString(16)}`;@@ -705,8 +782,8 @@
705782 document.getElementById('fido-extensions').textContent = info.extensions.join(', ');706783 document.getElementById('fido-pin').textContent = info.pin_retries;707784 } else {708- document.getElementById('fido-status').textContent = info.error || 'no device';709- document.getElementById('fido-status').style.color = 'var(--destructive)';785+ el.textContent = info.error || 'no device';786+ el.style.color = 'var(--muted-fg)';710787 }711788 } catch (e) {712789 console.error(e);@@ -715,12 +792,15 @@
715792 716793 // ─── Panel ──────────────────────────────────────────────────────────717794 function togglePanel() {718- const panel = document.getElementById('panel');719- const overlay = document.getElementById('panel-overlay');720- panel.classList.toggle('visible');721- overlay.classList.toggle('visible');795+ document.getElementById('panel').classList.toggle('visible');796+ document.getElementById('panel-overlay').classList.toggle('visible');722797 }723798 799+ // ─── Host connection events ─────────────────────────────────────────800+ listen('host-connections', (event) => {801+ document.getElementById('host-conn-count').textContent = event.payload;802+ });803+724804 // ─── Frames ─────────────────────────────────────────────────────────725805 const canvas = document.getElementById('frame-canvas');726806 const ctx = canvas.getContext('2d');@@ -749,15 +829,11 @@
749829 });750830 751831 listen('frame-error', (event) => {752- setStatus('err', 'disconnected');753- document.getElementById('frame-canvas').style.display = 'none';754- document.getElementById('placeholder').classList.remove('hidden');755- document.getElementById('placeholder').textContent = event.payload;756- document.getElementById('stats').classList.remove('visible');757- document.getElementById('control-bar').classList.remove('visible');758- connected = false;759- controlMode = false;760- updateControlUI();832+ if (connected) {833+ disconnect();834+ setStatus('err', 'disconnected');835+ document.getElementById('placeholder').textContent = event.payload;836+ }761837 });762838 763839 // ─── Input ──────────────────────────────────────────────────────────@@ -858,6 +934,7 @@
858934 859935 // ─── Init ───────────────────────────────────────────────────────────860936 checkRegistration();937+ scanFido();861938 </script>862939 </body>863940 </html>