From e88026adf92a70275200c3cc89f40009e52da803 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 17:05:49 +0800 Subject: [PATCH 01/10] feat(account): persist device aliases and publish model/OS metadata Keep the same-account device directory usable across desktop, CLI, and mobile by storing aliases on Relay and reporting device model/OS from each host, with older Relays remaining explicitly unsupported. Co-authored-by: bitfun-ai --- docs/interactive-capabilities/README.md | 4 +- .../capabilities.json | 14 +- .../capabilities/feature.remote-connect.md | 4 +- .../technical/product-control-open-audit.json | 12 +- .../technical/tauri-command-map.json | 40 +- src/apps/cli/src/account.rs | 21 +- src/apps/cli/src/ui/login_form.rs | 15 +- .../desktop/src/api/remote_connect_api.rs | 112 ++++-- src/apps/desktop/src/lib.rs | 2 + src/apps/relay-server/README.md | 59 +++ .../service/remote_connect/account_runtime.rs | 155 +++++++- .../contracts/product-domains/src/account.rs | 40 ++ .../generated/product-control-catalog.json | 14 +- .../generated/remote-surface-registry.json | 26 +- .../src/remote_surface/table.rs | 2 + .../app-server/src/management/owner.rs | 4 + .../src/account_transport_tests.rs | 46 +++ src/crates/services/relay-service/src/db.rs | 135 ++++++- src/crates/services/relay-service/src/lib.rs | 8 +- .../relay-service/src/realtime/mod.rs | 4 +- .../relay-service/src/realtime/presence.rs | 17 +- .../services/relay-service/src/routes/api.rs | 2 + .../services/relay-service/src/routes/auth.rs | 74 +++- .../relay-service/src/routes/devices.rs | 357 +++++++++++++++++- .../src/remote_connect/account.rs | 196 +++++++++- .../src/remote_connect/device.rs | 172 +++++++++ .../src/remote_connect/relay_client.rs | 28 +- src/mobile-web/src/App.tsx | 32 +- .../src/components/CompactSettingsSheet.tsx | 11 +- src/mobile-web/src/i18n/messages.ts | 15 + src/mobile-web/src/pages/DevicesPage.tsx | 52 ++- src/mobile-web/src/pages/SessionListPage.tsx | 17 +- .../src/services/RelayHttpClient.ts | 64 +++- .../interactive-capabilities/catalog.json | 10 +- .../components/DeviceStatusControl.tsx | 8 +- .../useDeviceInterconnectionOverview.ts | 8 +- .../sections/sessions/SessionsSection.tsx | 4 +- .../RemoteConnectDialog/AccountPanel.test.tsx | 22 +- .../RemoteConnectDialog/AccountPanel.tsx | 97 ++++- .../RelayHttpClient.generation.test.ts | 47 ++- .../generated/interactive-capabilities.json | 14 +- .../dispatch/DispatchInstallDialog.tsx | 4 +- .../features/dispatch/DispatchJobObserver.ts | 3 +- .../dispatch/DispatchTargetPicker.tsx | 4 +- .../features/dispatch/useDispatchTargets.ts | 4 +- .../src/flow_chat/components/ChatInput.tsx | 6 +- .../components/ChatInputWorkspaceStrip.tsx | 4 +- .../account/deviceDirectory.test.ts | 36 ++ .../infrastructure/account/deviceDirectory.ts | 49 +++ .../account/useAccountLoginState.ts | 4 +- .../api/generated/productControl.ts | 2 +- .../api/generated/remoteSurface.ts | 4 +- .../api/service-api/RemoteConnectAPI.ts | 36 +- .../peer-device/PeerDeviceContext.tsx | 14 +- .../peer-device/useAccountDeviceRoster.ts | 50 +-- src/web-ui/src/locales/en-US/common.json | 6 + src/web-ui/src/locales/zh-CN/common.json | 6 + src/web-ui/src/locales/zh-TW/common.json | 6 + 58 files changed, 1983 insertions(+), 219 deletions(-) create mode 100644 src/web-ui/src/infrastructure/account/deviceDirectory.test.ts create mode 100644 src/web-ui/src/infrastructure/account/deviceDirectory.ts 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..11393f4eff 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -55,6 +55,10 @@ 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, online: device.online, }) .collect(), @@ -355,6 +359,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 +534,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..2c4d104d53 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -299,11 +299,20 @@ impl LoginFormState { let badge = if is_local { " [this device]" } else { "" }; device_lines.push(Line::from(Span::styled( format!( - " {}{} {} · {}", - d.device_name, + " {}{} {} · {} {}", + d.display_name(), badge, truncate_id(&d.device_id), - status + status, + [ + 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..ef90136aa2 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,10 @@ 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 online: bool, pub last_seen_at: Option, } @@ -2957,12 +2957,54 @@ 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, 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 +3506,7 @@ mod sync_state_tests { vec![OnlineDeviceInfo { device_id: "a-device".to_string(), device_name: "A".to_string(), + ..Default::default() }], )); @@ -3473,6 +3516,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 +3543,20 @@ 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()), }], )); - 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..1bc110d062 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,64 @@ 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 | + +`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. + +Login and provisioning accept optional model/OS metadata. Omission by an older +client preserves stored metadata, and neither registration nor reconnect changes +the alias. 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..62f1a1ba61 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,10 @@ 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, pub online: bool, } @@ -220,6 +224,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 +237,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 +256,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 +505,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 +541,10 @@ 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, online: device.online, }) .collect()) @@ -663,6 +724,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..bc2283982a 100644 --- a/src/crates/contracts/product-domains/src/account.rs +++ b/src/crates/contracts/product-domains/src/account.rs @@ -19,9 +19,29 @@ 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, pub online: bool, } +impl AccountDevice { + 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 +112,26 @@ 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 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..e960c3b307 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -1732,6 +1732,10 @@ 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, 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..321bf75a83 100644 --- a/src/crates/services/relay-service/src/account_transport_tests.rs +++ b/src/crates/services/relay-service/src/account_transport_tests.rs @@ -228,6 +228,52 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { assert_eq!(acknowledgement(&mut socket, 1).await["ok"], true); let mut controller = connect_scope(address, prefix, &tokens[1], "user-scoped").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"]) + ); let devices = json_body( request(&app, "GET", &route("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/api/devices"), &tokens[1], json!(null)).await, ) diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index 8ade9a563c..4b11ade8c8 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -31,6 +31,10 @@ 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, public_key TEXT, last_seen_at INTEGER, online INTEGER NOT NULL DEFAULT 0, @@ -138,6 +142,13 @@ 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", +]; + /// 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 +234,13 @@ 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}")); + } + } + } 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,12 +553,40 @@ 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)) + } +} + #[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 public_key: Option, pub last_seen_at: Option, pub online: i64, @@ -554,6 +600,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 +628,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 +645,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 +676,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, public_key, last_seen_at, online \ FROM devices WHERE user_id = ?", ) .bind(user_id) @@ -687,6 +760,7 @@ impl AuthToken { device_kind: Option<&str>, request_id: &str, public_key: &str, + metadata: &DeviceMetadata, ) -> Result> { let mut tx = pool .begin() @@ -744,8 +818,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 +827,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 +2587,54 @@ 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 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..1c8874a7b3 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}; @@ -411,7 +411,7 @@ async fn install(socket: SocketRef, state: AppState, io: SocketIo) { "auth-ok", &json!({"userId":identity.account,"deviceId":identity.device}), ); - presence::broadcast(&io, &state, &identity.account); + presence::broadcast(&io, &state, &identity.account).await; } #[cfg(test)] diff --git a/src/crates/services/relay-service/src/realtime/presence.rs b/src/crates/services/relay-service/src/realtime/presence.rs index bd5cbfa53c..0bd0a5df8c 100644 --- a/src/crates/services/relay-service/src/realtime/presence.rs +++ b/src/crates/services/relay-service/src/realtime/presence.rs @@ -83,17 +83,18 @@ pub(super) async fn register( // Logout/delete may already remove the directory mapping before the // socket observes cancellation. Broadcast the current owner state in // either case, without marking a replacement connection offline. - broadcast(&io, &state, &account); + broadcast(&io, &state, &account).await; }); true } -pub(super) fn broadcast(io: &SocketIo, state: &AppState, account: &str) { - let devices: Vec<_> = 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})).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..2fc3eeb3fa 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,6 @@ 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, } #[derive(Deserialize)] @@ -165,6 +171,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 +339,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 +367,14 @@ 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| { @@ -513,6 +534,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 +594,7 @@ pub async fn provision_device( ), &body.request_id, &body.public_key, + &metadata, ) .await .map_err(|error| { @@ -757,6 +787,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(); @@ -798,11 +853,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 +878,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..73fce4b5dc 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,10 @@ 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 online: bool, pub last_seen_at: Option, } @@ -127,6 +134,10 @@ async fn list_devices( 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, online: is_online, last_seen_at: row.last_seen_at, }); @@ -146,6 +157,10 @@ 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, online: true, last_seen_at: None, }); @@ -155,7 +170,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 +336,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; 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..ed2dad2dae 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -320,10 +320,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 { @@ -497,6 +501,10 @@ impl AccountClient { .map(|e| DeviceInfo { device_id: e.device_id, device_name: e.device_name, + device_alias: e.device_alias, + device_model: e.device_model, + device_os: e.device_os, + device_os_version: e.device_os_version, // Legacy relays omit `online` and only return currently-online // devices; treat a missing field as online. online: e.online.unwrap_or(true), @@ -505,6 +513,98 @@ impl AccountClient { .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). pub async fn delete_device( &self, @@ -627,6 +727,14 @@ 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, pub online: bool, pub last_seen_at: Option, } @@ -635,6 +743,14 @@ pub struct DeviceInfo { 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, /// Absent on legacy relays that only listed in-memory online devices. /// `None` means "legacy online list" → treat as online. #[serde(default)] @@ -647,6 +763,78 @@ struct DeviceListEntry { 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); + } + + #[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/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index fc0641644a..6c82c5747c 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,18 @@ 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, } #[derive(Debug, Clone)] pub enum RelayEvent { @@ -435,6 +443,24 @@ 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()); + } + #[tokio::test] async fn base_endpoint_preserves_proxy_prefix_without_legacy_ws_conversion() { let (client, _events) = super::RelayClient::new(); 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..5efc998572 100644 --- a/src/mobile-web/src/components/CompactSettingsSheet.tsx +++ b/src/mobile-web/src/components/CompactSettingsSheet.tsx @@ -5,6 +5,7 @@ import { Sun as LucideSun, X as LucideX, } from 'lucide-react'; +import { deviceDisplayName, type RelayDeviceInfo } from '../services/RelayHttpClient'; import AccountAvatar from './AccountAvatar'; import React from 'react'; import { @@ -18,11 +19,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; @@ -139,8 +136,8 @@ export default function CompactSettingsSheet({ className={`harmony-sidebar__settings-device${current ? ' is-current' : ''}`} disabled={!device.online} key={device.device_id} - label={device.device_name || device.device_id} - leading={{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')} diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 660467195a..fdb44c35af 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -321,6 +321,11 @@ 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.', }, tools: { ls: 'LS', @@ -645,6 +650,11 @@ export const messages: Record = { deviceUnavailable: '该设备离线或未响应。', unknownDevice: '未命名设备', controllingDevice: '正在控制 {name}', + alias: '设备别名', + aliasPlaceholder: '可选显示名称', + editAlias: '编辑名称', + saveAlias: '保存名称', + aliasUnsupported: '旧版 Relay 不支持设备别名,请升级 Relay 后重试。', }, tools: { ls: '列表', @@ -969,6 +979,11 @@ export const messages: Record = { deviceUnavailable: '該裝置離線或未回應。', unknownDevice: '未命名裝置', controllingDevice: '正在控制 {name}', + alias: '裝置別名', + aliasPlaceholder: '選填顯示名稱', + editAlias: '編輯名稱', + saveAlias: '儲存名稱', + aliasUnsupported: '舊版 Relay 不支援裝置別名,請升級 Relay 後重試。', }, tools: { ls: '列表', diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index 4863e8fede..e042a553ee 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'; -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,6 +178,18 @@ 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; if (client.targetDeviceId === d.device_id) return; @@ -210,7 +224,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) { @@ -248,8 +262,8 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac const isSwitching = switchingId === d.device_id; const clickable = d.online && !isCurrent && !switchingId; return ( +
= ({ client, onBack, onDeviceSelected = onBac label={( - {d.device_name || t('devices.unknownDevice')} + {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')} @@ -283,15 +298,15 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac : t('devices.offline')} )} - trailing={isSwitching ? ( - - ) : ( - clickable && ( -
); })} @@ -376,6 +391,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..cb1a8e0204 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -71,6 +71,8 @@ import logoMarkLight from '../assets/openbitfun-mark-light.png'; import { isAccountIdentityChangedError, type RelayHttpClient, + type RelayDeviceInfo, + deviceDisplayName, } from '../services/RelayHttpClient'; const PAGE_SIZE = 30; @@ -95,11 +97,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,7 +803,8 @@ 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; @@ -841,7 +840,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); @@ -1614,9 +1613,9 @@ const SessionListPage: React.FC = ({ onClick={() => void handleSelectCompactDevice(device)} > - {device.device_name || device.device_id} + {deviceDisplayName(device)} {isSwitching ? : } diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts index 1744926f7f..f0dceab6db 100644 --- a/src/mobile-web/src/services/RelayHttpClient.ts +++ b/src/mobile-web/src/services/RelayHttpClient.ts @@ -32,12 +32,39 @@ 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; + 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 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 +96,8 @@ export class RelayHttpClient { this.realtime.onReconnect(notifyDirectory); this.realtime.onDeviceEvent(envelope => { void this.receiveDeviceEvent(envelope); }); this.accountEpochValue += 1; + this.directorySnapshot = []; + this.directoryRequest += 1; this.deviceMessageKeys.clear(); this.setTargetDeviceId(null); for (const listener of this.ownerListeners) listener({ kind, epoch: this.accountEpochValue, userId: identity.userId }); @@ -81,6 +110,8 @@ export class RelayHttpClient { this.identity = null; this.identityGeneration += 1; this.accountEpochValue += 1; + this.directorySnapshot = []; + this.directoryRequest += 1; this.deviceMessageKeys.clear(); this.setTargetDeviceId(null); for (const listener of this.ownerListeners) listener({ kind: 'unavailable', epoch: this.accountEpochValue, userId: null }); @@ -259,7 +290,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 +305,36 @@ 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(); + if (request !== this.directoryRequest) throw new AccountIdentityChangedError(); + 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/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/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index f342751b5b..017e38a0b1 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 } 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)), ] }); @@ -131,7 +133,7 @@ const DeviceStatusControl: React.FC = ({ 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 +158,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( 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..4c03547074 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 } 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: resolveDeviceName(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.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx index 867b463c52..4991749adb 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx @@ -7,11 +7,12 @@ 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(), t: (key: string) => key, })); 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/RemoteConnectAPI', async importOriginal => ({ ...await importOriginal(), remoteConnectAPI: mocks })); vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: () => () => {} } })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t, formatRelativeTime: () => '' }) })); vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceMode: () => ({ peerMode: { active: false } }) })); @@ -20,7 +21,7 @@ vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ succe 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 }; + return { Avatar: ({ src, alt }: { src?: string; alt?: string }) => {alt}, OverflowText: Box, Alert: ({ message }: { message: string }) =>
{message}
, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box }; }); let container: HTMLDivElement; let root: Root; @@ -34,6 +35,7 @@ beforeEach(() => { // deterministically instead of creating an unbounded render loop. mocks.accountStatus.mockResolvedValueOnce({ logged_in: true, user_id: '42' }) .mockImplementation(() => new Promise(() => {})); + mocks.accountRelayCapabilities.mockResolvedValue([]); mocks.accountConnectDevices.mockResolvedValue([{ device_id: 'local', device_name: 'My computer' }]); mocks.accountListDevices.mockResolvedValue([{ device_id: 'local', device_name: 'My computer', online: true }]); container = document.createElement('div'); document.body.append(container); root = createRoot(container); @@ -187,3 +189,19 @@ it('lets a pending login reopen its external sign-in page', async () => { await act(async () => { reopen!.click(); }); expect(mocks.reopenSignIn).toHaveBeenCalledOnce(); }); + +it('shows explicit old Relay capability state before any mutation', async () => { + mocks.accountRelayCapabilities.mockResolvedValue([]); + await act(async () => { root.render( {}} />); }); + expect(container.textContent).toContain('accountLogin.deviceAliasUnsupported'); + expect(mocks.accountUpdateDevice).not.toHaveBeenCalled(); +}); +it('accepts the negotiated alias capability and displays only alias plus metadata', async () => { + mocks.accountRelayCapabilities.mockResolvedValue(['device_alias_v1']); + mocks.accountListDevices.mockResolvedValue([{ device_id: 'local', device_name: 'technical', device_alias: 'Studio', device_model: 'Model', device_os: 'Linux', online: true }]); + await act(async () => { root.render( {}} />); }); + expect(container.textContent).not.toContain('accountLogin.deviceAliasUnsupported'); + expect(container.textContent).not.toContain('technical'); + expect(container.textContent).toContain('Studio'); + expect(container.textContent).toContain('Model'); +}); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index da7299ae11..9b06d80329 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -1,13 +1,14 @@ +import { refreshDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; /** Account login and authenticated device connections. */ -import { OverflowText, Alert, Avatar, Button, Icon, IconButton, ScrollArea, StatusPill } from '@openbitfun/ui'; +import { OverflowText, Alert, Avatar, Button, Icon, IconButton, Input, ScrollArea, StatusPill } from '@openbitfun/ui'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { confirmDanger, } from '@/infrastructure/confirm-dialog'; -import { LogIn, Monitor } from 'lucide-react'; -import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { LogIn, Monitor, Pencil, Check, X } from 'lucide-react'; +import { remoteConnectAPI, deviceDisplayName, deviceMetadataLabel } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import type { AccountDeviceInfo, OnlineDeviceInfo, @@ -80,6 +81,10 @@ export const AccountPanel: React.FC = ({ const [view, setView] = useState('login'); const [devices, setDevices] = useState([]); + const [editingDeviceId, setEditingDeviceId] = useState(null); + const [aliasDraft, setAliasDraft] = useState(''); + const [aliasSupported, setAliasSupported] = useState(false); + const refreshDirtyRef = useRef(false); const [localDeviceId, setLocalDeviceId] = useState(null); // Device discovery updates presentation, not the account lifecycle. Keep // refresh callbacks stable so adopting an ID cannot restart initialization. @@ -131,12 +136,16 @@ export const AccountPanel: React.FC = ({ const rightLocal = right.device_id === localDeviceId; if (leftLocal !== rightLocal) return leftLocal ? -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)); }), [devices, localDeviceId]); const resetState = useCallback(() => { setActiveAccountEpoch(null); setDevices([]); + setAliasSupported(false); + refreshDirtyRef.current = false; + setEditingDeviceId(null); + setAliasDraft(''); setLocalDeviceId(null); setDevicesReady(false); setRelayError(null); @@ -171,7 +180,8 @@ export const AccountPanel: React.FC = ({ const refreshDevices = useCallback(async () => { const epoch = accountEpochRef.current; if (refreshInFlightRef.current?.epoch === epoch) { - log.debug('Device list refresh already in flight; coalescing duplicate request'); + refreshDirtyRef.current = true; + log.debug('Device list refresh already in flight; scheduling successor'); return; } const requestId = ++refreshRequestRef.current; @@ -180,6 +190,9 @@ export const AccountPanel: React.FC = ({ isAccountEpochCurrent(epoch) && refreshRequestRef.current === requestId ); try { + void remoteConnectAPI.accountRelayCapabilities().then(capabilities => { + if (isCurrent()) setAliasSupported(capabilities.includes('device_alias_v1')); + }).catch(() => { if (isCurrent()) setAliasSupported(false); }); let list = await remoteConnectAPI.accountListDevices(); if (!isCurrent()) return; const currentLocalDeviceId = localDeviceIdRef.current; @@ -213,6 +226,10 @@ export const AccountPanel: React.FC = ({ if (refreshInFlightRef.current?.epoch === epoch && refreshInFlightRef.current.requestId === requestId) { refreshInFlightRef.current = null; + if (refreshDirtyRef.current && isAccountEpochCurrent(epoch)) { + refreshDirtyRef.current = false; + void refreshDevicesRef.current(); + } } } }, [handleSessionExpired, isAccountEpochCurrent, markRelayUnreachable]); @@ -436,6 +453,7 @@ export const AccountPanel: React.FC = ({ deviceListFailureCountRef.current = 0; } applyPresenceOnline(payload.devices); + void refreshDevicesRef.current(); } }, ); @@ -533,6 +551,32 @@ export const AccountPanel: React.FC = ({ t, ]); + const handleUpdateAlias = useCallback(async (device: AccountDeviceInfo) => { + const epoch = accountEpochRef.current; + if (!aliasSupported) { setError(t('accountLogin.deviceAliasUnsupported')); return; } + const alias = aliasDraft.trim() || null; + setLoading(true); + setError(null); + try { + await remoteConnectAPI.accountUpdateDevice(device.device_id, alias); + if (!isAccountEpochCurrent(epoch)) return; + setEditingDeviceId(null); + void refreshDeviceDirectory(); + await refreshDevices(); + if (isAccountEpochCurrent(epoch)) success(t('accountLogin.deviceAliasUpdated')); + } catch (e: unknown) { + if (!isAccountEpochCurrent(epoch)) return; + const message = e instanceof Error ? e.message : String(e); + setError(/unsupported|not found|unknown command|404|405/i.test(message) ? t('accountLogin.deviceAliasUnsupported') : message); + } finally { + if (isAccountEpochCurrent(epoch)) setLoading(false); + } + }, [aliasDraft, aliasSupported, refreshDevices, isAccountEpochCurrent, success, t]); + + const handleStartAliasEdit = useCallback((device: AccountDeviceInfo) => { + setEditingDeviceId(device.device_id); + setAliasDraft(device.device_alias ?? ''); + }, []); const selectDevice = useCallback(async (device: AccountDeviceInfo) => { if (!device.online) return; // Picking this machine is a normal surface switch back, not a no-op: the @@ -548,9 +592,9 @@ export const AccountPanel: React.FC = ({ success(t('accountLogin.deviceSwitcher.switchedLocal')); } } else { - outcome = await switchToDevice(device.device_id, device.device_name); + outcome = await switchToDevice(device.device_id, deviceDisplayName(device)); if (outcome === 'activated') { - success(t('accountLogin.enteredPeerMode', { name: device.device_name })); + success(t('accountLogin.enteredPeerMode', { name: deviceDisplayName(device) })); } } if (outcome === 'activated') { @@ -623,6 +667,7 @@ export const AccountPanel: React.FC = ({
+ {!aliasSupported && } {relayError && (
= ({ 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, @@ -676,19 +722,32 @@ export const AccountPanel: React.FC = ({ {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')} - + {d.online + ? t('accountLogin.online') + : d.last_seen_at + ? t('accountLogin.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) + : t('accountLogin.offline')} + {metadata && {metadata}} + {editingDeviceId === d.device_id && ( + + setAliasDraft(e.target.value)} placeholder={t('accountLogin.deviceAliasPlaceholder')} aria-label={t('accountLogin.deviceAlias')} /> + } onClick={() => void handleUpdateAlias(d)} size="sm" variant="quiet" /> + } onClick={() => setEditingDeviceId(null)} size="sm" variant="quiet" /> + + )} {isSelectable && } + } + onClick={(e) => { e.stopPropagation(); handleStartAliasEdit(d); }} + size="sm" + title={t('accountLogin.editDeviceAlias')} + variant="quiet" + /> ({ token: `token-${userId}`, userId, deviceId: 'browser', masterKey: new Uint8Array(32).fill(userId === 'user-a' ? 7 : 8) }); @@ -10,6 +10,51 @@ 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('rejects older directory completions and publishes authoritative names to active-label subscribers', 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 stale = client.listDevices(); + await client.listDevices(); + old.resolve(Response.json([{ device_id: 'desktop', device_name: 'old', online: true }])); + await expect(stale).rejects.toBeInstanceOf(AccountIdentityChangedError); + 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/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.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx index dc15740f0b..eaa16df51d 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 }); diff --git a/src/web-ui/src/features/dispatch/useDispatchTargets.ts b/src/web-ui/src/features/dispatch/useDispatchTargets.ts index d4778d6775..eb337c4292 100644 --- a/src/web-ui/src/features/dispatch/useDispatchTargets.ts +++ b/src/web-ui/src/features/dispatch/useDispatchTargets.ts @@ -1,3 +1,4 @@ +import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; import { useCallback, useEffect, useState } from 'react'; import { createLogger } from '@/shared/utils/logger'; import { dispatchApi } from './dispatchApi'; @@ -11,6 +12,7 @@ export function useDispatchTargets(enabled = true): { error: boolean; refresh: () => Promise; } { + useDeviceDirectory(); const [targets, setTargets] = useState([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); @@ -42,5 +44,5 @@ export function useDispatchTargets(enabled = true): { // 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) } : target), loading: loading || (enabled && !loaded), error, refresh }; } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 3e2762fd78..20025d70f6 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, resolveDeviceName } 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' ? resolveDeviceName(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/infrastructure/account/deviceDirectory.test.ts b/src/web-ui/src/infrastructure/account/deviceDirectory.test.ts new file mode 100644 index 0000000000..d39de0be44 --- /dev/null +++ b/src/web-ui/src/infrastructure/account/deviceDirectory.test.ts @@ -0,0 +1,36 @@ +/** @vitest-environment jsdom */ +import { afterEach, expect, it, vi } from 'vitest'; +import { startDeviceDirectory, refreshDeviceDirectory, resolveDeviceName, useDeviceDirectory } 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'); +}); 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..d9fe24d13d --- /dev/null +++ b/src/web-ui/src/infrastructure/account/deviceDirectory.ts @@ -0,0 +1,49 @@ +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; + +export function resolveDeviceName(deviceId: string, fallback = deviceId): string { + const device = useDeviceDirectory.getState().devices.find(item => item.device_id === deviceId); + return device ? deviceDisplayName(device) : fallback; +} + +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..20f02e147f 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,22 @@ 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; +} + +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 +141,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 +424,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/PeerDeviceContext.tsx b/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx index ab7162d28a..1c48a6da02 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx @@ -1,3 +1,5 @@ +import { useAccountIdentity } from '@/infrastructure/account-identity'; +import { startDeviceDirectory, useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; /** Thin React binding for the window-wide device-surface controller. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -9,6 +11,13 @@ import { } from './peerDeviceContextState'; export const PeerDeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const identity = useAccountIdentity(); + const accountId = identity.me?.user.accountId ?? identity.me?.user.githubId; + const directory = useDeviceDirectory(); + useEffect(() => { + if (identity.status !== 'signed-in' || accountId === undefined) return; + return startDeviceDirectory(); + }, [identity.status, accountId]); const [snapshot, setSnapshot] = useState( () => peerDeviceSurfaceController.getSnapshot(), ); @@ -54,8 +63,8 @@ export const PeerDeviceProvider: React.FC<{ children: React.ReactNode }> = ({ ch )?.capabilities ?? null : null; return { - peerMode: snapshot.peerMode, - attachments: [...snapshot.attachments], + peerMode: snapshot.peerMode.active ? { ...snapshot.peerMode, deviceName: resolveDeviceName(snapshot.peerMode.deviceId, snapshot.peerMode.deviceName) } : snapshot.peerMode, + attachments: snapshot.attachments.map(item => ({ ...item, deviceName: resolveDeviceName(item.deviceId, item.deviceName) })), currentPeerCapabilities, switchToDevice, switchToLocal, @@ -65,6 +74,7 @@ export const PeerDeviceProvider: React.FC<{ children: React.ReactNode }> = ({ ch }, [ snapshot, + directory, switchToDevice, switchToLocal, disconnectDevice, diff --git a/src/web-ui/src/infrastructure/peer-device/useAccountDeviceRoster.ts b/src/web-ui/src/infrastructure/peer-device/useAccountDeviceRoster.ts index 8fe01e1458..2ab82f8d11 100644 --- a/src/web-ui/src/infrastructure/peer-device/useAccountDeviceRoster.ts +++ b/src/web-ui/src/infrastructure/peer-device/useAccountDeviceRoster.ts @@ -1,3 +1,5 @@ +import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; +import { useAccountIdentity } from '@/infrastructure/account-identity'; /** * Account device roster for the sidebar device switcher. * @@ -9,7 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@/infrastructure/api/service-api/ApiClient'; -import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { remoteConnectAPI, deviceDisplayName } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import { createLogger } from '@/shared/utils/logger'; import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState'; @@ -34,6 +36,9 @@ export interface AccountDeviceRoster { } export function useAccountDeviceRoster(): AccountDeviceRoster { + const identity = useAccountIdentity(); + const directory = useDeviceDirectory(); + const accountId = identity.me?.user.accountId ?? identity.me?.user.githubId; const { loggedIn, deviceName: localDeviceName } = useAccountLoginState(); const [localDeviceId, setLocalDeviceId] = useState(null); const [peers, setPeers] = useState([]); @@ -59,7 +64,7 @@ export function useAccountDeviceRoster(): AccountDeviceRoster { .filter(device => device.device_id !== info.device_id) .map(device => ({ deviceId: device.device_id, - deviceName: device.device_name, + deviceName: deviceDisplayName(device), online: device.online, isLocal: false, })), @@ -70,7 +75,7 @@ export function useAccountDeviceRoster(): AccountDeviceRoster { log.warn('Failed to refresh account device roster', error); } })(); - }, [loggedIn]); + }, [loggedIn, accountId]); useEffect(() => { if (!loggedIn) { @@ -79,47 +84,22 @@ export function useAccountDeviceRoster(): AccountDeviceRoster { setPeers([]); return; } + setPeers([]); + setLocalDeviceId(null); refresh(); const poll = setInterval(refresh, ROSTER_POLL_MS); - return () => clearInterval(poll); + return () => { generationRef.current += 1; clearInterval(poll); }; }, [loggedIn, refresh]); useEffect(() => { if (!loggedIn) { return; } - return api.listen<{ devices: Array<{ device_id: string; device_name: string }> }>( - 'account://device-presence', - (payload) => { - const online = payload?.devices ?? []; - const onlineIds = new Set(online.map(device => device.device_id)); - setPeers(prev => { - const byId = new Map(prev.map(device => [device.deviceId, device])); - for (const device of online) { - if (device.device_id === localDeviceId) { - continue; - } - const existing = byId.get(device.device_id); - byId.set(device.device_id, { - deviceId: device.device_id, - deviceName: device.device_name || existing?.deviceName || device.device_id, - online: true, - isLocal: false, - }); - } - for (const [deviceId, device] of byId) { - if (!onlineIds.has(deviceId) && device.online) { - byId.set(deviceId, { ...device, online: false }); - } - } - return Array.from(byId.values()); - }); - }, - ); - }, [loggedIn, localDeviceId]); + return api.listen('account://device-presence', refresh); + }, [loggedIn, refresh]); const devices = useMemo(() => { - const sortedPeers = [...peers].sort((a, b) => { + const sortedPeers = peers.map(peer => ({ ...peer, deviceName: resolveDeviceName(peer.deviceId, peer.deviceName) })).sort((a, b) => { if (a.online !== b.online) { return a.online ? -1 : 1; } @@ -137,7 +117,7 @@ export function useAccountDeviceRoster(): AccountDeviceRoster { }, ...sortedPeers, ]; - }, [peers, localDeviceId, localDeviceName]); + }, [peers, localDeviceId, localDeviceName, directory]); return { loggedIn, localDeviceId, localDeviceName, devices, refresh }; } diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 7133c25a8a..8ca6444824 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -790,6 +790,12 @@ "returnedToThisDevice": "Returned to local device" }, "accountLogin": { + "deviceAlias": "Device alias", + "deviceAliasPlaceholder": "Optional display name (empty restores technical name)", + "editDeviceAlias": "Edit name", + "saveDeviceAlias": "Save name", + "deviceAliasUpdated": "Device name updated", + "deviceAliasUnsupported": "Device aliases are unsupported by this host or Relay. Update it and retry.", "signedInAccount": "Signed-in account", "username": "Username", "password": "Password", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index f3650c5d0c..5296c0942e 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -790,6 +790,12 @@ "returnedToThisDevice": "已返回本地设备" }, "accountLogin": { + "deviceAlias": "设备别名", + "deviceAliasPlaceholder": "可选显示名称(留空恢复技术名称)", + "editDeviceAlias": "编辑名称", + "saveDeviceAlias": "保存名称", + "deviceAliasUpdated": "设备名称已更新", + "deviceAliasUnsupported": "当前主机或 Relay 不支持设备别名,请升级后重试。", "signedInAccount": "已登录账号", "username": "用户名", "password": "密码", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 42f23cf945..1fa71f0cac 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -790,6 +790,12 @@ "returnedToThisDevice": "已返回本地裝置" }, "accountLogin": { + "deviceAlias": "裝置別名", + "deviceAliasPlaceholder": "選填顯示名稱(留空恢復技術名稱)", + "editDeviceAlias": "編輯名稱", + "saveDeviceAlias": "儲存名稱", + "deviceAliasUpdated": "裝置名稱已更新", + "deviceAliasUnsupported": "目前主機或 Relay 不支援裝置別名,請升級後重試。", "signedInAccount": "已登入帳號", "username": "帳號", "password": "密碼", From 367c9af78ed757920dd1f04ea582d622e3788452 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 17:30:30 +0800 Subject: [PATCH 02/10] fix(account): keep alias edits local and presence metadata intact The row editor now saves and cancels in place without covering the directory, and presence updates no longer mark a device offline or clear alias/model fields an older relay omitted. Co-authored-by: bitfun-ai --- src/mobile-web/src/pages/DevicesPage.tsx | 29 ++- .../src/styles/components/devices.scss | 16 ++ .../RemoteConnectDialog/AccountPanel.scss | 24 +++ .../RemoteConnectDialog/AccountPanel.test.tsx | 96 +++++++++- .../RemoteConnectDialog/AccountPanel.tsx | 173 ++++++++++++------ 5 files changed, 278 insertions(+), 60 deletions(-) diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index e042a553ee..e1aa18501f 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -301,11 +301,30 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac selected={isCurrent} /> - {editingId === d.device_id ? <> - setAliasDraft(e.target.value)} placeholder={t('devices.aliasPlaceholder')} aria-label={t('devices.alias')} /> - void updateAlias(d)}>{t('devices.saveAlias')} - setEditingId(null)}>{t('common.cancel')} - : { setEditingId(d.device_id); setAliasDraft(d.device_alias ?? ''); }}>{t('devices.editAlias')}} + {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')} +
+
+ ) : ( +
+ { setEditingId(d.device_id); setAliasDraft(d.device_alias ?? ''); }}>{t('devices.editAlias')} +
+ )}
); })} diff --git a/src/mobile-web/src/styles/components/devices.scss b/src/mobile-web/src/styles/components/devices.scss index df86642a92..e80f1bbdca 100644 --- a/src/mobile-web/src/styles/components/devices.scss +++ b/src/mobile-web/src/styles/components/devices.scss @@ -139,6 +139,22 @@ } } +.devices-page__alias-editor { + display: flex; + flex-direction: column; + gap: var(--size-gap-2); + padding: var(--size-gap-3) var(--size-gap-4) var(--size-gap-4); +} + +.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/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index a31fb62bfa..8e0325cccb 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -196,9 +196,33 @@ } &__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-alias-editor { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-width: 0; + margin-top: var(--openbitfun-space-1); + + > [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 4991749adb..ddf19730aa 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx @@ -11,9 +11,17 @@ const mocks = vi.hoisted(() => ({ accountConnectDevices: vi.fn(), accountListDevices: 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', async importOriginal => ({ ...await importOriginal(), remoteConnectAPI: mocks })); -vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: () => () => {} } })); +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/confirm-dialog', () => ({ confirmDanger: vi.fn() })); @@ -21,7 +29,11 @@ vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ succe 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: ({ message }: { message: string }) =>
{message}
, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box }; + const IconButton = ({ 'aria-label': ariaLabel, onClick, disabled }: { 'aria-label'?: string; onClick?: React.MouseEventHandler; disabled?: boolean }) =>
); })} From 10727730128354f1ee8dc35e9f513ec2040e06af Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 17:50:16 +0800 Subject: [PATCH 03/10] fix(flowchat): drop the gap behind a hidden exploration tool card An errored Read/Grep/Glob/LS card hides itself while its wrapper stays in the DOM at zero height, so removing that item gap keeps two visible rows at the intended 8px spacing. Co-authored-by: bitfun-ai --- src/web-ui/src/flow_chat/_item-rhythm.scss | 9 +++++++++ .../components/modern/FlowChatRhythm.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) 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/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'); From e9b0209378fd95afe4b2b633a8a49ddca8d0ec95 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 17:53:30 +0800 Subject: [PATCH 04/10] fix(account): edit a device alias in the name slot Keeping the row label while the editor is open repeats the same device name; replace it in place and leave status and metadata visible. Co-authored-by: bitfun-ai --- src/mobile-web/src/pages/DevicesPage.tsx | 80 ++++++++++--------- .../src/styles/components/devices.scss | 8 +- src/mobile-web/tests/account-login.test.mjs | 13 +++ .../RemoteConnectDialog/AccountPanel.scss | 1 - .../RemoteConnectDialog/AccountPanel.test.tsx | 14 ++++ .../RemoteConnectDialog/AccountPanel.tsx | 24 +++--- 6 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index e1aa18501f..782c9d5f34 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -263,44 +263,8 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac const clickable = d.online && !isCurrent && !switchingId; return (
- 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')} - - )} - - - )} - supportingText={( - - - {d.online - ? t('devices.online') - : d.last_seen_at - ? t('devices.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) - : t('devices.offline')} - - )} - - selected={isCurrent} - /> + {/* Renaming replaces the row in place: keeping the row and an editor + under it shows the same device name twice. */} {editingId === d.device_id ? (
= ({ client, onBack, onDeviceSelected = onBac
) : ( + <> + 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')} + + )} + + + )} + supportingText={( + + + {d.online + ? t('devices.online') + : d.last_seen_at + ? t('devices.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) + : t('devices.offline')} + + )} + + selected={isCurrent} + />
{ setEditingId(d.device_id); setAliasDraft(d.device_alias ?? ''); }}>{t('devices.editAlias')}
+ )}
); diff --git a/src/mobile-web/src/styles/components/devices.scss b/src/mobile-web/src/styles/components/devices.scss index e80f1bbdca..fd0ade27b7 100644 --- a/src/mobile-web/src/styles/components/devices.scss +++ b/src/mobile-web/src/styles/components/devices.scss @@ -139,11 +139,17 @@ } } +/* 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(--size-gap-3) var(--size-gap-4) var(--size-gap-4); + 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 { diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index a6277aac33..330ead66d0 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -138,6 +138,19 @@ test('account UI entry precedes discovery and mounts no remote workspace surface 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 }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index 8e0325cccb..64cac363ab 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -215,7 +215,6 @@ align-items: center; gap: var(--openbitfun-space-2); min-width: 0; - margin-top: var(--openbitfun-space-1); > [data-openbitfun-component='icon-button'] { flex-shrink: 0; } } 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 ddf19730aa..4e3b0ac9f8 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx @@ -240,6 +240,20 @@ async function openAliasEditor() { return input!; } +it('replaces the device name in place instead of showing two names', async () => { + mocks.accountRelayCapabilities.mockResolvedValue(['device_alias_v1']); + await act(async () => { root.render( {}} />); }); + expect(container.querySelector('.account-panel__device-name')).not.toBeNull(); + const input = await openAliasEditor(); + // The editor occupies the name's slot; the technical name is not repeated. + expect(input.value).toBe(''); + expect(container.querySelector('.account-panel__device-name')).toBeNull(); + expect(container.querySelector('.account-panel__device-badge')).toBeNull(); + expect(container.textContent).not.toContain('My computer'); + // Losing the row must not lose the device's status or hardware line. + expect(container.querySelector('.account-panel__device-meta')).not.toBeNull(); +}); + it('saves an edited alias from the device row with Enter', async () => { mocks.accountRelayCapabilities.mockResolvedValue(['device_alias_v1']); await act(async () => { root.render( {}} />); }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index 3d94c46eb5..3013213dfc 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -751,10 +751,8 @@ export const AccountPanel: React.FC = ({ > - - {displayName} - {isLocal && {t('accountLogin.thisDevice')}} - + {/* Renaming replaces the name in place: showing the old + name next to an editor reads as two device names. */} {editingDeviceId === d.device_id ? ( = ({ /> ) : ( - - {d.online - ? t('accountLogin.online') - : d.last_seen_at - ? t('accountLogin.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) - : t('accountLogin.offline')} - {metadata && {` · ${metadata}`}} + + {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}`}} + {isSelectable && } From d2a41c58501a7f0f77385bf9f657e6f3998a2f38 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 18:15:51 +0800 Subject: [PATCH 05/10] fix(desktop-pet): keep 3px above and below the bubble slot The window was sized exactly to the pet, and the bubble filled its slot to the last pixel. The slot clips (`overflow: hidden`), so the hover lift and the attention pulse pushed the bubble's top border out of the visible area. Reserve WINDOW_VERTICAL_BUFFER (3px) above and below: the window grows by twice that amount, the bubble slot carries it as padding and keeps the bubble at its original size, and the dock keeps the pet centered in an equally taller slot. The multi-bubble measurement now sums the bubbles instead of reading the slot's scrollHeight, so the slot's own padding is not counted twice. Co-authored-by: bitfun-ai --- .../AgentCompanionDesktopPet.scss | 22 ++++++++++++++-- .../AgentCompanionDesktopPet.tsx | 25 +++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) 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'); From 383303e9cc997275edb87a71b2e441436325ccf4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 18:45:59 +0800 Subject: [PATCH 06/10] feat(account): gate remote control on matching client builds Devices report their client build in the account login body and in every realtime handshake. The relay records it per device, exposes it together with a relay-computed `compatible` flag in the device directory, and refuses a device RPC unless both ends report the same client protocol, so a mixed client set cannot control each other even outside the UI. Compatibility rule: only a pair where both ends reported a protocol and both numbers match is compatible. An unreported device, and any mismatch, is incompatible; legacy relays predate the field and return no value, so clients keep treating those devices as usable. Incompatible devices stay listed and are marked in the device area ("client version incompatible, remote control unavailable"), and are no longer offered as control targets in the account panel, the sidebar switcher and dispatch target selection. Alias editing is a directory operation and stays available. Verified with cargo test -p openbitfun-relay-service (117 passed), pnpm run check:web, mobile type-check plus the device-compatibility tests, i18n:audit, theme:color-audit:all and check-core-boundaries. Co-authored-by: bitfun-ai --- src/apps/cli/src/account.rs | 3 + src/apps/cli/src/ui/login_form.rs | 9 +- .../desktop/src/api/remote_connect_api.rs | 7 + src/apps/relay-server/README.md | 39 +- .../service/remote_connect/account_runtime.rs | 12 + .../contracts/product-domains/src/account.rs | 96 +++++ .../app-server/src/management/owner.rs | 3 + .../src/account_transport_tests.rs | 342 +++++++++++++++++- src/crates/services/relay-service/src/db.rs | 216 ++++++++++- .../relay-service/src/realtime/mod.rs | 39 ++ .../relay-service/src/realtime/presence.rs | 2 +- .../services/relay-service/src/routes/api.rs | 6 +- .../services/relay-service/src/routes/auth.rs | 109 ++++++ .../relay-service/src/routes/devices.rs | 100 +++++ .../src/remote_connect/account.rs | 173 +++++++-- .../src/remote_connect/realtime_client.rs | 11 +- .../src/remote_connect/relay_client.rs | 84 +++++ .../tests/remote_connect_contracts.rs | 16 +- src/mobile-web/package.json | 1 + .../src/components/CompactSettingsSheet.tsx | 9 +- src/mobile-web/src/i18n/messages.ts | 3 + src/mobile-web/src/pages/DevicesPage.tsx | 13 +- src/mobile-web/src/pages/SessionListPage.tsx | 6 +- .../src/services/CloudAccountClient.ts | 4 + .../src/services/RelayHttpClient.ts | 13 + .../src/services/accountDeviceSelection.ts | 35 +- src/mobile-web/tests/account-login.test.mjs | 20 +- .../tests/device-compatibility.test.mjs | 58 +++ src/shared/relay-transport/AccountRealtime.ts | 6 +- src/shared/relay-transport/ClientBuild.ts | 35 ++ .../src/app/components/NavPanel/NavPanel.scss | 13 + .../components/DeviceStatusControl.test.tsx | 50 ++- .../components/DeviceStatusControl.tsx | 43 ++- .../RemoteConnectDialog/AccountPanel.scss | 7 + .../RemoteConnectDialog/AccountPanel.test.tsx | 45 ++- .../RemoteConnectDialog/AccountPanel.tsx | 35 +- .../dispatch/DispatchTargetPicker.test.tsx | 41 ++- .../dispatch/DispatchTargetPicker.tsx | 14 +- src/web-ui/src/features/dispatch/types.ts | 2 + .../features/dispatch/useDispatchTargets.ts | 26 +- .../account/clientBuild.contract.test.ts | 47 +++ .../account/deviceDirectory.test.ts | 26 +- .../infrastructure/account/deviceDirectory.ts | 23 ++ .../api/service-api/RemoteConnectAPI.ts | 24 ++ .../peer-device/DeviceSurfaceSwitcher.scss | 3 +- .../peer-device/DeviceSurfaceSwitcher.tsx | 9 +- .../peer-device/useAccountDeviceRoster.ts | 6 +- src/web-ui/src/locales/en-US/common.json | 7 +- src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 7 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 7 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 53 files changed, 1824 insertions(+), 84 deletions(-) create mode 100644 src/mobile-web/tests/device-compatibility.test.mjs create mode 100644 src/shared/relay-transport/ClientBuild.ts create mode 100644 src/web-ui/src/infrastructure/account/clientBuild.contract.test.ts diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index 11393f4eff..5bbf659995 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -59,6 +59,9 @@ pub(crate) fn account_snapshot_projection( 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/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index 2c4d104d53..5c7c6e214b 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -297,13 +297,20 @@ 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.display_name(), badge, truncate_id(&d.device_id), status, + compat, [ d.device_model.as_deref(), d.device_os.as_deref(), diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index ef90136aa2..8e89b68fd8 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -2930,6 +2930,9 @@ pub struct AccountDeviceInfo { 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, } @@ -2961,6 +2964,9 @@ pub async fn account_list_devices() -> Result, String> { 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, }) @@ -3547,6 +3553,7 @@ mod sync_state_tests { device_model: Some("Mac14,7".into()), device_os: Some("macos".into()), device_os_version: Some("15".into()), + ..Default::default() }], )); diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 1bc110d062..67b86418f0 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -222,6 +222,9 @@ The additive nullable fields are: | `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 @@ -256,11 +259,41 @@ negotiation before use; clients must not probe old servers with unknown mutation 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. 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. +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. 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 62f1a1ba61..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 @@ -84,6 +84,15 @@ pub struct AccountDevice { 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, } @@ -545,6 +554,9 @@ impl AccountRuntime { 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/contracts/product-domains/src/account.rs b/src/crates/contracts/product-domains/src/account.rs index bc2283982a..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 { @@ -27,10 +58,40 @@ pub struct AccountDevice { 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() { @@ -132,6 +193,41 @@ mod identity_tests { 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/interfaces/app-server/src/management/owner.rs b/src/crates/interfaces/app-server/src/management/owner.rs index e960c3b307..7617301912 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -1736,6 +1736,9 @@ fn project_account_snapshot( 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 321bf75a83..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,7 +279,14 @@ 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( @@ -272,7 +332,11 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { assert_eq!(info["protocol_version"], 3); assert_eq!( info["capabilities"], - json!(["device_alias_v1", "device_metadata_v1"]) + 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, @@ -429,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 4b11ade8c8..b65a6639a0 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS devices ( 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, @@ -149,6 +151,14 @@ const MIGRATE_DEVICE_DIRECTORY_METADATA: [&str; 4] = [ "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 @@ -241,6 +251,15 @@ async fn connect_with_presence_reset(db_path: &str, reset_presence: bool) -> Res } } } + // 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", @@ -577,6 +596,36 @@ impl DeviceMetadata { } } +/// 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, @@ -587,12 +636,25 @@ pub struct DeviceRow { 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, @@ -676,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, device_alias, device_model, device_os, device_os_version, 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) @@ -686,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` @@ -2635,6 +2727,128 @@ mod tests { 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/realtime/mod.rs b/src/crates/services/relay-service/src/realtime/mod.rs index 1c8874a7b3..d175bfafcb 100644 --- a/src/crates/services/relay-service/src/realtime/mod.rs +++ b/src/crates/services/relay-service/src/realtime/mod.rs @@ -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 = 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})).collect(), + 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; diff --git a/src/crates/services/relay-service/src/routes/api.rs b/src/crates/services/relay-service/src/routes/api.rs index 2fc3eeb3fa..e45dcc0aab 100644 --- a/src/crates/services/relay-service/src/routes/api.rs +++ b/src/crates/services/relay-service/src/routes/api.rs @@ -83,6 +83,10 @@ pub(crate) async fn server_info_for_host(host_version: &'static str) -> Json, #[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)] @@ -383,6 +390,23 @@ pub(crate) async fn login( 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| { + err( + "device registration failed", + registration_error_status(&error), + ) + })?; let token = AuthToken::create_idempotent(db, &user.user_id, &body.device_id, &body.request_id) .await .map_err(|error| err("token creation failed", registration_error_status(&error)))?; @@ -841,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; diff --git a/src/crates/services/relay-service/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs index 73fce4b5dc..efdc94d27b 100644 --- a/src/crates/services/relay-service/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -98,6 +98,11 @@ pub struct DeviceListEntry { 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, } @@ -113,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 = @@ -130,6 +149,7 @@ 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(), @@ -138,6 +158,12 @@ async fn list_devices( 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, }); @@ -161,6 +187,9 @@ async fn list_devices( 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, }); @@ -725,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 ed2dad2dae..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 @@ -496,21 +517,7 @@ 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, - device_alias: e.device_alias, - device_model: e.device_model, - device_os: e.device_os, - device_os_version: e.device_os_version, - // 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. @@ -735,10 +742,38 @@ pub struct DeviceInfo { 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, @@ -751,6 +786,15 @@ struct DeviceListEntry { 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)] @@ -759,6 +803,24 @@ 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::*; @@ -783,6 +845,71 @@ mod tests { 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}; 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 6c82c5747c..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 @@ -30,6 +30,45 @@ pub struct DevicePresenceEntry { 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 { @@ -461,6 +500,51 @@ mod tests { 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/components/CompactSettingsSheet.tsx b/src/mobile-web/src/components/CompactSettingsSheet.tsx index 5efc998572..e99988ec34 100644 --- a/src/mobile-web/src/components/CompactSettingsSheet.tsx +++ b/src/mobile-web/src/components/CompactSettingsSheet.tsx @@ -6,6 +6,7 @@ import { 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 { @@ -130,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(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 fdb44c35af..acd520022f 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -326,6 +326,7 @@ export const messages: Record = { 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', @@ -655,6 +656,7 @@ export const messages: Record = { editAlias: '编辑名称', saveAlias: '保存名称', aliasUnsupported: '旧版 Relay 不支持设备别名,请升级 Relay 后重试。', + clientIncompatible: '客户端版本不兼容,无法互控。', }, tools: { ls: '列表', @@ -984,6 +986,7 @@ export const messages: Record = { 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 782c9d5f34..3e051622b8 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -34,7 +34,7 @@ import { } from '../services/RelayHttpClient'; import { useI18n } from '../i18n'; import { useMobileStore } from '../services/store'; -import { selectAccountDevice } from '../services/accountDeviceSelection'; +import { selectAccountDevice, isDeviceControllable } from '../services/accountDeviceSelection'; type DeviceInfo = RelayDeviceInfo; @@ -191,7 +191,8 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac } }, [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; @@ -260,7 +261,8 @@ 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 (
{/* Renaming replaces the row in place: keeping the row and an editor @@ -308,6 +310,11 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac {t('devices.current')} )} + {!controllable && ( + + {t('devices.clientIncompatible')} + + )} )} diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index cb1a8e0204..65a8a4e4f4 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -74,6 +74,7 @@ import { type RelayDeviceInfo, deviceDisplayName, } from '../services/RelayHttpClient'; +import { isDeviceControllable } from '../services/accountDeviceSelection'; const PAGE_SIZE = 30; @@ -807,7 +808,8 @@ const SessionListPage: React.FC = ({ }, [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) { @@ -1609,7 +1611,7 @@ 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)} >