From 6cd50239782ca6e767dc557080cc6132d0cece1b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 6 Sep 2026 17:58:25 +0800 Subject: [PATCH 1/4] fix: synchronize relay invitation status with account control --- .../desktop/src/api/remote_connect_api.rs | 248 +++++++++++++++++- .../src/remote_connect/relay_client.rs | 2 +- .../RemoteConnectDialog.tsx | 1 + .../RemotePairingCard.test.tsx | 25 ++ .../RemoteConnectDialog/RemotePairingCard.tsx | 9 +- .../api/service-api/RemoteConnectAPI.ts | 2 + 6 files changed, 277 insertions(+), 10 deletions(-) diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index a9dc105bd6..5366102acd 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -112,6 +112,10 @@ struct DeviceRoutingOwner { struct DeviceRoutingState { owner: Option, 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, } static DEVICE_ROUTING_STATE: OnceLock> = OnceLock::new(); @@ -684,9 +688,9 @@ async fn send_rpc_envelope( session: &AccountSession, correlation_id: &str, resp_value: serde_json::Value, -) { +) -> bool { if !device_routing_owner_is_current(owner).await { - return; + return false; } let resp_json = match serde_json::to_string(&resp_value) { Ok(s) => s, @@ -702,7 +706,7 @@ async fn send_rpc_envelope( use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; match encrypt_to_base64(&session.master_key, &resp_json) { Ok((enc_resp, resp_nonce)) => { - if let Err(e) = send_device_message_with_routing_lease( + match send_device_message_with_routing_lease( owner, "rpc", correlation_id, @@ -711,11 +715,16 @@ async fn send_rpc_envelope( ) .await { - log::warn!("RPC: send response failed: {e}"); + Ok(()) => true, + Err(e) => { + log::warn!("RPC: send response failed: {e}"); + false + } } } Err(e) => { log::warn!("RPC: encrypt response failed: {e}"); + false } } } @@ -1069,9 +1078,83 @@ 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_ping_generation = 0; + }); +} + +fn control_ping_generation(owner: &DeviceRoutingOwner) -> Option { + with_device_routing_state(|state| { + (state.owner.as_ref() == Some(owner)).then_some(state.control_ping_generation) + }) +} + +fn record_control_ping_if_owner( + owner: &DeviceRoutingOwner, + generation: u64, + now: std::time::Instant, +) { + with_device_routing_state(|state| { + if state.owner.as_ref() == Some(owner) && state.control_ping_generation == generation { + state.last_control_ping = Some(now); + } }); } +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_ping_generation = state.control_ping_generation.wrapping_add(1); + } + }); +} + +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 + }) + }) +} + +fn is_successful_control_ping( + command: &openbitfun_core::service::remote_connect::remote_server::RemoteCommand, + response: &serde_json::Value, +) -> bool { + use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; + match command { + RemoteCommand::Ping => response.get("resp").and_then(|v| v.as_str()) == Some("pong"), + RemoteCommand::HostInvoke { command, .. } if command == "peer_mode_ping" => { + response.get("resp").and_then(|v| v.as_str()) == Some("host_invoke_result") + && response.get("ok").and_then(|v| v.as_bool()) == Some(true) + } + _ => false, + } +} + +fn connection_method_matches_account_relay( + method: Option<&ConnectionMethod>, + relay_url: &str, +) -> bool { + let method_url = match method { + Some(ConnectionMethod::OpenBitFunServer) => { + RemoteConnectConfig::default().openbitfun_server_url + } + Some(ConnectionMethod::CustomServer { url }) => url.clone(), + _ => return false, + }; + match ( + normalize_relay_url(&method_url), + normalize_relay_url(relay_url), + ) { + (Ok(method_url), Ok(account_url)) => method_url == account_url, + _ => false, + } +} + fn device_routing_owner_is_registered(owner: &DeviceRoutingOwner) -> bool { with_device_routing_state(|state| state.owner.as_ref() == Some(owner)) } @@ -1146,6 +1229,7 @@ fn clear_device_routing_if_owner(owner: &DeviceRoutingOwner) -> bool { } state.owner = None; state.online_devices.clear(); + state.last_control_ping = None; true }) } @@ -1154,6 +1238,7 @@ 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; had_owner }) } @@ -1716,13 +1801,17 @@ pub struct StartRemoteConnectRequest { pub lan_ip: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct RemoteConnectStatusResponse { pub is_connected: bool, pub pairing_state: PairingState, pub active_method: Option, pub peer_device_name: Option, pub peer_user_id: Option, + /// A browser/phone has reached this host through the active account relay. + /// This does not alter the independent QR-room pairing state. + #[serde(default)] + pub account_control_connected: bool, /// Independent bot connection info — e.g. "Telegram(7096812005)". /// Present when a bot is active, regardless of relay pairing state. pub bot_connected: Option, @@ -2102,6 +2191,13 @@ pub async fn remote_connect_status() -> Result Result Result, String> // path depends on them finishing in order. let rpc_owner = event_owner.clone(); let rpc_session = event_session.clone(); + let ping_generation = control_ping_generation(&rpc_owner); tokio::spawn(async move { // Held for the whole call: teardown takes // the write lease, so an in-flight RPC now @@ -3054,13 +3152,24 @@ pub async fn account_connect_devices() -> Result, String> } match execution { Ok(resp_value) => { - send_rpc_envelope( + let control_ping = + is_successful_control_ping(&cmd, &resp_value); + let sent = send_rpc_envelope( &rpc_owner, &rpc_session, &correlation_id, resp_value, ) .await; + if let Some(generation) = + ping_generation.filter(|_| sent && control_ping) + { + record_control_ping_if_owner( + &rpc_owner, + generation, + std::time::Instant::now(), + ); + } } Err(e) => { log::warn!("RPC: execute command failed: {e}"); @@ -3131,6 +3240,7 @@ pub async fn account_connect_devices() -> Result, String> if !replace_device_presence_if_owner(&event_owner, Vec::new()) { break 'routing_events; } + clear_control_ping_if_owner(&event_owner); log::info!("Device routing disconnected"); let request_ids = crate::api::peer_host_invoke::take_tracked_permission_requests(); @@ -4753,6 +4863,132 @@ 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_ping_requires_a_successful_control_response() { + use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; + assert!(is_successful_control_ping( + &RemoteCommand::Ping, + &serde_json::json!({"resp": "pong"}) + )); + assert!(!is_successful_control_ping( + &RemoteCommand::Ping, + &serde_json::json!({"resp": "error"}) + )); + let peer_ping = RemoteCommand::HostInvoke { + command: "peer_mode_ping".into(), + args: serde_json::json!({}), + }; + assert!(is_successful_control_ping( + &peer_ping, + &serde_json::json!({"resp": "host_invoke_result", "ok": true}) + )); + assert!(!is_successful_control_ping( + &peer_ping, + &serde_json::json!({"resp": "host_invoke_result", "ok": false}) + )); + let directory = RemoteCommand::HostInvoke { + command: "account_list_devices".into(), + args: serde_json::json!({}), + }; + assert!(!is_successful_control_ping( + &directory, + &serde_json::json!({"resp": "host_invoke_result", "ok": true}) + )); + } + + #[test] + fn account_control_ping_expires_disconnects_and_fences_replaced_routes() { + use openbitfun_services_integrations::remote_connect::relay_client::RELAY_INBOUND_IDLE_TIMEOUT; + let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.blocking_lock(); + let first = new_device_routing_owner(1, "first", 1); + let second = new_device_routing_owner(2, "second", 2); + 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); + assert!(has_recent_control_ping(&first, now)); + assert!(!has_recent_control_ping( + &first, + now + RELAY_INBOUND_IDLE_TIMEOUT + )); + clear_control_ping_if_owner(&first); + assert!(!has_recent_control_ping(&first, now)); + record_control_ping_if_owner(&first, 0, now); + 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); + assert!(has_recent_control_ping(&first, now)); + install_device_routing_owner(second.clone()); + record_control_ping_if_owner(&first, 0, now); + assert!(!has_recent_control_ping(&first, now)); + assert!(!has_recent_control_ping(&second, now)); + record_control_ping_if_owner(&second, 0, now); + clear_control_ping_if_owner(&first); + assert!(has_recent_control_ping(&second, now)); + clear_device_routing_state(); + assert!(!has_recent_control_ping(&second, now)); + } + + #[test] + fn account_control_status_is_scoped_to_the_invitation_relay() { + let official = RemoteConnectConfig::default().openbitfun_server_url; + assert!(connection_method_matches_account_relay( + Some(&ConnectionMethod::OpenBitFunServer), + &official + )); + let custom = ConnectionMethod::CustomServer { + url: "https://relay.example/base/".into(), + }; + assert!(connection_method_matches_account_relay( + Some(&custom), + "https://relay.example/base" + )); + assert!(!connection_method_matches_account_relay( + Some(&custom), + "https://other.example/base" + )); + assert!(!connection_method_matches_account_relay( + Some(&custom), + "https://relay.example/other" + )); + assert!(!connection_method_matches_account_relay( + Some(&ConnectionMethod::Lan { ip: None }), + &official + )); + assert!(!connection_method_matches_account_relay(None, &official)); + } + + #[test] + fn account_control_status_preserves_legacy_room_payloads() { + let legacy = serde_json::json!({ + "is_connected": false, + "pairing_state": "waiting_for_scan", + "active_method": "OpenBitFunServer", + "peer_device_name": null, + "peer_user_id": null, + "bot_connected": null, + "bot_verbose_mode": false, + }); + let mut status: RemoteConnectStatusResponse = + serde_json::from_value(legacy.clone()).unwrap(); + assert!(!status.account_control_connected); + status.account_control_connected = true; + let mut serialized = serde_json::to_value(&status).unwrap(); + assert_eq!(serialized["pairing_state"], "waiting_for_scan"); + assert_eq!(serialized["is_connected"], false); + assert_eq!( + serialized + .as_object_mut() + .unwrap() + .remove("account_control_connected"), + Some(serde_json::json!(true)) + ); + assert_eq!(serialized, legacy); + } + #[test] fn relay_url_normalization_removes_all_trailing_slashes() { assert_eq!( 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 e77ea0fbfd..7f14ad0d2b 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 @@ -38,7 +38,7 @@ const RELAY_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1 /// Heartbeats are sent every 30 seconds. Two missed acknowledgements plus /// scheduling/network slack indicates a half-open socket that should be /// replaced even when the OS has not surfaced a read error yet. -const RELAY_INBOUND_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); +pub const RELAY_INBOUND_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); /// Messages in the relay protocol (both directions). #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index ce877ba1da..a9ce25642f 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -1003,6 +1003,7 @@ export const RemoteConnectDialog: React.FC = ({ qrUrl={connectionResult.qr_url} pairingCode={connectionResult.bot_pairing_code} owner={connectionOwner === 'bot' ? 'bot' : 'network'} + connected={connectionOwner === 'network' && status?.account_control_connected === true} copied={qrCopied} onCopyUrl={handleCopyPairingUrl} /> diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.test.tsx index e634194a8e..8cab09f5f6 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.test.tsx @@ -18,6 +18,31 @@ function renderCard(props: Partial { + it('shows a real account control connection while retaining the independent invitation', () => { + const view = renderCard({ owner: 'network', qrUrl: 'https://example.test/pair', connected: true }); + expect(view.querySelector('[role="status"]')?.textContent).toBe('remoteConnect.stateConnected'); + expect(view.textContent).not.toContain('remoteConnect.stateWaiting'); + expect(view.querySelector('svg')).not.toBeNull(); + expect(view.querySelectorAll('button[aria-label="remoteConnect.copyUrl"]')).toHaveLength(2); + }); + + it('returns to waiting after account control expires and recovers on a later poll', async () => { + const container = document.createElement('div'); + const root = createRoot(container); + try { + for (const connected of [true, false, true]) { + await act(async () => { + root.render( {}} />); + }); + expect(container.querySelector('[role="status"]')?.textContent).toBe( + connected ? 'remoteConnect.stateConnected' : 'remoteConnect.stateWaiting', + ); + } + } finally { + await act(async () => root.unmount()); + } + }); + it.each(['bot', 'network'] as const)('renders one waiting status for a %s pairing code', owner => { const view = renderCard({ owner, pairingCode: '123456' }); expect(view.querySelectorAll('[role="status"]')).toHaveLength(1); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx index c8b96b04be..c893ca232a 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx @@ -6,12 +6,13 @@ interface RemotePairingCardProps { qrUrl?: string | null; pairingCode?: string | null; owner: 'bot' | 'network'; + connected?: boolean; copied: boolean; onCopyUrl: () => void | Promise; } /** One shared pending state for chat-app codes, network QR links, and restored connections. */ -export function RemotePairingCard({ qrUrl, pairingCode, owner, copied, onCopyUrl }: RemotePairingCardProps) { +export function RemotePairingCard({ qrUrl, pairingCode, owner, connected = false, copied, onCopyUrl }: RemotePairingCardProps) { const { t } = useI18n('common'); const hasCopiedUrl = Boolean(qrUrl && copied); @@ -43,10 +44,12 @@ export function RemotePairingCard({ qrUrl, pairingCode, owner, copied, onCopyUrl )}
- + {hasCopiedUrl ? t('remoteConnect.urlCopied') - : owner === 'bot' + : connected + ? t('remoteConnect.stateConnected') + : owner === 'bot' ? t('remoteConnect.stateWaitingBot') : t('remoteConnect.stateWaiting')} 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 2573e0f27b..e1ff70a2cb 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -60,6 +60,8 @@ export interface RemoteConnectStatus { active_method: string | null; peer_device_name: string | null; peer_user_id: string | null; + /** Added by hosts that track authenticated account-route control heartbeats. */ + account_control_connected?: boolean; bot_connected: string | null; bot_verbose_mode: boolean; } From 923d243a1fa97ee67b26dac2a4ede9f8bead127c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 6 Sep 2026 18:01:00 +0800 Subject: [PATCH 2/4] fix: keep delayed control replies within their original lease --- .../desktop/src/api/remote_connect_api.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 5366102acd..9db3288c1e 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1096,7 +1096,10 @@ fn record_control_ping_if_owner( ) { with_device_routing_state(|state| { if state.owner.as_ref() == Some(owner) && state.control_ping_generation == generation { - state.last_control_ping = Some(now); + // 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))); } }); } @@ -3133,6 +3136,7 @@ pub async fn account_connect_devices() -> Result, String> let rpc_owner = event_owner.clone(); let rpc_session = event_session.clone(); let ping_generation = control_ping_generation(&rpc_owner); + let ping_received_at = std::time::Instant::now(); tokio::spawn(async move { // Held for the whole call: teardown takes // the write lease, so an in-flight RPC now @@ -3167,7 +3171,7 @@ pub async fn account_connect_devices() -> Result, String> record_control_ping_if_owner( &rpc_owner, generation, - std::time::Instant::now(), + ping_received_at, ); } } @@ -4928,6 +4932,17 @@ mod sync_state_tests { record_control_ping_if_owner(&second, 0, now); 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); + assert!(has_recent_control_ping( + &second, + now + RELAY_INBOUND_IDLE_TIMEOUT + )); + assert!(!has_recent_control_ping( + &second, + newer + RELAY_INBOUND_IDLE_TIMEOUT + )); clear_device_routing_state(); assert!(!has_recent_control_ping(&second, now)); } From 7bc9ff0aff170971948783ae86b8867d7306b98b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 6 Sep 2026 18:47:39 +0800 Subject: [PATCH 3/4] fix: separate account control presence from relay invitations --- .../desktop/src/api/remote_connect_api.rs | 146 ++++++++++-------- .../api/service-api/RemoteConnectAPI.ts | 10 +- 2 files changed, 91 insertions(+), 65 deletions(-) diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 9db3288c1e..f3e8607d3a 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1128,34 +1128,22 @@ fn is_successful_control_ping( response: &serde_json::Value, ) -> bool { use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; - match command { - RemoteCommand::Ping => response.get("resp").and_then(|v| v.as_str()) == Some("pong"), - RemoteCommand::HostInvoke { command, .. } if command == "peer_mode_ping" => { - response.get("resp").and_then(|v| v.as_str()) == Some("host_invoke_result") - && response.get("ok").and_then(|v| v.as_bool()) == Some(true) - } - _ => false, - } + // 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) + && response.get("resp").and_then(|v| v.as_str()) == Some("pong") } -fn connection_method_matches_account_relay( - method: Option<&ConnectionMethod>, - relay_url: &str, -) -> bool { - let method_url = match method { - Some(ConnectionMethod::OpenBitFunServer) => { - RemoteConnectConfig::default().openbitfun_server_url - } - Some(ConnectionMethod::CustomServer { url }) => url.clone(), - _ => return false, - }; - match ( - normalize_relay_url(&method_url), - normalize_relay_url(relay_url), - ) { - (Ok(method_url), Ok(account_url)) => method_url == account_url, - _ => false, - } +async fn account_control_relay_url(now: std::time::Instant) -> Option { + 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) } fn device_routing_owner_is_registered(owner: &DeviceRoutingOwner) -> bool { @@ -1811,10 +1799,13 @@ pub struct RemoteConnectStatusResponse { pub active_method: Option, pub peer_device_name: Option, pub peer_user_id: Option, - /// A browser/phone has reached this host through the active account relay. - /// This does not alter the independent QR-room pairing state. + /// A browser/phone has reached this host through its authenticated account route. + /// This is independent of the temporary QR-room invitation and pairing state. #[serde(default)] pub account_control_connected: bool, + /// Source of the live account control channel, separate from `active_method`. + #[serde(default)] + pub account_control_relay_url: Option, /// Independent bot connection info — e.g. "Telegram(7096812005)". /// Present when a bot is active, regardless of relay pairing state. pub bot_connected: Option, @@ -2194,13 +2185,7 @@ pub async fn remote_connect_status() -> Result Result Date: Sun, 6 Sep 2026 19:04:40 +0800 Subject: [PATCH 4/4] fix: synchronize remote connection status across dialog and sidebar --- .../components/DeviceStatusControl.tsx | 2 +- .../useDeviceInterconnectionOverview.ts | 23 +- .../NavPanel/deviceInterconnectionOverview.ts | 15 +- .../RemoteConnectDialog.contract.test.ts | 6 +- .../RemoteConnectDialog.status.test.tsx | 451 ++++++++++++++++++ .../RemoteConnectDialog.tsx | 189 +++++--- .../RemoteConnectDialog/RemotePairingCard.tsx | 23 +- .../remoteConnectStatus.test.ts | 89 ++++ .../remote-connect/remoteConnectStatus.ts | 65 +++ .../remoteConnectionState.test.ts | 99 ++++ .../remote-connect/remoteConnectionState.ts | 94 ++++ src/web-ui/src/locales/en-US/common.json | 4 + src/web-ui/src/locales/zh-CN/common.json | 4 + src/web-ui/src/locales/zh-TW/common.json | 4 + 14 files changed, 975 insertions(+), 93 deletions(-) create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx create mode 100644 src/web-ui/src/infrastructure/remote-connect/remoteConnectStatus.test.ts create mode 100644 src/web-ui/src/infrastructure/remote-connect/remoteConnectStatus.ts create mode 100644 src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts create mode 100644 src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts 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 3951745d64..3315545a47 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -91,7 +91,7 @@ const DeviceStatusControl: React.FC = ({ overview, refresh, accountService, - } = useDeviceInterconnectionOverview(localDeviceLabel); + } = useDeviceInterconnectionOverview(localDeviceLabel, t('remoteConnect.mobileBrowserTitle')); const [returningLocal, setReturningLocal] = useState(false); const triggerRef = useRef(null); const popoverRef = useRef(null); 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 0d5523e493..7a8b4dea09 100644 --- a/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts +++ b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts @@ -4,8 +4,8 @@ import { api } from '@/infrastructure/api/service-api/ApiClient'; import { remoteConnectAPI, type DeviceInfo, - type RemoteConnectStatus, } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { remoteConnectStatusSource, useRemoteConnectStatus } from '@/infrastructure/remote-connect/remoteConnectStatus'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { useDispatchJobStore } from '@/features/dispatch/dispatchJobStore'; import { @@ -17,14 +17,13 @@ import { const TOPOLOGY_POLL_MS = 15_000; -export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string) { +export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string, fallbackMobileDeviceName?: string) { const account = useAccountLoginState(); const peerContext = usePeerDeviceModeOptional(); const dispatchJobs = useDispatchJobStore(state => state.jobs); const [localDevice, setLocalDevice] = useState(null); - const [remoteStatus, setRemoteStatus] = useState(null); - const [remoteStatusState, setRemoteStatusState] = useState<'loading' | 'ready' | 'unavailable'>('loading'); + const { status: remoteStatus, state: remoteStatusState } = useRemoteConnectStatus(); const [accountService, setAccountService] = useState(null); const refreshGenerationRef = useRef(0); @@ -38,18 +37,7 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string }) .catch(() => undefined); - const statusPromise = remoteConnectAPI.getStatus() - .then(status => { - if (!isCurrent()) return; - setRemoteStatus(status); - setRemoteStatusState('ready'); - }) - .catch(() => { - if (!isCurrent()) return; - // A failed probe cannot prove a previous connection still exists. - setRemoteStatus(null); - setRemoteStatusState('unavailable'); - }); + const statusPromise = remoteConnectStatusSource.refresh().catch(() => undefined); const relayPromise = account.loggedIn ? remoteConnectAPI.accountGetCredentialHint().then(hint => { @@ -82,6 +70,7 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string useEffect(() => { const unlistenLogin = api.listen('account://login-state', () => { + remoteConnectStatusSource.invalidate(); void refresh(); }); const unlistenPresence = api.listen('account://device-presence', () => { @@ -137,6 +126,7 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string const localDeviceName = localDevice?.device_name?.trim() || fallbackLocalDeviceName; const overview = useMemo(() => projectDeviceInterconnectionOverview({ localDeviceName, + fallbackMobileDeviceName, peer, remoteStatus, remoteStatusState, @@ -145,6 +135,7 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string }), [ accountService, localDeviceName, + fallbackMobileDeviceName, peer, projectedDispatchJobs, remoteStatus, diff --git a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts index f1ddc21f5a..4e15275f40 100644 --- a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts +++ b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts @@ -1,4 +1,5 @@ import type { RemoteConnectStatus } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { selectRemoteNetworkConnection } from '@/infrastructure/remote-connect/remoteConnectionState'; export type DeviceOverviewMode = 'local' | 'connected'; export type DeviceOverviewDeviceKind = @@ -144,6 +145,7 @@ export interface DeviceOverviewDispatchJob { export interface DeviceInterconnectionOverviewInput { localDeviceName: string; + fallbackMobileDeviceName?: string; peer: { deviceId: string; deviceName: string } | null; remoteStatus: RemoteConnectStatus | null; remoteStatusState: 'loading' | 'ready' | 'unavailable'; @@ -287,16 +289,21 @@ export function projectDeviceInterconnectionOverview( let connectionService = input.peer ? input.accountService : null; - if (input.remoteStatus?.is_connected) { + const network = selectRemoteNetworkConnection(input.remoteStatus); + if (network.connected && input.remoteStatus) { addOrMergeDevice(devices, { id: `mobile:${input.remoteStatus.peer_user_id ?? input.remoteStatus.peer_device_name ?? 'connected'}`, - name: formatDeviceDisplayName(input.remoteStatus.peer_device_name) || 'Mobile device', + name: (network.roomConnected ? formatDeviceDisplayName(input.remoteStatus.peer_device_name) : '') + || input.fallbackMobileDeviceName || 'Mobile device', kind: 'mobile', local: false, activities: ['controlling'], backgroundTaskCount: 0, }); - connectionService ??= connectionServiceFromActiveMethod(input.remoteStatus.active_method); + connectionService ??= network.roomConnected + ? connectionServiceFromActiveMethod(input.remoteStatus.active_method) + : connectionServiceFromRelayUrl(network.accountRelayUrl) + ?? connectionServiceFromActiveMethod(input.remoteStatus.active_method); } // A paired bot contributes a controller and nothing else. It does not claim @@ -354,6 +361,6 @@ export function projectDeviceInterconnectionOverview( controllerCount, backgroundTaskCount, peerActive: input.peer !== null, - topologyUnavailable: mode === 'connected' && input.remoteStatusState === 'unavailable', + topologyUnavailable: input.remoteStatusState === 'unavailable', }; } diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index f1b533c549..4981ff0cad 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -404,11 +404,11 @@ describe('Remote Connect safety contracts', () => { dialogSource.indexOf("activeView !== 'network'"), ); const connectedRestore = restoreFlow.slice( - restoreFlow.indexOf('applyStatus(s)'), - restoreFlow.indexOf("if (['waiting_for_scan'"), + restoreFlow.indexOf('applyStatus(s, restoreSelection'), + restoreFlow.indexOf("if (!pendingOwnerRef.current"), ); - expect(applyStatus).toContain("remotePairingStateName(nextStatus.pairing_state) === 'connected'"); + expect(applyStatus).toContain('selectRemoteNetworkConnection(nextStatus, connectionResultRef.current)'); expect(applyStatus).toContain('setNetworkTab(connectedTab)'); expect(applyStatus).toContain('setBotTab(connectedBot)'); expect(dialogSource).toContain("useState(initialGroup ?? 'overview')"); 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 new file mode 100644 index 0000000000..1cfff00c99 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx @@ -0,0 +1,451 @@ +// @vitest-environment jsdom + +import React, { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { DesignSystemProvider } from '@openbitfun/ui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConnectionResult, RemoteConnectStatus } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { remoteConnectStatusSource } from '@/infrastructure/remote-connect/remoteConnectStatus'; +import DeviceStatusControl from '../NavPanel/components/DeviceStatusControl'; +import { RemoteConnectDialog } from './RemoteConnectDialog'; +import { setRemoteConnectDisclaimerAgreed } from './remoteConnectDisclaimerStorage'; + +const boundary = vi.hoisted(() => ({ + backend: null as RemoteConnectStatus | null, + getStatus: vi.fn(), + startConnection: vi.fn(), + stopConnection: vi.fn(), + stopBot: vi.fn(), + getFormState: vi.fn(), + listeners: new Map void>>(), + jobs: {}, + t: (key: string) => key, +})); + +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOriginal => ({ + ...await importOriginal(), + remoteConnectAPI: { + getStatus: boundary.getStatus, + startConnection: boundary.startConnection, + stopConnection: boundary.stopConnection, + stopBot: boundary.stopBot, + getFormState: boundary.getFormState, + setFormState: vi.fn().mockResolvedValue(undefined), + getLanNetworkInfo: vi.fn().mockResolvedValue(null), + getDeviceInfo: vi.fn().mockResolvedValue({ device_id: 'desktop', device_name: 'Workstation', mac_address: '' }), + accountGetCredentialHint: vi.fn().mockResolvedValue({ username: 'sora', relay_url: 'https://relay.example.test/remote/a' }), + }, +})); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + listen: (name: string, listener: (payload: unknown) => void) => { + if (!boundary.listeners.has(name)) boundary.listeners.set(name, new Set()); + boundary.listeners.get(name)!.add(listener); + return () => { boundary.listeners.get(name)?.delete(listener); }; + }, + }, +})); +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: boundary.t, currentLanguage: 'en-US' }), +})); +vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ + useI18n: () => ({ t: boundary.t, currentLanguage: 'en-US' }), +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ hasWorkspace: true }) })); +vi.mock('@/infrastructure/account/useAccountLoginState', () => ({ + useAccountLoginState: () => ({ loggedIn: true, deviceName: 'Workstation' }), +})); +vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ getAppearanceOverlayHost: () => document.body })); +vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => null })); +vi.mock('@/features/dispatch/dispatchJobStore', () => ({ useDispatchJobStore: (select: (value: unknown) => unknown) => select({ jobs: boundary.jobs }) })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn(), warning: vi.fn(), error: vi.fn() }) })); +vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmWarning: vi.fn().mockResolvedValue(true) })); +vi.mock('./AccountPanel', () => ({ AccountPanel: () => null })); +vi.mock('@/features/relay-deploy', () => ({ RelayDeployWizard: () => null })); + +const relayA = 'https://relay.example.test/remote/a'; +const relayB = 'https://relay.example.test/remote/b'; + +function status(overrides: Partial = {}): RemoteConnectStatus { + return { + is_connected: false, pairing_state: 'idle', active_method: null, + peer_device_name: null, peer_user_id: null, + bot_connected: 'Weixin (desktop-bot)', bot_verbose_mode: false, + account_control_connected: false, account_control_relay_url: null, + ...overrides, + }; +} + +function invitation(relay = relayA): ConnectionResult { + return { + method: { custom_server: { url: relay } }, + qr_data: null, qr_svg: null, + qr_url: `https://mobile.example.test/#/pair?relay=${encodeURIComponent(relay)}`, + bot_pairing_code: null, bot_link: null, pairing_state: 'waiting_for_scan', + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(resolvePromise => { resolve = resolvePromise; }); + return { promise, resolve }; +} + +function Harness() { + const [open, setOpen] = useState(true); + const [sidebarOpen, setSidebarOpen] = useState(false); + return + + setOpen(true)} /> + setOpen(false)} /> + ; +} + +let root: Root; +let container: HTMLDivElement; +let mounted: boolean; + +function element(selector: string): HTMLElement { + const result = document.querySelector(selector); + expect(result, selector).not.toBeNull(); + return result!; +} +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 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"]'); + +async function click(target: HTMLElement) { await act(async () => { target.click(); }); } +async function clickText(key: string) { + const button = Array.from(dialog().querySelectorAll('button')).find(candidate => candidate.textContent?.trim() === key); + expect(button, key).toBeDefined(); + 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 openNetwork() { await click(overviewNetwork()); } +async function generateInvitation(relay = relayA) { + await openNetwork(); + await click(element('#remote-connect-network-tab-custom_server')); + if (relay !== relayA) { + const input = element('input[placeholder="https://relay.example.com:9700"]') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, relay); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + await clickText('remoteConnect.showConnectionCode'); +} +async function closeDialog() { + const close = document.querySelector('.openbitfun-remote-connect-dialog__header button'); + expect(close).not.toBeNull(); + await click(close!); + await tick(300); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + boundary.listeners.clear(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { storage.set(key, value); }, + removeItem: (key: string) => { storage.delete(key); }, + }); + setRemoteConnectDisclaimerAgreed(); + boundary.backend = status(); + boundary.getStatus.mockImplementation(async () => ({ ...boundary.backend! })); + boundary.getFormState.mockResolvedValue({ custom_server_url: relayA }); + boundary.startConnection.mockImplementation(async (_method: string, relay: string) => { + boundary.backend = { ...boundary.backend!, active_method: `CustomServer { url: "${relay}" }`, pairing_state: 'waiting_for_scan' }; + return invitation(relay); + }); + boundary.stopConnection.mockImplementation(async () => { + boundary.backend = { ...boundary.backend!, is_connected: false, pairing_state: 'idle', active_method: null, peer_device_name: null, peer_user_id: null }; + }); + boundary.stopBot.mockImplementation(async () => { boundary.backend = { ...boundary.backend!, bot_connected: null }; }); + remoteConnectStatusSource.invalidate(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + mounted = true; +}); + +afterEach(async () => { + if (mounted) await act(async () => { root.unmount(); }); + container.remove(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('Remote Connect shared status through the real dialog and sidebar', () => { + it('keeps QR, overview, close/reopen and sidebar connected, then permits another invitation', async () => { + await render(); + expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); + expect(attachedMobile()).toBeNull(); + expect(attachedBot()).not.toBeNull(); + await generateInvitation(); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + await tick(); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect(attachedMobile()).not.toBeNull(); + expect(attachedBot()).not.toBeNull(); + expect(dialog().textContent).toContain('remoteConnect.cancelInvitation'); + expect(dialog().textContent).toContain('remoteConnect.accountConnectedHint'); + expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); + await clickText('remoteConnect.backToOverview'); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + expect(boundary.stopConnection).toHaveBeenCalledOnce(); + expect(boundary.stopBot).not.toHaveBeenCalled(); + expect(boundary.backend!.account_control_connected).toBe(true); + await closeDialog(); + expect(attachedMobile()).not.toBeNull(); + await click(element('[data-testid="nav-footer-device-status"]')); + const devices = element('[data-testid="nav-device-status-connected-devices"]'); + expect(devices.querySelector('[data-openbitfun-device-kind="mobile"] strong')?.textContent).toBe('remoteConnect.mobileBrowserTitle'); + expect(devices.querySelector('[data-openbitfun-device-kind="message-app"] strong')?.textContent).toBe('remoteConnect.weixin'); + const service = element('[data-testid="nav-device-connection-service"]'); + expect(service.getAttribute('data-openbitfun-service-kind')).toBe('self-hosted'); + expect(service.textContent).toContain('relay.example.test'); + await click(element('[data-testid="nav-footer-device-status"]')); + await click(element('[data-testid="reopen-remote-connect"]')); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + await openNetwork(); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); + await clickText('remoteConnect.showConnectionCode'); + expect(boundary.startConnection).toHaveBeenCalledTimes(2); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + }); + + it('propagates expiry and reconnect while pairing, on the overview, and after closing the dialog', async () => { + await render(); + await generateInvitation(); + for (const connected of [true, false, true]) { + boundary.backend = { ...boundary.backend!, account_control_connected: connected, account_control_relay_url: relayA }; + await tick(); + expect(cardStatus()).toBe(connected ? 'remoteConnect.stateConnected' : 'remoteConnect.stateWaiting'); + expect(Boolean(attachedMobile())).toBe(connected); + expect(attachedBot()).not.toBeNull(); + } + await clickText('remoteConnect.backToOverview'); + boundary.backend = { ...boundary.backend!, account_control_connected: false }; + await tick(); + expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); + boundary.backend = { ...boundary.backend!, account_control_connected: true }; + await tick(); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + await closeDialog(); + boundary.backend = { ...boundary.backend!, account_control_connected: false }; + await tick(15_000); + expect(attachedMobile()).toBeNull(); + expect(attachedBot()).not.toBeNull(); + boundary.backend = { ...boundary.backend!, account_control_connected: true }; + await tick(15_000); + expect(attachedMobile()).not.toBeNull(); + }); + + it('keeps configuration selectable beside an account connection and does not mark another relay path connected', async () => { + boundary.backend = status({ account_control_connected: true, account_control_relay_url: relayA }); + await render(); + await openNetwork(); + await click(element('#remote-connect-network-tab-lan')); + await tick(4000); + expect(element('#remote-connect-network-tab-lan').getAttribute('aria-selected')).toBe('true'); + await clickText('remoteConnect.backToOverview'); + await generateInvitation(relayB); + expect(boundary.startConnection).toHaveBeenCalledWith('custom_server', relayB, undefined); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + await tick(4000); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + expect(element('#remote-connect-network-tab-custom_server').getAttribute('aria-selected')).toBe('true'); + expect((element('#remote-connect-network-tab-lan') as HTMLButtonElement).disabled).toBe(true); + expect(attachedMobile()).not.toBeNull(); + await clickText('remoteConnect.cancelAndBack'); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + expect(boundary.backend!.account_control_relay_url).toBe(relayA); + }); + + it('keeps legacy room disconnect explicit and independent of a coexisting account route and WeChat', async () => { + boundary.backend = status({ is_connected: true, pairing_state: 'connected', active_method: 'OpenBitFunServer', peer_device_name: 'Phone' }); + delete boundary.backend.account_control_connected; + delete boundary.backend.account_control_relay_url; + await render(); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + expect(attachedMobile()).not.toBeNull(); + await openNetwork(); + await clickText('remoteConnect.backToOverview'); + expect(boundary.stopConnection).not.toHaveBeenCalled(); + boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + await tick(); + await openNetwork(); + await clickText('remoteConnect.disconnect'); + expect(boundary.stopConnection).toHaveBeenCalledOnce(); + expect(boundary.stopBot).not.toHaveBeenCalled(); + expect(boundary.backend!.account_control_connected).toBe(true); + expect(attachedMobile()).not.toBeNull(); + await clickText('remoteConnect.backToOverview'); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + }); + + it('does not override a selected method when account control appears during the initial status probes', async () => { + boundary.backend = status({ bot_connected: null }); + await render(); + await openNetwork(); + await click(element('#remote-connect-network-tab-custom_server')); + await click(element('#remote-connect-network-tab-lan')); + boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + await tick(4000); + expect(element('#remote-connect-network-tab-lan').getAttribute('aria-selected')).toBe('true'); + expect(attachedMobile()).not.toBeNull(); + expect(boundary.startConnection).not.toHaveBeenCalled(); + }); + + it('preserves a fresh QR across initial probes when no chat app is connected', async () => { + boundary.backend = status({ bot_connected: null }); + await render(); + await generateInvitation(); + await tick(4000); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + expect(dialog().textContent).toContain(invitation().qr_url); + expect(dialog().querySelector('.openbitfun-remote-connect__qr-box svg')).not.toBeNull(); + expect(boundary.startConnection).toHaveBeenCalledOnce(); + expect(boundary.stopConnection).not.toHaveBeenCalled(); + }); + + it('preserves an explicit method selection while the first dialog read refreshes a cached sidebar snapshot', async () => { + boundary.backend = status({ bot_connected: null }); + await remoteConnectStatusSource.refresh(); + const pending = deferred(); + boundary.getStatus.mockReturnValueOnce(pending.promise); + await render(); + await openNetwork(); + await click(element('#remote-connect-network-tab-lan')); + boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + await act(async () => { pending.resolve(boundary.backend!); }); + expect(element('#remote-connect-network-tab-lan').getAttribute('aria-selected')).toBe('true'); + expect(attachedMobile()).not.toBeNull(); + }); + + it('publishes a slow read to both surfaces and shows unavailable instead of waiting when a later read fails', async () => { + const pending = deferred(); + boundary.getStatus.mockReturnValueOnce(pending.promise); + await render(); + expect(overviewNetwork().textContent).toContain('remoteConnect.statusChecking'); + await tick(4000); + expect(boundary.getStatus).toHaveBeenCalledOnce(); + boundary.backend = status({ account_control_connected: true, account_control_relay_url: relayA }); + await act(async () => { pending.resolve(boundary.backend!); }); + expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); + expect(attachedMobile()).not.toBeNull(); + boundary.getStatus.mockRejectedValueOnce(new Error('transport temporarily unavailable')); + await tick(); + expect(overviewNetwork().textContent).toContain('remoteConnect.statusUnavailable'); + expect(overviewNetwork().textContent).not.toContain('remoteConnect.notConnected'); + await openNetwork(); + expect(cardStatus()).toBe('remoteConnect.statusUnavailable'); + await tick(); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect(attachedMobile()).not.toBeNull(); + }); + + it('cleans up an invitation created after closing or unmounting without publishing it back into the UI', async () => { + for (const unmount of [false, true]) { + const pending = deferred(); + boundary.startConnection.mockReturnValueOnce(pending.promise); + await render(); + await generateInvitation(); + if (unmount) { + await act(async () => { root.unmount(); }); + mounted = false; + } else await closeDialog(); + const stopCount = boundary.stopConnection.mock.calls.length; + await act(async () => { + boundary.backend = status({ active_method: `CustomServer { url: "${relayA}" }`, pairing_state: 'waiting_for_scan' }); + pending.resolve(invitation()); + }); + expect(boundary.stopConnection).toHaveBeenCalledTimes(stopCount + 1); + expect(boundary.backend!.pairing_state).toBe('idle'); + expect(document.querySelector('[data-openbitfun-part="pairingCard"]')).toBeNull(); + expect(attachedMobile()).toBeNull(); + if (!unmount) await click(element('[data-testid="reopen-remote-connect"]')); + } + }); + + it('keeps a bot read failure explicit without opening another login, and exposes retry in the sidebar', async () => { + await render(); + await click(element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="bot"]')); + expect(dialog().textContent).toContain('remoteConnect.disconnect'); + boundary.getStatus.mockRejectedValue(new Error('status unavailable')); + await tick(); + expect(cardStatus()).toBe('remoteConnect.statusUnavailable'); + expect(dialog().textContent).not.toContain('remoteConnect.botWeixinQrButton'); + expect(dialog().textContent).not.toContain('remoteConnect.getPairingCode'); + expect(dialog().querySelector('input')).toBeNull(); + await clickText('remoteConnect.backToOverview'); + expect(element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="bot"]').textContent).toContain('remoteConnect.statusUnavailable'); + await closeDialog(); + await click(element('[data-testid="nav-footer-device-status"]')); + const notice = element('.openbitfun-device-overview__notice'); + expect(notice.textContent).toBe('deviceOverview.statusUnavailable'); + boundary.getStatus.mockImplementation(async () => ({ ...boundary.backend! })); + await click(notice); + expect(document.querySelector('.openbitfun-device-overview__notice')).toBeNull(); + expect(attachedBot()).not.toBeNull(); + expect(boundary.stopBot).not.toHaveBeenCalled(); + expect(boundary.startConnection).not.toHaveBeenCalled(); + }); + + it('preserves an invitation during a failed read and restores its confirmed account state afterward', async () => { + await render(); + await generateInvitation(); + boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + await tick(); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + boundary.getStatus.mockRejectedValueOnce(new Error('status unavailable')); + await tick(); + expect(cardStatus()).toBe('remoteConnect.statusUnavailable'); + expect(dialog().textContent).toContain(invitation().qr_url); + expect(dialog().textContent).toContain('remoteConnect.cancelInvitation'); + await tick(); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect(boundary.stopConnection).not.toHaveBeenCalled(); + }); + + it('ignores connected replies begun before and during the actual Disconnect handler', async () => { + const connected = status({ is_connected: true, pairing_state: 'connected', active_method: 'OpenBitFunServer' }); + boundary.backend = connected; + await render(); + await openNetwork(); + const before = deferred(); + const during = deferred(); + boundary.getStatus.mockReturnValueOnce(before.promise).mockReturnValueOnce(during.promise); + await tick(); + const stop = deferred(); + boundary.stopConnection.mockImplementationOnce(async () => { + await stop.promise; + boundary.backend = status(); + }); + await clickText('remoteConnect.disconnect'); + await tick(); + await act(async () => { stop.resolve(); }); + expect(attachedMobile()).toBeNull(); + expect(attachedBot()).not.toBeNull(); + expect(dialog().textContent).toContain('remoteConnect.showConnectionCode'); + await act(async () => { + during.resolve(connected); + before.resolve(connected); + }); + expect(attachedMobile()).toBeNull(); + expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); + await clickText('remoteConnect.backToOverview'); + expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); + expect(boundary.stopConnection).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index a9ce25642f..9f22c1944c 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -40,6 +40,8 @@ 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 { useNotification } from '@/shared/notification-system'; import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { AccountPanel } from './AccountPanel'; @@ -73,7 +75,7 @@ import './RemoteConnectDialog.scss'; type ActiveGroup = 'network' | 'bot' | 'account'; type ActiveView = 'overview' | ActiveGroup; type ConnectionOwner = Exclude; -type NetworkTab = 'lan' | 'ngrok' | 'openbitfun_server' | 'custom_server'; +type NetworkTab = RemoteNetworkMethod; type BotTab = 'telegram' | 'feishu' | 'weixin'; /** @@ -138,14 +140,7 @@ function parseRelayServer(value: string): URL | null { } } -const methodToNetworkTab = (method: string | null | undefined): NetworkTab | null => { - if (!method) return null; - if (method.startsWith('Lan')) return 'lan'; - if (method.startsWith('Ngrok')) return 'ngrok'; - if (method.startsWith('OpenBitFunServer')) return 'openbitfun_server'; - if (method.startsWith('CustomServer')) return 'custom_server'; - return null; -}; +const methodToNetworkTab = remoteNetworkMethod; const botInfoToBotTab = (info: string | null | undefined): BotTab | null => { if (!info) return null; @@ -186,7 +181,7 @@ export const RemoteConnectDialog: React.FC = ({ const [connectionResult, setConnectionResult] = useState(null); const [connectionOwner, setConnectionOwner] = useState(null); - const [status, setStatus] = useState(null); + const { status, state: statusState } = useRemoteConnectStatus(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lanNetworkInfo, setLanNetworkInfo] = useState<{ @@ -225,9 +220,11 @@ export const RemoteConnectDialog: React.FC = ({ const pollRef = useRef | null>(null); const pollGenerationRef = useRef(0); + const networkSelectionGenerationRef = useRef(0); const operationGenerationRef = useRef(0); const pendingOwnerRef = useRef(null); const connectionOwnerRef = useRef(null); + const connectionResultRef = useRef(null); const pendingStartRef = useRef<{ owner: ConnectionOwner; generation: number; @@ -237,22 +234,26 @@ export const RemoteConnectDialog: React.FC = ({ const weixinVerifyCodeRef = useRef(null); const isOpenRef = useRef(isOpen); connectionOwnerRef.current = connectionOwner; + connectionResultRef.current = connectionResult; isOpenRef.current = isOpen; // ── Derived state ──────────────────────────────────────────────── - const isRelayConnected = remotePairingStateName(status?.pairing_state) === 'connected'; + const networkConnection = selectRemoteNetworkConnection(status, connectionResult); + const isRelayConnected = networkConnection.connected; const isBotConnected = !!status?.bot_connected; - const connectedNetworkTab = methodToNetworkTab(status?.active_method); + const connectedNetworkTab = networkConnection.method; const connectedBotTab = botInfoToBotTab(status?.bot_connected); const cancelPendingWork = useCallback(async () => { operationGenerationRef.current += 1; - pollGenerationRef.current += 1; - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; - - const owner = pendingOwnerRef.current ?? connectionOwnerRef.current; + const currentStatus = remoteConnectStatusSource.getSnapshot().status; + const candidateOwner = pendingOwnerRef.current ?? connectionOwnerRef.current; + // Leaving a view cancels only its unfinished invitation. A completed room + // or bot connection requires its explicit Disconnect action. + const owner = (candidateOwner === 'network' && selectRemoteNetworkConnection(currentStatus).roomConnected) + || (candidateOwner === 'bot' && currentStatus?.bot_connected) + ? null : candidateOwner; const pendingStart = pendingStartRef.current; pendingOwnerRef.current = null; connectionOwnerRef.current = null; @@ -278,10 +279,16 @@ export const RemoteConnectDialog: React.FC = ({ } try { if (owner === 'bot') { + remoteConnectStatusSource.invalidateReads(); await remoteConnectAPI.stopBot(); } else if (owner === 'network') { + remoteConnectStatusSource.invalidateReads(); await remoteConnectAPI.stopConnection(); } + if (owner) { + remoteConnectStatusSource.invalidateReads(); + await remoteConnectStatusSource.refresh(); + } } catch { // Best-effort cleanup; the generation still blocks late UI writes. } @@ -313,41 +320,56 @@ export const RemoteConnectDialog: React.FC = ({ if (!isOpen) void cancelPendingWork(); }, [cancelPendingWork, isOpen]); + useEffect(() => { + isOpenRef.current = isOpen; + return () => { + isOpenRef.current = false; + void cancelPendingWork(); + }; + }, [cancelPendingWork, isOpen]); + // ── Polling ────────────────────────────────────────────────────── - const applyStatus = useCallback((nextStatus: RemoteConnectStatus) => { - setStatus(nextStatus); + const applyStatus = useCallback((nextStatus: RemoteConnectStatus, restoreSelection = false) => { + const network = selectRemoteNetworkConnection(nextStatus, connectionResultRef.current); // Relay and bot connections can coexist. Restore both selected subtabs // before choosing which group to show, otherwise the bot-first open path // can leave a connected OpenBitFun Server relay rendering the default LAN UI. - if (remotePairingStateName(nextStatus.pairing_state) === 'connected') { - const connectedTab = methodToNetworkTab(nextStatus.active_method); + const hasPendingInvitation = connectionOwnerRef.current === 'network' && connectionResultRef.current !== null; + if (network.roomConnected || (restoreSelection && network.accountConnected + && (!hasPendingInvitation || network.invitationAccountConnected))) { + const connectedTab = network.method; if (connectedTab) setNetworkTab(connectedTab); } const connectedBot = botInfoToBotTab(nextStatus.bot_connected); if (connectedBot) setBotTab(connectedBot); + const owner = connectionOwnerRef.current; + if ((owner === 'network' && network.roomConnected) || (owner === 'bot' && connectedBot)) { + pendingOwnerRef.current = null; + connectionOwnerRef.current = null; + setConnectionOwner(null); + setConnectionResult(null); + } else if (owner === 'network' && !nextStatus.active_method && !pendingStartRef.current) { + pendingOwnerRef.current = null; + connectionOwnerRef.current = null; + setConnectionOwner(null); + setConnectionResult(null); + } }, []); - const startPolling = useCallback((target: 'relay' | 'bot') => { + useEffect(() => { + if (isOpen && status) applyStatus(status); + }, [applyStatus, isOpen, status]); + + const startPolling = useCallback((_target?: 'relay' | 'bot') => { const pollGeneration = ++pollGenerationRef.current; if (pollRef.current) clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { - const s = await remoteConnectAPI.getStatus(); - if (!isOpenRef.current || pollGenerationRef.current !== pollGeneration) return; + const s = await remoteConnectStatusSource.refresh(); + if (!s || !isOpenRef.current || pollGenerationRef.current !== pollGeneration) return; applyStatus(s); - const done = target === 'relay' - ? remotePairingStateName(s.pairing_state) === 'connected' - : !!s.bot_connected; - if (done) { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; - pendingOwnerRef.current = null; - connectionOwnerRef.current = null; - setConnectionOwner(null); - setConnectionResult(null); - } } catch { /* ignore */ } }, 2000); }, [applyStatus]); @@ -363,21 +385,26 @@ export const RemoteConnectDialog: React.FC = ({ const agreed = getRemoteConnectDisclaimerAgreed(); setHasAgreedDisclaimer(agreed); if (!agreed) return; + // Overview and established connections still need expiry/reconnect updates. + startPolling(); let cancelled = false; + const networkSelectionGeneration = networkSelectionGenerationRef.current; const checkExisting = async () => { + let restoreSelection = true; for (let attempt = 0; attempt < 3; attempt++) { try { - const s = await remoteConnectAPI.getStatus(); - if (cancelled) return; - applyStatus(s); + const s = await remoteConnectStatusSource.refresh(); + if (cancelled || !s) return; + applyStatus(s, restoreSelection && networkSelectionGenerationRef.current === networkSelectionGeneration); + restoreSelection = false; setBotVerboseMode(s.bot_verbose_mode); - if (['waiting_for_scan', 'verifying', 'handshaking'].includes( + if (!pendingOwnerRef.current && !connectionOwnerRef.current && ['waiting_for_scan', 'verifying', 'handshaking'].includes( remotePairingStateName(s.pairing_state), )) { const tab = methodToNetworkTab(s.active_method); - setActiveView('network'); + if (!selectRemoteNetworkConnection(s).connected) setActiveView('network'); if (tab) setNetworkTab(tab); pendingOwnerRef.current = 'network'; connectionOwnerRef.current = 'network'; @@ -397,6 +424,7 @@ export const RemoteConnectDialog: React.FC = ({ startPolling('relay'); return; } + if (selectRemoteNetworkConnection(s).connected || s.bot_connected) return; } catch { /* ignore */ } if (attempt < 2) { await new Promise(r => setTimeout(r, 1500)); @@ -477,7 +505,9 @@ export const RemoteConnectDialog: React.FC = ({ // Account changes rotate an unpaired QR invitation, but an established // room is an independent control channel and stays connected. Refresh // first, then clear only UI state that the backend actually retired. - void remoteConnectAPI.getStatus().then((nextStatus) => { + remoteConnectStatusSource.invalidate(); + void remoteConnectStatusSource.refresh().then((nextStatus) => { + if (!nextStatus) return; if (!isOpenRef.current) return; applyStatus(nextStatus); if (remotePairingStateName(nextStatus.pairing_state) !== 'connected') { @@ -543,7 +573,11 @@ export const RemoteConnectDialog: React.FC = ({ weixinBaseUrl: baseUrl || undefined, weixinBotAccountId: botAccountId, }); - return await remoteConnectAPI.startConnection('bot_weixin'); + remoteConnectStatusSource.invalidateReads(); + const result = await remoteConnectAPI.startConnection('bot_weixin'); + remoteConnectStatusSource.invalidateReads(); + void remoteConnectStatusSource.refresh().catch(() => undefined); + return result; }, []); // WeChat QR login: poll iLink until confirmed or error (session key cleared on completion). @@ -755,10 +789,12 @@ export const RemoteConnectDialog: React.FC = ({ if (networkTab === 'custom_server') serverUrl = customUrl || undefined; } const lanIp = networkTab === 'lan' ? (selectedLanIp || undefined) : undefined; + remoteConnectStatusSource.invalidateReads(); const startPromise = remoteConnectAPI.startConnection(method, serverUrl, lanIp); const pendingStart = { owner, generation: operationGeneration, promise: startPromise }; pendingStartRef.current = pendingStart; const result = await startPromise; + remoteConnectStatusSource.invalidateReads(); if (pendingStartRef.current === pendingStart) pendingStartRef.current = null; if (!isCurrent()) return; connectionOwnerRef.current = owner; @@ -766,6 +802,7 @@ export const RemoteConnectDialog: React.FC = ({ setConnectionResult(result); ownsConnection = true; startPolling(owner === 'bot' ? 'bot' : 'relay'); + void remoteConnectStatusSource.refresh().catch(() => undefined); } catch (e: any) { if (pendingStartRef.current?.generation === operationGeneration) { pendingStartRef.current = null; @@ -832,25 +869,29 @@ export const RemoteConnectDialog: React.FC = ({ const handleDisconnectRelay = useCallback(async () => { try { + remoteConnectStatusSource.invalidateReads(); await remoteConnectAPI.stopConnection(); + remoteConnectStatusSource.invalidateReads(); pendingOwnerRef.current = null; connectionOwnerRef.current = null; setConnectionOwner(null); setConnectionResult(null); - const s = await remoteConnectAPI.getStatus(); - applyStatus(s); + const s = await remoteConnectStatusSource.refresh(); + if (s) applyStatus(s); } catch { /* best effort */ } }, [applyStatus]); const handleDisconnectBot = useCallback(async () => { try { + remoteConnectStatusSource.invalidateReads(); await remoteConnectAPI.stopBot(); + remoteConnectStatusSource.invalidateReads(); pendingOwnerRef.current = null; connectionOwnerRef.current = null; setConnectionOwner(null); setConnectionResult(null); - const s = await remoteConnectAPI.getStatus(); - applyStatus(s); + const s = await remoteConnectStatusSource.refresh(); + if (s) applyStatus(s); } catch { /* best effort */ } }, [applyStatus]); @@ -864,8 +905,8 @@ export const RemoteConnectDialog: React.FC = ({ await cancelPendingWork(); if (!isOpenRef.current) return; try { - const s = await remoteConnectAPI.getStatus(); - if (isOpenRef.current) applyStatus(s); + const s = await remoteConnectStatusSource.refresh(); + if (s && isOpenRef.current) applyStatus(s); } catch { /* best effort */ } }, [applyStatus, cancelPendingWork]); @@ -934,7 +975,7 @@ export const RemoteConnectDialog: React.FC = ({ // ── Sub-tab disabled logic ─────────────────────────────────────── const isNetworkSubDisabled = (tabId: NetworkTab): boolean => { - if (isRelayConnected && connectedNetworkTab && connectedNetworkTab !== tabId) return true; + if (networkConnection.roomConnected && networkConnection.roomMethod && networkConnection.roomMethod !== tabId) return true; return false; }; @@ -1003,15 +1044,19 @@ export const RemoteConnectDialog: React.FC = ({ qrUrl={connectionResult.qr_url} pairingCode={connectionResult.bot_pairing_code} owner={connectionOwner === 'bot' ? 'bot' : 'network'} - connected={connectionOwner === 'network' && status?.account_control_connected === true} + connected={connectionOwner === 'network' && networkConnection.invitationAccountConnected} + statusState={statusState} copied={qrCopied} onCopyUrl={handleCopyPairingUrl} />
+ {connectionOwner === 'network' && networkConnection.invitationAccountConnected && ( +

{t('remoteConnect.accountConnectedHint')}

+ )}
); }; @@ -1026,7 +1071,10 @@ export const RemoteConnectDialog: React.FC = ({ }; const renderNetworkContent = () => { - if (isRelayConnected && connectedNetworkTab === networkTab) { + if (statusState !== 'ready' && !connectionResult) { + return {}} />; + } + if (networkConnection.roomConnected && networkConnection.roomMethod === networkTab) { return ( <> {networkTab === 'ngrok' && ( @@ -1058,6 +1106,12 @@ 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')}

+
+ )}