From 1bd0a280af1e579cc94da594fd81d71818994501 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 7 Sep 2026 10:46:04 +0800 Subject: [PATCH 1/2] fix(remote-connect): unify relay connections and show live clients --- .../desktop/src/api/remote_connect_api.rs | 224 +++++++++++++++--- .../src/remote_connect.rs | 14 +- .../tests/remote_connect_contracts.rs | 28 ++- .../src/services/RemoteSessionManager.ts | 3 +- .../src/services/controlClientIdentity.ts | 20 ++ .../src/app/components/NavPanel/NavPanel.scss | 9 +- .../RemoteConnectDialog.scss | 82 +++++++ .../RemoteConnectDialog.status.test.tsx | 104 +++++++- .../RemoteConnectDialog.tsx | 128 ++++------ .../RemoteNetworkConnections.tsx | 138 +++++++++++ .../RemoteSessionManager.routing.test.ts | 11 + .../RemoteConnectDialog/appearance.ts | 1 + .../api/service-api/RemoteConnectAPI.ts | 3 + .../remote-connect/remoteConnectionState.ts | 6 +- src/web-ui/src/locales/en-US/common.json | 12 +- src/web-ui/src/locales/zh-CN/common.json | 12 +- src/web-ui/src/locales/zh-TW/common.json | 12 +- 17 files changed, 667 insertions(+), 140 deletions(-) create mode 100644 src/mobile-web/src/services/controlClientIdentity.ts create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index f3e8607d3a..dbd61f0d4a 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -114,8 +114,9 @@ struct DeviceRoutingState { online_devices: Vec, /// Account-auth QR clients use HTTP device RPC instead of the QR room. /// A successful control heartbeat proves that a client reached this host. - last_control_ping: Option, control_ping_generation: u64, + control_clients: std::collections::BTreeMap, + last_unidentified_control_ping: Option, } static DEVICE_ROUTING_STATE: OnceLock> = OnceLock::new(); @@ -1078,7 +1079,8 @@ fn install_device_routing_owner(owner: DeviceRoutingOwner) { with_device_routing_state(|state| { state.owner = Some(owner); state.online_devices.clear(); - state.last_control_ping = None; + state.control_clients.clear(); + state.last_unidentified_control_ping = None; state.control_ping_generation = 0; }); } @@ -1093,13 +1095,38 @@ fn record_control_ping_if_owner( owner: &DeviceRoutingOwner, generation: u64, now: std::time::Instant, + client: Option<&openbitfun_services_integrations::remote_connect::RemoteControlClient>, ) { with_device_routing_state(|state| { if state.owner.as_ref() == Some(owner) && state.control_ping_generation == generation { - // Concurrent replies can finish out of order. Preserve the latest - // received heartbeat without extending a delayed request's lease. - state.last_control_ping = - Some(state.last_control_ping.map_or(now, |last| last.max(now))); + // Leases use receipt time, not the completion time of queued replies. + use openbitfun_services_integrations::remote_connect::relay_client::RELAY_INBOUND_IDLE_TIMEOUT; + state.control_clients.retain(|_, (_, last)| { + now.saturating_duration_since(*last) < RELAY_INBOUND_IDLE_TIMEOUT + }); + if let Some(client) = + client.filter(|client| !client.id.trim().is_empty() && client.id.len() <= 128) + { + let name: String = client + .name + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect(); + let entry = state + .control_clients + .entry(client.id.clone()) + .or_insert((name.clone(), now)); + if now >= entry.1 { + *entry = (name, now); + } + } else { + state.last_unidentified_control_ping = Some( + state + .last_unidentified_control_ping + .map_or(now, |last| last.max(now)), + ); + } } }); } @@ -1107,20 +1134,17 @@ fn record_control_ping_if_owner( fn clear_control_ping_if_owner(owner: &DeviceRoutingOwner) { with_device_routing_state(|state| { if state.owner.as_ref() == Some(owner) { - state.last_control_ping = None; + state.control_clients.clear(); + state.last_unidentified_control_ping = None; state.control_ping_generation = state.control_ping_generation.wrapping_add(1); } }); } +#[cfg(test)] fn has_recent_control_ping(owner: &DeviceRoutingOwner, now: std::time::Instant) -> bool { - use openbitfun_services_integrations::remote_connect::relay_client::RELAY_INBOUND_IDLE_TIMEOUT; - with_device_routing_state(|state| { - state.owner.as_ref() == Some(owner) - && state.last_control_ping.is_some_and(|last_ping| { - now.saturating_duration_since(last_ping) < RELAY_INBOUND_IDLE_TIMEOUT - }) - }) + let (clients, unidentified) = account_control_clients(owner, now); + !clients.is_empty() || unidentified } fn is_successful_control_ping( @@ -1131,19 +1155,63 @@ fn is_successful_control_ping( // The mobile/browser connection-health loop pings its selected target. // `peer_mode_ping` is also used before attaching or switching a device; // accepting that capability probe would manufacture a mobile connection. - matches!(command, RemoteCommand::Ping) + matches!(command, RemoteCommand::Ping { .. }) && response.get("resp").and_then(|v| v.as_str()) == Some("pong") } -async fn account_control_relay_url(now: std::time::Instant) -> Option { +fn account_control_clients( + owner: &DeviceRoutingOwner, + now: std::time::Instant, +) -> ( + Vec, + bool, +) { + use openbitfun_services_integrations::remote_connect::{ + relay_client::RELAY_INBOUND_IDLE_TIMEOUT, RemoteControlClient, + }; + with_device_routing_state(|state| { + if state.owner.as_ref() != Some(owner) { + return (Vec::new(), false); + } + let clients = state + .control_clients + .iter() + .filter(|(_, (_, last))| { + now.saturating_duration_since(*last) < RELAY_INBOUND_IDLE_TIMEOUT + }) + .map(|(id, (name, _))| RemoteControlClient { + id: id.clone(), + name: name.clone(), + }) + .collect(); + let unidentified = state + .last_unidentified_control_ping + .is_some_and(|last| now.saturating_duration_since(last) < RELAY_INBOUND_IDLE_TIMEOUT); + (clients, unidentified) + }) +} + +async fn account_control_snapshot( + now: std::time::Instant, +) -> Option<( + String, + Vec, + bool, +)> { let generation = account_context_generation(); let (session, relay_url) = read_account_context_for_generation(generation).await.ok()?; let owner = device_routing_owner_for_account(generation, &session.token)?; - // Account control outlives the temporary QR invitation. Its own route and - // heartbeat lease are authoritative even when the room has been stopped - // or a different relay/LAN invitation is currently open. - (has_recent_control_ping(&owner, now) && account_context_is_current(generation)) - .then_some(relay_url) + let (clients, unidentified) = account_control_clients(&owner, now); + if clients.is_empty() && !unidentified { + return None; + } + (account_context_is_current(generation) && device_routing_owner_is_registered(&owner)) + .then_some((relay_url, clients, unidentified)) +} + +#[cfg(test)] +async fn account_control_relay_url(now: std::time::Instant) -> Option { + account_control_snapshot(now).await.map(|(url, _, _)| url) } fn device_routing_owner_is_registered(owner: &DeviceRoutingOwner) -> bool { @@ -1220,7 +1288,8 @@ fn clear_device_routing_if_owner(owner: &DeviceRoutingOwner) -> bool { } state.owner = None; state.online_devices.clear(); - state.last_control_ping = None; + state.control_clients.clear(); + state.last_unidentified_control_ping = None; true }) } @@ -1229,7 +1298,8 @@ fn clear_device_routing_state() -> bool { with_device_routing_state(|state| { let had_owner = state.owner.take().is_some(); state.online_devices.clear(); - state.last_control_ping = None; + state.control_clients.clear(); + state.last_unidentified_control_ping = None; had_owner }) } @@ -1806,6 +1876,12 @@ pub struct RemoteConnectStatusResponse { /// Source of the live account control channel, separate from `active_method`. #[serde(default)] pub account_control_relay_url: Option, + /// Live browser sessions; absent on hosts without client-level presence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_control_clients: + Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_control_has_unidentified_clients: Option, /// Independent bot connection info — e.g. "Telegram(7096812005)". /// Present when a bot is active, regardless of relay pairing state. pub bot_connected: Option, @@ -2185,7 +2261,11 @@ pub async fn remote_connect_status() -> Result Result Result, String> &rpc_owner, generation, ping_received_at, + match &cmd { + RemoteCommand::Ping { client } => { + client.as_ref() + } + _ => None, + }, ); } } @@ -4853,15 +4941,54 @@ mod sync_state_tests { /// because they run without an ambient runtime. static ACCOUNT_CONTEXT_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[test] + fn account_control_clients_deduplicate_expire_and_fence_disconnects() { + use openbitfun_services_integrations::remote_connect::{ + relay_client::RELAY_INBOUND_IDLE_TIMEOUT, RemoteControlClient, + }; + let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.blocking_lock(); + let owner = new_device_routing_owner(1, "clients", 1); + install_device_routing_owner(owner.clone()); + let now = std::time::Instant::now(); + let later = now + std::time::Duration::from_secs(10); + let phone = RemoteControlClient { + id: "phone".into(), + name: "Safari · iOS".into(), + }; + let browser = RemoteControlClient { + id: "browser".into(), + name: "Chrome · Windows".into(), + }; + record_control_ping_if_owner(&owner, 0, now, Some(&phone)); + record_control_ping_if_owner(&owner, 0, later, Some(&browser)); + record_control_ping_if_owner(&owner, 0, now, Some(&browser)); + assert_eq!( + account_control_clients(&owner, later), + (vec![browser.clone(), phone], false) + ); + assert_eq!( + account_control_clients(&owner, now + RELAY_INBOUND_IDLE_TIMEOUT), + (vec![browser], false) + ); + record_control_ping_if_owner(&owner, 0, later, None); + assert!(account_control_clients(&owner, later).1); + clear_control_ping_if_owner(&owner); + record_control_ping_if_owner(&owner, 0, later, None); + assert_eq!(account_control_clients(&owner, later), (vec![], false)); + install_device_routing_owner(new_device_routing_owner(2, "replacement", 2)); + assert_eq!(account_control_clients(&owner, later), (vec![], false)); + clear_device_routing_state(); + } + #[test] fn account_control_ping_requires_a_successful_control_response() { use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; assert!(is_successful_control_ping( - &RemoteCommand::Ping, + &RemoteCommand::Ping { client: None }, &serde_json::json!({"resp": "pong"}) )); assert!(!is_successful_control_ping( - &RemoteCommand::Ping, + &RemoteCommand::Ping { client: None }, &serde_json::json!({"resp": "error"}) )); let peer_ping = RemoteCommand::HostInvoke { @@ -4895,7 +5022,7 @@ mod sync_state_tests { let now = std::time::Instant::now(); install_device_routing_owner(first.clone()); assert!(!has_recent_control_ping(&first, now)); - record_control_ping_if_owner(&first, 0, now); + record_control_ping_if_owner(&first, 0, now, None); assert!(has_recent_control_ping(&first, now)); assert!(!has_recent_control_ping( &first, @@ -4903,24 +5030,24 @@ mod sync_state_tests { )); clear_control_ping_if_owner(&first); assert!(!has_recent_control_ping(&first, now)); - record_control_ping_if_owner(&first, 0, now); + record_control_ping_if_owner(&first, 0, now, None); assert!( !has_recent_control_ping(&first, now), "a queued pre-disconnect ping must not revive connectivity" ); let reconnected = control_ping_generation(&first).unwrap(); - record_control_ping_if_owner(&first, reconnected, now); + record_control_ping_if_owner(&first, reconnected, now, None); assert!(has_recent_control_ping(&first, now)); install_device_routing_owner(second.clone()); - record_control_ping_if_owner(&first, 0, now); + record_control_ping_if_owner(&first, 0, now, None); assert!(!has_recent_control_ping(&first, now)); assert!(!has_recent_control_ping(&second, now)); - record_control_ping_if_owner(&second, 0, now); + record_control_ping_if_owner(&second, 0, now, None); clear_control_ping_if_owner(&first); assert!(has_recent_control_ping(&second, now)); let newer = now + std::time::Duration::from_secs(1); - record_control_ping_if_owner(&second, 0, newer); - record_control_ping_if_owner(&second, 0, now); + record_control_ping_if_owner(&second, 0, newer, None); + record_control_ping_if_owner(&second, 0, now, None); assert!(has_recent_control_ping( &second, now + RELAY_INBOUND_IDLE_TIMEOUT @@ -4955,7 +5082,7 @@ mod sync_state_tests { "login alone is not a connection" ); - record_control_ping_if_owner(&owner, 0, now); + record_control_ping_if_owner(&owner, 0, now, None); assert_eq!( account_control_relay_url(now).await.as_deref(), Some(relay_url) @@ -4968,7 +5095,7 @@ mod sync_state_tests { assert_eq!(account_control_relay_url(now).await, None); let reconnect_generation = control_ping_generation(&owner).unwrap(); - record_control_ping_if_owner(&owner, reconnect_generation, now); + record_control_ping_if_owner(&owner, reconnect_generation, now, None); assert_eq!( account_control_relay_url(now).await.as_deref(), Some(relay_url) @@ -5002,6 +5129,13 @@ mod sync_state_tests { assert!(status.account_control_relay_url.is_none()); status.account_control_connected = true; status.account_control_relay_url = Some("https://relay.example/base".into()); + status.account_control_clients = Some(vec![ + openbitfun_services_integrations::remote_connect::RemoteControlClient { + id: "phone".into(), + name: "Safari".into(), + }, + ]); + status.account_control_has_unidentified_clients = Some(true); let mut serialized = serde_json::to_value(&status).unwrap(); assert_eq!(serialized["pairing_state"], "waiting_for_scan"); assert_eq!(serialized["is_connected"], false); @@ -5019,6 +5153,26 @@ mod sync_state_tests { .remove("account_control_relay_url"), Some(serde_json::json!("https://relay.example/base")) ); + let round_trip: RemoteConnectStatusResponse = + serde_json::from_value(serialized.clone()).unwrap(); + assert_eq!( + round_trip.account_control_clients, + status.account_control_clients + ); + assert_eq!( + serialized + .as_object_mut() + .unwrap() + .remove("account_control_clients"), + Some(serde_json::json!([{"id": "phone", "name": "Safari"}])) + ); + assert_eq!( + serialized + .as_object_mut() + .unwrap() + .remove("account_control_has_unidentified_clients"), + Some(serde_json::json!(true)) + ); assert_eq!(serialized, legacy); } diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index d46e3c0967..f36e0f652c 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -2268,6 +2268,13 @@ pub enum RemotePermissionMode { FullAccess, } +/// Display-only identity for a live browser connection, never an authorization identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteControlClient { + pub id: String, + pub name: String, +} + /// Commands that remote clients can send to the desktop runtime. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] @@ -2428,7 +2435,10 @@ pub enum RemoteCommand { device_name: String, request_id: String, }, - Ping, + Ping { + #[serde(default, skip_serializing_if = "Option::is_none")] + client: Option, + }, // ── Device-to-device distributed control ────────────────────────────── // @@ -2728,7 +2738,7 @@ where H: RemoteCommandRuntimeHost + ?Sized, { match command { - RemoteCommand::Ping => RemoteResponse::Pong, + RemoteCommand::Ping { .. } => RemoteResponse::Pong, RemoteCommand::GetWorkspaceInfo | RemoteCommand::ListRecentWorkspaces 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 6fdd4c60f9..af31c8c76d 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -1183,7 +1183,7 @@ async fn remote_connect_command_owner_preserves_cancel_and_group_routing() { assert_eq!( handle_remote_command( &host, - &RemoteCommand::Ping, + &RemoteCommand::Ping { client: None }, RemoteConnectSubmissionSource::Relay ) .await, @@ -1786,7 +1786,8 @@ async fn remote_connect_file_command_handler_owns_owner_flow_and_uses_host_root( &[Some("session-1".to_string())] ); - let error = handle_remote_workspace_file_command(&host, &RemoteCommand::Ping).await; + let error = + handle_remote_workspace_file_command(&host, &RemoteCommand::Ping { client: None }).await; assert_eq!( error, RemoteResponse::Error { @@ -3151,3 +3152,26 @@ fn remote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strin assert!(make_slim_tool_params(&serde_json::json!(42)).is_none()); } + +#[test] +fn control_ping_accepts_legacy_and_additive_client_identity() { + use openbitfun_services_integrations::remote_connect::RemoteCommand; + let legacy = serde_json::json!({ "cmd": "ping" }); + let old: RemoteCommand = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(old, RemoteCommand::Ping { client: None }); + assert_eq!(serde_json::to_value(old).unwrap(), legacy); + let current = + serde_json::json!({ "cmd": "ping", "client": { "id": "page-1", "name": "Safari · iOS" } }); + let decoded: RemoteCommand = serde_json::from_value(current.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), current); + // Previous hosts use an internally tagged unit variant and ignore additive fields. + #[derive(serde::Deserialize)] + #[serde(tag = "cmd", rename_all = "snake_case")] + enum LegacyCommand { + Ping, + } + assert!(matches!( + serde_json::from_value::(current).unwrap(), + LegacyCommand::Ping + )); +} diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index 2d45a7de2a..f931eea875 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -12,6 +12,7 @@ import { RelayHttpClient, type ControlTargetSnapshot, } from './RelayHttpClient'; +import { getControlClientIdentity } from './controlClientIdentity'; export class RemoteControlTargetChangedError extends Error { constructor() { @@ -571,7 +572,7 @@ export class RemoteSessionManager { } async ping(): Promise { - await this.request({ cmd: 'ping' }); + await this.request({ cmd: 'ping', client: getControlClientIdentity() }); } /** diff --git a/src/mobile-web/src/services/controlClientIdentity.ts b/src/mobile-web/src/services/controlClientIdentity.ts new file mode 100644 index 0000000000..78f8e847ce --- /dev/null +++ b/src/mobile-web/src/services/controlClientIdentity.ts @@ -0,0 +1,20 @@ +let identity: { id: string; name: string } | undefined; + +/** One identity per browser page, shared by managers across target/reconnect changes. */ +export function getControlClientIdentity(): { id: string; name: string } { + if (identity) return identity; + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const id = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\/|FxiOS\//.test(ua) ? 'Firefox' + : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' : 'Browser'; + const platform = /iPhone|iPad|iPod/.test(ua) ? 'iOS' + : /Android/.test(ua) ? 'Android' + : /Windows/.test(ua) ? 'Windows' + : /Macintosh/.test(ua) ? 'macOS' + : /Linux/.test(ua) ? 'Linux' : ''; + identity = { id, name: [browser, platform].filter(Boolean).join(' · ') }; + return identity; +} diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index a98a095ec3..a821a7c744 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1472,8 +1472,8 @@ $_section-header-height: 22px; line-height: var(--openbitfun-type-modifier-leading-dense-line-height); } -// Attached remote parties are shown once per device kind. Message-app marks -// stay unframed and inherit the theme foreground; connection state is already +// Attached remote parties are shown once per device kind with unframed icons. +// Message-app marks inherit the theme foreground; connection state is already // communicated by the device overview rather than by recoloring the brand. .openbitfun-nav-panel__footer-device-status-attached { display: flex; @@ -1492,14 +1492,9 @@ $_section-header-height: 22px; justify-content: center; gap: 1px; box-sizing: border-box; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-sm); - background: var(--openbitfun-color-surface-subtle); line-height: var(--openbitfun-type-modifier-leading-none-line-height); &[data-openbitfun-device-kind='message-app'] { - border: 0; - background: transparent; color: var(--openbitfun-color-content-primary); } } diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index c2794c2f4a..4438d8a8f9 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -1190,3 +1190,85 @@ transition: none; } } + +.openbitfun-remote-connect__relay-address { + padding: var(--openbitfun-space-4) var(--openbitfun-space-5); +} + +.openbitfun-remote-connect__connections-content { + padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); +} + +.openbitfun-remote-connect__connections-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--openbitfun-space-2); + + h4 { + margin: 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); + } +} + +.openbitfun-remote-connect__connections-list { + list-style: none; + padding: 0; + margin: var(--openbitfun-space-2) 0 0; + max-height: 132px; + overflow-y: auto; + + li { + display: flex; + align-items: center; + gap: var(--openbitfun-space-3); + padding-block: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + strong { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; + color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-selected-font-weight); + } +} + +.openbitfun-remote-connect__connections-note { + margin: var(--openbitfun-space-2) 0 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); +} + +.openbitfun-remote-connect__relay-actions { + padding: var(--openbitfun-space-3) var(--openbitfun-space-5); + border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); +} + +.openbitfun-remote-connect__relay-action-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: var(--openbitfun-space-3); +} + +.openbitfun-remote-connect__relay-invitation { + padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + + .openbitfun-remote-connect__pairing-card { + margin: 0; + padding: 0; + background: transparent; + } +} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx index 1cfff00c99..f5a8794f1e 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx @@ -12,6 +12,7 @@ import { setRemoteConnectDisclaimerAgreed } from './remoteConnectDisclaimerStora const boundary = vi.hoisted(() => ({ backend: null as RemoteConnectStatus | null, + hasWorkspace: true, getStatus: vi.fn(), startConnection: vi.fn(), stopConnection: vi.fn(), @@ -19,7 +20,9 @@ const boundary = vi.hoisted(() => ({ getFormState: vi.fn(), listeners: new Map void>>(), jobs: {}, - t: (key: string) => key, + copyText: vi.fn(), + t: (key: string, values?: { count?: number; number?: number }) => + values?.count !== undefined ? `${key}:${values.count}` : values?.number !== undefined ? `${key}:${values.number}` : key, })); vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOriginal => ({ @@ -36,6 +39,7 @@ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOrigina accountGetCredentialHint: vi.fn().mockResolvedValue({ username: 'sora', relay_url: 'https://relay.example.test/remote/a' }), }, })); +vi.mock('@/shared/utils/textSelection', () => ({ copyTextToClipboard: boundary.copyText })); vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: (name: string, listener: (payload: unknown) => void) => { @@ -46,12 +50,12 @@ vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ }, })); vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ t: boundary.t, currentLanguage: 'en-US' }), + useI18n: () => ({ t: boundary.t, currentLanguage: 'en-US', formatNumber: String }), })); vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ - useI18n: () => ({ t: boundary.t, currentLanguage: 'en-US' }), + useI18n: () => ({ t: boundary.t, currentLanguage: 'en-US', formatNumber: String }), })); -vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ hasWorkspace: true }) })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ hasWorkspace: boundary.hasWorkspace }) })); vi.mock('@/infrastructure/account/useAccountLoginState', () => ({ useAccountLoginState: () => ({ loggedIn: true, deviceName: 'Workstation' }), })); @@ -91,13 +95,13 @@ function deferred() { return { promise, resolve }; } -function Harness() { +function Harness({ initialGroup }: { initialGroup?: 'network' | 'bot' }) { const [open, setOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(false); return setOpen(true)} /> - setOpen(false)} /> + setOpen(false)} initialGroup={initialGroup} /> ; } @@ -112,7 +116,7 @@ function element(selector: string): HTMLElement { } const dialog = () => element('[data-openbitfun-component="remote-connect-dialog"][data-openbitfun-part="root"]'); const overviewNetwork = () => element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="network"]'); -const cardStatus = () => element('[data-openbitfun-part="pairingCard"] [role="status"]').textContent; +const cardStatus = () => (document.querySelector('[data-openbitfun-part="pairingCard"] [role="status"]') ?? element('[data-openbitfun-part="connections"] [role="status"]')).textContent; const attachedMobile = () => document.querySelector('[data-testid="nav-footer-device-status"] [data-openbitfun-device-kind="mobile"]'); const attachedBot = () => document.querySelector('[data-testid="nav-footer-device-status"] [data-openbitfun-device-kind="message-app"]'); @@ -123,7 +127,7 @@ async function clickText(key: string) { await click(button!); } async function tick(ms = 2000) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); } -async function render() { await act(async () => { root.render(); }); } +async function render(initialGroup?: 'network' | 'bot') { await act(async () => { root.render(); }); } async function openNetwork() { await click(overviewNetwork()); } async function generateInvitation(relay = relayA) { await openNetwork(); @@ -157,6 +161,8 @@ beforeEach(() => { removeItem: (key: string) => { storage.delete(key); }, }); setRemoteConnectDisclaimerAgreed(); + boundary.hasWorkspace = true; + boundary.copyText.mockResolvedValue(true); boundary.backend = status(); boundary.getStatus.mockImplementation(async () => ({ ...boundary.backend! })); boundary.getFormState.mockResolvedValue({ custom_server_url: relayA }); @@ -184,6 +190,88 @@ afterEach(async () => { }); describe('Remote Connect shared status through the real dialog and sidebar', () => { + it.each([ + ['openbitfun_server', 'https://remote.openbitfun.com/relay'], + ['custom_server', relayA], + ] as const)('shows the same live clients and relay URL for %s', async (method, relay) => { + boundary.backend = status({ + account_control_connected: true, + account_control_relay_url: relay, + account_control_clients: [ + { id: 'phone', name: 'Safari · iOS' }, + { id: 'browser', name: 'Chrome · Windows' }, + ], + account_control_has_unidentified_clients: false, + }); + await render('network'); + const connections = element('[data-openbitfun-part="connections"]'); + expect(connections.textContent).toContain('remoteConnect.clientCount:2'); + expect(connections.querySelectorAll('li')).toHaveLength(2); + expect(connections.textContent).toContain('Safari · iOS'); + expect(connections.textContent).toContain('Chrome · Windows'); + expect(dialog().querySelectorAll('.openbitfun-remote-connect__network-card')).toHaveLength(1); + expect(connections.querySelectorAll('input[type="url"]')).toHaveLength(1); + const input = element('input[type="url"]') as HTMLInputElement; + expect(input.value).toBe(relay); + expect(input.readOnly).toBe(method === 'openbitfun_server'); + await click(element('button[aria-label="remoteConnect.copyServerUrl"]')); + expect(boundary.copyText).toHaveBeenCalledWith(relay); + boundary.backend = { ...boundary.backend!, account_control_clients: [{ id: 'browser', name: 'Chrome · Windows' }] }; + await tick(); + expect(connections.textContent).toContain('remoteConnect.clientCount:1'); + expect(connections.textContent).not.toContain('Safari · iOS'); + boundary.backend = { ...boundary.backend!, account_control_connected: false, account_control_clients: [] }; + await tick(); + expect(element('[data-openbitfun-part="connections"]').textContent).toContain('remoteConnect.clientCount:0'); + expect(element('[data-openbitfun-part="connections"]').querySelectorAll('li')).toHaveLength(0); + }); + + it('does not invent a total for old clients or mix account clients into another relay tab', async () => { + boundary.backend = status({ account_control_connected: true, account_control_relay_url: relayA }); + await render('network'); + expect(element('[data-openbitfun-part="connections"]').textContent).toContain('remoteConnect.clientDetailsUnavailable'); + expect(dialog().textContent).not.toContain('remoteConnect.clientCount:'); + await click(element('#remote-connect-network-tab-openbitfun_server')); + expect(element('[data-openbitfun-part="connections"]').textContent).toContain('remoteConnect.clientCount:0'); + expect(element('[data-openbitfun-part="connections"]').querySelectorAll('li')).toHaveLength(0); + }); + + it('allows connection setup and shows live status without a selected workspace', async () => { + boundary.hasWorkspace = false; + await render(); + expect((overviewNetwork() as HTMLButtonElement).disabled).toBe(false); + expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); + const bot = element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="bot"]') as HTMLButtonElement; + expect(bot.disabled).toBe(false); + expect(bot.textContent).toContain('remoteConnect.stateConnected'); + await click(bot); + expect(element('#remote-connect-bot-tabpanel')).toBeDefined(); + await clickText('remoteConnect.backToOverview'); + await generateInvitation(); + expect(boundary.startConnection).toHaveBeenCalledOnce(); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + await tick(); + await clickText('remoteConnect.backToOverview'); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + }); + + it.each(['network', 'bot'] as const)('keeps the contextual %s destination open without a selected workspace', async group => { + boundary.hasWorkspace = false; + await render(group); + expect(element(`#remote-connect-${group}-tabpanel`)).toBeDefined(); + expect(document.querySelector('[data-openbitfun-part="overviewAction"]')).toBeNull(); + }); + + it('keeps an active invitation when the selected workspace is cleared', async () => { + await render(); + await generateInvitation(); + boundary.hasWorkspace = false; + await render(); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + expect(boundary.stopConnection).not.toHaveBeenCalled(); + }); + it('keeps QR, overview, close/reopen and sidebar connected, then permits another invitation', async () => { await render(); expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 9f22c1944c..219951fc95 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -7,8 +7,8 @@ * - My devices (account, sync, and peer-device control) * - Phone or browser (LAN / ngrok / OpenBitFun Relay / self-hosted) * - Chat apps (Telegram / Feishu / WeChat) - * Network and Chat Apps require an open workspace and can be active - * simultaneously; My Devices works without a workspace. + * Connections are host-level services and do not require a selected project; + * remote clients can use the primary assistant workspace. */ import { @@ -38,10 +38,9 @@ import { getLocaleFallbackChain, type LocaleId } from '@/infrastructure/i18n/pre import { confirmWarning } from '@/infrastructure/confirm-dialog'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; -import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState'; import { remoteConnectStatusSource, useRemoteConnectStatus } from '@/infrastructure/remote-connect/remoteConnectStatus'; -import { remoteNetworkMethod, selectRemoteNetworkConnection, type RemoteNetworkMethod } from '@/infrastructure/remote-connect/remoteConnectionState'; +import { OFFICIAL_RELAY_URL, relayUrlFromMethod, remoteNetworkMethod, selectRemoteNetworkConnection, type RemoteNetworkMethod } from '@/infrastructure/remote-connect/remoteConnectionState'; import { useNotification } from '@/shared/notification-system'; import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { AccountPanel } from './AccountPanel'; @@ -67,6 +66,7 @@ import { } from './remoteConnectOperationCleanup'; import { ChatAppBrandIcon } from './ChatAppBrandIcon'; import { RemotePairingCard } from './RemotePairingCard'; +import { RemoteNetworkConnections } from './RemoteNetworkConnections'; import { WeixinLoginProgress } from './WeixinLoginProgress'; import './RemoteConnectDialog.scss'; @@ -169,7 +169,6 @@ export const RemoteConnectDialog: React.FC = ({ }) => { const { t, currentLanguage } = useI18n('common'); const { error: notifyError } = useNotification(); - const { hasWorkspace } = useCurrentWorkspace(); const { loggedIn: accountLoggedIn, deviceName: accountDeviceName, @@ -1074,6 +1073,30 @@ export const RemoteConnectDialog: React.FC = ({ if (statusState !== 'ready' && !connectionResult) { return {}} />; } + if (networkTab === 'openbitfun_server' || networkTab === 'custom_server') { + const invitation = connectionOwner === 'network' ? connectionResult : null; + const relayUrl = networkTab === 'openbitfun_server' ? OFFICIAL_RELAY_URL + : invitation ? relayUrlFromMethod(invitation.method) ?? customUrl + : networkConnection.roomConnected ? relayUrlFromMethod(status?.active_method) ?? customUrl + : customUrl; + return ; + } if (networkConnection.roomConnected && networkConnection.roomMethod === networkTab) { return ( <> @@ -1106,12 +1129,6 @@ export const RemoteConnectDialog: React.FC = ({ data-openbitfun-part="body" className="openbitfun-remote-connect__body openbitfun-remote-connect__body--network" > - {networkConnection.accountConnected && networkConnection.accountMethod === networkTab && ( -
- {}} /> -

