From aa229b44767fa1ae61bb3cbcdac3062f8192c8f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:10:39 +0000 Subject: [PATCH 1/2] feat(cec): cec://viewing tells the customer what routes actually carry The customer GUI's "Viewing your screen" chip keyed on session state, but a session outlives the console on purpose (chat rides it) - so the chip stayed lit after the technician closed the remote-control console. Nothing told the customer's GUI what the technician's routes were actually doing. - The consent sweep now derives, every pass, a per-technician map of what their LIVE routes carry - screen (display/video route) and control (input route) - and emits it as cec://viewing whenever it changes (and once at startup, so a GUI that hydrated first gets its baseline). Routes this pass is tearing down for lapsed consent don't count. - New cec_viewing command: the pull twin for GUI hydrate, so an app that starts mid-session paints the chip without waiting for a transition. - The sweep's is_technician early-out is gone: the role flips permanently on the first dial, so a DUAL-ROLE node (dialed someone once, yet also reachable as a customer) skipped consent enforcement entirely - and would have skipped this signal too. On a pure technician node the body still no-ops via knows_technician. cargo test --lib: 154 passed. fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YYdqmManehKSKxwzcfeeU4 --- node/src/mesh.rs | 125 +++++++++++++++++++++++++++++++-------- node/src/node_control.rs | 1 + 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/node/src/mesh.rs b/node/src/mesh.rs index bfc0670..e67fbee 100644 --- a/node/src/mesh.rs +++ b/node/src/mesh.rs @@ -1148,14 +1148,21 @@ impl Mesh { const SWEEP: std::time::Duration = std::time::Duration::from_secs(2); let mesh = Arc::downgrade(self); crate::spawn(async move { + // The last `cec://viewing` map this sweep emitted — technician + // canonical id → (screen live, control live). `None` until the + // first pass so a fresh node always emits once (even an empty + // map), giving a GUI that hydrated before us a baseline. + let mut last_viewing: Option> = None; loop { tokio::time::sleep(SWEEP).await; let Some(mesh) = mesh.upgrade() else { break }; - // A technician node enforces no consent (it isn't the host of - // the gated screen/input) — skip the whole pass. - if mesh.cec.is_technician() { - continue; - } + // NOTE: no `is_technician` skip here. That early-out assumed a + // technician node hosts nothing consent-gated — but the role + // flips permanently on the first dial, and a DUAL-ROLE node + // (dialed someone once, yet also reachable as a customer) very + // much still hosts a consent-gated screen. On a pure technician + // node the body no-ops anyway: its routes point at customers, + // and `knows_technician` is false for those peers. // Snapshot every live route (peer, id, is-screen, drive-plane) // under the state lock, then drop it before touching the CEC // store or tearing anything down (`disconnect` re-locks state). @@ -1179,6 +1186,16 @@ impl Mesh { let mut stale_routes: Vec = Vec::new(); let mut lapsed_techs: std::collections::BTreeSet = std::collections::BTreeSet::new(); + // What each technician is ACTUALLY doing right now, from the + // routes themselves: screen = a live display route, control = a + // live input route. This — not session state — is what the + // customer's "Viewing/Controlling your screen" chip keys on: + // a technician closing their console tears the routes down but + // sends no session event, and chat keeps the session itself + // alive, so session state over-claims. A route this very pass + // is tearing down for lapsed consent doesn't count. + let mut viewing: std::collections::BTreeMap = + std::collections::BTreeMap::new(); for (peer, route_id, is_screen, plane) in routes { // Only CEC technicians are consent-gated; an owner/fleet or // ordinary peer's routes are none of this sweep's business. @@ -1190,35 +1207,76 @@ impl Mesh { if screen_lapsed || drive_lapsed { stale_routes.push(route_id); lapsed_techs.insert(crate::cec::pubkey_part(&peer).to_string()); + continue; } - } - if stale_routes.is_empty() { - continue; - } - for id in &stale_routes { - tracing::info!( - "CEC consent lapsed — tearing down route {id} (approval revoked or expired)" - ); - let _ = mesh.disconnect(id.clone()).await; - } - // End the lapsed technicians' sessions so the customer's - // "connected" banner clears — a route teardown alone emits no - // session event — and retire any leftover "Approve Once" grant. - for tech in lapsed_techs { - for sid in mesh.cec.end_sessions_for(&tech) { - mesh.sink.emit( - "cec://session", - json!({ "session_id": sid, "state": "ended" }), + let entry = viewing + .entry(crate::cec::pubkey_part(&peer).to_string()) + .or_insert((false, false)); + entry.0 |= is_screen; + entry.1 |= plane == Some(DrivePlane::Input); + } + if !stale_routes.is_empty() { + for id in &stale_routes { + tracing::info!( + "CEC consent lapsed — tearing down route {id} (approval revoked or expired)" ); + let _ = mesh.disconnect(id.clone()).await; } - mesh.cec.retire_once(&tech); + // End the lapsed technicians' sessions so the customer's + // "connected" banner clears — a route teardown alone emits no + // session event — and retire any leftover "Approve Once" grant. + for tech in lapsed_techs { + for sid in mesh.cec.end_sessions_for(&tech) { + mesh.sink.emit( + "cec://session", + json!({ "session_id": sid, "state": "ended" }), + ); + } + mesh.cec.retire_once(&tech); + } + mesh.cec_emit_grants(); + mesh.emit_snapshot(); + } + // Tell the GUI what's live whenever the picture changes (and + // once at startup, so a GUI that hydrated before this sweep + // gets its baseline). The event carries the whole map, so a + // missed frame self-heals on the next change. + if last_viewing.as_ref() != Some(&viewing) { + mesh.sink.emit("cec://viewing", cec_viewing_value(&viewing)); + last_viewing = Some(viewing); } - mesh.cec_emit_grants(); - mesh.emit_snapshot(); } }); } + /// `cec_viewing` (customer): what each connected technician is actually + /// doing right now — `{ techs: { : { screen, control } } }` + /// — derived from the LIVE routes, not session state. The event twin + /// (`cec://viewing`) is pushed by the consent sweep on every change; this + /// command is the pull for GUI hydrate, so an app that starts mid-session + /// paints the chip without waiting for a transition. + pub async fn cec_viewing(self: &Arc) -> Result { + let mut viewing: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + { + let st = self.state.lock(); + if let Some(session) = st.session.as_ref() { + for r in session.routes() { + let peer = r.peer.as_str(); + if !self.cec.knows_technician(peer) { + continue; + } + let entry = viewing + .entry(crate::cec::pubkey_part(peer).to_string()) + .or_insert((false, false)); + entry.0 |= matches!(r.route.media, MediaKind::Display | MediaKind::Video); + entry.1 |= route_drive_plane(&r.route) == Some(DrivePlane::Input); + } + } + } + Ok(cec_viewing_value(&viewing)) + } + /// Re-scan this machine's inventory every [`INVENTORY_RESCAN`] and /// refresh the live presence profile when the device picture changed, /// so a display that woke (or detached), a camera that appeared, or a @@ -10870,6 +10928,21 @@ fn fresh_share_token() -> String { /// session so the receiver can dedupe and the sender can recognise the echo of /// its own message. Mirrors [`fresh_share_token`]; the `msg_` prefix only tells /// the two apart in a trace. +/// The `cec://viewing` event / `cec_viewing` command payload: technician +/// canonical id → what their live routes actually carry right now. +fn cec_viewing_value(viewing: &std::collections::BTreeMap) -> Value { + let techs: serde_json::Map = viewing + .iter() + .map(|(tech, (screen, control))| { + ( + tech.clone(), + json!({ "screen": screen, "control": control }), + ) + }) + .collect(); + json!({ "techs": techs }) +} + fn fresh_chat_id() -> String { let mut bytes = [0u8; 16]; if getrandom::getrandom(&mut bytes).is_err() { diff --git a/node/src/node_control.rs b/node/src/node_control.rs index 3f058a2..64a10f4 100644 --- a/node/src/node_control.rs +++ b/node/src/node_control.rs @@ -1473,6 +1473,7 @@ pub async fn dispatch( json_result(mesh.cec_revoke(tech).await) } "cec_grants" => json_result(mesh.cec_grants().await), + "cec_viewing" => json_result(mesh.cec_viewing().await), "cec_dialed" => json_result(mesh.cec_dialed().await), "cec_cancel_dial" => json_result(mesh.cec_cancel_dial().await), "cec_ask_help" => { From 3d2a8e7de6b708da89911470348d125bc8c5cd78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:10:54 +0000 Subject: [PATCH 2/2] feat(cec): help cards grow an action row; the Site door opens for techs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manufacturer web Site button never showed for a help technician: kvmTwin gated on kvmAllowed (ours / co-fleet / shared), and a tech is none of those for a customer's appliance - so the feature's whole point (the tech opening the KVM's own web UI) never fired. And the sidebar squeezed its doors in as icons beside the name, with nowhere to grow and no status while a connect was pending. - kvmTwin authorizes EITHER side of the tech/owner divide now: ours / co-fleet as before, or a dialed CEC customer (mirroring capabilitiesFor's isCec arm). Queue rows pass { raisedHand: true }, which skips the ACL entirely - the hand-raise IS the ask, a beacon the customer deliberately lit, and the KVM itself still authorizes the actual tunnel by its mesh roster. The gate here only decides whether the button shows. - Help-sidebar cards restructured: identity on top, one action row along the bottom (Answer / Open / Reconnect, then the row's doors), with room for more buttons as they earn a spot. Site is a labeled button now, not a bare globe. - A KVM's row offers its Site and no Chat, in both the sidebar and the settings console panel - a KVM appliance isn't someone to chat with. - Inline connect status: while a dial to a row is in flight ("Connecting…") or the customer's approve prompt is up ("Waiting for them to approve…"), that row's action row becomes a pulsing status line + Stop. The sidebar has no status strip like the settings panel, so the card itself says what's happening. New cecDialingNode tracks WHICH row a by-node dial targets (those never set cecDialingNumber), and cecWaitPhase() classifies the two phases. svelte-check 152 files 0 errors; pnpm build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YYdqmManehKSKxwzcfeeU4 --- gui/src/store.svelte.ts | 44 +++- gui/src/ui/HelpTab.svelte | 305 +++++++++++++++++--------- gui/src/ui/settings/CecSection.svelte | 43 ++-- 3 files changed, 266 insertions(+), 126 deletions(-) diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index 65f72a0..b2cc447 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -561,6 +561,10 @@ class AppStore { * CEC tab renders it as a live "Dialing…" row so a connect attempt is * visible from the first click (discovery alone can take up to ~45s). */ cecDialingNumber = $state(null); + /** The NODE currently being dialed (Answer / Open / Chat go by node, which + * never sets `cecDialingNumber`) — what lets the help sidebar pin its + * inline "Connecting…" status to the row that was actually clicked. */ + cecDialingNode = $state(null); /** Live CEC sessions this app is party to, newest first. */ cecSessions = $state<{ sessionId: string; state: string; node?: string }[]>([]); /** The customer's live consent grants (populated when hosting). */ @@ -4280,11 +4284,24 @@ class AppStore { * raised-hand row offer the KVM's web Site (and anything else the graph * knows) alongside the remote-control console. Undefined for an ordinary * customer, a KVM outside our graph, or one whose KVM affordances aren't - * ours to use. */ - kvmTwin(cecNode: string | undefined): MeshNode | undefined { + * ours to use. + * + * Authorization is EITHER side of the tech/owner divide: `kvmAllowed` + * (ours / co-fleet / shared) for a KVM in our own fleet, or — mirroring + * `capabilitiesFor`'s `isCec` arm — a dialed CEC customer. A help + * technician is neither owner nor co-fleet of the customer's appliance, + * yet the KVM's whole point on the help queue is that the tech can open + * its manufacturer web UI; the KVM itself authorizes that tunnel by its + * mesh roster, so the gate here only decides whether the button shows. + * `raisedHand` (queue rows) skips the ACL entirely: the hand-raise IS the + * ask — a beacon this customer deliberately lit — so the Site door shows + * the moment the graph knows the machine is a KVM, even before Answer. */ + kvmTwin(cecNode: string | undefined, opts?: { raisedHand?: boolean }): MeshNode | undefined { if (!cecNode) return undefined; const node = this.nodeByCanonical(cecNode); - return this.isKvm(node) && this.kvmAllowed(node) ? node : undefined; + if (!this.isKvm(node)) return undefined; + if (opts?.raisedHand) return node; + return this.kvmAllowed(node) || this.isCecCustomer(cecNode) ? node : undefined; } /** Open a KVM's own web UI through the mesh — map its web site to a local @@ -7223,6 +7240,10 @@ class AppStore { const byNode = "node" in target; this.cecDialing = true; this.cecDialingNumber = byNode ? null : target.number; + // Which ROW is being dialed, for the help sidebar's inline status — a + // by-node dial (Answer / Open / Chat) never sets `cecDialingNumber`, so + // without this the sidebar had nothing to pin its "Connecting…" line to. + this.cecDialingNode = byNode ? target.node : null; clientLog(`[cec] dialing ${display}…`); try { // Hard cap the wait so a wedged socket request can't leave cecDialing @@ -7258,6 +7279,7 @@ class AppStore { } finally { this.cecDialing = false; this.cecDialingNumber = null; + this.cecDialingNode = null; void this.loadCec(); } } @@ -7270,6 +7292,22 @@ class AppStore { await cecCancelDial(); } + /** The inline wait status a CEC row should show for `node`: "dialing" while + * the connect RPC is in flight to that machine, "approval" once the dial + * landed and the ball is in the customer's court (their approve prompt is + * up), null when nothing is pending. The help sidebar renders this in the + * row itself — it has no status strip like the settings console panel. */ + cecWaitPhase(node: string | undefined): "dialing" | "approval" | null { + if (!node) return null; + if (this.cecAutoOpenNode && this.isSameMachine(this.cecAutoOpenNode, node)) { + return "approval"; + } + if (this.cecDialing && this.cecDialingNode && this.isSameMachine(this.cecDialingNode, node)) { + return "dialing"; + } + return null; + } + /** Flip the "Watch the help queue" opt-in, then re-read the node's status — * membership is the saved state, so the toggle shows what actually took * (and keeps showing it after a restart, no local flag needed). A failure diff --git a/gui/src/ui/HelpTab.svelte b/gui/src/ui/HelpTab.svelte index 6ef9fad..4cb806f 100644 --- a/gui/src/ui/HelpTab.svelte +++ b/gui/src/ui/HelpTab.svelte @@ -119,46 +119,72 @@
    {#each app.cecHelpWaiting as w (w.node)} {@const shownName = app.cecAliases[w.number]?.trim() || w.label?.trim() || "Customer"} - {@const kvm = app.kvmTwin(w.node)} + {@const kvm = app.kvmTwin(w.node, { raisedHand: true })} + {@const phase = app.cecWaitPhase(w.node)}
  • - -
    - {shownName}{hostTail(shownName, w.hostname)} - - CEC {groupNumber(w.number)} - · {waitingLabel(w.asked_at)} - +
    + +
    + {shownName}{hostTail(shownName, w.hostname)} + + CEC {groupNumber(w.number)} + · {waitingLabel(w.asked_at)} + +
    +
    + +
    + {#if phase} + + + {phase === "approval" ? "Waiting for them to approve…" : "Connecting…"} + + + {:else} + + {#if kvm} + + + {:else} + + + {/if} + {/if}
    - - {#if kvm} - - - {/if} -
  • {/each}
@@ -169,65 +195,86 @@ {@const name = app.cecCustomerName(c)} {@const kvm = app.kvmTwin(c.node)}
  • - -
    - {#if editingKey === c.number} - - saveRename(c.number)} - onkeydown={(e) => { - if (e.key === "Enter") saveRename(c.number); - else if (e.key === "Escape") cancelRename(); - }} - /> - {:else} - - - #{groupNumber(c.number)} - · {lastUsedLabel(c.last_used)} - - {/if} +
    + +
    + {#if editingKey === c.number} + + saveRename(c.number)} + onkeydown={(e) => { + if (e.key === "Enter") saveRename(c.number); + else if (e.key === "Escape") cancelRename(); + }} + /> + {:else} + + + #{groupNumber(c.number)} + · {lastUsedLabel(c.last_used)} + + {/if} +
    {#if editingKey !== c.number} - - {#if kvm} - - {/if} - + + {@const phase = app.cecWaitPhase(c.node)} +
    + {#if phase} + + + {phase === "approval" ? "Waiting for them to approve…" : "Connecting…"} + + + {:else} + + {#if kvm} + + {:else} + + + {/if} + {/if} +
    {/if}
  • {/snippet} @@ -316,14 +363,61 @@ flex-direction: column; gap: 0.4rem; } + /* Each entry is a small card: identity on top, its action buttons in a + row along the bottom (with room for more as they earn a spot). */ .row { display: flex; - align-items: center; + flex-direction: column; gap: 0.5rem; padding: 0.5rem 0.55rem; background: var(--surface-2); border-radius: var(--r-sm); } + .row-head { + display: flex; + align-items: center; + gap: 0.5rem; + } + .row-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + } + /* Inline connect status - takes the action row's place while a dial to + this row is in flight or the customer's approve prompt is up. */ + .wait-line { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.74rem; + font-weight: 600; + color: var(--ok); + } + .wait-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--ok); + animation: pulse 1.8s ease-out infinite; + } + .stop-btn { + flex-shrink: 0; + border: 1px solid var(--danger); + background: transparent; + color: var(--danger); + font: inherit; + font-size: 0.74rem; + font-weight: 700; + padding: 0.28rem 0.6rem; + border-radius: var(--r-sm); + cursor: pointer; + } + .stop-btn:hover { + background: var(--danger-soft); + } .dot { flex-shrink: 0; width: 0.55rem; @@ -398,25 +492,27 @@ opacity: 0.5; cursor: default; } - /* The KVM's web-Site door — same compact icon-button shape as the chat - companion, shown only when the row's machine is one of our KVMs. */ + /* The KVM's manufacturer web-Site door — a labeled button on the card's + action row, shown only when the row's machine is a KVM. */ .site-btn { flex-shrink: 0; border: 1px solid var(--line-strong); background: transparent; color: var(--ink-soft); font: inherit; - font-size: 0.82rem; + font-size: 0.74rem; + font-weight: 700; line-height: 1; - padding: 0.28rem 0.4rem; + padding: 0.3rem 0.6rem; border-radius: var(--r-sm); cursor: pointer; } .site-btn:hover { background: var(--surface); } - /* The compact chat companion to Answer / Open — an icon button so the narrow - sidebar row stays readable, with an unread badge riding its corner. */ + /* The chat companion to Answer / Open — a labeled button on the action row + (people only; a KVM row shows its Site instead), with an unread badge + riding its corner. */ .chat-btn { position: relative; flex-shrink: 0; @@ -424,9 +520,10 @@ background: transparent; color: var(--ink-soft); font: inherit; - font-size: 0.82rem; + font-size: 0.74rem; + font-weight: 700; line-height: 1; - padding: 0.28rem 0.4rem; + padding: 0.3rem 0.6rem; border-radius: var(--r-sm); cursor: pointer; } diff --git a/gui/src/ui/settings/CecSection.svelte b/gui/src/ui/settings/CecSection.svelte index 8d203f3..6cfdc24 100644 --- a/gui/src/ui/settings/CecSection.svelte +++ b/gui/src/ui/settings/CecSection.svelte @@ -225,7 +225,7 @@
      {#each app.cecHelpWaiting as w (w.node)} {@const shownName = app.cecAliases[w.number]?.trim() || w.label?.trim() || "Customer"} - {@const kvm = app.kvmTwin(w.node)} + {@const kvm = app.kvmTwin(w.node, { raisedHand: true })}
    • @@ -247,8 +247,9 @@ Control {#if kvm} - + + {:else} + {/if} -
    • {/each} @@ -372,6 +374,8 @@ {/if} {#if kvm} + + {:else} + {/if} -