diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 930662b1f0..2da5f6829c 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **660** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **662** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **660** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **662** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 142e08c96e..a2abd684b8 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", + "digest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -23834,13 +23834,13 @@ }, { "id": "devices", - "titleZh": "列出、连接、查看在线状态和删除同账户设备", - "titleEn": "List, connect, inspect online status, and remove same-account devices", + "titleZh": "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "titleEn": "List, connect, inspect model and OS, set aliases, and remove same-account devices", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看在线状态和删除同账户设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." } }, { @@ -23919,8 +23919,8 @@ "Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently", "使用 GitHub 登录、退出并查看身份状态", "Sign in with GitHub, sign out, and inspect identity status", - "列出、连接、查看在线状态和删除同账户设备", - "List, connect, inspect online status, and remove same-account devices", + "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "List, connect, inspect model and OS, set aliases, and remove same-account devices", "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", "Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane", "打开 Remote Connect", diff --git a/docs/interactive-capabilities/capabilities/feature.remote-connect.md b/docs/interactive-capabilities/capabilities/feature.remote-connect.md index 31fd20b236..bc350ef80f 100644 --- a/docs/interactive-capabilities/capabilities/feature.remote-connect.md +++ b/docs/interactive-capabilities/capabilities/feature.remote-connect.md @@ -27,8 +27,8 @@ Connect to this host from mobile, a bot, or another OpenBitFun device to inspect - Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 使用 GitHub 登录、退出并查看身份状态 - Sign in with GitHub, sign out, and inspect identity status -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 列出、连接、查看在线状态和删除同账户设备 - - List, connect, inspect online status, and remove same-account devices +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 列出、连接、查看设备型号与系统、设置别名和删除同账户设备 + - List, connect, inspect model and OS, set aliases, and remove same-account devices - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面 - Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 43848b2768..4bd8df1d79 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", + "catalogDigest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025", "count": 211, "reasonCounts": { "externalAuth": 4, @@ -1965,17 +1965,19 @@ { "capabilityId": "feature.remote-connect", "itemId": "devices", - "titleZh": "列出、连接、查看在线状态和删除同账户设备", - "titleEn": "List, connect, inspect online status, and remove same-account devices", + "titleZh": "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "titleEn": "List, connect, inspect model and OS, set aliases, and remove same-account devices", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看在线状态和删除同账户设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user.", "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect" }, "evidence": [ "command:account_list_devices", + "command:account_relay_capabilities", + "command:account_update_device_alias", "command:account_connect_devices", "command:account_online_devices", "command:account_delete_device", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 6edcd0e16c..9248d46a97 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", - "commandCount": 660, + "catalogDigest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025", + "commandCount": 662, "coverage": { - "commandCount": 660, - "documentedCommandCount": 613, + "commandCount": 662, + "documentedCommandCount": 615, "implementationCommandCount": 47, "implementationDigest": "401fd0a5f64377e65573d1b0883fbfa844570096adfafd4c11a999d749ba5859" }, @@ -268,6 +268,22 @@ "signature": "fn account_online_devices() -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "account_relay_capabilities", + "moduleId": "remote_connect", + "capabilityId": "feature.remote-connect", + "capabilityIds": [ + "feature.remote-connect" + ], + "documentedItemIds": [ + "feature.remote-connect:devices" + ], + "visibility": "documented", + "rustPath": "api::remote_connect_api::account_relay_capabilities", + "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", + "signature": "fn account_relay_capabilities() -> Result, String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "account_status", "moduleId": "remote_connect", @@ -326,6 +342,22 @@ "signature": "fn account_unsubscribe_session(request: UnsubscribeSessionRequest) -> Result<(), String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "account_update_device_alias", + "moduleId": "remote_connect", + "capabilityId": "feature.remote-connect", + "capabilityIds": [ + "feature.remote-connect" + ], + "documentedItemIds": [ + "feature.remote-connect:devices" + ], + "visibility": "documented", + "rustPath": "api::remote_connect_api::account_update_device_alias", + "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", + "signature": "fn account_update_device_alias( request: AccountUpdateDeviceAliasRequest, ) -> Result<(), String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "acknowledge_external_ecosystems_command", "moduleId": "external_sources", diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index d17c20aa8a..5bbf659995 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -55,6 +55,13 @@ pub(crate) fn account_snapshot_projection( .map(|device| AccountDevice { device_id: device.device_id, device_name: device.device_name, + device_alias: device.device_alias, + device_model: device.device_model, + device_os: device.device_os, + device_os_version: device.device_os_version, + device_client_version: device.device_client_version, + device_client_protocol: device.device_client_protocol, + compatible: device.compatible, online: device.online, }) .collect(), @@ -355,6 +362,17 @@ impl CliAccountRoutingHost { .read_account_context_for_generation(account_generation) .await { + // AuthOk is emitted on initial connection and every reconnect. + // Use its authenticated id, never a controller's target id. + if session.token != expected_token { + return; + } + if let Err(error) = runtime + .report_local_device_metadata(account_generation, &device_id) + .await + { + tracing::warn!("Failed to report device metadata on connection: {error}"); + } if session.token == expected_token && self .routing_loop_is_current(account_generation, relay_client) @@ -519,7 +537,11 @@ impl CliAccountRoutingHost { tracing::info!("Device routing disconnected"); crate::peer_host::update_controller_presence(Vec::new()).await; } - RelayEvent::Reconnected => tracing::info!("Device routing reconnected"), + RelayEvent::Reconnected => { + tracing::info!("Device routing reconnected"); + // The following AuthOk reports metadata with the server-confirmed + // device id. Do not race it with a second PATCH using cached identity. + } RelayEvent::Error { message } => { tracing::warn!("Device routing error: {message}") } diff --git a/src/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index 18d04f8e08..5c7c6e214b 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -297,13 +297,29 @@ impl LoginFormState { let is_local = local_id == Some(d.device_id.as_str()); let status = if d.online { "online" } else { "offline" }; let badge = if is_local { " [this device]" } else { "" }; + // A missing flag (older Relay) is unknown and shows nothing. + let compat = if d.is_compatible() { + "" + } else { + " · incompatible" + }; device_lines.push(Line::from(Span::styled( format!( - " {}{} {} · {}", - d.device_name, + " {}{} {} · {}{} {}", + d.display_name(), badge, truncate_id(&d.device_id), - status + status, + compat, + [ + d.device_model.as_deref(), + d.device_os.as_deref(), + d.device_os_version.as_deref() + ] + .into_iter() + .flatten() + .collect::>() + .join(" ") ), if d.online { Style::default().fg(Color::White) diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index d72ca04708..8e89b68fd8 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -265,16 +265,8 @@ fn tag_peer_event_source(payload: serde_json::Value, source_device_id: &str) -> } } -fn emit_device_presence(devices: &[(String, String)]) { - let payload = serde_json::json!({ - "devices": devices - .iter() - .map(|(id, name)| serde_json::json!({ - "device_id": id, - "device_name": name, - })) - .collect::>(), - }); +fn emit_device_presence(devices: &[OnlineDeviceInfo]) { + let payload = serde_json::json!({ "devices": devices }); emit_account_event("account://device-presence", payload); } @@ -2367,11 +2359,7 @@ pub async fn account_logout(app: tauri::AppHandle) -> Result<(), String> { // ── P2: Device routing commands ────────────────────────────────────────── -#[derive(Clone, Debug, Serialize, PartialEq, Eq)] -pub struct OnlineDeviceInfo { - pub device_id: String, - pub device_name: String, -} +pub use openbitfun_core::service::remote_connect::relay_client::DevicePresenceEntry as OnlineDeviceInfo; const STARTUP_DEVICE_CONNECT_MAX_ATTEMPTS: usize = 5; @@ -2499,6 +2487,13 @@ pub async fn account_connect_devices() -> Result, String> log::warn!("Failed to persist AuthOk device_id into session: {e}"); } + if let Err(error) = AccountClient::new() + .report_local_metadata(&relay_url, &session, &auth_device_id) + .await + { + log::warn!("Failed to report device metadata on connection: {error}"); + } + // Background task: consume events (presence / device messages / auth errors) // Note: AuthOk is consumed inside start_device_connection (adopt happens there). let event_relay_url = relay_url.clone(); @@ -2536,14 +2531,7 @@ pub async fn account_connect_devices() -> Result, String> else { break 'routing_events; }; - let presence = devices - .iter() - .map(|device| OnlineDeviceInfo { - device_id: device.device_id.clone(), - device_name: device.device_name.clone(), - }) - .collect(); - if !replace_device_presence_if_owner(&event_owner, presence) { + if !replace_device_presence_if_owner(&event_owner, devices.clone()) { break 'routing_events; } log::info!("Device presence updated: {} online", devices.len()); @@ -2559,11 +2547,7 @@ pub async fn account_connect_devices() -> Result, String> if !device_routing_owner_is_current(&event_owner).await { break 'routing_events; } - let pairs: Vec<(String, String)> = devices - .iter() - .map(|d| (d.device_id.clone(), d.device_name.clone())) - .collect(); - emit_device_presence(&pairs); + emit_device_presence(&devices); } RelayEvent::DeviceMessageReceived { source_device_id, @@ -2849,6 +2833,18 @@ pub async fn account_connect_devices() -> Result, String> else { break 'routing_events; }; + if let Ok(identity) = current_device_identity() { + if let Err(error) = AccountClient::new() + .report_local_metadata( + &event_relay_url, + &event_session, + &identity.device_id, + ) + .await + { + log::warn!("Failed to report device metadata after reconnect: {error}"); + } + } log::info!("Device routing reconnected — AuthConnect re-sent by transport"); } _ => {} @@ -2930,6 +2926,13 @@ pub async fn account_execute_on_device( pub struct AccountDeviceInfo { pub device_id: String, pub device_name: String, + pub device_alias: Option, + pub device_model: Option, + pub device_os: Option, + pub device_os_version: Option, + pub device_client_version: Option, + pub device_client_protocol: Option, + pub compatible: Option, pub online: bool, pub last_seen_at: Option, } @@ -2957,12 +2960,57 @@ pub async fn account_list_devices() -> Result, String> { .map(|d| AccountDeviceInfo { device_id: d.device_id, device_name: d.device_name, + device_alias: d.device_alias, + device_model: d.device_model, + device_os: d.device_os, + device_os_version: d.device_os_version, + device_client_version: d.device_client_version, + device_client_protocol: d.device_client_protocol, + compatible: d.compatible, online: d.online, last_seen_at: d.last_seen_at, }) .collect()) } +#[derive(Deserialize)] +pub struct AccountUpdateDeviceAliasRequest { + pub device_id: String, + pub device_alias: Option, +} + +#[tauri::command] +pub async fn account_update_device_alias( + request: AccountUpdateDeviceAliasRequest, +) -> Result<(), String> { + let generation = account_context_generation(); + let _operation = lock_account_operation(generation).await?; + let (session, relay_url) = read_account_context_for_generation(generation).await?; + AccountClient::new() + .update_device_alias( + &relay_url, + &session, + &request.device_id, + request.device_alias.as_deref(), + ) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn account_relay_capabilities() -> Result, String> { + let generation = account_context_generation(); + let (_, relay_url) = read_account_context_for_generation(generation).await?; + let capabilities = AccountClient::new() + .relay_capabilities(&relay_url) + .await + .map_err(|error| error.to_string())?; + if !account_context_is_current(generation) { + return Err("account context changed".into()); + } + Ok(capabilities) +} + /// Remove a device from the account. #[tauri::command] pub async fn account_delete_device(targetDeviceId: String) -> Result<(), String> { @@ -3464,6 +3512,7 @@ mod sync_state_tests { vec![OnlineDeviceInfo { device_id: "a-device".to_string(), device_name: "A".to_string(), + ..Default::default() }], )); @@ -3473,6 +3522,7 @@ mod sync_state_tests { vec![OnlineDeviceInfo { device_id: "late-a-device".to_string(), device_name: "Late A".to_string(), + ..Default::default() }], )); assert!(!clear_device_routing_if_owner(&owner_a)); @@ -3499,10 +3549,21 @@ mod sync_state_tests { vec![OnlineDeviceInfo { device_id: "current-device".to_string(), device_name: "Current".to_string(), + device_alias: Some("My laptop".into()), + device_model: Some("Mac14,7".into()), + device_os: Some("macos".into()), + device_os_version: Some("15".into()), + ..Default::default() }], )); - assert!(device_presence_for_account(20, "token-current").is_some()); + let presence = device_presence_for_account(20, "token-current").unwrap(); + let payload = serde_json::json!({ "devices": presence }); + assert_eq!(payload["devices"][0]["device_alias"], "My laptop"); + assert_eq!(payload["devices"][0]["device_name"], "Current"); + assert_eq!(payload["devices"][0]["device_model"], "Mac14,7"); + assert_eq!(payload["devices"][0]["device_os"], "macos"); + assert_eq!(payload["devices"][0]["device_os_version"], "15"); assert!(device_presence_for_account(21, "token-current").is_none()); assert!(device_presence_for_account(20, "token-replaced").is_none()); clear_device_routing_state(); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b3a0644bd3..69a0b70a1c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1736,6 +1736,8 @@ pub async fn run() { api::remote_connect_api::account_get_credential_hint, api::remote_connect_api::account_token_expired, api::remote_connect_api::account_list_devices, + api::remote_connect_api::account_update_device_alias, + api::remote_connect_api::account_relay_capabilities, api::remote_connect_api::account_delete_device, api::remote_connect_api::account_device_rpc, api::remote_connect_api::account_subscribe_session, diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index b0a1efc076..67b86418f0 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -195,6 +195,7 @@ location and process. | `POST /api/auth/delegate` | Issue a separately keyed, restricted controller credential | | `POST /api/auth/provision-device` | Authorized SSH host bootstrap | | `GET /api/devices` | Same-account device directory | +| `PATCH /api/devices/{id}` | Update an account device alias or self-reported metadata | | `GET /api/devices/{id}/key` | Same-account device public key | | `DELETE /api/devices/{id}` | Explicit device removal and revocation | | `GET /v1/updates` | Authenticated Socket.IO account and machine scopes | @@ -208,6 +209,97 @@ A lost acknowledgement reports an unknown outcome and never replays a mutation. Small encrypted messages travel over the live connection; larger RPC bodies use short-lived HTTP references that expire on their own. +### Device-directory compatibility + +`GET /api/devices` retains its existing scope: only same-account desktop/session-host +control targets are listed, including offline devices. Registered mobile and watch +devices remain hidden; legacy rows without a device kind remain desktop targets. +The additive nullable fields are: + +| Field | Meaning | +|---|---| +| `device_alias` | Account-owned user alias, persisted independently of the technical name | +| `device_model` | Host-reported device model | +| `device_os` | Host-reported operating-system name | +| `device_os_version` | Host-reported operating-system version | +| `client_version` | Client build string reported at login/handshake, null when unreported | +| `client_protocol` | Client protocol number reported at login/handshake, null when unreported | +| `compatible` | Relay-computed; whether the requesting token's device may remote-control this device | + +`device_name` remains the technical/self-reported name; the Relay never replaces it +with the alias. New clients display alias, then technical name, then device id as +fallback. Older clients continue displaying the technical name and can ignore the +additive response fields. Clients must tolerate absent fields from older Relays. + +`PATCH /api/devices/{id}` requires an authenticated full device bearer token and +returns `204 No Content` on success. A device may change the alias of any device +in its own account, but may update model/OS metadata only for its own authenticated +device id. Delegated controller tokens cannot patch devices (`403`); missing or +other-account targets return `404` without revealing ownership. + +- `{"device_alias":"Build host"}` sets the alias; `{"device_alias":null}` clears it. +- Missing fields mean no change, including a missing alias. An empty object is a + no-op, not a request to clear fields. +- `device_model`, `device_os`, and `device_os_version` accept strings only when + present in a PATCH; explicit `null` is rejected rather than clearing metadata. +- Each alias or metadata string is limited to **256 UTF-8 bytes**, not characters, + and must be nonblank and contain no control characters. Use alias `null`, not an + empty string, to clear an alias. +- PATCH uses strict `deny_unknown_fields`: unknown mutation fields, including an + arbitrary metadata extension object, are rejected rather than silently ignored + and reported as successful. This differs deliberately from extensible response + objects, whose unknown fields clients may ignore. + +Before sending mutations, clients negotiate capabilities through `GET /api/info`: +`device_alias_v1` enables alias updates and `device_metadata_v1` enables metadata +reporting/updates. These are strings in the `capabilities` array; +`protocol_version` remains `3`. Missing capabilities mean unsupported, regardless +of package version. Future PATCH fields or behaviors require their own capability +negotiation before use; clients must not probe old servers with unknown mutations. +An older Relay returning `404`/`405` must produce an explicit unsupported state, +not a successful local-only rename. + +### Client-build compatibility + +Newer clients also report their build so the Relay can refuse a remote-control +pair it cannot prove compatible. `device_client_build_v1` advertises this: an +optional `clientVersion` string and `clientProtocol` number travel with the +realtime handshake and with `POST /api/auth/login`. + +- The device row stores the build from the **current** connection. Every login + and handshake refreshes both values, including writing `NULL` when the client + reports nothing, so a stale build is never left behind. `clientVersion` is + limited to 64 UTF-8 bytes with no control characters; a malformed or absent + value is treated as unreported rather than rejected, so an older client still + connects. +- Compatibility is decided only from `clientProtocol`, and a report is required + rather than merely tolerated: control is allowed only when both sides reported + a protocol number and the numbers match. Two legacy clients that report + nothing, and any pair where either side never reported, are incompatible. +- `GET /api/devices` reports the relay-computed `compatible` flag per target, + based on the requesting token's device row. Incompatible devices are still + listed, never hidden or deleted. +- A `rpc-call` between incompatible devices is answered with a `failure` + acknowledgement — `incompatible client build: remote control requires matching + client versions` — and is not dispatched. + +Presence `device-presence` entries carry the raw `client_version` and +`client_protocol` values only; the relay-computed `compatible` flag appears +exclusively in `GET /api/devices`. + +Login and provisioning accept optional model/OS metadata. Omission by an older +client preserves stored metadata, and neither registration nor reconnect changes +the alias. Login (and the realtime handshake) additionally report the client +build described above, which is refreshed rather than preserved. Additive SQLite +migrations preserve existing device rows; aliases and metadata survive Relay +restart when the same persistent database is used. Startup resets stale online +presence, not the alias or technical metadata. + +Successful patches notify online same-account clients through the existing +`device-presence` channel, whose device entries also carry the new fields. +Notifications are refresh hints, not durable directory state: re-fetch +`GET /api/devices` after a patch or notification and on reconnect/normal refresh. + ### Forwarding only The relay stores no session content. Session transcripts, terminal output and diff --git a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs index 8f41cdda3f..229ed62efb 100644 --- a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs +++ b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs @@ -80,6 +80,19 @@ pub struct AccountInfo { pub struct AccountDevice { pub device_id: String, pub device_name: String, + pub device_alias: Option, + pub device_model: Option, + pub device_os: Option, + pub device_os_version: Option, + /// Build string the device last reported to the Relay; absent for legacy + /// devices and older Relays. + pub device_client_version: Option, + /// Control-contract protocol number the device last reported; absent for + /// legacy devices and older Relays. + pub device_client_protocol: Option, + /// Relay-computed compatibility with our control contract. `None` means + /// unknown (older Relay) and must be treated as compatible. + pub compatible: Option, pub online: bool, } @@ -220,6 +233,7 @@ impl AccountRuntime { } let transition = self.begin_account_transition().await; self.host.stop_device_routing().await; + let mut metadata_device_id = None; let restored = match session_store::load_session_detailed() { Ok(Some(loaded)) => { let relay_url = match normalize_relay_url(&loaded.relay_url) { @@ -232,8 +246,11 @@ impl AccountRuntime { }; let user_id = loaded.user_id.clone(); if let Some(device_id) = loaded.device_id.as_deref() { - if let Err(error) = DeviceIdentity::adopt_account_device_id(device_id) { - log::warn!("Failed to adopt restored session device_id: {error}"); + match DeviceIdentity::adopt_account_device_id(device_id) { + Ok(_) => metadata_device_id = Some(device_id.to_string()), + Err(error) => { + log::warn!("Failed to adopt restored session device_id: {error}") + } } } let session = AccountSession::new(loaded.token, user_id.clone(), loaded.master_key); @@ -248,10 +265,33 @@ impl AccountRuntime { None } }; - transition.finish(); + let generation = transition.finish(); + // Headless exec/Shared/dispatch hosts may never start routing. Report + // their own persisted account identity here, not a controller's SSH + // provisioning target. Legacy sessions without an id wait for AuthOk. + if let Some(device_id) = metadata_device_id { + if let Err(error) = self + .report_local_device_metadata(generation, &device_id) + .await + { + log::warn!("Failed to report restored host metadata: {error}"); + } + } restored } + /// Called on the account-owning host, using its restored or authenticated id. + pub async fn report_local_device_metadata( + &self, + generation: u64, + device_id: &str, + ) -> Result<()> { + let (session, relay_url) = self.read_account_context_for_generation(generation).await?; + AccountClient::new() + .report_local_metadata(&relay_url, &session, device_id) + .await + } + pub async fn advance_github_login( self: &Arc, transaction_id: Option, @@ -474,6 +514,32 @@ impl AccountRuntime { }) } + pub async fn relay_capabilities(&self) -> Result> { + let generation = self.account_context_generation(); + let (_, relay_url) = self.read_account_context_for_generation(generation).await?; + let capabilities = AccountClient::new().relay_capabilities(&relay_url).await?; + if !self.account_context_is_current(generation) { + return Err(anyhow!("account context changed")); + } + Ok(capabilities) + } + + pub async fn update_device_alias( + &self, + device_id: &str, + device_alias: Option<&str>, + ) -> Result<()> { + let generation = self.account_context_generation(); + let (session, relay_url) = self.read_account_context_for_generation(generation).await?; + AccountClient::new() + .update_device_alias(&relay_url, &session, device_id, device_alias) + .await?; + if !self.account_context_is_current(generation) { + return Err(anyhow!("account context changed")); + } + Ok(()) + } + pub async fn list_devices(&self) -> Result> { let (session, relay_url) = self.read_account_context().await?; let devices = AccountClient::new() @@ -484,6 +550,13 @@ impl AccountRuntime { .map(|device| AccountDevice { device_id: device.device_id, device_name: device.device_name, + device_alias: device.device_alias, + device_model: device.device_model, + device_os: device.device_os, + device_os_version: device.device_os_version, + device_client_version: device.device_client_version, + device_client_protocol: device.device_client_protocol, + compatible: device.compatible, online: device.online, }) .collect()) @@ -663,6 +736,94 @@ mod tests { AccountRuntime::new(Arc::new(TestAccountRuntimeHost)) } + #[tokio::test] + async fn metadata_report_rejects_stale_or_missing_account_before_network() { + let runtime = test_runtime(); + let generation = runtime.account_context_generation(); + assert_eq!( + runtime + .report_local_device_metadata(generation, "host") + .await + .unwrap_err() + .to_string(), + "not logged in" + ); + assert_eq!( + runtime + .report_local_device_metadata(generation + 1, "host") + .await + .unwrap_err() + .to_string(), + "account context changed" + ); + } + + #[tokio::test] + async fn metadata_report_uses_host_account_and_negotiates_capability() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + for index in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let header_end = loop { + let mut buffer = [0; 4096]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + break end + 4; + } + }; + let headers = String::from_utf8(bytes[..header_end].to_vec()) + .unwrap() + .to_lowercase(); + let length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .map(|value| value.parse::().unwrap()) + .unwrap_or(0); + while bytes.len() < header_end + length { + let mut buffer = [0; 4096]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + } + let body = if index == 0 { + assert!(headers.starts_with("get /api/info ")); + r#"{"capabilities":["device_metadata_v1"]}"# + } else { + assert!(headers.starts_with("patch /api/devices/executing-host ")); + assert!(headers.contains("authorization: bearer host-token\r\n")); + let payload: serde_json::Value = + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap(); + assert!(payload["device_os"].is_string()); + assert!(payload.get("device_alias").is_none()); + "{}" + }; + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + let runtime = test_runtime(); + *runtime.account_context.write().await = Some(AccountContextState { + session: AccountSession::new("host-token".into(), "host-user".into(), [0; 32]), + relay_url: url, + }); + tokio::time::timeout(Duration::from_secs(20), async { + runtime + .report_local_device_metadata( + runtime.account_context_generation(), + "executing-host", + ) + .await + .unwrap(); + server.await.unwrap(); + }) + .await + .unwrap(); + } + #[tokio::test] async fn rejected_account_transition_does_not_advance_the_generation() { let runtime = test_runtime(); diff --git a/src/crates/contracts/product-domains/src/account.rs b/src/crates/contracts/product-domains/src/account.rs index 3f55e28ae7..cb029c4e8b 100644 --- a/src/crates/contracts/product-domains/src/account.rs +++ b/src/crates/contracts/product-domains/src/account.rs @@ -5,6 +5,37 @@ use serde::{Deserialize, Serialize}; /// Versioned hosted Relay deployment for the GitHub account/device-key protocol. pub const DEFAULT_RELAY_URL: &str = "https://remote.openbitfun.com/v/1.0.2"; +/// Control-contract protocol number reported to the Relay alongside the build +/// string when a device registers or opens its realtime connection. +/// +/// Two devices are mutually controllable only when BOTH report a number AND the +/// numbers are equal: `(Some(a), Some(b)) && a == b`. A device that has never +/// reported a protocol number is therefore incompatible, never +/// "legacy-compatible", and so is any pair whose numbers differ. +/// +/// Compatibility is decided solely by the Relay from the two stored numbers: +/// clients read the Relay-computed `compatible` flag on the device directory +/// instead of re-deriving this rule locally. +/// +/// Semantics: +/// +/// - unreported: a build that never sent a protocol number (incompatible); +/// - this build: `2`; +/// - increment only on a breaking change to the client control contract. +pub const CLIENT_PROTOCOL_VERSION: u32 = 2; + +/// The product build string reported to the Relay for diagnostics next to +/// [`CLIENT_PROTOCOL_VERSION`]. +/// +/// Every workspace member inherits `version.workspace = true`, so this +/// compile-time value is the single product version actually shipped to users. +/// Keeping it beside the protocol number lets the shared account/relay owners +/// report it without threading a host parameter through every call site, and +/// without a lower crate importing an upper crate's version. +pub fn client_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccountInfo { @@ -19,9 +50,59 @@ pub struct AccountInfo { pub struct AccountDevice { pub device_id: String, pub device_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_alias: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_os_version: Option, + /// Build string the device last reported to the Relay. Absent for legacy + /// devices and for Relays that predate the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_client_version: Option, + /// Control-contract protocol number the device last reported. Absent for + /// legacy devices and for Relays that predate the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_client_protocol: Option, + /// Relay-computed compatibility with this client's control contract. + /// `None` means unknown (an older Relay without the field) and must be + /// treated as compatible rather than incompatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compatible: Option, pub online: bool, } +impl AccountDevice { + /// Whether the Relay considers this device compatible with our control + /// contract. + /// + /// A `Some(false)` answer means the pair must not be remote-controlled: + /// this is what the Relay computes for a device that never reported a + /// [`CLIENT_PROTOCOL_VERSION`], or one whose number differs from ours. A + /// missing flag (`None`) means an older Relay that predates the gating field + /// and cannot judge at all, so it is unknown and treated as compatible. + /// + /// Keep these two cases distinct: "device lacks a version" is judged + /// incompatible by the Relay (`Some(false)`); "old Relay cannot judge" is + /// `None`. Do not widen the fallback to cover a device that simply has no + /// version. + pub fn is_compatible(&self) -> bool { + self.compatible.unwrap_or(true) + } + + pub fn display_name(&self) -> &str { + self.device_alias.as_deref().unwrap_or_else(|| { + if self.device_name.is_empty() { + &self.device_id + } else { + &self.device_name + } + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccountSnapshotProjection { @@ -92,6 +173,61 @@ impl GitHubUser { #[cfg(test)] mod identity_tests { use super::*; + #[test] + fn device_directory_legacy_round_trip_preserves_technical_name() { + let legacy = serde_json::json!({"deviceId":"id", "deviceName":"technical", "online":true}); + let mut device: AccountDevice = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(serde_json::to_value(&device).unwrap(), legacy); + device.device_alias = Some("My laptop".into()); + device.device_model = Some("Mac14,7".into()); + device.device_os = Some("macos".into()); + device.device_os_version = Some("15.0".into()); + let round_trip: AccountDevice = + serde_json::from_value(serde_json::to_value(&device).unwrap()).unwrap(); + assert_eq!(device, round_trip); + assert_eq!(round_trip.display_name(), "My laptop"); + assert_eq!(round_trip.device_name, "technical"); + device.device_alias = None; + assert_eq!(device.display_name(), "technical"); + device.device_name.clear(); + assert_eq!(device.display_name(), "id"); + } + + #[test] + fn device_directory_client_compatibility_preserves_legacy_shape() { + // A legacy payload without the new fields round-trips unchanged and is + // treated as compatible, so an older Relay is never misjudged. + let legacy = serde_json::json!({"deviceId":"id", "deviceName":"technical", "online":true}); + let device: AccountDevice = serde_json::from_value(legacy.clone()).unwrap(); + assert!(device.compatible.is_none()); + assert!(device.is_compatible()); + assert!(device.device_client_version.is_none()); + assert!(device.device_client_protocol.is_none()); + assert_eq!(serde_json::to_value(&device).unwrap(), legacy); + + // A device that never reported a version is judged incompatible by the + // Relay: `compatible` is present and false. + let unversioned: AccountDevice = serde_json::from_value(serde_json::json!({ + "deviceId":"id", "deviceName":"technical", "online":true, "compatible":false + })) + .unwrap(); + assert!(unversioned.device_client_protocol.is_none()); + assert!(!unversioned.is_compatible()); + + // A Relay that reports a mismatched build carries the values through. + let reported: AccountDevice = serde_json::from_value(serde_json::json!({ + "deviceId":"id", "deviceName":"technical", "online":true, + "deviceClientVersion":"0.9.0", "deviceClientProtocol":1, "compatible":false + })) + .unwrap(); + assert_eq!(reported.device_client_version.as_deref(), Some("0.9.0")); + assert_eq!(reported.device_client_protocol, Some(1)); + assert!(!reported.is_compatible()); + let round_trip: AccountDevice = + serde_json::from_value(serde_json::to_value(&reported).unwrap()).unwrap(); + assert_eq!(reported, round_trip); + } + #[test] fn legacy_profile_round_trip_and_independent_email_identity() { let legacy = r#"{"githubId":42,"login":"alice","avatarUrl":""}"#; diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index f5f032e7fc..649af5c69c 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", + "digest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -23875,13 +23875,13 @@ }, { "id": "devices", - "titleZh": "列出、连接、查看在线状态和删除同账户设备", - "titleEn": "List, connect, inspect online status, and remove same-account devices", + "titleZh": "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "titleEn": "List, connect, inspect model and OS, set aliases, and remove same-account devices", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看在线状态和删除同账户设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." } }, { @@ -23960,8 +23960,8 @@ "Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently", "使用 GitHub 登录、退出并查看身份状态", "Sign in with GitHub, sign out, and inspect identity status", - "列出、连接、查看在线状态和删除同账户设备", - "List, connect, inspect online status, and remove same-account devices", + "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "List, connect, inspect model and OS, set aliases, and remove same-account devices", "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", "Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane", "打开 Remote Connect", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 05435480ad..c3e1355478 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:5fd9f0407289f440", + "digest": "fnv1a64:86d6295909ba287d", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -257,6 +257,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "account_relay_capabilities", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "account_status", "surface": "tauri_command", @@ -305,6 +317,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "account_update_device_alias", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "acknowledge_external_ecosystems_command", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index d3c6956b34..de20e97004 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -100,10 +100,12 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("account_login", Agnostic, ControllerLocal, REFUSED), op("account_logout", Agnostic, ControllerLocal, REFUSED), op("account_online_devices", Agnostic, ControllerLocal, REFUSED), + op("account_relay_capabilities", Agnostic, ControllerLocal, REFUSED), op("account_status", Agnostic, ControllerLocal, REFUSED), op("account_subscribe_session", Agnostic, ControllerLocal, REFUSED), op("account_token_expired", Agnostic, ControllerLocal, REFUSED), op("account_unsubscribe_session", Agnostic, ControllerLocal, REFUSED), + op("account_update_device_alias", Agnostic, ControllerLocal, REFUSED), op("acknowledge_external_ecosystems_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("activate_session_goal", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("add_skill", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/interfaces/app-server/src/management/owner.rs b/src/crates/interfaces/app-server/src/management/owner.rs index 903a6eb246..7617301912 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -1732,6 +1732,13 @@ fn project_account_snapshot( .map(|device| AccountDevice { device_id: device.device_id, device_name: device.device_name, + device_alias: device.device_alias, + device_model: device.device_model, + device_os: device.device_os, + device_os_version: device.device_os_version, + device_client_version: device.device_client_version, + device_client_protocol: device.device_client_protocol, + compatible: device.compatible, online: device.online, }) .collect(), diff --git a/src/crates/services/relay-service/src/account_transport_tests.rs b/src/crates/services/relay-service/src/account_transport_tests.rs index 04f3d01523..81baaec845 100644 --- a/src/crates/services/relay-service/src/account_transport_tests.rs +++ b/src/crates/services/relay-service/src/account_transport_tests.rs @@ -65,6 +65,16 @@ async fn connect_scope( prefix: &str, token: &str, scope: &str, +) -> Socket { + connect_scope_with_build(address, prefix, token, scope, None).await +} + +async fn connect_scope_with_build( + address: std::net::SocketAddr, + prefix: &str, + token: &str, + scope: &str, + build: Option<(&str, u32)>, ) -> Socket { let (mut socket, _) = tokio_tungstenite::connect_async(format!( "ws://{address}{prefix}/v1/updates/?EIO=4&transport=websocket" @@ -72,10 +82,13 @@ async fn connect_scope( .await .unwrap(); assert!(packet(&mut socket).await.starts_with('0')); + let mut handshake = json!({"token":token,"clientType":scope}); + if let Some((version, protocol)) = build { + handshake["clientVersion"] = json!(version); + handshake["clientProtocol"] = json!(protocol); + } socket - .send(Message::Text( - format!("40{}", json!({"token":token,"clientType":scope})).into(), - )) + .send(Message::Text(format!("40{handshake}").into())) .await .unwrap(); assert!(packet(&mut socket).await.starts_with("40")); @@ -91,6 +104,35 @@ async fn connect_scope( socket } +/// Drain a socket for `window`, returning `false` if a forwarded `rpc-request` +/// arrived. Engine.IO pings are answered so the connection stays healthy. +async fn forwarded_no_rpc_request(socket: &mut Socket, window: Duration) -> bool { + let deadline = tokio::time::Instant::now() + window; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return true; + } + match tokio::time::timeout(remaining, socket.next()).await { + Err(_) | Ok(None) | Ok(Some(Err(_))) => return true, + Ok(Some(Ok(Message::Text(text)))) => { + if text == "2" { + let _ = socket.send(Message::Text("3".into())).await; + } else if text + .strip_prefix("42") + .is_some_and(|rest| rest.contains("rpc-request")) + { + return false; + } + } + Ok(Some(Ok(Message::Ping(bytes)))) => { + let _ = socket.send(Message::Pong(bytes)).await; + } + Ok(Some(Ok(_))) => {} + } + } +} + async fn event(socket: &mut Socket, id: u32, name: &str, value: Value) { socket .send(Message::Text( @@ -217,7 +259,18 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { let server = tokio::spawn(async move { axum::serve(listener, server_app).await.unwrap(); }); - let mut socket = connect_scope(address, prefix, &tokens[0], "machine-scoped").await; + // The host and controller report the same client protocol so the + // control pair is compatible under the strict "matching builds only" + // rule; the outsider stays legacy and is only used to prove + // rejection. + let mut socket = connect_scope_with_build( + address, + prefix, + &tokens[0], + "machine-scoped", + Some(("1.0.0", 1)), + ) + .await; event( &mut socket, 1, @@ -226,8 +279,65 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { ) .await; assert_eq!(acknowledgement(&mut socket, 1).await["ok"], true); - let mut controller = connect_scope(address, prefix, &tokens[1], "user-scoped").await; + let mut controller = connect_scope_with_build( + address, + prefix, + &tokens[1], + "user-scoped", + Some(("1.0.0", 1)), + ) + .await; let mut outsider = connect_scope(address, prefix, &tokens[2], "user-scoped").await; + assert_eq!( + request( + &app, + "PATCH", + &route("/api/devices/desktop"), + &tokens[0], + json!({"device_alias":"Build host"}) + ) + .await + .status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + request( + &app, + "PATCH", + &route("/api/devices/desktop"), + &tokens[0], + json!({"device_model":"Model", "device_os":"Linux", "device_os_version":"6"}) + ) + .await + .status(), + StatusCode::NO_CONTENT + ); + loop { + let frame = packet(&mut controller).await; + if let Some(payload) = frame.strip_prefix("42") { + let event: Value = serde_json::from_str(payload).unwrap(); + if event[0] == "ephemeral" && event[1]["type"] == "device-presence" { + if event[1]["devices"].as_array().unwrap().iter().any(|row| { + row["device_alias"] == "Build host" + && row["device_model"] == "Model" + && row["device_os_version"] == "6" + }) { + break; + } + } + } + } + let info = + json_body(request(&app, "GET", &route("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/api/info"), "", json!(null)).await).await; + assert_eq!(info["protocol_version"], 3); + assert_eq!( + info["capabilities"], + json!([ + "device_alias_v1", + "device_metadata_v1", + "device_client_build_v1" + ]) + ); let devices = json_body( request(&app, "GET", &route("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/api/devices"), &tokens[1], json!(null)).await, ) @@ -383,3 +493,269 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { } authority_task.abort(); } + +/// Two same-account desktop-kind devices, each with its own full device token. +struct BuildFixture { + app: Router, + db: Arc, + desktop_token: String, + mobile_token: String, + outsider_token: String, +} + +async fn client_build_fixture() -> BuildFixture { + let db = Arc::new(db::connect(":memory:").await.unwrap()); + db::UserRow::create(&db, "owner", "alice").await.unwrap(); + db::UserRow::create(&db, "outsider", "bob").await.unwrap(); + for (device, account) in [ + ("desktop", "owner"), + ("mobile", "owner"), + ("outsider", "outsider"), + ] { + db::DeviceRow::upsert(&db, device, account, device, Some("desktop"), None) + .await + .unwrap(); + } + let desktop_token = db::AuthToken::create(&db, "owner", "desktop") + .await + .unwrap() + .token; + let mobile_token = db::AuthToken::create(&db, "owner", "mobile") + .await + .unwrap() + .token; + let outsider_token = db::AuthToken::create(&db, "outsider", "outsider") + .await + .unwrap() + .token; + let app = build_relay_router( + Arc::new(MemoryAssetStore::new()), + Instant::now(), + db.clone(), + "test", + ); + BuildFixture { + app, + db, + desktop_token, + mobile_token, + outsider_token, + } +} + +async fn spawn(app: Router) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (address, handle) +} + +async fn next_forwarded_rpc_request(socket: &mut Socket) -> (String, Value) { + loop { + let message = packet(socket).await; + if let Some(rest) = message.strip_prefix("42") { + let index = rest.find('[').unwrap(); + let data: Value = serde_json::from_str(&rest[index..]).unwrap(); + if data[0] == "rpc-request" { + return (rest[..index].to_string(), data[1].clone()); + } + } + } +} + +#[tokio::test] +async fn rpc_call_is_rejected_for_incompatible_client_builds() { + let fixture = client_build_fixture().await; + let (address, server) = spawn(fixture.app.clone()).await; + + let mut desktop = connect_scope_with_build( + address, + "", + &fixture.desktop_token, + "machine-scoped", + Some(("2.0.0", 3)), + ) + .await; + event( + &mut desktop, + 1, + "rpc-register", + json!({"method":"desktop:remote-command"}), + ) + .await; + assert_eq!(acknowledgement(&mut desktop, 1).await["ok"], true); + + // A controller reporting a different protocol is refused before dispatch. + let mut mismatched = connect_scope_with_build( + address, + "", + &fixture.mobile_token, + "user-scoped", + Some(("1.0.0", 4)), + ) + .await; + event(&mut mismatched,2,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":1000})).await; + let ack = acknowledgement(&mut mismatched, 2).await; + assert_eq!(ack["ok"], false); + assert_eq!( + ack["error"], + "incompatible client build: remote control requires matching client versions" + ); + assert!( + forwarded_no_rpc_request(&mut desktop, Duration::from_millis(400)).await, + "an incompatible call must never be dispatched" + ); + + // A legacy controller that reports no protocol at all is also refused: a + // missing build cannot be proven compatible. + let mut legacy = connect_scope(address, "", &fixture.mobile_token, "user-scoped").await; + event(&mut legacy,4,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":500})).await; + let ack = acknowledgement(&mut legacy, 4).await; + assert_eq!(ack["ok"], false); + assert_eq!( + ack["error"], + "incompatible client build: remote control requires matching client versions" + ); + assert!(forwarded_no_rpc_request(&mut desktop, Duration::from_millis(300)).await); + + // A controller reporting the same protocol is forwarded as before. + let mut matched = connect_scope_with_build( + address, + "", + &fixture.mobile_token, + "user-scoped", + Some(("2.0.0", 3)), + ) + .await; + event(&mut matched,3,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":3000})).await; + let (ack_id, incoming) = next_forwarded_rpc_request(&mut desktop).await; + assert_eq!(incoming["sourceDeviceId"], "mobile"); + desktop + .send(Message::Text( + format!("43{ack_id}{}", json!([{"encryptedData":"Zg=="}])).into(), + )) + .await + .unwrap(); + let response = acknowledgement(&mut matched, 3).await; + assert_eq!(response["ok"], true); + + server.abort(); +} + +#[tokio::test] +async fn rpc_call_stays_account_scoped_with_matching_builds() { + let fixture = client_build_fixture().await; + let (address, server) = spawn(fixture.app.clone()).await; + + let mut desktop = connect_scope_with_build( + address, + "", + &fixture.desktop_token, + "machine-scoped", + Some(("2.0.0", 3)), + ) + .await; + event( + &mut desktop, + 1, + "rpc-register", + json!({"method":"desktop:remote-command"}), + ) + .await; + assert_eq!(acknowledgement(&mut desktop, 1).await["ok"], true); + + // Matching builds do not bypass account scoping: the room is per account. + let mut outsider = connect_scope_with_build( + address, + "", + &fixture.outsider_token, + "user-scoped", + Some(("2.0.0", 3)), + ) + .await; + event(&mut outsider,2,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":300})).await; + assert_eq!(acknowledgement(&mut outsider, 2).await["ok"], false); + assert!(forwarded_no_rpc_request(&mut desktop, Duration::from_millis(200)).await); + + // A legacy outsider (no reported build) is likewise rejected and still + // cannot reach the other account's target. + let mut legacy_outsider = + connect_scope(address, "", &fixture.outsider_token, "user-scoped").await; + event(&mut legacy_outsider,3,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":300})).await; + assert_eq!(acknowledgement(&mut legacy_outsider, 3).await["ok"], false); + assert!(forwarded_no_rpc_request(&mut desktop, Duration::from_millis(200)).await); + + server.abort(); +} + +#[tokio::test] +async fn rpc_call_is_rejected_for_two_legacy_clients() { + let fixture = client_build_fixture().await; + let (address, server) = spawn(fixture.app.clone()).await; + + // Neither end reports a protocol. A matching build cannot be proven, so the + // pair is incompatible and the call is refused instead of dispatched. + let mut desktop = connect_scope(address, "", &fixture.desktop_token, "machine-scoped").await; + event( + &mut desktop, + 1, + "rpc-register", + json!({"method":"desktop:remote-command"}), + ) + .await; + assert_eq!(acknowledgement(&mut desktop, 1).await["ok"], true); + + let mut controller = connect_scope(address, "", &fixture.mobile_token, "user-scoped").await; + event(&mut controller,2,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":500})).await; + let ack = acknowledgement(&mut controller, 2).await; + assert_eq!(ack["ok"], false); + assert_eq!( + ack["error"], + "incompatible client build: remote control requires matching client versions" + ); + assert!(forwarded_no_rpc_request(&mut desktop, Duration::from_millis(300)).await); + + server.abort(); +} + +#[tokio::test] +async fn handshake_records_and_clears_the_client_build() { + let fixture = client_build_fixture().await; + let (address, server) = spawn(fixture.app.clone()).await; + + let mut socket = connect_scope_with_build( + address, + "", + &fixture.desktop_token, + "machine-scoped", + Some(("2.0.0", 9)), + ) + .await; + let row = db::DeviceRow::list_by_user(&fixture.db, "owner") + .await + .unwrap() + .into_iter() + .find(|row| row.device_id == "desktop") + .unwrap(); + assert_eq!(row.client_version.as_deref(), Some("2.0.0")); + assert_eq!(row.client_protocol_u32(), Some(9)); + socket.close(None).await.unwrap(); + let _ = socket.close(None).await; + + // A reconnect that reports nothing clears both values instead of keeping + // the previous, now-stale build. + let mut socket = connect_scope(address, "", &fixture.desktop_token, "machine-scoped").await; + let row = db::DeviceRow::list_by_user(&fixture.db, "owner") + .await + .unwrap() + .into_iter() + .find(|row| row.device_id == "desktop") + .unwrap(); + assert!(row.client_version.is_none()); + assert!(row.client_protocol.is_none()); + let _ = socket.close(None).await; + + server.abort(); +} diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index 8ade9a563c..b65a6639a0 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -31,6 +31,12 @@ CREATE TABLE IF NOT EXISTS devices ( user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, device_name TEXT, device_kind TEXT, + device_alias TEXT, + device_model TEXT, + device_os TEXT, + device_os_version TEXT, + client_version TEXT, + client_protocol INTEGER, public_key TEXT, last_seen_at INTEGER, online INTEGER NOT NULL DEFAULT 0, @@ -138,6 +144,21 @@ const MIGRATE_DEVICE_KIND: &str = r#" ALTER TABLE devices ADD COLUMN device_kind TEXT; "#; +const MIGRATE_DEVICE_DIRECTORY_METADATA: [&str; 4] = [ + "ALTER TABLE devices ADD COLUMN device_alias TEXT", + "ALTER TABLE devices ADD COLUMN device_model TEXT", + "ALTER TABLE devices ADD COLUMN device_os TEXT", + "ALTER TABLE devices ADD COLUMN device_os_version TEXT", +]; + +/// The client build a device last connected with. Unlike directory metadata, +/// these are refreshed from the *current* connection on every login and +/// handshake, so an unreported value is stored as NULL rather than preserved. +const MIGRATE_DEVICE_CLIENT_BUILD: [&str; 2] = [ + "ALTER TABLE devices ADD COLUMN client_version TEXT", + "ALTER TABLE devices ADD COLUMN client_protocol INTEGER", +]; + /// Open (or create) the SQLite database and ensure the schema exists. pub async fn connect(db_path: &str) -> Result { connect_with_presence_reset(db_path, true).await @@ -223,6 +244,22 @@ async fn connect_with_presence_reset(db_path: &str, reset_presence: bool) -> Res return Err(anyhow!("migrate device kinds: {error}")); } } + for migration in MIGRATE_DEVICE_DIRECTORY_METADATA { + if let Err(error) = sqlx::query(migration).execute(&pool).await { + if !error.to_string().contains("duplicate column name") { + return Err(anyhow!("migrate device directory metadata: {error}")); + } + } + } + // Runs after the account-scoping rebuild and the device-kind column, so it + // covers both a rebuilt table and a database that never needed rebuilding. + for migration in MIGRATE_DEVICE_CLIENT_BUILD { + if let Err(error) = sqlx::query(migration).execute(&pool).await { + if !error.to_string().contains("duplicate column name") { + return Err(anyhow!("migrate device client build: {error}")); + } + } + } sqlx::query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_request_id \ ON auth_tokens(request_id) WHERE request_id IS NOT NULL", @@ -535,18 +572,89 @@ pub fn device_kind_is_desktop(kind: Option<&str>) -> bool { matches!(kind, None | Some(DEVICE_KIND_DESKTOP)) } +/// Self-reported technical metadata. Missing values never erase stored facts. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct DeviceMetadata { + pub device_model: Option, + pub device_os: Option, + pub device_os_version: Option, +} + +pub const MAX_DEVICE_DIRECTORY_TEXT_BYTES: usize = 256; + +pub fn valid_device_directory_text(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= MAX_DEVICE_DIRECTORY_TEXT_BYTES + && !value.chars().any(char::is_control) +} + +impl DeviceMetadata { + pub fn is_valid(&self) -> bool { + [&self.device_model, &self.device_os, &self.device_os_version] + .into_iter() + .all(|value| value.as_deref().is_none_or(valid_device_directory_text)) + } +} + +/// Upper bound on a self-reported client build string. It is deliberately +/// short: it records a released client identity, not user content. +pub const MAX_CLIENT_VERSION_BYTES: usize = 64; + +/// Normalize a client-reported build string. A blank, oversized, or +/// control-character-bearing value is treated as unreported (`None`) so a noisy +/// or buggy client still connects instead of being rejected at the handshake. +pub fn normalize_client_version(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() + || value.len() > MAX_CLIENT_VERSION_BYTES + || value.chars().any(char::is_control) + { + return None; + } + Some(value.to_string()) +} + +/// Decide whether a caller and a target may exchange remote control. +/// +/// A reported build is required, not merely tolerated: control is allowed only +/// when both sides reported a protocol number and the numbers match. Two legacy +/// clients that report nothing, and any pair where either side never reported, +/// are incompatible because a matching build cannot be proven. This is the +/// single implementation used by the directory projection and the RPC dispatch +/// gate. +pub fn client_builds_compatible(caller: Option, target: Option) -> bool { + matches!((caller, target), (Some(caller), Some(target)) if caller == target) +} + #[derive(Debug, Clone, sqlx::FromRow)] pub struct DeviceRow { pub device_id: String, pub user_id: String, pub device_name: Option, pub device_kind: Option, + pub device_alias: Option, + pub device_model: Option, + pub device_os: Option, + pub device_os_version: Option, + pub client_version: Option, + pub client_protocol: Option, pub public_key: Option, pub last_seen_at: Option, pub online: i64, } +/// Convert a stored `client_protocol` integer into the wire type. An absent or +/// out-of-range value is reported as `None` (unreported). +pub fn stored_client_protocol(value: Option) -> Option { + value.and_then(|value| u32::try_from(value).ok()) +} + impl DeviceRow { + /// The stored client protocol as the wire type. `None` means the device has + /// never reported one (a legacy row) or the stored value is out of range. + pub fn client_protocol_u32(&self) -> Option { + stored_client_protocol(self.client_protocol) + } pub async fn upsert( pool: &DbPool, device_id: &str, @@ -554,6 +662,27 @@ impl DeviceRow { device_name: &str, device_kind: Option<&str>, public_key: Option<&str>, + ) -> Result<()> { + Self::upsert_with_metadata( + pool, + device_id, + user_id, + device_name, + device_kind, + public_key, + &DeviceMetadata::default(), + ) + .await + } + + pub async fn upsert_with_metadata( + pool: &DbPool, + device_id: &str, + user_id: &str, + device_name: &str, + device_kind: Option<&str>, + public_key: Option<&str>, + metadata: &DeviceMetadata, ) -> Result<()> { let now = Utc::now().timestamp(); // `device_kind` is only overwritten when the caller actually reported @@ -561,11 +690,14 @@ impl DeviceRow { // known kind on every login and put the device back in the list. sqlx::query( "INSERT INTO devices \ - (device_id, user_id, device_name, device_kind, public_key, last_seen_at, online) \ - VALUES (?, ?, ?, ?, ?, ?, 0) \ + (device_id, user_id, device_name, device_kind, public_key, last_seen_at, device_model, device_os, device_os_version, online) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) \ ON CONFLICT(user_id, device_id) DO UPDATE SET \ device_name = excluded.device_name, \ device_kind = COALESCE(excluded.device_kind, devices.device_kind), \ + device_model = COALESCE(excluded.device_model, devices.device_model), \ + device_os = COALESCE(excluded.device_os, devices.device_os), \ + device_os_version = COALESCE(excluded.device_os_version, devices.device_os_version), \ public_key = COALESCE(excluded.public_key, devices.public_key), \ last_seen_at = excluded.last_seen_at", ) @@ -575,6 +707,9 @@ impl DeviceRow { .bind(device_kind) .bind(public_key) .bind(now) + .bind(&metadata.device_model) + .bind(&metadata.device_os) + .bind(&metadata.device_os_version) .execute(pool) .await .map_err(|e| anyhow!("upsert device: {e}"))?; @@ -603,7 +738,7 @@ impl DeviceRow { pub async fn list_by_user(pool: &DbPool, user_id: &str) -> Result> { let rows = sqlx::query_as::<_, DeviceRow>( - "SELECT device_id, user_id, device_name, device_kind, public_key, last_seen_at, online \ + "SELECT device_id, user_id, device_name, device_kind, device_alias, device_model, device_os, device_os_version, client_version, client_protocol, public_key, last_seen_at, online \ FROM devices WHERE user_id = ?", ) .bind(user_id) @@ -613,6 +748,36 @@ impl DeviceRow { Ok(rows) } + /// Record the client build of the connection that just authenticated. + /// + /// Both columns are overwritten from the *current* connection, including + /// with NULL when the client reports nothing. This intentionally differs + /// from directory metadata: a build string is a fact about the live + /// connection, so a client that upgrades, downgrades, or stops reporting + /// must never leave a stale value behind. Returns the number of device rows + /// updated; a delegated controller resolves to a routing id with no row, so + /// it updates nothing. + pub async fn set_client_build( + pool: &DbPool, + user_id: &str, + device_id: &str, + client_version: Option<&str>, + client_protocol: Option, + ) -> Result { + let result = sqlx::query( + "UPDATE devices SET client_version = ?, client_protocol = ? \ + WHERE user_id = ? AND device_id = ?", + ) + .bind(client_version) + .bind(client_protocol.map(i64::from)) + .bind(user_id) + .bind(device_id) + .execute(pool) + .await + .map_err(|e| anyhow!("set device client build: {e}"))?; + Ok(result.rows_affected()) + } + /// Delete a device owned by `user_id` and revoke all of its auth tokens. /// /// Tokens must be removed before the device because `auth_tokens.device_id` @@ -687,6 +852,7 @@ impl AuthToken { device_kind: Option<&str>, request_id: &str, public_key: &str, + metadata: &DeviceMetadata, ) -> Result> { let mut tx = pool .begin() @@ -744,8 +910,8 @@ impl AuthToken { let inserted = sqlx::query( "INSERT OR IGNORE INTO devices \ - (device_id, user_id, device_name, device_kind, public_key, last_seen_at, online) \ - VALUES (?, ?, ?, ?, ?, ?, 0)", + (device_id, user_id, device_name, device_kind, public_key, last_seen_at, device_model, device_os, device_os_version, online) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", ) .bind(device_id) .bind(user_id) @@ -753,6 +919,9 @@ impl AuthToken { .bind(device_kind) .bind(public_key) .bind(now) + .bind(&metadata.device_model) + .bind(&metadata.device_os) + .bind(&metadata.device_os_version) .execute(&mut *tx) .await .map_err(|error| anyhow!("register provisioned device: {error}"))?; @@ -2510,6 +2679,176 @@ mod tests { let _ = std::fs::remove_file(db_path); } + #[tokio::test] + async fn directory_metadata_migration_is_additive_and_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let path = path.to_str().unwrap(); + let pool = connect(path).await.unwrap(); + UserRow::create(&pool, "owner", "owner").await.unwrap(); + DeviceRow::upsert(&pool, "device", "owner", "Technical", None, None) + .await + .unwrap(); + let token = AuthToken::create(&pool, "owner", "device").await.unwrap(); + for column in [ + "device_alias", + "device_model", + "device_os", + "device_os_version", + ] { + sqlx::query(&format!("ALTER TABLE devices DROP COLUMN {column}")) + .execute(&pool) + .await + .unwrap(); + } + pool.close().await; + let pool = connect(path).await.unwrap(); + let rows = DeviceRow::list_by_user(&pool, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].device_alias.is_none()); + assert!(rows[0].device_model.is_none()); + assert!(rows[0].device_os.is_none()); + assert!(rows[0].device_os_version.is_none()); + assert!(AuthToken::find(&pool, &token.token) + .await + .unwrap() + .is_some()); + sqlx::query("UPDATE devices SET device_alias='Alias', device_model='Model', device_os='Linux', device_os_version='6'").execute(&pool).await.unwrap(); + pool.close().await; + let pool = connect(path).await.unwrap(); + DeviceRow::upsert(&pool, "device", "owner", "New technical", None, None) + .await + .unwrap(); + let rows = DeviceRow::list_by_user(&pool, "owner").await.unwrap(); + assert_eq!(rows[0].device_alias.as_deref(), Some("Alias")); + assert_eq!(rows[0].device_model.as_deref(), Some("Model")); + assert_eq!(rows[0].device_os.as_deref(), Some("Linux")); + assert_eq!(rows[0].device_os_version.as_deref(), Some("6")); + pool.close().await; + } + + #[tokio::test] + async fn client_build_migration_is_additive_and_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let path = path.to_str().unwrap(); + let pool = connect(path).await.unwrap(); + UserRow::create(&pool, "owner", "owner").await.unwrap(); + DeviceRow::upsert(&pool, "device", "owner", "Technical", None, None) + .await + .unwrap(); + let token = AuthToken::create(&pool, "owner", "device").await.unwrap(); + // Simulate a database written before the client-build columns existed. + for column in ["client_version", "client_protocol"] { + sqlx::query(&format!("ALTER TABLE devices DROP COLUMN {column}")) + .execute(&pool) + .await + .unwrap(); + } + pool.close().await; + + // Reopening re-adds the columns as NULL and keeps the existing row/token. + let pool = connect(path).await.unwrap(); + let rows = DeviceRow::list_by_user(&pool, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].client_version.is_none()); + assert!(rows[0].client_protocol.is_none()); + assert!(AuthToken::find(&pool, &token.token) + .await + .unwrap() + .is_some()); + DeviceRow::set_client_build(&pool, "owner", "device", Some("1.2.3"), Some(7)) + .await + .unwrap(); + pool.close().await; + + // The values survive another startup, and the ALTER stays idempotent. + let pool = connect(path).await.unwrap(); + let rows = DeviceRow::list_by_user(&pool, "owner").await.unwrap(); + assert_eq!(rows[0].client_version.as_deref(), Some("1.2.3")); + assert_eq!(rows[0].client_protocol_u32(), Some(7)); + pool.close().await; + } + + #[tokio::test] + async fn set_client_build_refreshes_to_null_when_unreported() { + let pool = connect(":memory:").await.unwrap(); + UserRow::create(&pool, "owner", "owner").await.unwrap(); + DeviceRow::upsert(&pool, "device", "owner", "Technical", None, None) + .await + .unwrap(); + + // A reconnect that reports a new build replaces the old one rather than + // preserving it. + DeviceRow::set_client_build(&pool, "owner", "device", Some("1.0.0"), Some(3)) + .await + .unwrap(); + let row = DeviceRow::list_by_user(&pool, "owner") + .await + .unwrap() + .remove(0); + assert_eq!(row.client_version.as_deref(), Some("1.0.0")); + assert_eq!(row.client_protocol_u32(), Some(3)); + + DeviceRow::set_client_build(&pool, "owner", "device", Some("1.1.0"), Some(4)) + .await + .unwrap(); + let row = DeviceRow::list_by_user(&pool, "owner") + .await + .unwrap() + .remove(0); + assert_eq!(row.client_version.as_deref(), Some("1.1.0")); + assert_eq!(row.client_protocol_u32(), Some(4)); + + // A later connection that reports nothing must clear the values rather + // than roll back to an earlier one. + DeviceRow::set_client_build(&pool, "owner", "device", None, None) + .await + .unwrap(); + let row = DeviceRow::list_by_user(&pool, "owner") + .await + .unwrap() + .remove(0); + assert!(row.client_version.is_none()); + assert!(row.client_protocol.is_none()); + + // A delegated controller resolves to a routing id with no device row. + assert_eq!( + DeviceRow::set_client_build(&pool, "owner", "controller-elsewhere", Some("9"), Some(1)) + .await + .unwrap(), + 0 + ); + } + + #[test] + fn client_build_compatibility_requires_matching_reports() { + // Only an explicit, matching report is compatible. No report on either + // side is incompatible, including legacy-to-legacy. + assert!(!client_builds_compatible(None, None)); + assert!(client_builds_compatible(Some(3), Some(3))); + assert!(!client_builds_compatible(Some(3), Some(4))); + // A single-sided report cannot prove compatibility. + assert!(!client_builds_compatible(Some(3), None)); + assert!(!client_builds_compatible(None, Some(3))); + } + + #[test] + fn client_version_normalization_rejects_unreported_shapes() { + assert_eq!(normalize_client_version(None), None); + assert_eq!(normalize_client_version(Some(" ")), None); + assert_eq!(normalize_client_version(Some("back\nline")), None); + assert_eq!(normalize_client_version(Some(&"a".repeat(65))), None); + assert_eq!( + normalize_client_version(Some(" 1.2.3 ")), + Some("1.2.3".to_string()) + ); + assert_eq!( + normalize_client_version(Some(&"a".repeat(64))), + Some("a".repeat(64)) + ); + } + #[tokio::test] async fn reopening_a_database_keeps_the_device_kind_column_and_its_values() { let db_path = std::env::temp_dir().join(format!( diff --git a/src/crates/services/relay-service/src/lib.rs b/src/crates/services/relay-service/src/lib.rs index 8309ca418f..8343731b53 100644 --- a/src/crates/services/relay-service/src/lib.rs +++ b/src/crates/services/relay-service/src/lib.rs @@ -1220,7 +1220,13 @@ pub fn build_relay_router_with_page_data_origins_and_page_auth( router.layer( CorsLayer::new() .allow_origin(allow_origin) - .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]), ) } diff --git a/src/crates/services/relay-service/src/realtime/mod.rs b/src/crates/services/relay-service/src/realtime/mod.rs index 974d197c38..d175bfafcb 100644 --- a/src/crates/services/relay-service/src/realtime/mod.rs +++ b/src/crates/services/relay-service/src/realtime/mod.rs @@ -10,7 +10,7 @@ mod device_lifecycle; mod origin; mod payloads; -mod presence; +pub(crate) mod presence; pub(crate) mod retired_session_history; use crate::{db::AuthToken, routes::api::AppState}; @@ -39,6 +39,13 @@ struct Identity { device: String, token: String, scope: Scope, + /// Client build reported by this connection, normalized at the handshake. + /// Malformed or absent values are `None` (unreported), never a rejection. + /// The version text is recorded on the device row; compatibility is decided + /// from the protocol number below. + #[allow(dead_code)] + client_version: Option, + client_protocol: Option, account_calls: Arc, server_calls: Arc, _connection: Arc, @@ -58,6 +65,11 @@ struct Handshake { token: String, client_type: Scope, machine_id: Option, + /// Optional self-reported client build. Older clients omit both. + #[serde(default)] + client_version: Option, + #[serde(default)] + client_protocol: Option, /// Sent by session-scoped clients of earlier releases; the handshake is /// rejected before it is read. #[allow(dead_code)] @@ -219,11 +231,31 @@ pub(crate) fn mount(router: Router, state: AppState) -> Router = state - .device_manager - .online_devices(account) - .into_iter() - .map(|(id, name)| json!({"device_id":id,"device_name":name})) - .collect(); +pub(crate) async fn broadcast(io: &SocketIo, state: &AppState, account: &str) { + let devices: Vec<_> = match crate::db::DeviceRow::list_by_user(&state.db, account).await { + Ok(rows) => rows.into_iter().filter(|row| state.device_manager.is_device_online(account, &row.device_id)).map(|row| json!({"device_id":row.device_id,"device_name":row.device_name.unwrap_or_default(),"device_alias":row.device_alias,"device_model":row.device_model,"device_os":row.device_os,"device_os_version":row.device_os_version,"client_version":row.client_version,"client_protocol":row.client_protocol})).collect(), + Err(error) => { + tracing::warn!(%error, "Failed to read device presence metadata"); + return; + } + }; let event = json!({"type":"device-presence","devices":devices}); for socket in io.within(account_room(account)).sockets() { if socket.emit("ephemeral", &event).is_err() { diff --git a/src/crates/services/relay-service/src/routes/api.rs b/src/crates/services/relay-service/src/routes/api.rs index 62d0a8fabf..e45dcc0aab 100644 --- a/src/crates/services/relay-service/src/routes/api.rs +++ b/src/crates/services/relay-service/src/routes/api.rs @@ -71,6 +71,7 @@ pub struct ServerInfo { pub name: String, pub version: String, pub protocol_version: u8, + pub capabilities: Vec<&'static str>, } pub async fn server_info() -> Json { @@ -82,5 +83,10 @@ pub(crate) async fn server_info_for_host(host_version: &'static str) -> Json, + #[serde(default)] + pub device_os: Option, + #[serde(default)] + pub device_os_version: Option, + // The client build is reported camelCase, matching the realtime handshake + // and the client's login body, while the rest of this request stays + // snake_case for historical compatibility. + #[serde(default, rename = "clientVersion")] + pub client_version: Option, + #[serde(default, rename = "clientProtocol")] + pub client_protocol: Option, } #[derive(Deserialize)] @@ -165,6 +178,12 @@ pub struct ProvisionDeviceRequest { #[serde(default)] pub device_kind: Option, pub request_id: String, + #[serde(default)] + pub device_model: Option, + #[serde(default)] + pub device_os: Option, + #[serde(default)] + pub device_os_version: Option, } #[derive(Serialize, Deserialize)] @@ -327,6 +346,14 @@ pub(crate) async fn login( verifier: Option>, Json(body): Json, ) -> Result, (StatusCode, Json)> { + let metadata = DeviceMetadata { + device_model: body.device_model.clone(), + device_os: body.device_os.clone(), + device_os_version: body.device_os_version.clone(), + }; + if !metadata.is_valid() { + return Err(err("invalid device metadata", StatusCode::BAD_REQUEST)); + } let public_key = BASE64.decode(&body.public_key).ok(); if !valid_device_id(&body.device_id) || !valid_bounded_text(&body.device_name, MAX_DEVICE_NAME_BYTES) @@ -347,13 +374,31 @@ pub(crate) async fn login( ) .await?; let db = state.db.as_ref(); - DeviceRow::upsert( + DeviceRow::upsert_with_metadata( db, &body.device_id, &user.user_id, &body.device_name, Some(&body.device_kind), Some(&body.public_key), + &metadata, + ) + .await + .map_err(|error| { + err( + "device registration failed", + registration_error_status(&error), + ) + })?; + // Refresh the client build from this login. An older client that omits the + // fields clears the stored values to NULL rather than leaving a stale build. + let client_version = crate::db::normalize_client_version(body.client_version.as_deref()); + crate::db::DeviceRow::set_client_build( + db, + &user.user_id, + &body.device_id, + client_version.as_deref(), + body.client_protocol, ) .await .map_err(|error| { @@ -513,6 +558,14 @@ pub async fn provision_device( headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { + let metadata = DeviceMetadata { + device_model: body.device_model.clone(), + device_os: body.device_os.clone(), + device_os_version: body.device_os_version.clone(), + }; + if !metadata.is_valid() { + return Err(err("invalid device metadata", StatusCode::BAD_REQUEST)); + } let public_key = BASE64.decode(&body.public_key).ok(); if !public_key .as_ref() @@ -565,6 +618,7 @@ pub async fn provision_device( ), &body.request_id, &body.public_key, + &metadata, ) .await .map_err(|error| { @@ -757,6 +811,31 @@ mod tests { let second: serde_json::Value = serde_json::from_slice(&to_bytes(second.into_body(), 16384).await.unwrap()).unwrap(); assert_eq!(second["token"], first["token"]); + let mut metadata_request = request.clone(); + metadata_request["device_model"] = serde_json::json!("Model"); + metadata_request["device_os"] = serde_json::json!("Linux"); + metadata_request["device_os_version"] = serde_json::json!("6"); + assert_eq!( + post_json(&app, "/api/auth/login", "", metadata_request) + .await + .status(), + StatusCode::OK + ); + sqlx::query("UPDATE devices SET device_alias='Independent' WHERE user_id='123'") + .execute(&*db) + .await + .unwrap(); + assert_eq!( + post_json(&app, "/api/auth/login", "", request.clone()) + .await + .status(), + StatusCode::OK + ); + let rows = DeviceRow::list_by_user(&db, "123").await.unwrap(); + assert_eq!(rows[0].device_alias.as_deref(), Some("Independent")); + assert_eq!(rows[0].device_model.as_deref(), Some("Model")); + assert_eq!(rows[0].device_os.as_deref(), Some("Linux")); + assert_eq!(rows[0].device_os_version.as_deref(), Some("6")); DeviceRow::upsert(&db, "new-device", "123", "Laptop", None, None) .await .unwrap(); @@ -786,6 +865,91 @@ mod tests { task.abort(); } + #[tokio::test] + async fn login_records_and_clears_the_client_build() { + let (app, db, _) = setup_app().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/me", listener.local_addr().unwrap()); + let authority = axum::Router::new().route( + "/me", + axum::routing::get(|headers: HeaderMap| async move { + if headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + != Some("Bearer shared-account-token") + { + return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({}))); + } + ( + StatusCode::OK, + Json(serde_json::json!({"user":{"githubId":123,"login":"github-user"}})), + ) + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, authority).await.unwrap(); + }); + let app = app.layer(Extension( + crate::identity::IdentityVerifier::with_url(&url).unwrap(), + )); + let base = serde_json::json!({ + "access_token":"shared-account-token", + "device_id":"new-device", "device_name":"Laptop", "device_kind":"desktop", + "public_key":BASE64.encode([9u8; 32]), + "request_id":uuid::Uuid::new_v4().to_string(), + }); + + // An old client that sends no build fields still logs in and stores NULL. + assert_eq!( + post_json(&app, "/api/auth/login", "", base.clone()) + .await + .status(), + StatusCode::OK + ); + let row = DeviceRow::list_by_user(&db, "123").await.unwrap().remove(0); + assert!(row.client_version.is_none()); + assert!(row.client_protocol.is_none()); + + // A newer client reports a build, which is written to the device row. + let mut reporting = base.clone(); + reporting["clientVersion"] = serde_json::json!("1.4.0"); + reporting["clientProtocol"] = serde_json::json!(7); + assert_eq!( + post_json(&app, "/api/auth/login", "", reporting) + .await + .status(), + StatusCode::OK + ); + let row = DeviceRow::list_by_user(&db, "123").await.unwrap().remove(0); + assert_eq!(row.client_version.as_deref(), Some("1.4.0")); + assert_eq!(row.client_protocol_u32(), Some(7)); + + // An older client logging in again clears the recorded build to NULL + // instead of leaving the newer value behind. + assert_eq!( + post_json(&app, "/api/auth/login", "", base.clone()) + .await + .status(), + StatusCode::OK + ); + let row = DeviceRow::list_by_user(&db, "123").await.unwrap().remove(0); + assert!(row.client_version.is_none()); + assert!(row.client_protocol.is_none()); + + // A malformed build string is treated as unreported, not rejected. + let mut malformed = base.clone(); + malformed["clientVersion"] = serde_json::json!("bad\nbuild"); + assert_eq!( + post_json(&app, "/api/auth/login", "", malformed) + .await + .status(), + StatusCode::OK + ); + let row = DeviceRow::list_by_user(&db, "123").await.unwrap().remove(0); + assert!(row.client_version.is_none()); + task.abort(); + } + #[tokio::test] async fn device_provisioning_is_full_scope_idempotent_and_device_only() { let (app, db, device_token) = setup_app().await; @@ -798,11 +962,15 @@ mod tests { "request_id": request_id, }); + let mut with_metadata = request.clone(); + with_metadata["device_model"] = serde_json::json!("Build server"); + with_metadata["device_os"] = serde_json::json!("Linux"); + with_metadata["device_os_version"] = serde_json::json!("6"); let first = post_json( &app, "/api/auth/provision-device", &device_token, - request.clone(), + with_metadata, ) .await; assert_eq!(first.status(), StatusCode::OK); @@ -819,6 +987,15 @@ mod tests { let replay_body = to_bytes(replay.into_body(), 16 * 1024).await.unwrap(); let replay: ProvisionDeviceResponse = serde_json::from_slice(&replay_body).unwrap(); assert_eq!(replay.token, first.token); + let row = DeviceRow::list_by_user(&db, "owner") + .await + .unwrap() + .into_iter() + .find(|row| row.device_id == device_id) + .unwrap(); + assert_eq!(row.device_model.as_deref(), Some("Build server")); + assert_eq!(row.device_os.as_deref(), Some("Linux")); + assert_eq!(row.device_os_version.as_deref(), Some("6")); let conflict = post_json( &app, diff --git a/src/crates/services/relay-service/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs index 2227ad11d0..efdc94d27b 100644 --- a/src/crates/services/relay-service/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -2,9 +2,9 @@ use axum::extract::{Path, State}; use axum::http::{header, HeaderMap, StatusCode}; -use axum::routing::{delete, get}; +use axum::routing::{get, patch}; use axum::{Json, Router}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::db::AuthToken; use crate::routes::api::AppState; @@ -46,7 +46,10 @@ pub fn device_router() -> Router { Router::new() .route("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/api/devices", get(list_devices)) .route("/api/devices/{target_device_id}/key", get(device_key)) - .route("/api/devices/{target_device_id}", delete(delete_device)) + .route( + "/api/devices/{target_device_id}", + patch(patch_device).delete(delete_device), + ) } #[derive(Serialize)] @@ -91,6 +94,15 @@ pub struct DeviceListEntry { pub device_id: String, pub device_name: String, pub device_kind: Option, + pub device_alias: Option, + pub device_model: Option, + pub device_os: Option, + pub device_os_version: Option, + pub client_version: Option, + pub client_protocol: Option, + /// Relay-computed: whether this device can be remote-controlled by the + /// caller, based on the two stored client protocol versions. + pub compatible: bool, pub online: bool, pub last_seen_at: Option, } @@ -106,6 +118,20 @@ async fn list_devices( let auth = validate_user(&state, &headers).await?; let user_id = auth.user_id.clone(); + // The caller's own stored build decides compatibility. For a delegated + // controller token this is the row of the device the token belongs to. + let caller_protocol = crate::db::stored_client_protocol( + sqlx::query_scalar::<_, Option>( + "SELECT client_protocol FROM devices WHERE user_id = ? AND device_id = ?", + ) + .bind(&auth.user_id) + .bind(&auth.device_id) + .fetch_optional(state.db.as_ref()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .flatten(), + ); + // Get online devices from DeviceManager (in-memory) let online = state.device_manager.online_devices(&user_id); let online_ids: std::collections::HashSet = @@ -123,10 +149,21 @@ async fn list_devices( continue; } let is_online = online_ids.contains(&row.device_id); + let target_protocol = row.client_protocol_u32(); devices.push(DeviceListEntry { device_id: row.device_id, device_name: row.device_name.unwrap_or_default(), device_kind: row.device_kind, + device_alias: row.device_alias, + device_model: row.device_model, + device_os: row.device_os, + device_os_version: row.device_os_version, + client_version: row.client_version, + client_protocol: target_protocol, + compatible: crate::db::client_builds_compatible( + caller_protocol, + target_protocol, + ), online: is_online, last_seen_at: row.last_seen_at, }); @@ -146,6 +183,13 @@ async fn list_devices( device_id: id.clone(), device_name: name.clone(), device_kind: None, + device_alias: None, + device_model: None, + device_os: None, + device_os_version: None, + client_version: None, + client_protocol: None, + compatible: crate::db::client_builds_compatible(caller_protocol, None), online: true, last_seen_at: None, }); @@ -155,7 +199,96 @@ async fn list_devices( Ok(Json(devices)) } -// ── Device RPC ────────────────────────────────────────────────────────── +// A custom deserializer preserves explicit null rather than collapsing it into omission. +fn present_nullable<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Option::::deserialize(deserializer).map(Some) +} + +fn present_string<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + String::deserialize(deserializer).map(Some) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DevicePatch { + #[serde(default, deserialize_with = "present_nullable")] + device_alias: Option>, + #[serde(default, deserialize_with = "present_string")] + device_model: Option, + #[serde(default, deserialize_with = "present_string")] + device_os: Option, + #[serde(default, deserialize_with = "present_string")] + device_os_version: Option, +} + +async fn patch_device( + State(state): State, + axum::Extension(io): axum::Extension, + headers: HeaderMap, + Path(target_device_id): Path, + Json(body): Json, +) -> Result { + let auth = validate_user(&state, &headers).await?; + if !auth.is_device_token() { + return Err(StatusCode::FORBIDDEN); + } + if !is_valid_device_id(&target_device_id) { + return Err(StatusCode::BAD_REQUEST); + } + let metadata_present = + body.device_model.is_some() || body.device_os.is_some() || body.device_os_version.is_some(); + if ![ + body.device_alias.as_ref().and_then(|v| v.as_deref()), + body.device_model.as_deref(), + body.device_os.as_deref(), + body.device_os_version.as_deref(), + ] + .into_iter() + .flatten() + .all(crate::db::valid_device_directory_text) + { + return Err(StatusCode::BAD_REQUEST); + } + let _guard = state.device_manager.lock_presence_projection().await; + // BEGIN IMMEDIATE serializes authorization with external/admin revocation too. + let mut tx = state + .db + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let current = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM auth_tokens WHERE token=? AND user_id=? AND device_id=? AND token_kind='device' AND expires_at>unixepoch()") + .bind(&auth.token).bind(&auth.user_id).bind(&auth.device_id).fetch_one(&mut *tx).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if current != 1 { + return Err(StatusCode::UNAUTHORIZED); + } + let exists = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM devices WHERE user_id=? AND device_id=?", + ) + .bind(&auth.user_id) + .bind(&target_device_id) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if exists == 0 { + return Err(StatusCode::NOT_FOUND); + } + if metadata_present && target_device_id != auth.device_id { + return Err(StatusCode::FORBIDDEN); + } + sqlx::query("UPDATE devices SET device_alias=CASE WHEN ? THEN ? ELSE device_alias END, device_model=COALESCE(?,device_model), device_os=COALESCE(?,device_os), device_os_version=COALESCE(?,device_os_version) WHERE user_id=? AND device_id=?") + .bind(body.device_alias.is_some()).bind(body.device_alias.flatten()) + .bind(body.device_model).bind(body.device_os).bind(body.device_os_version) + .bind(&auth.user_id).bind(&target_device_id).execute(&mut *tx).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + crate::realtime::presence::broadcast(&io, &state, &auth.user_id).await; + Ok(StatusCode::NO_CONTENT) +} async fn delete_device( State(state): State, @@ -232,6 +365,251 @@ mod tests { other_token: String, } + async fn patch_json( + ctx: &TestContext, + token: &str, + id: &str, + body: serde_json::Value, + ) -> StatusCode { + ctx.app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/devices/{id}")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap() + .status() + } + + #[tokio::test] + async fn concurrent_patch_and_revocation_never_restore_deleted_devices() { + for revoke_caller in [false, true] { + let ctx = setup_app().await; + let target = if revoke_caller { + "owner-device" + } else { + "target-device" + }; + let (patched, deleted) = tokio::join!( + patch_json( + &ctx, + &ctx.owner_token, + "target-device", + serde_json::json!({"device_alias":"Racing"}) + ), + delete(&ctx.app, &ctx.target_token, target) + ); + assert_eq!(deleted, StatusCode::NO_CONTENT); + assert!(matches!( + patched, + StatusCode::NO_CONTENT | StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED + )); + assert!(!DeviceRow::list_by_user(&ctx.db, "owner") + .await + .unwrap() + .iter() + .any(|row| row.device_id == target)); + assert_eq!( + patch_json( + &ctx, + &ctx.owner_token, + "target-device", + serde_json::json!({"device_alias":"After deletion"}) + ) + .await, + if revoke_caller { + StatusCode::UNAUTHORIZED + } else { + StatusCode::NOT_FOUND + } + ); + } + } + + #[tokio::test] + async fn directory_patch_permissions_clearing_and_omission() { + use serde_json::json; + let ctx = setup_app().await; + for (token, id, body, status) in [ + ( + &ctx.delegated_token, + "target-device", + json!({"device_alias":"Alias"}), + StatusCode::FORBIDDEN, + ), + ( + &ctx.other_token, + "target-device", + json!({"device_alias":"Alias"}), + StatusCode::NOT_FOUND, + ), + ( + &ctx.owner_token, + "missing", + json!({"device_os":"Linux"}), + StatusCode::NOT_FOUND, + ), + ( + &ctx.owner_token, + "target-device", + json!({"device_alias":"Alias","device_os":"Linux"}), + StatusCode::FORBIDDEN, + ), + ( + &ctx.owner_token, + "target-device", + json!({"device_alias":"Alias"}), + StatusCode::NO_CONTENT, + ), + ( + &ctx.target_token, + "target-device", + json!({"device_model":"Model","device_os":"Linux","device_os_version":"6"}), + StatusCode::NO_CONTENT, + ), + ( + &ctx.owner_token, + "target-device", + json!({}), + StatusCode::NO_CONTENT, + ), + ] { + assert_eq!(patch_json(&ctx, token, id, body).await, status); + } + let row = DeviceRow::list_by_user(&ctx.db, "owner") + .await + .unwrap() + .into_iter() + .find(|r| r.device_id == "target-device") + .unwrap(); + assert_eq!(row.device_alias.as_deref(), Some("Alias")); + assert_eq!(row.device_name.as_deref(), Some("Target")); + assert_eq!(row.device_os.as_deref(), Some("Linux")); + DeviceRow::upsert( + &ctx.db, + "target-device", + "owner", + "New technical name", + None, + None, + ) + .await + .unwrap(); + let response = ctx + .app + .clone() + .oneshot( + Request::builder() + .uri("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/api/devices") + .header(header::AUTHORIZATION, format!("Bearer {}", ctx.owner_token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let entries: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 16384) + .await + .unwrap(), + ) + .unwrap(); + let row = entries + .as_array() + .unwrap() + .iter() + .find(|r| r["device_id"] == "target-device") + .unwrap(); + assert_eq!(row["device_alias"], "Alias"); + assert_eq!(row["device_name"], "New technical name"); + assert_eq!(row["device_model"], "Model"); + assert_eq!(row["device_os_version"], "6"); + assert_eq!( + patch_json( + &ctx, + &ctx.owner_token, + "target-device", + json!({"device_alias":null}) + ) + .await, + StatusCode::NO_CONTENT + ); + let row = DeviceRow::list_by_user(&ctx.db, "owner") + .await + .unwrap() + .into_iter() + .find(|r| r.device_id == "target-device") + .unwrap(); + assert!(row.device_alias.is_none()); + assert_eq!(row.device_os.as_deref(), Some("Linux")); + for value in [ + "".to_string(), + " ".to_string(), + "bad\nname".to_string(), + "a".repeat(257), + ] { + assert_eq!( + patch_json( + &ctx, + &ctx.owner_token, + "owner-device", + json!({"device_alias":value}) + ) + .await, + StatusCode::BAD_REQUEST + ); + assert_eq!( + patch_json( + &ctx, + &ctx.owner_token, + "owner-device", + json!({"device_model":value}) + ) + .await, + StatusCode::BAD_REQUEST + ); + } + for body in [ + json!({"device_os":null}), + json!({"metadata":{"extra":true}}), + json!({"device_alias":12}), + ] { + assert_eq!( + patch_json(&ctx, &ctx.owner_token, "owner-device", body).await, + StatusCode::UNPROCESSABLE_ENTITY + ); + } + assert_eq!( + delete(&ctx.app, &ctx.owner_token, "target-device").await, + StatusCode::NO_CONTENT + ); + assert_eq!( + patch_json( + &ctx, + &ctx.owner_token, + "target-device", + json!({"device_alias":"Gone"}) + ) + .await, + StatusCode::NOT_FOUND + ); + assert_eq!( + patch_json( + &ctx, + &ctx.target_token, + "owner-device", + json!({"device_alias":"Gone"}) + ) + .await, + StatusCode::UNAUTHORIZED + ); + } + #[tokio::test] async fn delegated_device_keys_are_scoped_and_follow_parent_revocation() { let ctx = setup_app().await; @@ -376,6 +754,77 @@ mod tests { .collect() } + async fn listed_devices(app: &axum::Router, token: &str) -> Vec { + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("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/api/devices") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&body).unwrap() + } + + fn entry<'a>(devices: &'a [serde_json::Value], id: &str) -> &'a serde_json::Value { + devices + .iter() + .find(|device| device["device_id"] == id) + .unwrap() + } + + #[tokio::test] + async fn device_directory_reports_client_build_compatibility() { + let ctx = setup_app().await; + + // Legacy-to-legacy: neither side reports a build, so compatibility + // cannot be proven and the device is reported as incompatible — but it + // is still listed, never hidden. + let devices = listed_devices(&ctx.app, &ctx.owner_token).await; + let target = entry(&devices, "target-device"); + assert_eq!(target["compatible"], false); + assert_eq!(target["device_name"], "Target"); + assert_eq!(target["client_version"], serde_json::Value::Null); + assert_eq!(target["client_protocol"], serde_json::Value::Null); + + // The caller reports but the target does not: compatibility cannot be + // proven, so the pair is reported as incompatible. + DeviceRow::set_client_build(&ctx.db, "owner", "owner-device", Some("1.0.0"), Some(5)) + .await + .unwrap(); + let devices = listed_devices(&ctx.app, &ctx.owner_token).await; + assert_eq!(entry(&devices, "target-device")["compatible"], false); + assert_eq!(entry(&devices, "owner-device")["compatible"], true); + + // Matching protocols are compatible and both fields are exposed. + DeviceRow::set_client_build(&ctx.db, "owner", "target-device", Some("1.2.0"), Some(5)) + .await + .unwrap(); + let devices = listed_devices(&ctx.app, &ctx.owner_token).await; + let target = entry(&devices, "target-device"); + assert_eq!(target["compatible"], true); + assert_eq!(target["client_version"], "1.2.0"); + assert_eq!(target["client_protocol"], 5); + + // Differing protocols are incompatible but the device stays listed. + DeviceRow::set_client_build(&ctx.db, "owner", "target-device", Some("1.3.0"), Some(6)) + .await + .unwrap(); + let devices = listed_devices(&ctx.app, &ctx.owner_token).await; + let target = entry(&devices, "target-device"); + assert_eq!(target["compatible"], false); + assert_eq!(target["device_name"], "Target"); + assert_eq!(target["client_protocol"], 6); + } + #[tokio::test] async fn device_list_hides_mobile_devices_and_keeps_unlabeled_rows() { let ctx = setup_app().await; diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index 48848c3979..90dc126a61 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -271,6 +271,29 @@ impl Default for AccountClient { } } +/// The GitHub/relay login request body. +/// +/// `clientVersion`/`clientProtocol` are optional from the Relay's point of view, +/// but a current build always reports both so the Relay can gate control +/// compatibility instead of treating this device as legacy. +fn login_request_body( + access_token: &str, + device: &DeviceIdentity, + public_key: String, + request_id: String, +) -> serde_json::Value { + serde_json::json!({ + "access_token": access_token, + "device_id": device.device_id, + "device_name": device.device_name, + "device_kind": "desktop", + "public_key": public_key, + "request_id": request_id, + "clientVersion": openbitfun_product_domains::account::client_version(), + "clientProtocol": openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION, + }) +} + impl AccountClient { /// Reuse the shared GitHub login used by the OpenBitFun marketplaces. pub async fn login_with_identity( @@ -297,14 +320,12 @@ impl AccountClient { .ok_or_else(|| anyhow!("Unsupported account identity"))?; let device_secret = super::session_store::device_secret(relay_url, &account_id, &device.device_id)?; - let body = serde_json::json!({ - "access_token": access_token, - "device_id": device.device_id, - "device_name": device.device_name, - "device_kind": "desktop", - "public_key": device_crypto::public_key_base64(&device_secret), - "request_id": uuid::Uuid::new_v4().to_string(), - }); + let body = login_request_body( + &access_token, + device, + device_crypto::public_key_base64(&device_secret), + uuid::Uuid::new_v4().to_string(), + ); let response = send_with_retry( "GitHub relay login", self.http @@ -320,10 +341,14 @@ impl AccountClient { if auth.user_id != account_id { return Err(anyhow!("relay returned a different account identity")); } - Ok(( - AccountSession::new(auth.token, auth.user_id, device_secret), - profile.user, - )) + let session = AccountSession::new(auth.token, auth.user_id, device_secret); + if let Err(error) = self + .report_local_metadata(relay_url, &session, &device.device_id) + .await + { + log::warn!("Failed to report device metadata after login: {error}"); + } + Ok((session, profile.user)) } pub fn new() -> Self { @@ -492,17 +517,99 @@ impl AccountClient { return Err(Self::into_buffered_error(resp)); } let entries: Vec = resp.json().await?; - Ok(entries - .into_iter() - .map(|e| DeviceInfo { - device_id: e.device_id, - device_name: e.device_name, - // Legacy relays omit `online` and only return currently-online - // devices; treat a missing field as online. - online: e.online.unwrap_or(true), - last_seen_at: e.last_seen_at, - }) - .collect()) + Ok(entries.into_iter().map(project_device_list_entry).collect()) + } + + /// Missing capabilities (including an old info endpoint) mean unsupported. + pub async fn relay_capabilities(&self, relay_url: &str) -> Result> { + let response = self + .http + .get(Self::endpoint(relay_url, "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/api/info")?) + .send() + .await?; + if matches!(response.status().as_u16(), 404 | 405) { + return Ok(Vec::new()); + } + if !response.status().is_success() { + return Err(Self::into_error(response).await); + } + #[derive(Deserialize)] + struct Info { + #[serde(default)] + capabilities: Vec, + } + Ok(response.json::().await?.capabilities) + } + + pub async fn update_device_alias( + &self, + relay_url: &str, + session: &AccountSession, + device_id: &str, + device_alias: Option<&str>, + ) -> Result<()> { + if !self + .relay_capabilities(relay_url) + .await? + .iter() + .any(|c| c == "device_alias_v1") + { + return Err(anyhow!("Relay does not support device_alias_v1")); + } + self.patch_device( + relay_url, + session, + device_id, + &serde_json::json!({"device_alias": device_alias}), + ) + .await + } + + async fn patch_device( + &self, + relay_url: &str, + session: &AccountSession, + device_id: &str, + body: &serde_json::Value, + ) -> Result<()> { + let response = self + .http + .patch(Self::endpoint( + relay_url, + &format!("/api/devices/{}", urlencoding::encode(device_id)), + )?) + .bearer_auth(&session.token) + .json(body) + .send() + .await?; + if matches!(response.status().as_u16(), 404 | 405) { + return Err(anyhow!("Device update unavailable: Relay does not support PATCH or device no longer exists (HTTP {})", response.status())); + } + if !response.status().is_success() { + return Err(Self::into_error(response).await); + } + Ok(()) + } + + /// Report only metadata collected on this authenticated host. Provisioning + /// another device must never call this on its controller. + pub async fn report_local_metadata( + &self, + relay_url: &str, + session: &AccountSession, + device_id: &str, + ) -> Result<()> { + if !self + .relay_capabilities(relay_url) + .await? + .iter() + .any(|c| c == "device_metadata_v1") + { + return Ok(()); + } + let metadata = super::device::local_device_metadata().await; + self.patch_device(relay_url, session, device_id, &metadata) + .await } /// Remove a device from the account (DELETE /api/devices/:id). @@ -627,14 +734,67 @@ impl AccountClient { pub struct DeviceInfo { pub device_id: String, pub device_name: String, + #[serde(default)] + pub device_alias: Option, + #[serde(default)] + pub device_model: Option, + #[serde(default)] + pub device_os: Option, + #[serde(default)] + pub device_os_version: Option, + /// Build string the device last reported to the Relay. Absent for legacy + /// devices and for Relays that predate the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_client_version: Option, + /// Control-contract protocol number the device last reported. Absent for + /// legacy devices and for Relays that predate the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_client_protocol: Option, + /// Relay-computed compatibility with this client's control contract. + /// `None` means unknown (an older Relay without the field) and must be + /// treated as compatible rather than incompatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compatible: Option, pub online: bool, pub last_seen_at: Option, } +impl DeviceInfo { + /// Whether the Relay considers this device compatible with our control + /// contract. + /// + /// `Some(false)` is the Relay's verdict that the pair must not be + /// remote-controlled (a device that never reported a protocol number, or + /// one whose number differs). `None` means an older Relay that predates the + /// gating field and cannot judge, which is unknown and treated as + /// compatible. These are different situations; do not fold the + /// missing-version case into the fallback. + pub fn is_compatible(&self) -> bool { + self.compatible.unwrap_or(true) + } +} + #[derive(Deserialize)] struct DeviceListEntry { device_id: String, device_name: String, + #[serde(default)] + device_alias: Option, + #[serde(default)] + device_model: Option, + #[serde(default)] + device_os: Option, + #[serde(default)] + device_os_version: Option, + /// Relay field `client_version`; absent on legacy Relays. + #[serde(default, rename = "client_version", alias = "clientVersion")] + device_client_version: Option, + /// Relay field `client_protocol`; absent on legacy Relays. + #[serde(default, rename = "client_protocol", alias = "clientProtocol")] + device_client_protocol: Option, + /// Relay-computed; absent on legacy Relays. + #[serde(default)] + compatible: Option, /// Absent on legacy relays that only listed in-memory online devices. /// `None` means "legacy online list" → treat as online. #[serde(default)] @@ -643,10 +803,165 @@ struct DeviceListEntry { last_seen_at: Option, } +fn project_device_list_entry(entry: DeviceListEntry) -> DeviceInfo { + DeviceInfo { + device_id: entry.device_id, + device_name: entry.device_name, + device_alias: entry.device_alias, + device_model: entry.device_model, + device_os: entry.device_os, + device_os_version: entry.device_os_version, + device_client_version: entry.device_client_version, + device_client_protocol: entry.device_client_protocol, + compatible: entry.compatible, + // Legacy relays omit `online` and only return currently-online + // devices; treat a missing field as online. + online: entry.online.unwrap_or(true), + last_seen_at: entry.last_seen_at, + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn device_directory_accepts_legacy_nullable_and_extended_payloads() { + let old = serde_json::json!({"device_id":"id", "device_name":"technical", "online":true, "last_seen_at":null}); + let device: DeviceInfo = serde_json::from_value(old).unwrap(); + assert!(device.device_alias.is_none()); + let round_trip: DeviceInfo = + serde_json::from_value(serde_json::to_value(&device).unwrap()).unwrap(); + assert_eq!(round_trip.device_name, "technical"); + let entry: DeviceListEntry = serde_json::from_value(serde_json::json!({ + "device_id":"id", "device_name":"technical", "device_alias":"alias", + "device_model":"model", "device_os":"linux", "device_os_version":null, + "future_field":true + })) + .unwrap(); + assert_eq!(entry.device_alias.as_deref(), Some("alias")); + assert_eq!(entry.device_name, "technical"); + assert_eq!(entry.online, None); + assert_eq!(entry.device_os_version, None); + } + + #[test] + fn login_request_body_always_reports_client_protocol_and_version() { + let device = DeviceIdentity { + device_id: "0123456789abcdef0123456789abcdef".to_string(), + device_name: "Laptop".to_string(), + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + }; + let body = login_request_body("token-1", &device, "public-key".to_string(), "req-1".into()); + assert_eq!( + body["clientProtocol"].as_u64(), + Some(openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION as u64) + ); + assert_eq!( + body["clientVersion"].as_str(), + Some(openbitfun_product_domains::account::client_version()) + ); + assert_eq!(body["device_id"], device.device_id); + assert_eq!(body["request_id"], "req-1"); + } + + #[test] + fn device_directory_projects_client_compatibility_for_legacy_and_extended_relays() { + // Legacy Relay rows have neither the new build fields nor `compatible`. + let legacy: DeviceListEntry = serde_json::from_value(serde_json::json!({ + "device_id": "id", "device_name": "technical", "online": true + })) + .unwrap(); + let projected = project_device_list_entry(legacy); + assert!(projected.device_client_version.is_none()); + assert!(projected.device_client_protocol.is_none()); + assert!(projected.compatible.is_none()); + assert!(projected.is_compatible()); + + // A current Relay reports the build fields and the computed flag. + let extended: DeviceListEntry = serde_json::from_value(serde_json::json!({ + "device_id": "id", "device_name": "technical", "online": true, + "client_version": "1.0.1", "client_protocol": 2, "compatible": false + })) + .unwrap(); + let projected = project_device_list_entry(extended); + assert_eq!(projected.device_client_version.as_deref(), Some("1.0.1")); + assert_eq!(projected.device_client_protocol, Some(2)); + assert!(!projected.is_compatible()); + + // A device that never reported a version is judged incompatible by the + // Relay: no build fields, present `compatible: false`. + let unversioned: DeviceListEntry = serde_json::from_value(serde_json::json!({ + "device_id": "id", "device_name": "technical", "online": true, "compatible": false + })) + .unwrap(); + let projected = project_device_list_entry(unversioned); + assert!(projected.device_client_protocol.is_none()); + assert!(!projected.is_compatible()); + + // The camelCase aliases are accepted for forward tolerance. + let aliased: DeviceListEntry = serde_json::from_value(serde_json::json!({ + "device_id": "id", "device_name": "technical", + "clientVersion": "1.0.1", "clientProtocol": 2, "compatible": true + })) + .unwrap(); + assert_eq!(aliased.device_client_version.as_deref(), Some("1.0.1")); + assert_eq!(aliased.device_client_protocol, Some(2)); + assert_eq!(aliased.compatible, Some(true)); + } + + #[tokio::test] + async fn alias_patch_negotiates_and_sends_explicit_null() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/prefix", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + for index in 0..2 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&chunk[..n]); + let text = String::from_utf8_lossy(&request); + if let Some((headers, body)) = text.split_once("\r\n\r\n") { + let length: usize = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|n| n.parse().ok()) + }) + .unwrap_or(0); + if body.len() >= length { + break; + } + } + } + let request = String::from_utf8(request).unwrap(); + let body = if index == 0 { + assert!(request.starts_with("GET /prefix/api/info ")); + r#"{"capabilities":["device_alias_v1"]}"# + } else { + assert!(request.starts_with("PATCH /prefix/api/devices/id ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer fixture")); + assert!(request.ends_with(r#"{"device_alias":null}"#)); + "{}" + }; + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).as_bytes()).await.unwrap(); + } + }); + let session = AccountSession::new("fixture".into(), "user".into(), [7; 32]); + AccountClient::new() + .update_device_alias(&url, &session, "id", None) + .await + .unwrap(); + server.await.unwrap(); + } + #[tokio::test] async fn slow_peer_lookup_does_not_block_another_device_or_invalidation() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/src/crates/services/services-integrations/src/remote_connect/device.rs b/src/crates/services/services-integrations/src/remote_connect/device.rs index 0964062e6a..1001c23d34 100644 --- a/src/crates/services/services-integrations/src/remote_connect/device.rs +++ b/src/crates/services/services-integrations/src/remote_connect/device.rs @@ -69,6 +69,118 @@ impl DeviceIdentity { } } +/// Collect metadata on the executing host, without changing its persisted identity. +/// Unsupported or failed probes are omitted, never sent as destructive nulls. +pub async fn local_device_metadata() -> serde_json::Value { + async fn probe(program: &str, args: &[&str]) -> Option { + let mut command = openbitfun_services_core::process_manager::create_tokio_command(program); + command.args(args).kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(5), command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) + } + let os = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + other => other, + }; + #[cfg(target_os = "macos")] + let (model, version) = ( + probe("/usr/sbin/sysctl", &["-n", "hw.model"]).await, + probe("/usr/bin/sw_vers", &["-productVersion"]).await, + ); + #[cfg(target_os = "linux")] + let (model, version) = ( + tokio::fs::read_to_string("/sys/class/dmi/id/product_name") + .await + .ok() + .or_else(|| std::fs::read_to_string("/proc/device-tree/model").ok()) + .map(|v| { + v.trim_matches(|c: char| c.is_whitespace() || c == '\0') + .to_string() + }) + .filter(|v| !v.is_empty()), + match tokio::fs::read_to_string("/etc/os-release") + .await + .ok() + .and_then(|text| linux_distribution_version(&text)) + { + Some(version) => Some(version), + None => probe("uname", &["-r"]).await, + }, + ); + #[cfg(target_os = "windows")] + let (model, version) = ( + probe( + "powershell.exe", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-CimInstance Win32_ComputerSystem).Model", + ], + ) + .await, + probe( + "powershell.exe", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-CimInstance Win32_OperatingSystem).Version", + ], + ) + .await, + ); + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let (model, version): (Option, Option) = (None, None); + metadata_payload(os, model, version) +} + +// Validate independently: a broken firmware/probe value must not reject the +// entire PATCH. Omission preserves previously reported metadata on the Relay. +fn metadata_value(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)) + .then(|| value.to_string()) +} + +fn metadata_payload(os: &str, model: Option, version: Option) -> serde_json::Value { + let mut metadata = serde_json::Map::new(); + for (key, value) in [ + ("device_os", Some(os.to_string())), + ("device_model", model), + ("device_os_version", version), + ] { + if let Some(value) = value.as_deref().and_then(metadata_value) { + metadata.insert(key.into(), value.into()); + } + } + serde_json::Value::Object(metadata) +} + +#[cfg(any(target_os = "linux", test))] +fn linux_distribution_version(text: &str) -> Option { + // Match the system-info PRETTY_NAME convention without pulling its desktop + // action feature into remote-connect or executing os-release as shell code. + text.lines().find_map(|line| { + let value = line.strip_prefix("PRETTY_NAME=")?.trim(); + let value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + metadata_value(value) + }) +} + fn is_valid_device_id(device_id: &str) -> bool { device_id.len() == 32 && device_id.chars().all(|c| c.is_ascii_hexdigit()) } @@ -185,6 +297,66 @@ mod tests { format!("{nanos:x}") } + #[test] + fn metadata_filters_each_field_independently_by_bytes_and_controls() { + assert_eq!(metadata_value(&"a".repeat(256)), Some("a".repeat(256))); + assert!(metadata_value(&"a".repeat(257)).is_none()); + assert!(metadata_value(&"界".repeat(86)).is_none()); + for invalid in ["", " ", "bad\0model", "bad\tmodel", "bad\u{7f}model"] { + assert!(metadata_value(invalid).is_none()); + } + assert_eq!( + metadata_payload( + "Linux", + Some("x".repeat(257)), + Some(" Ubuntu 24.04\n".into()) + ), + serde_json::json!({"device_os": "Linux", "device_os_version": "Ubuntu 24.04"}) + ); + assert_eq!( + metadata_payload("Windows", Some("PC".into()), Some("bad\0version".into())), + serde_json::json!({"device_os": "Windows", "device_model": "PC"}) + ); + assert_eq!( + metadata_payload("macOS", None, None), + serde_json::json!({"device_os": "macOS"}) + ); + } + + #[test] + fn linux_distribution_version_is_data_not_shell() { + assert_eq!( + linux_distribution_version("NAME=Ubuntu\nPRETTY_NAME=\"Ubuntu 24.04 LTS\"\n"), + Some("Ubuntu 24.04 LTS".into()) + ); + assert_eq!( + linux_distribution_version("PRETTY_NAME='Debian GNU/Linux 12'"), + Some("Debian GNU/Linux 12".into()) + ); + assert!(linux_distribution_version("VERSION_ID=24.04").is_none()); + assert!( + linux_distribution_version(&format!("PRETTY_NAME=\"{}\"", "x".repeat(257))).is_none() + ); + assert!(linux_distribution_version("PRETTY_NAME=\"bad\0version\"").is_none()); + } + + #[tokio::test] + async fn local_metadata_has_display_os_and_only_non_destructive_valid_fields() { + let metadata = local_device_metadata().await; + let expected = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + other => other, + }; + assert_eq!(metadata["device_os"], expected); + for (key, value) in metadata.as_object().unwrap() { + assert!(["device_os", "device_model", "device_os_version"].contains(&key.as_str())); + let value = value.as_str().unwrap(); + assert_eq!(metadata_value(value).as_deref(), Some(value)); + } + } + #[test] fn test_device_identity_creation() { with_temp_identity_path(|| { diff --git a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs index 1d8e45d0a0..50c972024f 100644 --- a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs @@ -7,8 +7,17 @@ use sioc::prelude::*; use std::time::Duration; /// Authentication payload shared by all Rust hosts and controllers. +/// +/// Every (re)connect carries the build string and control-contract protocol +/// number so the Relay can gate compatibility instead of treating this device +/// as legacy. pub fn account_auth_payload(token: &str, machine: bool) -> Value { - json!({"token": token, "clientType": if machine { "machine-scoped" } else { "user-scoped" }}) + json!({ + "token": token, + "clientType": if machine { "machine-scoped" } else { "user-scoped" }, + "clientVersion": openbitfun_product_domains::account::client_version(), + "clientProtocol": openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION, + }) } #[derive(Debug, AckType, SerializePayload, DeserializePayload)] diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index fc0641644a..5d54b52684 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -18,10 +18,57 @@ pub const RELAY_INBOUND_IDLE_TIMEOUT: Duration = Duration::from_secs(60); pub fn ensure_rustls_crypto_provider() { openbitfun_services_core::tls_provider::ensure_ring_crypto_provider(); } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DevicePresenceEntry { pub device_id: String, pub device_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_alias: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_os_version: Option, + /// Build string the device last reported to the Relay. Absent for legacy + /// devices and for Relays that predate the field. Accepts the `client_*` + /// and camelCase spellings some Relays use. + #[serde( + default, + alias = "client_version", + alias = "clientVersion", + skip_serializing_if = "Option::is_none" + )] + pub device_client_version: Option, + /// Control-contract protocol number the device last reported. Absent for + /// legacy devices and for Relays that predate the field. + #[serde( + default, + alias = "client_protocol", + alias = "clientProtocol", + skip_serializing_if = "Option::is_none" + )] + pub device_client_protocol: Option, + /// Relay-computed compatibility, present in presence only when the Relay + /// computes it. `None` means unknown and must be treated as compatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compatible: Option, +} + +impl DevicePresenceEntry { + /// Whether the Relay considers this device compatible with our control + /// contract. + /// + /// The Relay computes and exposes `compatible` only on `GET /api/devices`; + /// presence entries carry the raw `client_version`/`client_protocol` at + /// most, so this is normally `None`. A `Some(false)` value, when a Relay + /// does send one, means the pair must not be remote-controlled (a device + /// with no version, or a mismatched number). `None` means the Relay did not + /// judge here — treat it as compatible; do not read a missing flag as "the + /// device lacks a version". + pub fn is_compatible(&self) -> bool { + self.compatible.unwrap_or(true) + } } #[derive(Debug, Clone)] pub enum RelayEvent { @@ -435,6 +482,69 @@ fn reply_deadline(payload: &serde_json::Value) -> Instant { #[cfg(test)] mod tests { + #[test] + fn presence_metadata_and_legacy_round_trip() { + let legacy = serde_json::json!({"device_id":"id", "device_name":"technical"}); + let entry: super::DevicePresenceEntry = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(serde_json::to_value(entry).unwrap(), legacy); + let extended = serde_json::json!({"device_id":"id", "device_name":"technical", + "device_alias":"Laptop", "device_model":"Mac14,7", "device_os":"macos", "device_os_version":"15"}); + let entry: super::DevicePresenceEntry = serde_json::from_value(extended.clone()).unwrap(); + assert_eq!(entry.device_name, "technical"); + assert_eq!(serde_json::to_value(entry).unwrap(), extended); + let cleared: super::DevicePresenceEntry = serde_json::from_value(serde_json::json!({ + "device_id":"id", "device_name":"technical", "device_alias":null, + "device_model":null, "device_os":null, "device_os_version":null, "future":true + })) + .unwrap(); + assert!(cleared.device_alias.is_none()); + } + + #[test] + fn presence_client_compatibility_round_trips_and_legacy_stays_unknown() { + // Legacy presence carries no build fields and no `compatible` flag. + // Presence never judges compatibility, so a missing flag is unknown and + // treated as compatible; this is not the "device lacks a version" case, + // which the Relay reports as an explicit `compatible: false`. + let legacy: super::DevicePresenceEntry = serde_json::from_value( + serde_json::json!({"device_id":"id", "device_name":"technical"}), + ) + .unwrap(); + assert!(legacy.device_client_version.is_none()); + assert!(legacy.device_client_protocol.is_none()); + assert!(legacy.compatible.is_none()); + assert!(legacy.is_compatible()); + + // A device that never reported a version is judged incompatible when the + // Relay does send `compatible: false` (raw build fields stay absent). + let unversioned: super::DevicePresenceEntry = serde_json::from_value(serde_json::json!({ + "device_id":"id", "device_name":"technical", "compatible":false + })) + .unwrap(); + assert!(unversioned.device_client_protocol.is_none()); + assert!(!unversioned.is_compatible()); + + // A Relay that also includes the build fields carries them through. + let extended = serde_json::json!({ + "device_id":"id", "device_name":"technical", + "device_client_version":"1.0.1", "device_client_protocol":2, "compatible":false + }); + let entry: super::DevicePresenceEntry = serde_json::from_value(extended.clone()).unwrap(); + assert_eq!(entry.device_client_version.as_deref(), Some("1.0.1")); + assert_eq!(entry.device_client_protocol, Some(2)); + assert!(!entry.is_compatible()); + assert_eq!(serde_json::to_value(&entry).unwrap(), extended); + + // The `client_*` spellings some Relays use are accepted too. + let aliased: super::DevicePresenceEntry = serde_json::from_value(serde_json::json!({ + "device_id":"id", "device_name":"technical", + "client_version":"1.0.1", "client_protocol":2 + })) + .unwrap(); + assert_eq!(aliased.device_client_version.as_deref(), Some("1.0.1")); + assert_eq!(aliased.device_client_protocol, Some(2)); + } + #[tokio::test] async fn base_endpoint_preserves_proxy_prefix_without_legacy_ws_conversion() { let (client, _events) = super::RelayClient::new(); diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 50a1f289b0..fb2403d108 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -66,13 +66,25 @@ fn relay_invitations_and_authentication_use_the_same_protocol_for_all_endpoints( ); } use openbitfun_services_integrations::remote_connect::realtime_client::account_auth_payload; + // Every handshake reports the build string and control-contract protocol + // number, including on reconnect. assert_eq!( account_auth_payload("test-token", true), - serde_json::json!({"token":"test-token","clientType":"machine-scoped"}) + serde_json::json!({ + "token":"test-token", + "clientType":"machine-scoped", + "clientVersion": openbitfun_product_domains::account::client_version(), + "clientProtocol": openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION, + }) ); assert_eq!( account_auth_payload("test-token", false), - serde_json::json!({"token":"test-token","clientType":"user-scoped"}) + serde_json::json!({ + "token":"test-token", + "clientType":"user-scoped", + "clientVersion": openbitfun_product_domains::account::client_version(), + "clientProtocol": openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION, + }) ); } diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index db493fd803..dd6e8293a1 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -11,6 +11,7 @@ "dev": "vite", "test:ui-components": "node --test tests/mobile-ui-components.test.mjs", "test:account-login": "node --test tests/account-login.test.mjs", + "test:device-compatibility": "node --test tests/device-compatibility.test.mjs", "test:account-browser": "node --test tests/browser-account.test.mjs", "test:images": "node --test tests/image-compressor.test.mjs", "test:workspace-identity": "node --test tests/workspace-identity.test.mjs tests/workspace-routing.test.mjs", diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index 013e99e3b4..e236c5a01b 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -7,7 +7,8 @@ import SessionListPage from './pages/SessionListPage'; import DevicesPage from './pages/DevicesPage'; import { ErrorBoundary } from './components/ErrorBoundary'; import { I18nProvider, useI18n } from './i18n'; -import { RelayHttpClient } from './services/RelayHttpClient'; +import { InvalidationSync } from '../../shared/relay-transport/InvalidationSync'; +import { RelayHttpClient, deviceDisplayName } from './services/RelayHttpClient'; import { RemoteSessionManager, } from './services/RemoteSessionManager'; @@ -70,6 +71,35 @@ const AppContent: React.FC = () => { const [automaticDeviceSelection, setAutomaticDeviceSelection] = useState(true); const controlTarget = useMobileStore((state) => state.controlTarget); + useEffect(() => { + const client = clientRef.current; + if (!client || !sessionMgr) return; + const stopSnapshot = client.onDeviceDirectorySnapshot(devices => { + if (clientRef.current !== client) return; + const target = useMobileStore.getState().controlTarget; + const device = devices.find(item => item.device_id === target?.deviceId); + if (device && target) useMobileStore.getState().setControlTarget({ ...target, deviceName: deviceDisplayName(device) }); + }); + const sync = new InvalidationSync(async () => { + const epoch = client.accountEpoch; + try { + const devices = await client.listDevices(); + if (clientRef.current !== client || client.accountEpoch !== epoch) return; + const target = useMobileStore.getState().controlTarget; + const device = devices.find(item => item.device_id === target?.deviceId); + if (device && target) useMobileStore.getState().setControlTarget({ ...target, deviceName: deviceDisplayName(device) }); + } catch { /* Retain the last authoritative name while offline. */ } + }); + const refresh = () => { void sync.invalidate(); }; + const stop = client.onDeviceDirectoryChanged(refresh); + const stopOwner = client.onAccountOwnerChange(refresh); + const stopTarget = client.onControlTargetChange(refresh); + const visible = () => { if (document.visibilityState === 'visible') refresh(); }; + document.addEventListener('visibilitychange', visible); + refresh(); + return () => { sync.stop(); stop(); stopOwner(); stopTarget(); stopSnapshot(); document.removeEventListener('visibilitychange', visible); }; + }, [sessionMgr]); + // An authenticated account without a selected desktop has nothing to ping. useConnectionHealth(accountDirectoryOpen ? null : sessionMgr); diff --git a/src/mobile-web/src/components/CompactSettingsSheet.tsx b/src/mobile-web/src/components/CompactSettingsSheet.tsx index 128eab26e6..e99988ec34 100644 --- a/src/mobile-web/src/components/CompactSettingsSheet.tsx +++ b/src/mobile-web/src/components/CompactSettingsSheet.tsx @@ -5,6 +5,8 @@ import { Sun as LucideSun, X as LucideX, } from 'lucide-react'; +import { deviceDisplayName, type RelayDeviceInfo } from '../services/RelayHttpClient'; +import { isDeviceControllable } from '../services/accountDeviceSelection'; import AccountAvatar from './AccountAvatar'; import React from 'react'; import { @@ -18,11 +20,7 @@ import { import { useI18n } from '../i18n'; import { MOBILE_LOCALES } from '../i18n/localeRegistry'; -interface SettingsDevice { - device_id: string; - device_name: string; - online: boolean; -} +type SettingsDevice = RelayDeviceInfo; interface CompactSettingsSheetProps { accountLabel: string | null; @@ -133,17 +131,21 @@ export default function CompactSettingsSheet({ {devices.map((device) => { const current = device.device_id === selectedDeviceId; + // A confirmed-incompatible device stays listed but is never a target. + const controllable = isDeviceControllable(device); return ( {renderDeviceIcon(device.device_name || device.device_id)}} + label={deviceDisplayName(device)} + leading={{renderDeviceIcon(deviceDisplayName(device))}} onClick={() => onSelectDevice(device)} selected={current} - supportingText={current ? t('settings.currentDevice') : device.online ? t('devices.online') : t('devices.offline')} + supportingText={!controllable + ? t('devices.clientIncompatible') + : current ? t('settings.currentDevice') : device.online ? t('devices.online') : t('devices.offline')} trailing={} /> ); diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 660467195a..acd520022f 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -321,6 +321,12 @@ export const messages: Record = { deviceUnavailable: 'This device is offline or did not respond.', unknownDevice: 'Unnamed device', controllingDevice: 'Controlling {name}', + alias: 'Device alias', + aliasPlaceholder: 'Optional display name', + editAlias: 'Edit name', + saveAlias: 'Save name', + aliasUnsupported: 'This older Relay does not support device aliases. Update the Relay to rename devices.', + clientIncompatible: 'Incompatible client — this device cannot be controlled here', }, tools: { ls: 'LS', @@ -645,6 +651,12 @@ export const messages: Record = { deviceUnavailable: '该设备离线或未响应。', unknownDevice: '未命名设备', controllingDevice: '正在控制 {name}', + alias: '设备别名', + aliasPlaceholder: '可选显示名称', + editAlias: '编辑名称', + saveAlias: '保存名称', + aliasUnsupported: '旧版 Relay 不支持设备别名,请升级 Relay 后重试。', + clientIncompatible: '客户端版本不兼容,无法互控。', }, tools: { ls: '列表', @@ -969,6 +981,12 @@ export const messages: Record = { deviceUnavailable: '該裝置離線或未回應。', unknownDevice: '未命名裝置', controllingDevice: '正在控制 {name}', + alias: '裝置別名', + aliasPlaceholder: '選填顯示名稱', + editAlias: '編輯名稱', + saveAlias: '儲存名稱', + aliasUnsupported: '舊版 Relay 不支援裝置別名,請升級 Relay 後重試。', + clientIncompatible: '客戶端版本不相容,無法互控。', }, tools: { ls: '列表', diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index 4863e8fede..3e051622b8 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -1,7 +1,6 @@ import { InvalidationSync } from '../../../shared/relay-transport/InvalidationSync'; import { ChevronLeft as LucideChevronLeft, - ChevronRight as LucideChevronRight, Monitor as LucideMonitor, RefreshCw as LucideRefreshCw, UserRoundSearch as LucideUserRoundSearch, @@ -25,21 +24,19 @@ import { MobileListRow, MobilePageHeader, MobileStatus, + MobileTextField, } from '@openbitfun/ui/mobile'; import { RelayHttpClient, + deviceDisplayName, isAccountIdentityChangedError, + type RelayDeviceInfo, } from '../services/RelayHttpClient'; import { useI18n } from '../i18n'; import { useMobileStore } from '../services/store'; -import { selectAccountDevice } from '../services/accountDeviceSelection'; +import { selectAccountDevice, isDeviceControllable } from '../services/accountDeviceSelection'; -interface DeviceInfo { - device_id: string; - device_name: string; - online: boolean; - last_seen_at?: number | null; -} +type DeviceInfo = RelayDeviceInfo; interface Props { @@ -78,6 +75,9 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac const [directoryLoaded, setDirectoryLoaded] = useState(false); const [switchingId, setSwitchingId] = useState(null); const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [aliasDraft, setAliasDraft] = useState(''); + const [aliasSupported, setAliasSupported] = useState(false); const mountedRef = useRef(true); const identityRequestRef = useRef(0); const devicesRequestRef = useRef(0); @@ -91,7 +91,7 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac const rightCurrent = right.device_id === client.targetDeviceId; if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1; if (left.online !== right.online) return left.online ? -1 : 1; - return (left.device_name || left.device_id).localeCompare(right.device_name || right.device_id); + return deviceDisplayName(left).localeCompare(deviceDisplayName(right)); }); return listedDevices; }, [client, client.controllerDeviceId, client.targetDeviceId, connectionHealth, devices]); @@ -126,6 +126,8 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac && devicesRequestRef.current === requestId ); try { + const epoch = client.accountEpoch; + void client.supportsDeviceAlias().then(supported => { if (isCurrent() && client.accountEpoch === epoch) setAliasSupported(supported); }).catch(() => { if (isCurrent()) setAliasSupported(false); }); const list = await client.listDevices(); if (!isCurrent()) return; setDevices(list); @@ -176,8 +178,21 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac if (mountedRef.current) setLoading(false); }, [loading, refreshDevices, switchingId]); + const updateAlias = useCallback(async (device: DeviceInfo) => { + const epoch = client.accountEpoch; + try { + await client.updateDeviceAlias(device.device_id, aliasDraft.trim() || null); + if (!mountedRef.current || client.accountEpoch !== epoch) return; + setEditingId(null); + await refreshDevices(); + } catch (e) { + if (!mountedRef.current || client.accountEpoch !== epoch || isAccountIdentityChangedError(e)) return; + setError(String(e).includes('unsupported') ? t('devices.aliasUnsupported') : friendlyError(e, 'devices.loadFailed')); + } + }, [aliasDraft, client, friendlyError, refreshDevices, t]); const selectDevice = useCallback(async (d: DeviceInfo, probe = true) => { - if (!d.online || switchingId) return; + // A confirmed-incompatible device is shown but never a control target. + if (!d.online || !isDeviceControllable(d) || switchingId) return; if (client.targetDeviceId === d.device_id) return; const requestId = ++switchRequestRef.current; const accountEpoch = client.accountEpoch; @@ -210,7 +225,7 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac resetForDeviceSwitch(); setControlTarget({ deviceId: d.device_id, - deviceName: d.device_name, + deviceName: client.resolveDeviceName(d.device_id, deviceDisplayName(d)), }); onDeviceSelected(); } catch (e: unknown) { @@ -246,52 +261,82 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac {sortedDevices.map((d) => { const isCurrent = client.targetDeviceId === d.device_id; const isSwitching = switchingId === d.device_id; - const clickable = d.online && !isCurrent && !switchingId; + const controllable = isDeviceControllable(d); + const clickable = d.online && controllable && !isCurrent && !switchingId; return ( - clickable && selectDevice(d)} - leading={} - label={( - - - {d.device_name || t('devices.unknownDevice')} +
+ {/* Renaming replaces the row in place: keeping the row and an editor + under it shows the same device name twice. */} + {editingId === d.device_id ? ( +
+ setAliasDraft(e.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { event.preventDefault(); void updateAlias(d); } + if (event.key === 'Escape') { event.preventDefault(); setEditingId(null); } + }} + placeholder={t('devices.aliasPlaceholder')} + aria-label={t('devices.alias')} + /> +
+ void updateAlias(d)}>{t('devices.saveAlias')} + setEditingId(null)}>{t('common.cancel')} +
+
+ ) : ( + <> + clickable && selectDevice(d)} + leading={} + label={( + + + {deviceDisplayName(d) || t('devices.unknownDevice')} + + {d.device_model || d.device_os ? {[d.device_model, d.device_os, d.device_os_version].filter(Boolean).join(' · ')} : null} + {isCurrent && ( + + {t('devices.current')} + + )} + {!controllable && ( + + {t('devices.clientIncompatible')} + + )} + + + )} + supportingText={( + + + {d.online + ? t('devices.online') + : d.last_seen_at + ? t('devices.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) + : t('devices.offline')} - {isCurrent && ( - - {t('devices.current')} - - )} + )} - - )} - supportingText={( - - - {d.online - ? t('devices.online') - : d.last_seen_at - ? t('devices.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) - : t('devices.offline')} - - )} - trailing={isSwitching ? ( - - ) : ( - clickable && ( -
); })} @@ -376,6 +421,7 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac {accountLanding && sortedDevices.length > 0 &&

{t('devices.accountReady')}

} {error && {error}} + {!aliasSupported && {t('devices.aliasUnsupported')}}
{renderBody()}
diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index abb239b15a..65a8a4e4f4 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -71,7 +71,10 @@ import logoMarkLight from '../assets/openbitfun-mark-light.png'; import { isAccountIdentityChangedError, type RelayHttpClient, + type RelayDeviceInfo, + deviceDisplayName, } from '../services/RelayHttpClient'; +import { isDeviceControllable } from '../services/accountDeviceSelection'; const PAGE_SIZE = 30; @@ -95,11 +98,7 @@ interface SessionListPageProps { onControlTargetChanged?: () => void; } -type CompactDevice = { - device_id: string; - device_name: string; - online: boolean; -}; +type CompactDevice = RelayDeviceInfo; function compactSelectedDeviceIdForClient(client?: RelayHttpClient): string | null { @@ -805,10 +804,12 @@ const SessionListPage: React.FC = ({ useEffect(() => { if (!compact) return; void loadCompactDirectory(); - }, [compact, loadCompactDirectory]); + return client?.onDeviceDirectoryChanged(() => { void loadCompactDirectory(); }); + }, [client, compact, loadCompactDirectory]); const handleSelectCompactDevice = useCallback(async (device: CompactDevice) => { - if (!client || !device.online || compactSwitchingDeviceId) return; + // A confirmed-incompatible device stays listed but is never a control target. + if (!client || !device.online || !isDeviceControllable(device) || compactSwitchingDeviceId) return; setCompactSelectedDeviceId(device.device_id); if (client.targetDeviceId === device.device_id) { @@ -841,7 +842,7 @@ const SessionListPage: React.FC = ({ resetForDeviceSwitch(); setControlTarget({ deviceId: device.device_id, - deviceName: device.device_name || null, + deviceName: client.resolveDeviceName(device.device_id, deviceDisplayName(device)), }); onControlTargetChanged?.(); await loadCompactWorkspaceCatalog(switchedTargetEpoch); @@ -1610,13 +1611,13 @@ const SessionListPage: React.FC = ({ block className={`harmony-sidebar__device-row${isCurrent ? ' is-current' : ''}`} key={device.device_id} - disabled={!device.online || (!!compactSwitchingDeviceId && !isSwitching)} + disabled={!device.online || !isDeviceControllable(device) || (!!compactSwitchingDeviceId && !isSwitching)} onClick={() => void handleSelectCompactDevice(device)} > - {device.device_name || device.device_id} + {deviceDisplayName(device)} {isSwitching ? : } diff --git a/src/mobile-web/src/services/CloudAccountClient.ts b/src/mobile-web/src/services/CloudAccountClient.ts index 8ff9a55edf..9dbb57012f 100644 --- a/src/mobile-web/src/services/CloudAccountClient.ts +++ b/src/mobile-web/src/services/CloudAccountClient.ts @@ -1,5 +1,6 @@ import { toB64 } from './E2EEncryption'; import { x25519 } from '@noble/curves/ed25519.js'; +import { CLIENT_PROTOCOL_VERSION, CLIENT_VERSION } from '../../../shared/relay-transport/ClientBuild'; import { pairingRelayUrl } from './pairingLink'; export interface CloudAccountSession { token: string; userId: string; masterKey: Uint8Array; } @@ -166,6 +167,9 @@ export class CloudAccountClient { const auth = await requestJson<{ token: string; user_id: string }>(this.relayUrl, '/api/auth/login', { access_token: accessToken, device_id: deviceId, device_name: 'Mobile Browser', device_kind: 'mobile', public_key: toB64(keys.publicKey), request_id: generateRequestId(), + // Report the build so the Relay can gate control compatibility instead + // of treating this browser controller as a legacy client. + clientVersion: CLIENT_VERSION, clientProtocol: CLIENT_PROTOCOL_VERSION, }); if (!auth.token?.trim() || !auth.user_id?.trim()) throw new Error('Invalid account identity.'); return { token: auth.token, userId: auth.user_id, masterKey: keys.privateKey }; diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts index 1744926f7f..525958183c 100644 --- a/src/mobile-web/src/services/RelayHttpClient.ts +++ b/src/mobile-web/src/services/RelayHttpClient.ts @@ -32,12 +32,53 @@ const RELAY_HTTP_RETRY_BUDGET_MS = 120_000; const TRANSIENT_RELAY_STATUSES = new Set([408, 425, 500, 502, 503, 504]); type RelayRequestOptions = { retryable?: boolean; timeoutMs?: number }; +export interface RelayDeviceInfo { + device_id: string; + device_name: string; + device_alias?: string | null; + device_model?: string | null; + device_os?: string | null; + device_os_version?: string | null; + /** Client build the device reported to the Relay. Absent on older Relays. */ + client_version?: string | null; + /** Client wire protocol the device reported. Absent on older Relays. */ + client_protocol?: number | null; + /** + * Relay-computed mutual-control compatibility of this device with this one. + * + * `false` means confirmed incompatible: either a client build/protocol + * mismatch or a peer that reported no version information (an older client). + * Absent only on an older Relay that does not gate at all, which must be + * treated as "unknown but usable", never as incompatible. + */ + compatible?: boolean; + online: boolean; + last_seen_at?: number | null; +} + +export function deviceDisplayName(device: Pick): string { + return device.device_alias ?? device.device_name ?? device.device_id; +} + + export class RelayHttpClient { private readonly relayUrl: string; private realtime: AccountRealtime | null = null; private identity: AccountIdentitySnapshot | null = null; private identityGeneration = 0; private accountEpochValue = 0; + private directoryRequest = 0; + private appliedDirectoryRequest = 0; + private directorySnapshot: RelayDeviceInfo[] = []; + private directorySnapshotListeners = new Set<(devices: RelayDeviceInfo[]) => void>(); + onDeviceDirectorySnapshot(listener: (devices: RelayDeviceInfo[]) => void): () => void { + this.directorySnapshotListeners.add(listener); + return () => { this.directorySnapshotListeners.delete(listener); }; + } + resolveDeviceName(deviceId: string, fallback = deviceId): string { + const device = this.directorySnapshot.find(item => item.device_id === deviceId); + return device ? deviceDisplayName(device) : fallback; + } private directoryListeners = new Set<() => void>(); private ownerListeners = new Set<(change: AccountOwnerChange) => void>(); private authorizationExpiredListeners = new Set<(token: string) => void>(); @@ -69,6 +110,10 @@ export class RelayHttpClient { this.realtime.onReconnect(notifyDirectory); this.realtime.onDeviceEvent(envelope => { void this.receiveDeviceEvent(envelope); }); this.accountEpochValue += 1; + this.directorySnapshot = []; + this.directoryRequest += 1; + // A read that started under the previous identity must not seed the cache. + this.appliedDirectoryRequest = this.directoryRequest; this.deviceMessageKeys.clear(); this.setTargetDeviceId(null); for (const listener of this.ownerListeners) listener({ kind, epoch: this.accountEpochValue, userId: identity.userId }); @@ -81,6 +126,10 @@ export class RelayHttpClient { this.identity = null; this.identityGeneration += 1; this.accountEpochValue += 1; + this.directorySnapshot = []; + this.directoryRequest += 1; + // A read from the closed identity must not seed the cache either. + this.appliedDirectoryRequest = this.directoryRequest; this.deviceMessageKeys.clear(); this.setTargetDeviceId(null); for (const listener of this.ownerListeners) listener({ kind: 'unavailable', epoch: this.accountEpochValue, userId: null }); @@ -259,7 +308,8 @@ export class RelayHttpClient { throw lastError; } - async listDevices(): Promise> { + async listDevices(): Promise { + const request = ++this.directoryRequest; return this.withAccount(async (identity) => { const resp = await this.fetchWithRetry(`${this.relayUrl}/api/devices`, { headers: { 'Authorization': `Bearer ${identity.token}` }, @@ -273,8 +323,42 @@ export class RelayHttpClient { err.status = resp.status; throw err; } - return resp.json(); + const devices = await resp.json() as RelayDeviceInfo[]; + if (identity.generation !== this.identityGeneration) throw new AccountIdentityChangedError(); + // Several surfaces read the directory at once (this app's mount effect, the + // device page, the compact session list). A read that another read + // superseded answers with the newest completion instead of failing its + // caller: a spurious failure here leaves that surface on an empty device + // list until its next poll, which is exactly what a second tab used to do. + if (request < this.appliedDirectoryRequest) return this.directorySnapshot; + this.appliedDirectoryRequest = request; + this.directorySnapshot = devices; + for (const listener of this.directorySnapshotListeners) listener(devices); + return devices; + }); + } + + async supportsDeviceAlias(): Promise { + const response = await this.fetchWithTimeout(`${this.relayUrl}/api/info`, {}, 20_000); + if (response.status === 404 || response.status === 405) return false; + if (!response.ok) throw new Error(`Relay info failed: HTTP ${response.status}`); + const info = await response.json() as { capabilities?: string[] }; + return Array.isArray(info.capabilities) && info.capabilities.includes('device_alias_v1'); + } + + async updateDeviceAlias(deviceId: string, alias: string | null): Promise { + await this.withAccount(async identity => { + if (!await this.supportsDeviceAlias()) throw new Error('Device alias updates unsupported by this Relay'); + if (identity.generation !== this.identityGeneration) throw new AccountIdentityChangedError(); + const response = await this.fetchWithTimeout(`${this.relayUrl}/api/devices/${encodeURIComponent(deviceId)}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${identity.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_alias: alias }), + }, 20_000); + if (response.status === 404 || response.status === 405) throw new Error('Device alias updates unsupported or device unavailable'); + if (!response.ok) throw Object.assign(new Error(`Update device failed: HTTP ${response.status}`), { status: response.status }); }); + for (const listener of this.directoryListeners) listener(); } /** Send a command encrypted with the two account devices' X25519 key agreement. */ diff --git a/src/mobile-web/src/services/accountDeviceSelection.ts b/src/mobile-web/src/services/accountDeviceSelection.ts index 4b55be9cc4..4068f8d184 100644 --- a/src/mobile-web/src/services/accountDeviceSelection.ts +++ b/src/mobile-web/src/services/accountDeviceSelection.ts @@ -2,10 +2,39 @@ export interface AccountDevice { device_id: string; device_name: string; online: boolean; + /** Client build the device reported to the Relay. Absent on older Relays. */ + client_version?: string | null; + /** Client wire protocol the device reported. Absent on older Relays. */ + client_protocol?: number | null; + /** + * Relay-computed mutual-control compatibility of this device with this one. + * + * `false` means confirmed incompatible: either a client build/protocol + * mismatch or a peer that reported no version information (an older client). + * Absent only on an older Relay that does not gate at all, which must be + * treated as "unknown but usable", never as incompatible. + */ + compatible?: boolean; +} + +/** + * The single gate every mobile control entry point must reuse. + * + * The Relay computes `compatible` from *both* clients' reported build/protocol. + * It returns `false` for a version mismatch and also for a peer that reported + * no version information at all (an older client), so a `false` flag always + * means "this peer is not mutually controllable" regardless of the reason. + * + * `compatible` is absent only on an older Relay that does not gate at all; that + * is "unknown but usable" and must never block control. + */ +export function isDeviceControllable(device: Pick): boolean { + return device.compatible !== false; } /** Device availability is not an authentication condition. A scanned target - * must never silently fall back to an unrelated device on the same account. */ + * must never silently fall back to an unrelated device on the same account, + * nor to a device the Relay confirmed is incompatible. */ export function selectAccountDevice( devices: readonly AccountDevice[], controllerDeviceId: string | null, @@ -13,5 +42,7 @@ export function selectAccountDevice( ): AccountDevice | null { const preferred = preferredDeviceId?.trim(); return devices.find((device) => device.device_id !== controllerDeviceId - && device.online && (!preferred || device.device_id === preferred)) ?? null; + && device.online + && isDeviceControllable(device) + && (!preferred || device.device_id === preferred)) ?? null; } diff --git a/src/mobile-web/src/styles/components/devices.scss b/src/mobile-web/src/styles/components/devices.scss index df86642a92..fd0ade27b7 100644 --- a/src/mobile-web/src/styles/components/devices.scss +++ b/src/mobile-web/src/styles/components/devices.scss @@ -139,6 +139,28 @@ } } +/* Occupies the row's slot while renaming, so the device name is edited in + place instead of being repeated under the row. */ +.devices-page__alias-editor { + display: flex; + flex-direction: column; + gap: var(--size-gap-2); + padding: var(--openbitfun-space-2) var(--openbitfun-space-3); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-md); + background: var(--openbitfun-color-surface-panel); + box-shadow: var(--openbitfun-shadow-xs); +} + +.devices-page__alias-actions { + display: flex; + justify-content: flex-end; + gap: var(--size-gap-2); + padding: var(--size-gap-2) var(--size-gap-4) var(--size-gap-3); + + .devices-page__alias-editor & { padding: 0; } +} + .devices-page__device-spinner { flex-shrink: 0; width: 16px; diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index a6277aac33..52181235be 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -17,6 +17,15 @@ async function loadSource(relativePath, imports = {}) { const links = await loadSource('../src/services/pairingLink.ts'); const selection = await loadSource('../src/services/accountDeviceSelection.ts'); const { selectAccountDevice } = await import(selection.url); +// The shared build module reads the workspace version from package.json; the +// transpile loader cannot import JSON, so hand it an equivalent JS module. +const workspacePackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')); +const packageJsonStub = `data:text/javascript;base64,${Buffer.from( + `export const version = ${JSON.stringify(workspacePackage.version)};`, +).toString('base64')}`; +const clientBuild = await loadSource('../../shared/relay-transport/ClientBuild.ts', { '../../../package.json': packageJsonStub }); +const { CLIENT_PROTOCOL_VERSION, CLIENT_VERSION } = await import(clientBuild.url); +const clientBuildImports = { '../../../shared/relay-transport/ClientBuild': clientBuild.url }; const offline = { device_id: 'desktop-a', device_name: 'Offline desktop', online: false }; const online = { device_id: 'desktop-b', device_name: 'Online desktop', online: true }; const controller = { device_id: 'browser', device_name: 'Browser', online: true }; @@ -84,7 +93,7 @@ test('GitHub login registers the browser store key without generating another id const encryption = await loadSource('../src/services/E2EEncryption.ts'); const { deriveDeviceMessageKey } = await import(encryption.url); const { x25519 } = await import('@noble/curves/ed25519.js'); - const authModule = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryption.url, './pairingLink': links.url }); + const authModule = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryption.url, './pairingLink': links.url, ...clientBuildImports }); const { CloudAccountClient } = await import(authModule.url); const originalFetch = globalThis.fetch; const originalWindow = globalThis.window; @@ -98,6 +107,10 @@ test('GitHub login registers the browser store key without generating another id assert.equal(body.access_token, 'verified-github-token'); assert.equal(body.device_id, 'browser'); assert.equal(body.password_hash, undefined); + // The login body reports this client's build so the Relay can gate control + // compatibility instead of treating the browser as a legacy client. + assert.equal(body.clientProtocol, CLIENT_PROTOCOL_VERSION); + assert.equal(body.clientVersion, CLIENT_VERSION); assert.equal(Buffer.from(body.public_key, 'base64').length, 32); return Response.json({ token: 'test-account-token', user_id: '101' }); }; @@ -132,15 +145,28 @@ test('account UI entry precedes discovery and mounts no remote workspace surface assert.match(app, /!accountDirectoryOpen && page !== 'pairing' && sessionMgrRef\.current/); assert.equal((app.match(/\{renderDetailPage\(\)\}/g) || []).length, 1, 'one gated detail tree must preserve chat state across layout changes'); const devices = await readFile(new URL('../src/pages/DevicesPage.tsx', import.meta.url), 'utf8'); - assert.match(devices, /if \(!d.online \|\| switchingId\) return/); + assert.match(devices, /if \(!d\.online \|\| !isDeviceControllable\(d\) \|\| switchingId\) return/); assert.match(devices, /automaticSelectionAttemptedRef\.current = true/); assert.match(devices, /selectDevice\(target, false\)/, 'initial account selection must not require a new peer command'); assert.ok(devices.indexOf('await client.sendDeviceRpc') < devices.indexOf('client.setTargetDeviceId(d.device_id)')); }); +test('alias editing replaces the device row instead of repeating its name', async () => { + const devices = await readFile(new URL('../src/pages/DevicesPage.tsx', import.meta.url), 'utf8'); + const start = devices.indexOf('{editingId === d.device_id ? ('); + assert.ok(start > 0, 'the alias editor must be a mutually exclusive branch'); + const elseAt = devices.indexOf(') : (', start); + const rowAt = devices.indexOf(' start, 'the editing branch needs an else branch for the row'); + assert.ok(rowAt > elseAt, 'the device row must render only while the alias editor is closed'); + const editingBranch = devices.slice(start, elseAt); + assert.match(editingBranch, /devices-page__alias-editor/); + assert.match(editingBranch, /devices\.saveAlias/); +}); + test('authorization follows the central GitHub OAuth URL and rejects lookalike destinations', async () => { const encryption = await loadSource('../src/services/E2EEncryption.ts'); - const authModule = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryption.url, './pairingLink': links.url }); + const authModule = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryption.url, './pairingLink': links.url, ...clientBuildImports }); const { CloudAccountClient } = await import(authModule.url); const previous = { fetch: globalThis.fetch, window: globalThis.window, setTimeout: globalThis.setTimeout }; globalThis.window = { setTimeout: previous.setTimeout, clearTimeout }; @@ -324,6 +350,7 @@ const encryptionStub = `data:text/javascript;base64,${Buffer.from( const accountClient = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryptionStub, './pairingLink': links.url, + ...clientBuildImports, }); const { CloudAccountClient, CloudAccountRequestError, CloudAccountTransportError, diff --git a/src/mobile-web/tests/device-compatibility.test.mjs b/src/mobile-web/tests/device-compatibility.test.mjs new file mode 100644 index 0000000000..2ef38e2566 --- /dev/null +++ b/src/mobile-web/tests/device-compatibility.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import ts from 'typescript'; + +const source = await readFile( + new URL('../src/services/accountDeviceSelection.ts', import.meta.url), + 'utf8', +); +const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const { isDeviceControllable, selectAccountDevice } = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` +); + +const device = (id, extra = {}) => ({ device_id: id, device_name: id, online: true, ...extra }); + +test('only a confirmed incompatible flag is uncontrollable; a missing flag stays usable', () => { + // Older Relays and older payloads omit the flag entirely. + assert.equal(isDeviceControllable({}), true); + assert.equal(isDeviceControllable({ compatible: undefined }), true); + assert.equal(isDeviceControllable({ compatible: true }), true); + assert.equal(isDeviceControllable({ compatible: false }), false); +}); + +test('an incompatible device stays listed but is never auto-selected', () => { + const devices = [ + device('controller'), + device('incompatible', { compatible: false, client_version: '0.9.0' }), + ]; + // The device row remains available to the page for rendering. + assert.equal(devices.some(item => item.device_id === 'incompatible'), true); + assert.equal( + selectAccountDevice(devices, 'controller'), + null, + 'a confirmed-incompatible device is not a fallback target', + ); +}); + +test('automatic selection skips an incompatible peer and picks the next usable one', () => { + const devices = [ + device('controller'), + device('incompatible', { compatible: false }), + device('usable'), + ]; + assert.equal(selectAccountDevice(devices, 'controller')?.device_id, 'usable'); +}); + +test('a preferred incompatible device never overrides the compatibility gate', () => { + const devices = [device('controller'), device('incompatible', { compatible: false })]; + assert.equal(selectAccountDevice(devices, 'controller', 'incompatible'), null); +}); + +test('a preferred usable device is still honoured', () => { + const devices = [device('controller'), device('first'), device('preferred')]; + assert.equal(selectAccountDevice(devices, 'controller', 'preferred')?.device_id, 'preferred'); +}); diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 35c0a4eace..2190336b9e 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -4390,16 +4390,18 @@ }, { "id": "devices", - "titleZh": "列出、连接、查看在线状态和删除同账户设备", - "titleEn": "List, connect, inspect online status, and remove same-account devices", + "titleZh": "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "titleEn": "List, connect, inspect model and OS, set aliases, and remove same-account devices", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看在线状态和删除同账户设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." }, "evidence": [ "command:account_list_devices", + "command:account_relay_capabilities", + "command:account_update_device_alias", "command:account_connect_devices", "command:account_online_devices", "command:account_delete_device", diff --git a/src/shared/relay-transport/AccountRealtime.ts b/src/shared/relay-transport/AccountRealtime.ts index c7e7672876..89af6701b2 100644 --- a/src/shared/relay-transport/AccountRealtime.ts +++ b/src/shared/relay-transport/AccountRealtime.ts @@ -1,4 +1,5 @@ import { DEFAULT_RPC_TIMEOUT_MS } from './RpcPolicy'; +import { CLIENT_PROTOCOL_VERSION, CLIENT_VERSION } from './ClientBuild'; /** Shared account connection. Protocol reference: Happy apiSocket/RpcHandlerManager. */ import { io, type Socket } from 'socket.io-client'; import { RpcPayload } from './RpcPayload'; @@ -40,7 +41,10 @@ export class AccountRealtime { this.socket = io(url.origin, { path, transports: ['websocket'], forceNew: true, autoConnect: false, auth: { token: options.token, clientType: options.machineId ? 'machine-scoped' : 'user-scoped', - ...(options.machineId ? { machineId: options.machineId } : {}) }, + ...(options.machineId ? { machineId: options.machineId } : {}), + // The Relay gates control compatibility on the reported protocol number, + // so every (re)connect carries the build instead of looking legacy. + clientVersion: CLIENT_VERSION, clientProtocol: CLIENT_PROTOCOL_VERSION }, reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, randomizationFactor: 0.5, timeout: 15000, }); diff --git a/src/shared/relay-transport/ClientBuild.ts b/src/shared/relay-transport/ClientBuild.ts new file mode 100644 index 0000000000..714b815144 --- /dev/null +++ b/src/shared/relay-transport/ClientBuild.ts @@ -0,0 +1,35 @@ +import { version as PACKAGE_VERSION } from '../../../package.json'; + +/** + * Identity of THIS client build, reported to the Relay at login and on every + * realtime (re)connect so the Relay can gate remote control between devices. + * + * Compatibility is decided by the Relay alone from the two stored protocol + * numbers; clients read the Relay-computed `compatible` flag on the device + * directory instead of re-deriving the rule. The rule the Relay applies is: + * two devices are mutually controllable only when BOTH report a number AND the + * numbers are equal. + * + * Semantics of {@link CLIENT_PROTOCOL_VERSION}: + * + * - legacy clients: report nothing (unreported, so the Relay judges them + * incompatible, never "legacy-compatible"); + * - this build: `2`; + * - increment only on a breaking change to the client control contract. + * + * Kept numerically identical to + * `openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION` in + * `src/crates/contracts/product-domains/src/account.rs`; `clientBuild.contract.test.ts` + * pins the two together so one side can never drift. + */ +export const CLIENT_PROTOCOL_VERSION = 2; + +/** + * Product build string reported next to {@link CLIENT_PROTOCOL_VERSION} for + * diagnostics only; the Relay gates control on the protocol number, never on + * this string. Sourced from the workspace `package.json` — the release version + * release-please bumps together with `Cargo.toml` — so it is a single source and + * never a hand-maintained literal. This is where web-ui and mobile both read it + * from rather than each keeping a copy. + */ +export const CLIENT_VERSION: string = PACKAGE_VERSION; diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index e8622ff507..ecc2793d64 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -18,6 +18,11 @@ --openbitfun-agent-companion-gap: 8px; --openbitfun-agent-companion-pet-width: 96px; --openbitfun-agent-companion-pet-height: 96px; + /* Room reserved above and below the dock, mirrored by WINDOW_VERTICAL_BUFFER + in the component, which grows the host window to hold it. Without it a + bubble that lifts (hover, attention pulse) is clipped by the bubble slot + and by the window edge. */ + --openbitfun-agent-companion-vertical-buffer: 3px; width: 100vw; height: 100vh; @@ -52,12 +57,16 @@ &__dock { position: relative; flex: 0 1 auto; - min-height: 0; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: var(--openbitfun-agent-companion-gap); + /* The pet is centered in a slot one buffer taller than it, so it keeps the + same clearance from the window edge as a bubble. */ + min-height: calc( + var(--openbitfun-agent-companion-pet-height) + var(--openbitfun-agent-companion-vertical-buffer) * 2 + ); width: max-content; max-height: 100vh; pointer-events: none; @@ -209,6 +218,9 @@ max-width: 180px; min-height: 0; max-height: 100vh; + /* The slot clips (`overflow: hidden`), so the buffer has to sit inside it: + a bubble lifting out of its own box then keeps its border visible. */ + padding-block: var(--openbitfun-agent-companion-vertical-buffer); display: flex; flex-direction: column; align-items: flex-end; @@ -218,7 +230,13 @@ pointer-events: auto; &--single { - height: min(var(--openbitfun-agent-companion-pet-height), 100vh); + height: min( + calc( + var(--openbitfun-agent-companion-pet-height) + + var(--openbitfun-agent-companion-vertical-buffer) * 2 + ), + 100vh + ); overflow-y: hidden; } } diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 95fa70d791..4b5bbb6984 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -34,6 +34,12 @@ const BUBBLE_GAP = 6; const BUBBLE_WIDTH = 180; const BUBBLE_OUTPUT_TYPEWRITER_INTERVAL_MS = 28; const WINDOW_EDGE_BUFFER = 4; +/** + * Room kept above and below the dock. A bubble lifts on hover and pulses on + * attention, so its own box must stay that far away from the window edge or the + * border gets clipped. + */ +const WINDOW_VERTICAL_BUFFER = 3; const POINTER_HOVER_POLL_INTERVAL_MS = 120; const PET_LOOK_HOLD_MS = 960; /** Clicks shorter/smaller than this use `show_main_window`; beyond it we start dragging. */ @@ -426,19 +432,23 @@ export const AgentCompanionDesktopPet: React.FC = () => { const bubbleCount = visibleTasks.length; const bubbleElements = Array.from(bubblesRef.current?.children ?? []) .slice(0, MAX_VISIBLE_BUBBLES); + // Sum of the bubbles themselves: the slot's own vertical buffer is chrome + // the component adds back below, so it must not be measured twice. const visibleBubbleHeight = bubbleElements.reduce( (sum, child) => sum + child.getBoundingClientRect().height, 0, ) + Math.max(0, bubbleElements.length - 1) * BUBBLE_GAP; - const measuredBubbleHeight = bubblesRef.current?.scrollHeight ?? 0; const targetBubbleHeight = bubbleCount === 1 ? activePetSize.height - : bubbleCount > MAX_VISIBLE_BUBBLES - ? visibleBubbleHeight - : measuredBubbleHeight; - const nextHeight = bubbleCount > 0 - ? Math.max(activePetSize.height, Math.min(WINDOW_MAX_HEIGHT, targetBubbleHeight)) - : activePetSize.height; + : visibleBubbleHeight; + // The buffer is chrome around the content, so it is added after clamping: + // the window still never exceeds WINDOW_MAX_HEIGHT. + const nextHeight = (bubbleCount > 0 + ? Math.max( + activePetSize.height, + Math.min(WINDOW_MAX_HEIGHT - WINDOW_VERTICAL_BUFFER * 2, targetBubbleHeight), + ) + : activePetSize.height) + WINDOW_VERTICAL_BUFFER * 2; const measuredBubbleWidth = bubbleCount > 0 ? BUBBLE_WIDTH : 0; const measuredDockWidth = bubbleCount > 0 ? measuredBubbleWidth + WINDOW_HORIZONTAL_GAP + activePetSize.width + WINDOW_EDGE_BUFFER @@ -963,6 +973,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { '--openbitfun-agent-companion-pet-width': `${activePetSize.width}px`, '--openbitfun-agent-companion-pet-height': `${activePetSize.height}px`, '--openbitfun-agent-companion-gap': `${WINDOW_HORIZONTAL_GAP}px`, + '--openbitfun-agent-companion-vertical-buffer': `${WINDOW_VERTICAL_BUFFER}px`, } as React.CSSProperties; const isSingleTask = visibleTasks.length === 1; const hasAttentionTask = visibleTasks.some(task => task.state === 'attention'); diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 73a40e2343..ccb2c03181 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1843,6 +1843,19 @@ $_section-header-height: 22px; } } +.openbitfun-device-overview__incompatible { + max-width: calc(100% - var(--openbitfun-space-4) * 2); + padding: var(--openbitfun-space-1) var(--openbitfun-space-2); + border-radius: var(--openbitfun-radius-sm); + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-status-warning-content); + font-size: var(--openbitfun-type-meta-font-size); + line-height: var(--openbitfun-type-modifier-leading-ui-line-height); + text-align: center; + overflow-wrap: anywhere; + pointer-events: auto; +} + .openbitfun-device-overview__artwork { display: grid; position: relative; diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index 275bd50589..62c28fd0b9 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -15,6 +15,10 @@ const state = vi.hoisted(() => ({ overview: null as DeviceInterconnectionOverview | null, refresh: vi.fn().mockResolvedValue(undefined), switchToLocal: vi.fn().mockResolvedValue('activated'), + switchToDevice: vi.fn().mockResolvedValue('activated'), + identity: { status: 'signed-out', me: null } as { status: string; me: unknown }, + getDeviceInfo: vi.fn(), + accountListDevices: vi.fn(), })); vi.mock('./useDeviceInterconnectionOverview', () => ({ @@ -34,10 +38,25 @@ vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => ({ peerMode: { active: state.overview?.peerActive }, switchToLocal: state.switchToLocal, + switchToDevice: state.switchToDevice, }), })); +vi.mock('@/infrastructure/account-identity', () => ({ + useAccountIdentity: () => state.identity, +})); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOriginal => ({ + ...await importOriginal(), + remoteConnectAPI: { + getDeviceInfo: state.getDeviceInfo, + accountListDevices: state.accountListDevices, + }, +})); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { listen: () => () => {} }, +})); +const notifications = vi.hoisted(() => ({ success: vi.fn(), warning: vi.fn() })); vi.mock('@/shared/notification-system', () => ({ - useNotification: () => ({ success: vi.fn(), warning: vi.fn() }), + useNotification: () => notifications, })); function overview(overrides: Partial = {}) { @@ -79,6 +98,9 @@ function element(testId: string) { beforeEach(() => { vi.clearAllMocks(); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.identity = { status: 'signed-out', me: null }; + state.getDeviceInfo.mockReset(); + state.accountListDevices.mockReset(); state.overview = overview(); container = document.createElement('div'); document.body.append(container); @@ -189,4 +211,30 @@ describe('device status card', () => { expect(element('nav-device-status-summary').querySelector('[title]')?.getAttribute('title')).toBe(name); expect(element('nav-footer-device-status').getAttribute('aria-label')).toContain(name); }); + + it('keeps an incompatible peer in the switch list but never connects to it', async () => { + state.identity = { status: 'signed-in', me: { user: { accountId: 'acct', githubId: 42 } } }; + state.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'This computer' }); + state.accountListDevices.mockResolvedValue([ + { device_id: 'local', device_name: 'This computer', online: true }, + { device_id: 'peer', device_name: 'Old build', online: true, compatible: false, device_client_version: '0.9.0' }, + ]); + await act(async () => { + root.render(); + }); + + const next = document.querySelector('[aria-label="deviceOverview.nextDevice"]'); + expect(next).not.toBeNull(); + await act(async () => { next!.click(); }); + + // The peer stays visible in the carousel with its reason instead of a connect action. + const notice = document.querySelector('[data-testid="nav-device-status-incompatible"]'); + expect(notice).not.toBeNull(); + expect(notice?.textContent).toContain('deviceOverview.deviceClientIncompatibleWithVersion'); + expect(document.querySelector('.openbitfun-device-overview__device-name')?.textContent).toBeDefined(); + expect(Array.from(document.querySelectorAll('button')).some( + button => button.textContent === 'deviceOverview.connectDevice', + )).toBe(false); + expect(state.switchToDevice).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index f342751b5b..439a88f615 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName, isDeviceControllable, deviceClientVersion } from '@/infrastructure/account/deviceDirectory'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { OverflowText, Button, Card, CardBody, CardFooter, CardHeader, Icon, IconButton, ScrollArea } from '@openbitfun/ui'; import { createPortal } from 'react-dom'; @@ -70,6 +71,7 @@ const DeviceStatusControl: React.FC = ({ onOpenChange, onManageDevices, }) => { + useDeviceDirectory(); const { t } = useI18n('common'); const { success, warning } = useNotification(); const peerContext = usePeerDeviceModeOptional(); @@ -97,7 +99,7 @@ const DeviceStatusControl: React.FC = ({ ]); if (disposed || request !== generation) return; setSwitchTargets({ accountId, localId: local.device_id, devices: [ - { ...local, online: true, last_seen_at: null }, + { ...local, ...devices.find(device => device.device_id === local.device_id), online: true, last_seen_at: null }, ...devices.filter(device => device.online && device.device_id !== local.device_id) .sort((a, b) => a.device_id.localeCompare(b.device_id)), ] }); @@ -127,11 +129,14 @@ const DeviceStatusControl: React.FC = ({ const connectPreview = async () => { if (!peerContext || !availableTargets || !previewTarget || !isPreviewing || switchingRef.current || returningLocal) return; const target = previewTarget; + // A confirmed-incompatible peer is shown so its state is legible, but it is + // never a control target. This machine is never gated by its own flag. + if (target.device_id !== availableTargets.localId && !isDeviceControllable(target)) return; switchingRef.current = true; setSwitchingDevice(true); try { if (target.device_id === availableTargets.localId) await peerContext.switchToLocal('manual'); - else await peerContext.switchToDevice(target.device_id, target.device_name); + else await peerContext.switchToDevice(target.device_id, resolveDeviceName(target.device_id, target.device_alias ?? target.device_name ?? target.device_id)); } catch (error) { warning(error instanceof Error ? error.message : String(error)); } finally { @@ -156,7 +161,7 @@ const DeviceStatusControl: React.FC = ({ const previewDevices: DeviceOverviewDevice[] = availableTargets?.devices.map(target => target.device_id === currentId ? overview.primaryDevice : overview.devices.find(device => device.id === target.device_id) ?? { - id: target.device_id, name: target.device_name, kind: 'desktop', + id: target.device_id, name: resolveDeviceName(target.device_id, target.device_alias ?? target.device_name ?? target.device_id), kind: 'desktop', local: target.device_id === availableTargets.localId, activities: [], backgroundTaskCount: 0, }) ?? [overview.primaryDevice]; const previewIndex = Math.max(0, availableTargets?.devices.findIndex( @@ -370,17 +375,38 @@ const DeviceStatusControl: React.FC = ({
- {previewDevices.map((device, index) => ( + {previewDevices.map((device, index) => { + const slideTarget = availableTargets?.devices[index]; + // A confirmed-incompatible peer stays in the carousel so its + // state is legible, but it never becomes a control target. + const incompatible = Boolean(slideTarget + && slideTarget.device_id !== availableTargets?.localId + && !isDeviceControllable(slideTarget)); + const incompatibleVersion = slideTarget ? deviceClientVersion(slideTarget) : null; + const incompatibleNotice = incompatibleVersion + ? t('deviceOverview.deviceClientIncompatibleWithVersion', { version: incompatibleVersion }) + : t('deviceOverview.deviceClientIncompatible'); + return (
{index === previewIndex && isPreviewing && (
- + {incompatible ? ( + + {incompatibleNotice} + + ) : ( + + )}
)}
@@ -388,10 +414,13 @@ const DeviceStatusControl: React.FC = ({ {device.name} - {index === previewIndex && !isPreviewing ? deviceActivity(overview.primaryDevice) : '\u00a0'} + {index === previewIndex && !isPreviewing + ? (incompatible ? incompatibleNotice : deviceActivity(overview.primaryDevice)) + : '\u00a0'}
- ))} + ); + })}
diff --git a/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts index 7a8b4dea09..304b696226 100644 --- a/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts +++ b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName, resolveDeviceNameFrom } from '@/infrastructure/account/deviceDirectory'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState'; import { api } from '@/infrastructure/api/service-api/ApiClient'; @@ -19,6 +20,7 @@ const TOPOLOGY_POLL_MS = 15_000; export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string, fallbackMobileDeviceName?: string) { const account = useAccountLoginState(); + const directory = useDeviceDirectory(); const peerContext = usePeerDeviceModeOptional(); const dispatchJobs = useDispatchJobStore(state => state.jobs); @@ -108,11 +110,11 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string target: { kind: 'device' as const, id: job.target.deviceId, - name: job.target.displayName, + name: resolveDeviceNameFrom(directory.devices, job.target.deviceId, job.target.displayName), }, }; }) - ), [dispatchJobs]); + ), [dispatchJobs, directory]); const peer = useMemo(() => ( peerContext?.peerMode.active @@ -123,7 +125,7 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string : null ), [peerContext?.peerMode]); - const localDeviceName = localDevice?.device_name?.trim() || fallbackLocalDeviceName; + const localDeviceName = account.deviceName ?? (localDevice ? resolveDeviceName(localDevice.device_id, localDevice.device_name) : fallbackLocalDeviceName); const overview = useMemo(() => projectDeviceInterconnectionOverview({ localDeviceName, fallbackMobileDeviceName, diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index ba6ccf0462..24fbe0a0ae 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; import { requireSessionWorkspaceId } from '@/flow_chat/utils/sessionWorkspace'; /** * SessionsSection — inline accordion content for the "Sessions" nav item. @@ -218,6 +219,7 @@ const SessionsSection: React.FC = ({ workspaceScopes, layout = 'nested', }) => { + useDeviceDirectory(); const { t } = useI18n('common'); const storedSessionOrdering = useWorkspaceSessionViewStore(state => state.ordering); const storedSessionShow = useWorkspaceSessionViewStore(state => state.show); @@ -1512,7 +1514,7 @@ const SessionsSection: React.FC = ({ const isDispatched = isNonLocalDispatchTarget(dispatchTarget); const dispatchTargetLabel = dispatchTarget?.kind === 'ssh' || dispatchTarget?.kind === 'device' - ? dispatchTarget.displayName + ? (dispatchTarget.kind === 'device' ? resolveDeviceName(dispatchTarget.deviceId, dispatchTarget.displayName) : dispatchTarget.displayName) : ''; const dispatchState = session.config.dispatchJobState ?? 'submitting'; const dispatchStateLabel = { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index a31fb62bfa..07efdceaba 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -196,9 +196,39 @@ } &__device-meta { + /* Plain inline flow: the model/OS text must wrap inside the row instead of + overflowing it, which a wrapping flex row cannot guarantee for a + max-content child. */ + display: block; color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); + overflow-wrap: break-word; + } + + &__device-status { + color: var(--openbitfun-color-content-secondary); + } + + &__device-incompatible { + color: var(--openbitfun-color-status-warning-content); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + overflow-wrap: break-word; + } + + &__device-alias-editor { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-width: 0; + + > [data-openbitfun-component='icon-button'] { flex-shrink: 0; } + } + + &__device-alias-input { + flex: 1 1 auto; + min-inline-size: 0; } &__loading-overlay { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx index 867b463c52..dbbcfd372d 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx @@ -7,20 +7,41 @@ const mocks = vi.hoisted(() => ({ identity: { resolved: true, status: 'signed-in', me: { user: { githubId: 42, login: 'alice' } } } as { resolved: boolean; status: string; me: { user: { githubId: number; login: string } } | null }, reopenSignIn: vi.fn(), getDeviceInfo: vi.fn(), accountStatus: vi.fn(), accountLogin: vi.fn(), + accountRelayCapabilities: vi.fn(), accountUpdateDevice: vi.fn(), accountConnectDevices: vi.fn(), accountListDevices: vi.fn(), + peerMode: { active: false } as { active: boolean }, + switchToDevice: vi.fn(), switchToLocal: vi.fn(), t: (key: string) => key, })); +const presenceListeners = vi.hoisted(() => [] as Array<(payload: { devices: Array> }) => void>); vi.mock('@/infrastructure/account-identity', () => ({ useAccountIdentity: () => mocks.identity, accountIdentityService: { reopenSignIn: mocks.reopenSignIn } })); -vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: mocks })); -vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: () => () => {} } })); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOriginal => ({ ...await importOriginal(), remoteConnectAPI: mocks })); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + listen: (_event: string, handler: (payload: { devices: Array> }) => void) => { + presenceListeners.push(handler); + return () => {}; + }, + }, +})); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t, formatRelativeTime: () => '' }) })); -vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceMode: () => ({ peerMode: { active: false } }) })); +vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ + usePeerDeviceMode: () => ({ + peerMode: mocks.peerMode, + switchToDevice: mocks.switchToDevice, + switchToLocal: mocks.switchToLocal, + }), +})); vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmDanger: vi.fn() })); vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn() }) })); vi.mock('@openbitfun/ui', () => { const Box = ({ children }: { children?: React.ReactNode }) =>
{children}
; const Button = ({ children, onClick, disabled }: { children?: React.ReactNode; onClick?: React.MouseEventHandler; disabled?: boolean }) => ; - return { Avatar: ({ src, alt }: { src?: string; alt?: string }) => {alt}, OverflowText: Box, Alert: Box, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box }; + const IconButton = ({ 'aria-label': ariaLabel, onClick, disabled }: { 'aria-label'?: string; onClick?: React.MouseEventHandler; disabled?: boolean }) =>
+ {!aliasSupported && } {relayError && (
= ({ const isLocal = localDeviceId === d.device_id; // This machine is selectable while the window renders a peer, // so the dialog can bring the UI back without disconnecting. - const isSelectable = isLocal - ? peerMode.active - : d.online; + // A confirmed-incompatible peer stays listed but is never a + // control target; this machine is never gated by its own flag. + const deviceControllable = isLocal || isDeviceControllable(d); + const isSelectable = (isLocal ? peerMode.active : d.online) && deviceControllable; + const incompatible = isLocal ? false : !isDeviceControllable(d); + const incompatibleVersion = deviceClientVersion(d); + const incompatibleNotice = incompatibleVersion + ? t('accountLogin.deviceClientIncompatibleWithVersion', { version: incompatibleVersion }) + : t('accountLogin.deviceClientIncompatible'); const removeLabel = isLocal ? t('accountLogin.removeCurrentDevice') : t('accountLogin.removeDevice'); - const displayName = d.device_name || t('accountLogin.unknownDevice'); - const DeviceEntry = isSelectable ? 'button' : 'div'; + const displayName = deviceDisplayName(d); + const metadata = deviceMetadataLabel(d); + const DeviceEntry = isSelectable && editingDeviceId !== d.device_id ? 'button' : 'div'; return (
= ({ className={`account-panel__device-card ${isSelectable ? 'selectable' : ''} ${d.online ? '' : 'offline'} ${isLocal ? 'current' : ''}`}> void selectDevice(d), disabled: loading, @@ -671,33 +776,89 @@ export const AccountPanel: React.FC = ({ > - - {displayName} - {isLocal && {t('accountLogin.thisDevice')}} - - - - {d.online - ? t('accountLogin.online') - : d.last_seen_at - ? t('accountLogin.lastSeen', { - time: formatRelativeTime(d.last_seen_at * 1000), - }) - : t('accountLogin.offline')} + {/* Renaming replaces the name in place: showing the old + name next to an editor reads as two device names. */} + {editingDeviceId === d.device_id ? ( + + setAliasDraft(e.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { event.preventDefault(); void handleUpdateAlias(d); } + if (event.key === 'Escape') { event.preventDefault(); setEditingDeviceId(null); } + }} + placeholder={t('accountLogin.deviceAliasPlaceholder')} + aria-label={t('accountLogin.deviceAlias')} + autoFocus + disabled={savingAliasId === d.device_id} + /> + } + loading={savingAliasId === d.device_id} + onClick={() => void handleUpdateAlias(d)} + size="sm" + variant="primary" + /> + } + onClick={() => setEditingDeviceId(null)} + size="sm" + variant="quiet" + /> + + ) : ( + + {displayName} + {isLocal && {t('accountLogin.thisDevice')}} + )} + + {d.online + ? t('accountLogin.online') + : d.last_seen_at + ? t('accountLogin.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) + : t('accountLogin.offline')} + {metadata && {` · ${metadata}`}} + {incompatible && ( + + {incompatibleNotice} + + )} {isSelectable && } - } - onClick={(e) => { e.stopPropagation(); handleDeleteDevice(d.device_id, displayName); }} - size="sm" - title={removeLabel} - variant="quiet" - /> + {/* The open editor owns the row's actions; keeping rename and + delete beside it rendered two competing icon clusters. */} + {editingDeviceId !== d.device_id && ( + <> + } + onClick={(e) => { e.stopPropagation(); handleStartAliasEdit(d); }} + size="sm" + title={t('accountLogin.editDeviceAlias')} + variant="quiet" + /> + } + onClick={(e) => { e.stopPropagation(); handleDeleteDevice(d.device_id, displayName); }} + size="sm" + title={removeLabel} + variant="quiet" + /> + + )}
); })} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts index c9d3ba9d37..b72bb265c8 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { AccountRealtime } from '../../../../../shared/relay-transport/AccountRealtime'; -import { AccountIdentityChangedError, RelayHttpClient } from '../../../../../mobile-web/src/services/RelayHttpClient'; +import { AccountIdentityChangedError, RelayHttpClient, deviceDisplayName } from '../../../../../mobile-web/src/services/RelayHttpClient'; import { deriveDeviceMessageKey, encrypt, toB64, generateKeyPair } from '../../../../../mobile-web/src/services/E2EEncryption'; const identity = (userId = 'user-a') => ({ token: `token-${userId}`, userId, deviceId: 'browser', masterKey: new Uint8Array(32).fill(userId === 'user-a' ? 7 : 8) }); @@ -10,6 +10,55 @@ beforeEach(() => { vi.stubGlobal('window', globalThis); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers(); }); describe.each(['https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.9:9700'])('account device routing on %s', (relayUrl) => { + it('answers a superseded directory read with the newest completion and publishes authoritative names', async () => { + const client = new RelayHttpClient(relayUrl, identity()); + const old = deferred(); + vi.stubGlobal('fetch', vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(Response.json([ + { device_id: 'desktop', device_name: 'technical', device_alias: 'Current alias', online: true }, + ]))); + const changed = vi.fn(); client.onDeviceDirectorySnapshot(changed); + const superseded = client.listDevices(); + const newest = await client.listDevices(); + old.resolve(Response.json([{ device_id: 'desktop', device_name: 'old', online: true }])); + // Several surfaces read the directory at once, so a read that another read + // superseded still answers: failing its caller would leave that surface on an + // empty device list, and answering with its own older payload would regress + // the newest directory. The newest completion wins for everyone. + expect(await superseded).toEqual(newest); + expect(client.resolveDeviceName('desktop', 'saved name')).toBe('Current alias'); + expect(changed).toHaveBeenCalledOnce(); + client.setAccountIdentity(identity('user-b')); + expect(client.resolveDeviceName('desktop')).toBe('desktop'); + client.resetConnectionIdentity(); + }); + it('preserves additive metadata and gates alias mutations on Relay capabilities', async () => { + const client = new RelayHttpClient(relayUrl, identity()); + const device = { device_id: 'desktop', device_name: 'technical', device_alias: 'Work', device_model: 'Model', device_os: 'Linux', device_os_version: '1', online: false, future: true }; + const fetch = vi.fn().mockResolvedValueOnce(Response.json([device])) + .mockResolvedValueOnce(Response.json({ protocol_version: 1 })) + .mockResolvedValueOnce(Response.json({ capabilities: ['device_alias_v1'] })) + .mockResolvedValueOnce(Response.json({})); + vi.stubGlobal('fetch', fetch); + expect(await client.listDevices()).toEqual([device]); + expect(deviceDisplayName(device)).toBe('Work'); + expect(deviceDisplayName({ ...device, device_alias: null })).toBe('technical'); + await expect(client.updateDeviceAlias('desktop', 'Work')).rejects.toThrow('unsupported'); + expect(fetch).toHaveBeenCalledTimes(2); + const changed = vi.fn(); client.onDeviceDirectoryChanged(changed); + await client.updateDeviceAlias('desktop', null); + expect(fetch.mock.calls[3][1]).toMatchObject({ method: 'PATCH', body: '{"device_alias":null}', headers: { Authorization: 'Bearer token-user-a' } }); + expect(changed).toHaveBeenCalledOnce(); + client.resetConnectionIdentity(); + }); + it.each([404, 405])('reports unsupported PATCH HTTP %s without fake success', async status => { + const client = new RelayHttpClient(relayUrl, identity()); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(Response.json({ capabilities: ['device_alias_v1'] })) + .mockResolvedValueOnce(Response.json({}, { status }))); + const changed = vi.fn(); client.onDeviceDirectoryChanged(changed); + await expect(client.updateDeviceAlias('desktop', 'Work')).rejects.toThrow('unsupported'); + expect(changed).not.toHaveBeenCalled(); + client.resetConnectionIdentity(); + }); it('rejects an unauthenticated constructor and sends nothing after logout', async () => { expect(() => new RelayHttpClient(relayUrl, { ...identity(), token: '' })).toThrow(); const client = new RelayHttpClient(relayUrl, identity()); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx index d802337bf7..23298ce4e8 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx @@ -190,7 +190,13 @@ describe('SessionList target initialization ownership', () => { createSession, } as unknown as RemoteSessionManager; const openTool = vi.fn(); - await act(async () => root.render( root.render( () => {}, + } as any} onSelectSession={vi.fn()} onOpenWorkspace={vi.fn()} onOpenDeviceTools={openTool} onDisconnect={vi.fn()}/>)); await flushPromises(); await act(async () => container.querySelector('.harmony-sidebar__workspace-tools')!.click()); diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 142e08c96e..a2abd684b8 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", + "digest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -23834,13 +23834,13 @@ }, { "id": "devices", - "titleZh": "列出、连接、查看在线状态和删除同账户设备", - "titleEn": "List, connect, inspect online status, and remove same-account devices", + "titleZh": "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "titleEn": "List, connect, inspect model and OS, set aliases, and remove same-account devices", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看在线状态和删除同账户设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." } }, { @@ -23919,8 +23919,8 @@ "Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently", "使用 GitHub 登录、退出并查看身份状态", "Sign in with GitHub, sign out, and inspect identity status", - "列出、连接、查看在线状态和删除同账户设备", - "List, connect, inspect online status, and remove same-account devices", + "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", + "List, connect, inspect model and OS, set aliases, and remove same-account devices", "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", "Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane", "打开 Remote Connect", diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index 27a7117656..c7cb0365a2 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; import { Alert, Button, @@ -55,6 +56,7 @@ export const DispatchInstallDialog: React.FC = ({ onClose, onReady, }) => { + useDeviceDirectory(); const { t } = useI18n('common'); const [includeUncommitted, setIncludeUncommitted] = useState(false); const [baseRef, setBaseRef] = useState('HEAD'); @@ -413,7 +415,7 @@ export const DispatchInstallDialog: React.FC = ({ > - {t('dispatch.configureTitle', { target: target?.displayName ?? '' })} + {t('dispatch.configureTitle', { target: target?.kind === 'device' && target.deviceId ? resolveDeviceName(target.deviceId, target.displayName) : target?.displayName ?? '' })} {t('dispatch.configureSubtitle')} diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index 6e271f4648..bf4f45864e 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -1,3 +1,4 @@ +import { resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { createLogger } from '@/shared/utils/logger'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; @@ -945,7 +946,7 @@ async function refreshJobPage( task: job.title, target: job.target.kind === 'ssh' || job.target.kind === 'device' - ? job.target.displayName + ? (job.target.kind === 'device' ? resolveDeviceName(job.target.deviceId, job.target.displayName) : job.target.displayName) : i18nService.t('common:dispatch.localTarget'), }); void systemAPI.sendSystemNotification(title, body).catch(error => { diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx index 064b5d158e..1a5fa4fd38 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx @@ -18,13 +18,18 @@ vi.mock('@/infrastructure/i18n', () => ({ }), })); +const pickerState = vi.hoisted(() => ({ + loggedIn: false, + targets: [] as Array>, +})); + vi.mock('@/infrastructure/account/useAccountLoginState', () => ({ - useAccountLoginState: () => ({ loggedIn: false }), + useAccountLoginState: () => ({ loggedIn: pickerState.loggedIn }), })); vi.mock('./useDispatchTargets', () => ({ useDispatchTargets: () => ({ - targets: [], + targets: pickerState.targets, loading: false, error: false, refresh: vi.fn(async () => undefined), @@ -61,6 +66,8 @@ describe('DispatchTargetPicker overlay', () => { let root: Root; beforeEach(() => { + pickerState.loggedIn = false; + pickerState.targets = []; Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { @@ -183,4 +190,34 @@ describe('DispatchTargetPicker overlay', () => { expect(onSelectLocal).toHaveBeenCalledTimes(2); expect(onWorktreeChange).toHaveBeenLastCalledWith(false); }); + + it('lists a confirmed-incompatible device but never offers it as a target', async () => { + pickerState.loggedIn = true; + pickerState.targets = [ + { kind: 'device', deviceId: 'peer', displayName: 'Old build', online: true, incompatible: true }, + ]; + + await act(async () => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-dispatch-trigger"]', + ); + await act(async () => trigger?.click()); + + const option = Array.from( + document.querySelectorAll('[data-openbitfun-menu-item]'), + ).find(node => node.textContent?.includes('Old build')); + expect(option).toBeDefined(); + // The device is visible with its reason but cannot be selected. + expect(option?.textContent).toContain('chatInput.dispatch.deviceIncompatible'); + expect(option?.disabled).toBe(true); + }); }); diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx index dc15740f0b..885601cfc7 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; import React, { lazy, Suspense, @@ -55,6 +56,7 @@ export const DispatchTargetPicker: React.FC = ({ onSelectLocal, onSelectTarget, }) => { + useDeviceDirectory(); const { t } = useI18n('flow-chat'); const rootRef = useRef(null); const triggerRef = useRef(null); @@ -80,7 +82,7 @@ export const DispatchTargetPicker: React.FC = ({ : t('chatInput.dispatch.local'); const displayLabel = target.kind === 'local' ? localDisplayLabel - : target.displayName; + : target.kind === 'device' ? resolveDeviceName(target.deviceId, target.displayName) : target.displayName; const tooltip = locked ? t('chatInput.dispatch.locked', { target: displayLabel }) : t('chatInput.dispatch.current', { target: displayLabel }); @@ -219,16 +221,20 @@ export const DispatchTargetPicker: React.FC = ({ {deviceTargets.map(option => { const selected = target.kind === 'device' && target.deviceId === option.deviceId; const online = option.online !== false; + // A confirmed-incompatible device stays listed so the reason is legible, + // but it is never offered as a selectable dispatch target. + const incompatible = option.incompatible === true; return ( } metadata={selected ? : null} onClick={() => { + if (incompatible) return; setOpen(false); setConfigureTarget(option); }} @@ -236,9 +242,11 @@ export const DispatchTargetPicker: React.FC = ({ {option.displayName} - {online - ? t('chatInput.dispatch.deviceDescription') - : t('chatInput.dispatch.deviceOffline')} + {incompatible + ? t('chatInput.dispatch.deviceIncompatible') + : online + ? t('chatInput.dispatch.deviceDescription') + : t('chatInput.dispatch.deviceOffline')} diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index f84e135a92..2590c097e4 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -53,6 +53,8 @@ export interface DispatchTargetOption { displayName: string; description?: string; online?: boolean; + /** Relay-confirmed client incompatibility; `true` blocks device dispatch. */ + incompatible?: boolean; } export interface DispatchCliRelease { diff --git a/src/web-ui/src/features/dispatch/useDispatchTargets.ts b/src/web-ui/src/features/dispatch/useDispatchTargets.ts index d4778d6775..2cd44634de 100644 --- a/src/web-ui/src/features/dispatch/useDispatchTargets.ts +++ b/src/web-ui/src/features/dispatch/useDispatchTargets.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useDeviceDirectory, resolveDeviceName, isDeviceControllable } from '@/infrastructure/account/deviceDirectory'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { createLogger } from '@/shared/utils/logger'; import { dispatchApi } from './dispatchApi'; import type { DispatchTargetOption } from './types'; @@ -11,6 +12,7 @@ export function useDispatchTargets(enabled = true): { error: boolean; refresh: () => Promise; } { + const directory = useDeviceDirectory(); const [targets, setTargets] = useState([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); @@ -39,8 +41,26 @@ export function useDispatchTargets(enabled = true): { void refresh(); }, [refresh]); + /** Devices the Relay confirmed cannot be controlled from this build. */ + const incompatibleDeviceIds = useMemo(() => new Set( + directory.devices + .filter(device => !isDeviceControllable(device)) + .map(device => device.device_id), + ), [directory.devices]); + // Opening the picker enables this hook one render before the effect starts // the request. Treat that first render as loading so users never see a // misleading empty-target message flash before saved SSH targets arrive. - return { targets, loading: loading || (enabled && !loaded), error, refresh }; + return { + targets: targets.map(target => target.kind === 'device' && target.deviceId + ? { + ...target, + displayName: resolveDeviceName(target.deviceId, target.displayName), + incompatible: incompatibleDeviceIds.has(target.deviceId), + } + : target), + loading: loading || (enabled && !loaded), + error, + refresh, + }; } diff --git a/src/web-ui/src/flow_chat/_item-rhythm.scss b/src/web-ui/src/flow_chat/_item-rhythm.scss index f5f3a36a93..3bc6d519c8 100644 --- a/src/web-ui/src/flow_chat/_item-rhythm.scss +++ b/src/web-ui/src/flow_chat/_item-rhythm.scss @@ -7,6 +7,15 @@ margin: 0 0 var(--openbitfun-control-flow-chat-flow-item-gap) 0; } + // A card may render nothing for a settled state: an errored exploration tool + // (Read/Grep/Glob/LS/WebSearch...) hides its row on purpose. The item wrapper + // still measures zero height, so giving it a gap leaves invisible space + // behind — a boundary between two visible rows then reads as 16px or 24px + // instead of the one 8px item gap. + > .flowchat-flow-item:has(> .flow-tool-card-wrapper:empty) { + margin-bottom: 0; + } + // Consecutive collapsed ambient tools form a continuous run of 22px lines. > .flowchat-flow-item:has( > .flow-tool-card-wrapper[data-openbitfun-attention='ambient'] diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 3e2762fd78..618cbdd5f6 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceNameFrom } from '@/infrastructure/account/deviceDirectory'; import { ChatInputAttachments } from './ChatInputAttachments'; import { useExcerptComposerActions } from '../selection/useExcerptComposerActions'; import { isConversationExcerpt, formatConversationExcerpt } from '@/shared/utils/conversationExcerpt'; @@ -510,6 +511,7 @@ export const ChatInput: React.FC = ({ presentation = 'standard', }) => { const deviceSurfaceScope = getActiveSurfaceScope(); + const deviceDirectory = useDeviceDirectory(); const { t } = useTranslation('flow-chat'); const { t: tWorktrees } = useI18n('worktrees'); const canLaunchReview = isTauriRuntime(); @@ -2844,7 +2846,7 @@ export const ChatInput: React.FC = ({ const target = effectiveTargetSession.config.dispatchTarget; const providerLabel = target && target.kind !== 'local' - ? target.displayName + ? (target.kind === 'device' ? resolveDeviceNameFrom(deviceDirectory.devices, target.deviceId, target.displayName) : target.displayName) : t('chatInput.dispatch.remoteTarget'); const sessionId = effectiveTargetSession.sessionId; const jobId = effectiveTargetSession.config.dispatchJobId; @@ -2879,7 +2881,7 @@ export const ChatInput: React.FC = ({ } }, }; - }, [caps.submissionOptionsLocked, caps.targetModelSelection, effectiveTargetSession, t]); + }, [caps.submissionOptionsLocked, caps.targetModelSelection, effectiveTargetSession, t, deviceDirectory]); React.useEffect(() => { if (!slashCommandState.isActive || slashCommandState.kind !== 'all' || derivedState?.isProcessing) { diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 7278deec85..fa48520cf1 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; /** * Two fixed rails in the composer's upper context band. * @@ -145,6 +146,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( worktreeControl, dispatchControl, }) => { + useDeviceDirectory(); const { t } = useTranslation('flow-chat'); const { t: tWorktrees } = useI18n('worktrees'); const { t: tCommon } = useI18n('common'); @@ -831,7 +833,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( baselineWorktreePath={dispatchControl.baselineWorktreePath} baselineMissing={dispatchControl.baselineMissing} targetLabel={dispatchControl.target.kind !== 'local' - ? dispatchControl.target.displayName + ? (dispatchControl.target.kind === 'device' ? resolveDeviceName(dispatchControl.target.deviceId, dispatchControl.target.displayName) : dispatchControl.target.displayName) : undefined} onClose={() => setResultDialogOpen(false)} /> diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts index 1dea3bae29..a8b81d3087 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts @@ -72,6 +72,26 @@ describe('FlowChat transcript rhythm', () => { } }); + it('gives no item gap to a tool row whose card hides itself', () => { + const toolStyles = readSource('../../_item-rhythm.scss'); + + // Exploration cards hide themselves once their tool settles in an error + // state, so their composition wrapper stays in the DOM at zero height. A + // reserved gap there is invisible space: two visible rows with one hidden + // item between them would read as 16px, and 24px with two. + expect(toolStyles).toMatch( + /> \.flowchat-flow-item:has\(> \.flow-tool-card-wrapper:empty\) \{\s*margin-bottom: 0;\s*\}/, + ); + for (const hidingCard of [ + '../../tool-cards/ReadFileDisplay.tsx', + '../../tool-cards/GrepSearchDisplay.tsx', + '../../tool-cards/GlobSearchDisplay.tsx', + '../../tool-cards/LSDisplay.tsx', + ]) { + expect(readSource(hidingCard)).toMatch(/if \(status === 'error'\) \{\s*return null;/); + } + }); + it('gives every new user Turn one token-owned boundary gap', () => { const rendererStyles = readSource('./VirtualItemRenderer.scss'); const userMessageStyles = readSource('./UserMessageItem.scss'); diff --git a/src/web-ui/src/infrastructure/account/clientBuild.contract.test.ts b/src/web-ui/src/infrastructure/account/clientBuild.contract.test.ts new file mode 100644 index 0000000000..a565df97e4 --- /dev/null +++ b/src/web-ui/src/infrastructure/account/clientBuild.contract.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + CLIENT_PROTOCOL_VERSION, + CLIENT_VERSION, +} from '../../../../shared/relay-transport/ClientBuild'; + +function read(relativePath: string): string { + return readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + 'utf8', + ).replace(/\r\n/g, '\n'); +} + +describe('client build contract single source', () => { + // The Relay gates remote control on the reported protocol number, so the Rust + // owner and the shared TS constant must agree. A bump that misses one side + // fails here instead of silently mis-gating control between builds. + const accountContract = read( + '../../../../../src/crates/contracts/product-domains/src/account.rs', + ); + + it('pins the control protocol number to the shared Rust contract', () => { + expect(accountContract).toContain( + `pub const CLIENT_PROTOCOL_VERSION: u32 = ${CLIENT_PROTOCOL_VERSION};`, + ); + }); + + it('reports a well-formed diagnostic build string', () => { + // Derived from the workspace package.json (which release-please bumps in + // step with Cargo.toml), so it cannot silently rot. Pin only the shape the + // Relay accepts: non-empty semver under its 64 UTF-8 byte cap. + expect(CLIENT_VERSION).toMatch(/^\d+\.\d+\.\d+/); + expect(CLIENT_VERSION.length).toBeLessThanOrEqual(64); + }); + + it('reports the build on both the handshake and the login body', () => { + const realtime = read('../../../../shared/relay-transport/AccountRealtime.ts'); + expect(realtime).toContain('clientVersion: CLIENT_VERSION'); + expect(realtime).toContain('clientProtocol: CLIENT_PROTOCOL_VERSION'); + + const login = read('../../../../../src/mobile-web/src/services/CloudAccountClient.ts'); + expect(login).toContain('clientVersion: CLIENT_VERSION'); + expect(login).toContain('clientProtocol: CLIENT_PROTOCOL_VERSION'); + }); +}); diff --git a/src/web-ui/src/infrastructure/account/deviceDirectory.test.ts b/src/web-ui/src/infrastructure/account/deviceDirectory.test.ts new file mode 100644 index 0000000000..004025a224 --- /dev/null +++ b/src/web-ui/src/infrastructure/account/deviceDirectory.test.ts @@ -0,0 +1,60 @@ +/** @vitest-environment jsdom */ +import { afterEach, expect, it, vi } from 'vitest'; +import { + startDeviceDirectory, refreshDeviceDirectory, resolveDeviceName, useDeviceDirectory, + isDeviceControllable, deviceClientVersion, +} from './deviceDirectory'; +const mocks = vi.hoisted(() => ({ list: vi.fn(), local: vi.fn(), events: new Map void>() })); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async original => ({ + ...await original(), + remoteConnectAPI: { accountListDevices: mocks.list, getDeviceInfo: mocks.local }, +})); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: (name: string, listener: () => void) => { mocks.events.set(name, listener); return () => mocks.events.delete(name); } } })); +let stop = () => {}; +afterEach(() => { stop(); vi.resetAllMocks(); }); +const device = (alias: string | null) => ({ device_id: 'peer', device_name: 'technical', device_alias: alias, online: true, last_seen_at: null }); +it('resolves active, local and persisted dispatch labels by ID; presence triggers GET, never overwrites metadata', async () => { + mocks.local.mockResolvedValue({ device_id: 'peer' }); + mocks.list.mockResolvedValue([device('Alias')]); + stop = startDeviceDirectory(); await refreshDeviceDirectory(); + expect(resolveDeviceName('peer', 'stale saved name')).toBe('Alias'); + expect(useDeviceDirectory.getState().localId).toBe('peer'); + mocks.list.mockResolvedValue([device('Renamed')]); + mocks.events.get('account://device-presence')!(); await refreshDeviceDirectory(); + expect(resolveDeviceName('peer', 'technical')).toBe('Renamed'); + mocks.list.mockResolvedValue([device(null)]); await refreshDeviceDirectory(); + expect(resolveDeviceName('peer')).toBe('technical'); +}); +it('fences late old-account GET and preserves aliases on failed reads', async () => { + mocks.local.mockResolvedValue({ device_id: 'peer' }); + let finish!: (value: unknown) => void; + mocks.list.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + stop = startDeviceDirectory(); await Promise.resolve(); stop(); + mocks.list.mockResolvedValue([device('New account')]); + stop = startDeviceDirectory(); await refreshDeviceDirectory(); + finish([device('Old account')]); await Promise.resolve(); + expect(resolveDeviceName('peer')).toBe('New account'); + mocks.list.mockRejectedValue(new Error('offline')); await refreshDeviceDirectory(); + expect(resolveDeviceName('peer')).toBe('New account'); +}); + +it('treats only a confirmed incompatible flag as uncontrollable', () => { + // A missing flag (older Relay or older payload) must never block control. + expect(isDeviceControllable({})).toBe(true); + expect(isDeviceControllable({ compatible: undefined })).toBe(true); + expect(isDeviceControllable({ compatible: true })).toBe(true); + expect(isDeviceControllable({ compatible: false })).toBe(false); +}); + +it('exposes a peer client version only when the Relay reported a non-empty one', () => { + // The host projection emits `device_client_version`; the Relay's own + // `client_version` spelling is read too. + expect(deviceClientVersion({ device_client_version: '1.2.3' })).toBe('1.2.3'); + expect(deviceClientVersion({ client_version: '1.2.3' })).toBe('1.2.3'); + expect(deviceClientVersion({ device_client_version: ' 1.2.3 ' })).toBe('1.2.3'); + expect(deviceClientVersion({ device_client_version: '1.2.3', client_version: '0.9.0' })).toBe('1.2.3'); + expect(deviceClientVersion({ device_client_version: null, client_version: '0.9.0' })).toBe('0.9.0'); + expect(deviceClientVersion({ device_client_version: null })).toBe(null); + expect(deviceClientVersion({ device_client_version: ' ' })).toBe(null); + expect(deviceClientVersion({})).toBe(null); +}); diff --git a/src/web-ui/src/infrastructure/account/deviceDirectory.ts b/src/web-ui/src/infrastructure/account/deviceDirectory.ts new file mode 100644 index 0000000000..efa22eff9b --- /dev/null +++ b/src/web-ui/src/infrastructure/account/deviceDirectory.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { remoteConnectAPI, deviceDisplayName, type AccountDeviceInfo } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { InvalidationSync } from '../../../../shared/relay-transport/InvalidationSync'; + +/** Presentation only: presence and saved peer/dispatch names never overwrite GET metadata. */ +export const useDeviceDirectory = create<{ devices: AccountDeviceInfo[]; localId: string | null }>(() => ({ devices: [], localId: null })); +let invalidate: (() => Promise) | undefined; +let generation = 0; + +/** + * Alias resolution from a snapshot the caller already subscribed to. + * + * A memo body must use this form and list `useDeviceDirectory()` in its + * dependencies: an imperative store read is invisible to the dependency rules, + * so a memo that only calls `resolveDeviceName` keeps rendering a stale alias. + */ +export function resolveDeviceNameFrom(devices: AccountDeviceInfo[], deviceId: string, fallback = deviceId): string { + const device = devices.find(item => item.device_id === deviceId); + return device ? deviceDisplayName(device) : fallback; +} + +/** Resolve from the live store, for code outside React's render data flow. */ +export function resolveDeviceName(deviceId: string, fallback = deviceId): string { + return resolveDeviceNameFrom(useDeviceDirectory.getState().devices, deviceId, fallback); +} + +/** + * The single gate every mutual-control entry point must reuse. + * + * The Relay computes `compatible` from *both* clients' reported build/protocol. + * It returns `false` for a version mismatch and also for a peer that reported + * no version information at all (an older client), so a `false` flag always + * means "this peer is not mutually controllable" regardless of the reason. + * + * `compatible` is absent only on an older Relay that does not gate at all; that + * is "unknown but usable" and must never block control. + */ +export function isDeviceControllable(device: Pick): boolean { + return device.compatible !== false; +} + +/** Peer client build string for an incompatible device, when the Relay supplied one. */ +export function deviceClientVersion( + device: Pick, +): string | null { + const version = (device.device_client_version ?? device.client_version)?.trim(); + return version ? version : null; +} + +export function refreshDeviceDirectory(): Promise { + return invalidate?.() ?? Promise.resolve(); +} + +/** Mounted by the shell provider, fenced on account replacement and teardown. */ +export function startDeviceDirectory(): () => void { + const epoch = ++generation; + useDeviceDirectory.setState({ devices: [], localId: null }); + const sync = new InvalidationSync(async () => { + try { + const [devices, local] = await Promise.all([remoteConnectAPI.accountListDevices(), remoteConnectAPI.getDeviceInfo()]); + if (epoch === generation) useDeviceDirectory.setState({ devices, localId: local.device_id }); + } catch { + // An unavailable directory is not evidence that a device or alias was removed. + } + }); + const refresh = () => sync.invalidate(); + invalidate = refresh; + const stopPresence = api.listen('account://device-presence', () => { void refresh(); }); + const stopLogin = api.listen('account://login-state', () => { void refresh(); }); + const visible = () => { if (document.visibilityState === 'visible') void refresh(); }; + document.addEventListener('visibilitychange', visible); + const timer = setInterval(visible, 30_000); + void refresh(); + return () => { + sync.stop(); stopPresence(); stopLogin(); clearInterval(timer); + document.removeEventListener('visibilitychange', visible); + if (epoch === generation) { + generation += 1; + invalidate = undefined; + useDeviceDirectory.setState({ devices: [], localId: null }); + } + }; +} diff --git a/src/web-ui/src/infrastructure/account/useAccountLoginState.ts b/src/web-ui/src/infrastructure/account/useAccountLoginState.ts index a8a8d61ae6..52fc3fd584 100644 --- a/src/web-ui/src/infrastructure/account/useAccountLoginState.ts +++ b/src/web-ui/src/infrastructure/account/useAccountLoginState.ts @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName } from './deviceDirectory'; /** UI sign-in state follows the shared GitHub identity, independently of Relay availability. */ import { useEffect, useState } from 'react'; import { useAccountIdentity } from '@/infrastructure/account-identity'; @@ -10,6 +11,7 @@ export interface AccountLoginState { export function useAccountLoginState(): AccountLoginState { const identity = useAccountIdentity(); + const directory = useDeviceDirectory(); const githubId = (identity.me?.user.accountId ?? identity.me?.user.githubId); const loggedIn = identity.status === 'signed-in' && githubId !== undefined; const [device, setDevice] = useState<{ githubId: string | number; name: string | null } | null>(null); @@ -23,5 +25,5 @@ export function useAccountLoginState(): AccountLoginState { }); return () => { current = false; }; }, [loggedIn, githubId]); - return { loggedIn, deviceName: loggedIn && device?.githubId === githubId ? device.name : null }; + return { loggedIn, deviceName: loggedIn && directory.localId ? resolveDeviceName(directory.localId) : loggedIn && device?.githubId === githubId ? device.name : null }; } diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 927a49adae..e0fcea5643 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 815aac1192..fab22003fe 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:5fd9f0407289f440" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:86d6295909ba287d" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -23,10 +23,12 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "account_login", "account_logout", "account_online_devices", + "account_relay_capabilities", "account_status", "account_subscribe_session", "account_token_expired", "account_unsubscribe_session", + "account_update_device_alias", "appearance_market_browse", "appearance_market_download_release", "appearance_market_get_listing", diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index 8ea82932c2..8a5c81f277 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -8,7 +8,46 @@ import { createLogger } from '@/shared/utils/logger'; const log = createLogger('RemoteConnectAPI'); -export interface DeviceInfo { +export interface DeviceDirectoryMetadata { + device_alias?: string | null; + device_model?: string | null; + device_os?: string | null; + device_os_version?: string | null; + /** + * Client build the device reported to the Relay. The host projection emits + * `device_client_version`; the Relay's own snake_case spelling is + * `client_version`. Both are read, exactly like the Relay/backend tolerance, + * so an older or newer host projection is never misjudged. Absent on older + * Relays. + */ + device_client_version?: string | null; + client_version?: string | null; + /** + * Client wire protocol the device reported, using the same two spellings as + * the build string. Absent on older Relays. + */ + device_client_protocol?: number | null; + client_protocol?: number | null; + /** + * Relay-computed mutual-control compatibility of this device with this one. + * + * `false` means confirmed incompatible: either a client build/protocol + * mismatch or a peer that reported no version information (an older client). + * Absent only on an older Relay that does not gate at all, which must be + * treated as "unknown but usable", never as incompatible. + */ + compatible?: boolean; +} + +export function deviceDisplayName(device: DeviceDirectoryMetadata & { device_id: string; device_name?: string | null }): string { + return device.device_alias ?? device.device_name ?? device.device_id; +} + +export function deviceMetadataLabel(device: DeviceDirectoryMetadata): string { + return [device.device_model, [device.device_os, device.device_os_version].filter(Boolean).join(' ')].filter(Boolean).join(' · '); +} + +export interface DeviceInfo extends DeviceDirectoryMetadata { device_id: string; device_name: string; mac_address: string; @@ -126,12 +165,12 @@ export interface AccountStatus { user_id: string | null; } -export interface OnlineDeviceInfo { +export interface OnlineDeviceInfo extends DeviceDirectoryMetadata { device_id: string; device_name: string; } -export interface AccountDeviceInfo { +export interface AccountDeviceInfo extends DeviceDirectoryMetadata { device_id: string; device_name: string; online: boolean; @@ -409,6 +448,21 @@ class RemoteConnectAPIService { } } + async accountRelayCapabilities(): Promise { + return this.adapter.request('account_relay_capabilities'); + } + + async accountUpdateDevice(deviceId: string, deviceAlias: string | null): Promise { + try { + await this.adapter.request('account_update_device_alias', { + request: { device_id: deviceId, device_alias: deviceAlias }, + }); + } catch (e) { + log.error('accountUpdateDevice failed', e); + throw e; + } + } + async accountDeleteDevice(targetDeviceId: string): Promise { try { await this.adapter.request('account_delete_device', { targetDeviceId }); diff --git a/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss b/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss index 18b4d6ffb4..1357081d9c 100644 --- a/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss +++ b/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss @@ -123,7 +123,8 @@ font-size: var(--openbitfun-type-micro-font-size); white-space: nowrap; - &.is-busy { + &.is-busy, + &.is-incompatible { background: color-mix(in srgb, var(--openbitfun-color-status-warning-emphasis) 18%, transparent); color: var(--openbitfun-color-status-warning-content); } diff --git a/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.tsx b/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.tsx index 0ffc8b8a25..95777d4d07 100644 --- a/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.tsx +++ b/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.tsx @@ -80,7 +80,7 @@ export const DeviceSurfaceSwitcher: React.FC = () => { : currentDevice?.deviceName ?? t('accountLogin.thisDevice'); const handleSelect = useCallback(async (device: DeviceRosterEntry) => { - if (!peerDevice) { + if (!peerDevice || !device.controllable) { return; } setOpen(false); @@ -223,7 +223,7 @@ export const DeviceSurfaceSwitcher: React.FC = () => { const isCurrent = device.deviceId === activeDeviceId; const busy = isDeviceBusy(activityKeyFor(device)); const attached = attachedIds.has(device.deviceId); - const selectable = device.online && (!isCurrent || switching); + const selectable = device.online && device.controllable && (!isCurrent || switching); return ( { {t('accountLogin.offline')} )} + {!device.controllable && ( + + {t('accountLogin.deviceClientIncompatibleTag')} + + )} {isCurrent &&