Skip to main content

agentmux_srv/server/app_api/
viewer.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The desktop's side of pairing a device with the viewer listener
5//! (`backend::viewer`, agentmux-mobile's
6//! SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07 ยง13.2): the QR, the
7//! paired-devices list with Revoke, and an agent's "hide from paired
8//! devices" flag. Reached over the full-key WebSocket only, like every RPC.
9
10use super::*;
11
12pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
13    register_pair_start(engine, state);
14    register_devices(engine, state);
15    register_revoke(engine, state);
16    register_agent_hidden(engine, state);
17}
18
19/// Why there is no QR to show: the viewer listener is not up.
20pub(crate) const NOT_LISTENING: &str =
21    "The viewer isn't listening on this network. Turn on LAN discovery to pair a device.";
22
23/// The `agentmux://pair` URL for a fresh code (cancelling the last one).
24pub(crate) fn pair_start_impl(state: &AppState) -> Result<ViewerPairStartResult, String> {
25    let (Some(port), Some(host)) = (state.viewer.advertised_port(), state.viewer.pair_host()) else {
26        return Err(NOT_LISTENING.to_string());
27    };
28    let fingerprint = state.viewer.tls()?.fingerprint.clone();
29    let (code, ttl) = state.viewer.pairing.start();
30    let query = url::form_urlencoded::Serializer::new(String::new())
31        .append_pair("v", "1")
32        .append_pair("host", &host.to_string())
33        .append_pair("port", &port.to_string())
34        .append_pair("fp", &fingerprint)
35        .append_pair("code", &code)
36        .append_pair("hostname", &state.hostname)
37        .append_pair("channel", &crate::backend::reactive::registry::local_channel_id())
38        .finish();
39    Ok(ViewerPairStartResult {
40        url: format!("agentmux://pair?{query}"),
41        expires_ms: agentmux_common::time::now_ms() + ttl.as_millis() as i64,
42    })
43}
44
45fn register_pair_start(engine: &Arc<WshRpcEngine>, state: &AppState) {
46    let state = state.clone();
47    engine.register_typed(COMMAND_VIEWER_PAIR_START, move |_req: Option<NoArgsReq>, _ctx| {
48        let state = state.clone();
49        async move { pair_start_impl(&state) }
50    });
51}
52
53fn register_devices(engine: &Arc<WshRpcEngine>, state: &AppState) {
54    let mstore = state.mstore.clone();
55    engine.register_typed(COMMAND_VIEWER_DEVICES, move |_req: Option<NoArgsReq>, _ctx| {
56        let mstore = mstore.clone();
57        async move {
58            let devices = mstore
59                .viewer_device_list()
60                .map_err(|e| format!("viewer.devices: {e}"))?
61                .into_iter()
62                .map(|d| ViewerDeviceInfo {
63                    device_id: d.device_id,
64                    device_name: d.device_name,
65                    created_ms: d.created_ms,
66                    last_seen_ms: d.last_seen_ms,
67                })
68                .collect();
69            Ok(ViewerDevicesResult { devices })
70        }
71    });
72}
73
74/// Revoke a device: its token stops working on the next request, and every
75/// feed it has open is closed now.
76pub(crate) fn revoke_impl(state: &AppState, device_id: &str) -> Result<ViewerRevokeResult, String> {
77    let revoked = state.mstore.viewer_device_delete(device_id).map_err(|e| format!("viewer.revoke: {e}"))?;
78    let feeds_closed = state.viewer.close_device_feeds(device_id);
79    state.viewer.forget_device(device_id);
80    if revoked {
81        tracing::info!(%device_id, feeds_closed, "viewer: device revoked");
82    }
83    Ok(ViewerRevokeResult { revoked, feeds_closed })
84}
85
86fn register_revoke(engine: &Arc<WshRpcEngine>, state: &AppState) {
87    let state = state.clone();
88    engine.register_typed(COMMAND_VIEWER_REVOKE, move |cmd: CommandViewerRevokeData, _ctx| {
89        let state = state.clone();
90        async move { revoke_impl(&state, &cmd.device_id) }
91    });
92}
93
94fn register_agent_hidden(engine: &Arc<WshRpcEngine>, state: &AppState) {
95    let mstore = state.mstore.clone();
96    engine.register_typed(COMMAND_VIEWER_AGENT_HIDDEN, move |cmd: CommandViewerAgentHiddenData, _ctx| {
97        let mstore = mstore.clone();
98        async move {
99            if let Some(hidden) = cmd.hidden {
100                let found = mstore
101                    .agent_hide_from_devices_set(&cmd.agent_id, hidden)
102                    .map_err(|e| format!("viewer.agent-hidden: {e}"))?;
103                if !found {
104                    return Err(format!("viewer.agent-hidden: no agent {}", cmd.agent_id));
105                }
106            }
107            let hidden = mstore
108                .agent_hide_from_devices_get(&cmd.agent_id)
109                .map_err(|e| format!("viewer.agent-hidden: {e}"))?;
110            Ok(ViewerAgentHiddenResult { hidden })
111        }
112    });
113}