{t('remoteConnect.accountConnectedHint')}

-
- )}

- {networkTab === 'custom_server' ? ( - <> - {t('remoteConnect.desc_custom_server_prefix')} - { if (e.key === 'Enter') handleOpenRelayDeploy(); }} - > - {t('remoteConnect.desc_custom_server_link')} - - {t('remoteConnect.desc_custom_server_suffix')} - - ) : networkTab === 'ngrok' ? ( + {networkTab === 'ngrok' ? ( <> {t('remoteConnect.desc_ngrok_prefix')} = ({ })()}

)} - {networkTab === 'custom_server' && ( - - - - )}
{renderErrorBlock()} @@ -1527,17 +1514,6 @@ export const RemoteConnectDialog: React.FC = ({ setShowDisclaimer(false); }, []); - useEffect(() => { - if ( - isOpen - && hasAgreedDisclaimer - && !hasWorkspace - && (activeView === 'network' || activeView === 'bot') - ) { - handleViewChange('overview'); - } - }, [activeView, handleViewChange, hasAgreedDisclaimer, hasWorkspace, isOpen]); - const renderOverviewAction = ({ view, icon, @@ -1654,21 +1630,18 @@ export const RemoteConnectDialog: React.FC = ({ icon: , title: t('remoteConnect.mobileBrowserTitle'), description: t('remoteConnect.mobileBrowserDescription'), - statusLabel: !hasWorkspace - ? t('remoteConnect.requiresWorkspace') - : statusState === 'unavailable' - ? t('remoteConnect.statusUnavailable') - : statusState === 'loading' - ? t('remoteConnect.statusChecking') - : isRelayConnected - ? t('remoteConnect.stateConnected') - : t('remoteConnect.notConnected'), - statusDetail: hasWorkspace && isRelayConnected + statusLabel: statusState === 'unavailable' + ? t('remoteConnect.statusUnavailable') + : statusState === 'loading' + ? t('remoteConnect.statusChecking') + : isRelayConnected + ? t('remoteConnect.stateConnected') + : t('remoteConnect.notConnected'), + statusDetail: isRelayConnected ? networkLabel(connectedNetworkTab) : null, - statusPositive: hasWorkspace && isRelayConnected, - state: hasWorkspace && isRelayConnected ? 'connected' : undefined, - disabled: !hasWorkspace, + statusPositive: isRelayConnected, + state: isRelayConnected ? 'connected' : undefined, })} {renderOverviewAction({ view: 'bot', @@ -1687,21 +1660,18 @@ export const RemoteConnectDialog: React.FC = ({ ), title: t('remoteConnect.chatAppsTitle'), description: t('remoteConnect.chatAppsDescription'), - statusLabel: !hasWorkspace - ? t('remoteConnect.requiresWorkspace') - : statusState === 'unavailable' - ? t('remoteConnect.statusUnavailable') - : statusState === 'loading' - ? t('remoteConnect.statusChecking') - : isBotConnected - ? t('remoteConnect.stateConnected') - : t('remoteConnect.notConnected'), - statusDetail: hasWorkspace && isBotConnected + statusLabel: statusState === 'unavailable' + ? t('remoteConnect.statusUnavailable') + : statusState === 'loading' + ? t('remoteConnect.statusChecking') + : isBotConnected + ? t('remoteConnect.stateConnected') + : t('remoteConnect.notConnected'), + statusDetail: isBotConnected ? botLabel(connectedBotTab) : null, - statusPositive: hasWorkspace && isBotConnected, - state: hasWorkspace && isBotConnected ? 'connected' : undefined, - disabled: !hasWorkspace, + statusPositive: isBotConnected, + state: isBotConnected ? 'connected' : undefined, })}
diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx new file mode 100644 index 0000000000..dd9077e524 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx @@ -0,0 +1,138 @@ +import { Button, Field, Icon, IconButton, Input, StatusPill } from '@openbitfun/ui'; +import { useEffect, useState, type ReactNode } from 'react'; +import type { ConnectionResult, RemoteConnectStatus } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { normalizeRelayUrl, selectRemoteNetworkConnection } from '@/infrastructure/remote-connect/remoteConnectionState'; +import { copyTextToClipboard } from '@/shared/utils/textSelection'; +import { useNotification } from '@/shared/notification-system'; +import { RemotePairingCard } from './RemotePairingCard'; + +interface RemoteNetworkConnectionsProps { + status: RemoteConnectStatus | null; + method: 'openbitfun_server' | 'custom_server'; + title: string; + relayUrl: string; + onRelayUrlChange: (url: string) => void; + invitation: ConnectionResult | null; + statusState: 'loading' | 'ready' | 'unavailable'; + loading: boolean; + pairingUrlCopied: boolean; + error: ReactNode; + onCopyPairingUrl: () => Promise; + onConnect: () => void; + onCancel: () => void; + onDisconnect: () => void; + onDeploy: () => void; +} + +/** Preset and custom relays share one card; only the address source differs. */ +export function RemoteNetworkConnections({ + status, method, title, relayUrl, onRelayUrlChange, invitation, statusState, + loading, pairingUrlCopied, error, onCopyPairingUrl, onConnect, onCancel, + onDisconnect, onDeploy, +}: RemoteNetworkConnectionsProps) { + const { t, formatNumber } = useI18n('common'); + const { error: notifyError } = useNotification(); + const [copiedUrl, setCopiedUrl] = useState(null); + useEffect(() => { + if (!copiedUrl) return; + const timeout = window.setTimeout(() => setCopiedUrl(null), 2000); + return () => window.clearTimeout(timeout); + }, [copiedUrl]); + const connection = selectRemoteNetworkConnection(status, invitation); + const account = connection.accountConnected && connection.accountMethod === method + && connection.accountRelayUrl === normalizeRelayUrl(relayUrl); + const room = connection.roomConnected && connection.roomMethod === method; + const clients = account ? status?.account_control_clients ?? [] : []; + const unknown = account && (status?.account_control_clients === undefined || status.account_control_has_unidentified_clients !== false); + const count = clients.length + (room ? 1 : 0); + const connected = account || room; + const copied = copiedUrl !== null && copiedUrl === normalizeRelayUrl(relayUrl); + const copyUrl = async () => { + const url = normalizeRelayUrl(relayUrl); + if (!url) return; + if (await copyTextToClipboard(url)) setCopiedUrl(url); + else notifyError(t('remoteConnect.copyServerUrlFailed')); + }; + + return
+
+
+
+
+ + } + onClick={() => void copyUrl()} + />} + /> + +
+
+
+

{t('remoteConnect.connectedClients')}

+ + {unknown + ? count ? t('remoteConnect.clientCountAtLeast', { count, formattedCount: formatNumber(count) }) : t('remoteConnect.stateConnected') + : t('remoteConnect.clientCount', { count, formattedCount: formatNumber(count) })} + +
+ {count > 0 &&
    3 ? 0 : undefined}> + {clients.map((client, index) =>
  • +
  • )} + {room &&
  • +
  • } +
} + {unknown &&

{t('remoteConnect.clientDetailsUnavailable')}

} + {!connected &&

{t('remoteConnect.noConnectedClients')}

} +
+ {invitation &&
+ +
} +
+ {error} +
+ + {room ? + : invitation ? + : } +
+ {account && invitation &&

{t('remoteConnect.accountConnectedHint')}

} +
+
+
; +} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts index 7bc2a70716..3c0af987d8 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts @@ -16,6 +16,17 @@ function deferred() { } describe('mobile RemoteSessionManager target routing', () => { + it('keeps one browser-page identity across heartbeat requests and manager recreation', async () => { + const client = new RelayHttpClient('https://relay.example.com', 'room'); + const send = vi.spyOn(client, 'sendCommand').mockResolvedValue({ resp: 'pong' }); + await new RemoteSessionManager(client).ping(); + await new RemoteSessionManager(client).ping(); + const first = send.mock.calls[0][0] as { client: { id: string; name: string } }; + expect(first.client.id).toMatch(/^[a-f0-9]{32}$/); + expect(first.client.name).toBeTruthy(); + expect(send.mock.calls[1][0]).toEqual(expect.objectContaining({ cmd: 'ping', client: first.client })); + }); + it('attaches request-proven SSH identity to legacy session rows sharing one path', async () => { const client = new RelayHttpClient('https://relay.example.com', 'room'); const send = vi.spyOn(client, 'sendCommand').mockResolvedValue({ diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts b/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts index 9ac2ad553d..7fd1ae2db6 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts @@ -16,6 +16,7 @@ export const remoteConnectDialogAppearanceDescriptor: AppearanceSurfaceDescripto { id: 'panel' }, { id: 'body' }, { id: 'pairingCard' }, + { id: 'connections' }, { id: 'botCard' }, { id: 'status' }, { id: 'error' }, 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 d37f7a846d..9dd8a6cf4c 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -70,6 +70,9 @@ export interface RemoteConnectStatus { account_control_connected?: boolean; /** Relay of the live account route; independent of the temporary QR invitation. */ account_control_relay_url?: string | null; + /** Heartbeat leases for browser pages, not a count of physical devices. */ + account_control_clients?: Array<{ id: string; name: string }>; + account_control_has_unidentified_clients?: boolean; bot_connected: string | null; bot_verbose_mode: boolean; } diff --git a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts index eab56d58e0..ef05363a51 100644 --- a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts +++ b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts @@ -2,7 +2,7 @@ import type { ConnectionResult, RemoteConnectionMethod, RemoteConnectStatus } fr export type RemoteNetworkMethod = 'lan' | 'ngrok' | 'openbitfun_server' | 'custom_server'; -const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/relay'; +export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/relay'; export function remoteNetworkMethod(method: RemoteConnectionMethod | null | undefined): RemoteNetworkMethod | null { if (typeof method === 'object' && method !== null) { @@ -18,7 +18,7 @@ export function remoteNetworkMethod(method: RemoteConnectionMethod | null | unde return null; } -function normalizeRelayUrl(value: string | null | undefined): string | null { +export function normalizeRelayUrl(value: string | null | undefined): string | null { if (!value) return null; try { const url = new URL(value); @@ -31,7 +31,7 @@ function normalizeRelayUrl(value: string | null | undefined): string | null { } } -function relayUrlFromMethod(method: RemoteConnectionMethod | null | undefined): string | null { +export function relayUrlFromMethod(method: RemoteConnectionMethod | null | undefined): string | null { if (remoteNetworkMethod(method) === 'openbitfun_server') return OFFICIAL_RELAY_URL; if (typeof method === 'object' && method !== null && 'custom_server' in method) { return normalizeRelayUrl(method.custom_server.url); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 2949ccd73d..e5f8bc0548 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -927,6 +927,17 @@ "finishAndLogin": "Finish & Log In" }, "remoteConnect": { + "copyServerUrl": "Copy server address", + "noConnectedClients": "No devices connected.", + "serverUrlCopied": "Server address copied", + "copyServerUrlFailed": "Could not copy the server address. Please copy it manually.", + "connectedClients": "Connected browsers and devices", + "clientCountHint": "Each browser page counts as one connection.", + "clientCount": "{{formattedCount}} connected", + "clientCountAtLeast": "At least {{formattedCount}} connected", + "clientNumber": "Connection {{number}}", + "pairedClient": "Paired connection", + "clientDetailsUnavailable": "Connection details are currently unavailable.", "centerTitle": "Devices & Connections", "overviewIntro": "Manage your OpenBitFun devices, or let another device connect to the current workspace.", "myDevicesTitle": "My devices", @@ -940,7 +951,6 @@ "mobileBrowserDescription": "Open this workspace from a phone or another browser over your network or a relay.", "chatAppsTitle": "Chat apps", "chatAppsDescription": "Send messages to this workspace through Telegram, Feishu, or WeChat.", - "requiresWorkspace": "Open a workspace first", "notConnected": "Not connected", "backToOverview": "Back to Devices & Connections", "cancelAndBack": "Cancel connection and go back", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 9a66e64375..e4a58ef1ae 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -927,6 +927,17 @@ "finishAndLogin": "完成并登录" }, "remoteConnect": { + "copyServerUrl": "复制服务器地址", + "noConnectedClients": "暂无设备连接。", + "serverUrlCopied": "服务器地址已复制", + "copyServerUrlFailed": "无法复制服务器地址,请手动复制。", + "connectedClients": "已连接的浏览器与设备", + "clientCountHint": "每个浏览器页面计为一个连接。", + "clientCount": "{{formattedCount}} 个连接", + "clientCountAtLeast": "至少 {{formattedCount}} 个连接", + "clientNumber": "连接 {{number}}", + "pairedClient": "配对连接", + "clientDetailsUnavailable": "暂时无法获取连接明细。", "centerTitle": "设备与连接", "overviewIntro": "管理登录 OpenBitFun 的设备,或让其他设备接入当前工作区", "myDevicesTitle": "我的设备", @@ -940,7 +951,6 @@ "mobileBrowserDescription": "通过同一网络、中继或 ngrok,在手机或其他浏览器中打开当前工作区", "chatAppsTitle": "聊天应用", "chatAppsDescription": "通过 Telegram、飞书或微信向当前工作区发送消息", - "requiresWorkspace": "需要先打开工作区", "notConnected": "未连接", "backToOverview": "返回设备与连接", "cancelAndBack": "取消连接并返回", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index c48d60ef3a..17941c27da 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -927,6 +927,17 @@ "finishAndLogin": "完成並登入" }, "remoteConnect": { + "copyServerUrl": "複製伺服器位址", + "noConnectedClients": "尚無裝置連線。", + "serverUrlCopied": "已複製伺服器位址", + "copyServerUrlFailed": "無法複製伺服器位址,請手動複製。", + "connectedClients": "已連線的瀏覽器與裝置", + "clientCountHint": "每個瀏覽器頁面計為一個連線。", + "clientCount": "{{formattedCount}} 個連線", + "clientCountAtLeast": "至少 {{formattedCount}} 個連線", + "clientNumber": "連線 {{number}}", + "pairedClient": "配對連線", + "clientDetailsUnavailable": "暫時無法取得連線明細。", "centerTitle": "裝置與連線", "overviewIntro": "管理登入 OpenBitFun 的裝置,或讓其他裝置連線至目前工作區", "myDevicesTitle": "我的裝置", @@ -940,7 +951,6 @@ "mobileBrowserDescription": "透過相同網路、中繼或 ngrok,在手機或其他瀏覽器中開啟目前工作區", "chatAppsTitle": "聊天應用程式", "chatAppsDescription": "透過 Telegram、飛書或微信向目前工作區傳送訊息", - "requiresWorkspace": "需要先開啟工作區", "notConnected": "尚未連線", "backToOverview": "返回裝置與連線", "cancelAndBack": "取消連線並返回", From 2e6da4048ebfd1dfb5d22df1af14d79c584d9595 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 7 Sep 2026 10:47:41 +0800 Subject: [PATCH 2/2] fix(ui): retain explicit unframed chat icon styles --- src/web-ui/src/app/components/NavPanel/NavPanel.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index a821a7c744..a62898a710 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1495,6 +1495,8 @@ $_section-header-height: 22px; line-height: var(--openbitfun-type-modifier-leading-none-line-height); &[data-openbitfun-device-kind='message-app'] { + border: 0; + background: transparent; color: var(--openbitfun-color-content-primary); } }