diff --git a/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs index aecad12d85..65833defdc 100644 --- a/scripts/generate-brand-assets.mjs +++ b/scripts/generate-brand-assets.mjs @@ -57,11 +57,14 @@ const LEGACY_APPLICATION_ASSETS = [ 'src/apps/mobile/harmonyos/AppScope/resources/base/media/background.png', 'src/apps/mobile/harmonyos/AppScope/resources/base/media/foreground.png', 'src/apps/mobile/harmonyos/AppScope/resources/base/media/layered_image.json', + 'src/apps/mobile/harmonyos/AppScope/resources/base/media/openbitfun-app-icon.png', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_icon.png', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/background.png', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/foreground.png', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/layered_image.json', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/startIcon.png', + 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun-app-icon.png', + 'src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun-start-window.png', 'src/apps/mobile/ios/OpenBitFun/Resources.xcassets/AppIcon.appiconset/openbitfun_icon.png', 'src/apps/mobile/ios/OpenBitFun/Resources.xcassets/OpenBitFunLogo.imageset', 'src/apps/relay-server/static/assets/Logo-ICON-BOaKcXgO.png', @@ -258,15 +261,15 @@ async function generateBrandAssets() { ); await writePng( - outputPath('src', 'apps', 'mobile', 'harmonyos', 'AppScope', 'resources', 'base', 'media', 'openbitfun-app-icon.png'), + outputPath('src', 'apps', 'mobile', 'harmonyos', 'AppScope', 'resources', 'base', 'media', 'openbitfun_app_icon.png'), applicationIconLarge, ); await writePng( - outputPath('src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'resources', 'base', 'media', 'openbitfun-app-icon.png'), + outputPath('src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'resources', 'base', 'media', 'openbitfun_app_icon.png'), applicationIconLarge, ); await writePng( - outputPath('src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'resources', 'base', 'media', 'openbitfun-start-window.png'), + outputPath('src', 'apps', 'mobile', 'harmonyos', 'entry', 'src', 'main', 'resources', 'base', 'media', 'openbitfun_start_window.png'), await resizePng(lightMark, 144), ); diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index fdb356bc1d..8144309428 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -24,6 +24,12 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'deploy/openbitfun-host/README.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', 'src/apps/relay-server/README.md', + // HarmonyOS must keep its published bundle id and encrypted-storage names + // for in-place upgrades. Runtime identifiers are centralized in one source; + // the manifest and backup policy are the only declarative exceptions. + 'src/apps/mobile/harmonyos/AppScope/app.json5', + 'src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets', + 'src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json', ]); const noncanonicalIdentityDataBoundaryFiles = new Set([ 'deploy/openbitfun-host/migrate-market-data-v1.py', diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index cddc17ecc5..bc070216fe 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -119,6 +119,21 @@ test('limits retired identity data to the one-time production migration boundary ); }); +test('allows retired Harmony identifiers only at the upgrade identity boundary', () => { + const legacyBundle = `com.${retiredLowerName}.app`; + assert.deepEqual( + violationsFor( + `static readonly APP_BUNDLE: string = '${legacyBundle}';`, + 'src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets', + ), + [], + ); + assert.equal( + violationsFor(`const bundle = '${legacyBundle}';`, 'src/apps/mobile/harmonyos/entry/src/main/ets/services/example.ets').length, + 1, + ); +}); + test('rejects retired short CSS, DOM, dataset, layer, and environment prefixes', () => { const source = [ `--${shortPrefix}-surface: white;`, diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 874e55e1c7..a9dc105bd6 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -71,6 +71,13 @@ static ACCOUNT_AUTO_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::cons /// transition guard only after all login-time network requests complete. static ACCOUNT_LOGIN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static ACCOUNT_CONTEXT_TRANSITION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// Serializes QR-room starts with account identity boundaries. +/// +/// An unpaired QR advertises the authentication mode that existed when it was +/// created, so login/logout must retire that stale invitation. An established +/// room is an independent control channel and survives the account boundary; +/// its account-derived authority is cleared separately during the transition. +static ACCOUNT_ROOM_BOUNDARY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static ACCOUNT_AUTO_SYNC_CANCEL: OnceLock = OnceLock::new(); static ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID: AtomicU64 = AtomicU64::new(0); static ACCOUNT_CONTEXT_GENERATION: AtomicU64 = AtomicU64::new(1); @@ -754,6 +761,16 @@ async fn invalidate_local_account_session_if_current( expected_token: &str, reason: &str, ) -> bool { + if !account_context_matches(expected_generation, expected_token).await { + log::info!("Ignored auth failure from a stale account generation"); + return false; + } + let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; + if !account_context_matches(expected_generation, expected_token).await { + log::info!("Ignored auth failure from a stale account generation"); + return false; + } + retire_unpaired_room_for_account_boundary("account session expiry").await; let Some(_transition_guard) = cancel_and_wait_if_account_current(expected_generation).await else { log::info!("Ignored auth failure from a stale account generation"); @@ -1453,7 +1470,9 @@ pub fn init_on_startup() { Ok(url) => url, Err(error) => { log::warn!("Ignoring invalid persisted relay URL: {error}"); - session_store::clear_session(); + // Keep the record intact. A newer build, repaired + // configuration, or explicit user action may recover + // it; startup validation must never become data loss. sync_account_login_capability(false); if let Err(error) = ensure_service().await { log::warn!("Remote connect startup init failed: {error}"); @@ -2027,6 +2046,7 @@ fn parse_connection_method( pub async fn remote_connect_start( request: StartRemoteConnectRequest, ) -> Result { + let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; ensure_service().await?; let method = parse_connection_method(&request.method, request.custom_server_url, request.lan_ip)?; @@ -2360,13 +2380,25 @@ pub async fn account_finalize_login(request: PendingAccountLoginRequest) -> Resu pub async fn account_cancel_pending_login( request: PendingAccountLoginRequest, ) -> Result { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; let generation = account_context_generation(); if !account_context_is_current(generation) || !pending_login_is_owned_by(&request.pending_login_id) { return Ok(false); } + let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; + if !account_context_is_current(generation) + || !pending_login_is_owned_by(&request.pending_login_id) + { + return Ok(false); + } + retire_unpaired_room_for_account_boundary("pending account login cancellation").await; + let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; + if !account_context_is_current(generation) + || !pending_login_is_owned_by(&request.pending_login_id) + { + return Ok(false); + } let transition = AccountContextTransitionPermit::begin(); let sync_guard = ACCOUNT_AUTO_SYNC_LOCK.lock().await; @@ -2415,6 +2447,12 @@ pub async fn account_login(request: AccountAuthRequest) -> Result bool { + if !account_context_matches(expected_generation, expected_token).await { + return false; + } + let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; + if !account_context_matches(expected_generation, expected_token).await { + return false; + } + retire_unpaired_room_for_account_boundary("account session clear").await; let Some(_transition_guard) = cancel_and_wait_if_account_current(expected_generation).await else { return false; @@ -2585,6 +2633,38 @@ async fn clear_account_login_state(revoke_relay_token: bool) { ); } +fn pairing_room_requires_rotation(state: &PairingState) -> bool { + !matches!( + state, + PairingState::Idle | PairingState::Connected | PairingState::Disconnected + ) +} + +/// Retire only an invitation whose encoded account mode is now stale. +/// +/// Callers hold `ACCOUNT_ROOM_BOUNDARY_LOCK` and invoke this before acquiring +/// account sync/transition guards. That lock order lets an in-flight pairing +/// finish or be retired and avoids a room-lifecycle -> account-sync cycle. +/// A connected room retains its transport but loses account-derived authority +/// through the normal context cleanup below this boundary. +async fn retire_unpaired_room_for_account_boundary(reason: &str) { + let holder = get_service_holder(); + let guard = holder.read().await; + let Some(service) = guard.as_ref() else { + return; + }; + if service.active_method().await.is_none() { + return; + } + let pairing_state = service.pairing_state().await; + if pairing_room_requires_rotation(&pairing_state) { + log::info!("Retiring unpaired QR room at {reason}"); + service.stop_relay().await; + } else if pairing_state == PairingState::Connected { + log::info!("Preserving connected QR room across {reason}"); + } +} + #[tauri::command] pub async fn account_logout() -> Result<(), String> { clear_account_login(true).await; @@ -4681,6 +4761,21 @@ mod sync_state_tests { ); } + #[test] + fn account_boundaries_rotate_invitations_but_preserve_connected_rooms() { + assert!(pairing_room_requires_rotation( + &PairingState::WaitingForScan + )); + assert!(pairing_room_requires_rotation(&PairingState::Handshaking)); + assert!(pairing_room_requires_rotation(&PairingState::Verifying)); + assert!(pairing_room_requires_rotation(&PairingState::Failed { + reason: "verification failed".to_string(), + })); + assert!(!pairing_room_requires_rotation(&PairingState::Connected)); + assert!(!pairing_room_requires_rotation(&PairingState::Idle)); + assert!(!pairing_room_requires_rotation(&PairingState::Disconnected)); + } + #[test] fn settings_probe_errors_are_not_treated_as_an_empty_cloud() { assert!(!cloud_settings_exist_from_probe::(Ok(None)).unwrap()); diff --git a/src/apps/mobile/design-system/README.md b/src/apps/mobile/design-system/README.md index 4b4479a21f..a86c546c63 100644 --- a/src/apps/mobile/design-system/README.md +++ b/src/apps/mobile/design-system/README.md @@ -74,9 +74,9 @@ xcrun simctl launch booted com.openbitfun.mobile.ios \ --design-preview connected-conversation # HarmonyOS emulator (after installing a locally signed debug HAP) -hdc -t shell aa force-stop com.openbitfun.app +hdc -t shell aa force-stop hdc -t shell aa start \ - -a EntryAbility -b com.openbitfun.app \ + -a EntryAbility -b \ --ps openbitfunDesignPreview connected-conversation ``` diff --git a/src/apps/mobile/harmonyos/AppScope/app.json5 b/src/apps/mobile/harmonyos/AppScope/app.json5 index 7b9a2023db..e2361561c2 100644 --- a/src/apps/mobile/harmonyos/AppScope/app.json5 +++ b/src/apps/mobile/harmonyos/AppScope/app.json5 @@ -1,11 +1,13 @@ { "app": { - "bundleName": "com.openbitfun.app", + // Package identity is intentionally stable across the OpenBitFun rename so + // upgrades retain encrypted sessions, preferences, and watch handoff data. + "bundleName": "com.bitfun.app", "vendor": "OpenBitFun", "versionCode": 1, "versionName": "1.0.0", "buildVersion": "1", - "icon": "$media:openbitfun-app-icon", + "icon": "$media:openbitfun_app_icon", "label": "$string:app_name", "configuration": "$profile:configuration" } diff --git a/src/apps/mobile/harmonyos/AppScope/resources/base/media/openbitfun-app-icon.png b/src/apps/mobile/harmonyos/AppScope/resources/base/media/openbitfun_app_icon.png similarity index 100% rename from src/apps/mobile/harmonyos/AppScope/resources/base/media/openbitfun-app-icon.png rename to src/apps/mobile/harmonyos/AppScope/resources/base/media/openbitfun_app_icon.png diff --git a/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md index d197564b62..fafe0b7db0 100644 --- a/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md +++ b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md @@ -547,7 +547,7 @@ hdc -t 5ZU0226202001116 file recv /data/local/tmp/s.jpeg ./s.jpeg 设备侧注意事项(已踩过的坑): -- bundle 名是 **`com.openbitfun.app`**,和手表端共用一个 bundle。2026-08-10 从脚手架遗留的 `com.example.openbitfun_mobile` 改过来的,原因是 `distributedKVStore` 按 bundleName + storeId 隔离,跨设备同步的前提是同一个 app —— bundle 不一致时手机和手表各自建的是两个互不相干的库,手机↔手表的凭证交接物理上跑不通。改动的代价是已装的旧包等于另一个 app,数据不通、要重新登录; +- bundle 名以 `AppScope/app.json5` 为准,和手表端共用且作为升级兼容标识保持稳定;OpenBitFun 是产品展示名,不迁移 package identity、Preferences/HUKS/RDB/KV 的持久化标识。`distributedKVStore` 按 bundleName + storeId 隔离,任意改名都会让手机与手表落入互不相干的库,并让已安装用户丢失配对与登录恢复能力; - `hdc` 必须带 `-t `,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target); - 外屏分辨率 1080×2444;点击用 `hdc -t shell uinput -T -c X Y`。 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index b89e06e176..68e4ed9754 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -64,6 +64,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['settings.general.section', 'General'], ['settings.account.section', 'Account'], ['settings.account.current', 'Current account'], + ['settings.account.currentIdentity', 'Current identity'], ['settings.model.section', 'Model'], ['settings.model.default', 'Default model'], ['settings.devices.section', 'Devices'], @@ -232,6 +233,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.settings.accountDevice', 'Account device'], ['remote.settings.qrPairing', 'QR pairing'], ['remote.settings.disconnect', 'Disconnect'], + ['remote.settings.disconnectTemporary', 'End temporary access'], ['remote.settings.reconnect', 'Reconnect'], ['remote.settings.otherConnectionMethods', 'Other ways to connect'], ['remote.settings.desktopProduct', 'OpenBitFun Desktop'], @@ -242,8 +244,9 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.settings.openbitfunUser', 'OpenBitFun user'], ['remote.settings.profileDetails', 'Details'], ['remote.settings.account', 'OpenBitFun account'], - ['remote.settings.accountSignedIn', 'Authenticated'], - ['remote.settings.accountNotSignedIn', 'Not authenticated'], + ['remote.settings.accountSignedIn', 'Signed in'], + ['remote.settings.accountNotSignedIn', 'Signed out'], + ['remote.settings.accountTemporary', 'Temporary access'], ['remote.settings.accountSignedInBody', 'This connection is verified as account {0}. The password is not saved on the phone.'], ['remote.settings.accountNotSignedInBody', 'If the desktop asks for account verification while scanning, the phone completes it for this pairing.'], ['remote.settings.accountLoginTitle', 'Sign in to OpenBitFun'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 0c5fad688e..7026d6da2e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -64,6 +64,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['settings.general.section', '通用'], ['settings.account.section', '账号'], ['settings.account.current', '当前账号'], + ['settings.account.currentIdentity', '当前身份'], ['settings.model.section', '模型'], ['settings.model.default', '默认模型'], ['settings.devices.section', '设备'], @@ -232,6 +233,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.accountDevice', '账号设备'], ['remote.settings.qrPairing', '扫码配对'], ['remote.settings.disconnect', '断开'], + ['remote.settings.disconnectTemporary', '断开临时连接'], ['remote.settings.reconnect', '重新连接'], ['remote.settings.otherConnectionMethods', '其他连接方式'], ['remote.settings.desktopProduct', 'OpenBitFun 桌面版'], @@ -242,8 +244,9 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.openbitfunUser', 'OpenBitFun 用户'], ['remote.settings.profileDetails', '资料'], ['remote.settings.account', 'OpenBitFun 账号'], - ['remote.settings.accountSignedIn', '已认证'], - ['remote.settings.accountNotSignedIn', '未认证'], + ['remote.settings.accountSignedIn', '已登录'], + ['remote.settings.accountNotSignedIn', '未登录'], + ['remote.settings.accountTemporary', '临时登录'], ['remote.settings.accountSignedInBody', '当前连接已通过账号 {0} 验证。密码不会保存到手机。'], ['remote.settings.accountNotSignedInBody', '扫码连接时,如果桌面端要求账号验证,手机会在本次配对中完成认证。'], ['remote.settings.accountLoginTitle', '登录 OpenBitFun'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 34b7e0f13c..68ee424994 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -78,6 +78,7 @@ export interface SettingsPresentationActions { readonly close: () => void; readonly addConnection: () => void; readonly disconnect: () => void; + readonly clearPairing: () => void; readonly reconnect: () => void; readonly openAccount: () => void; readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; @@ -130,7 +131,8 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {} }, onSettings: { - close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, + close: () => {}, addConnection: () => {}, disconnect: () => {}, clearPairing: () => {}, + reconnect: () => {}, openAccount: () => {}, cloudLogin: async () => '', cloudLogout: async () => {}, cloudListDevices: async () => [], cloudSelectDevice: async () => {}, getPermissionMode: async () => 'ask', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets index 918dcbce06..378291df15 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets @@ -13,14 +13,17 @@ export struct AccountProfilePanel { @Param accountUserId: string = ''; @Param fallbackUserId: string = ''; @Param deviceId: string = ''; + @Param controlTargetType: string = 'none'; @Param controlTargetDeviceId: string = ''; @Param connectionState: string = 'idle'; @Param showNavigationHeader: boolean = false; @Param embeddedInSettings: boolean = false; @BuilderParam preferences: () => void = this.EmptyBuilder; + @BuilderParam connection: () => void = this.EmptyBuilder; @BuilderParam about: () => void = this.EmptyBuilder; @Event onBack: () => void = () => {}; @Event onLoggedOut: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; @Event onReconnect: () => void = () => {}; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = @@ -43,6 +46,7 @@ export struct AccountProfilePanel { Column({ space: 0 }) { if (this.embeddedInSettings) { this.CompactIdentityCard() + this.connection() this.preferences() this.DeviceManagementSection() this.about() @@ -71,7 +75,8 @@ export struct AccountProfilePanel { Row({ space: 14 }) { DefaultAccountAvatar({ avatarSize: 46 }) Column({ space: 4 }) { - Text(RemoteI18n.t('settings.account.current')) + Text(this.isAccountAuthenticated() ? RemoteI18n.t('settings.account.current') : + RemoteI18n.t('settings.account.currentIdentity')) .fontSize(MobileDesignTypography.bodyLarge.size) .fontWeight(FontWeight.Medium) .fontColor(INK) @@ -87,11 +92,17 @@ export struct AccountProfilePanel { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - Text(this.isAccountAuthenticated() ? RemoteI18n.t('remote.settings.accountSignedIn') : - RemoteI18n.t('remote.settings.accountNotSignedIn')) - .fontSize(MobileDesignTypography.bodySmall.size) - .fontWeight(FontWeight.Medium) - .fontColor(this.isAccountAuthenticated() ? STATUS_SUCCESS : MUTED) + Row({ space: 6 }) { + Text(this.compactAccountStatus()) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(this.isAccountAuthenticated() ? STATUS_SUCCESS : MUTED) + if (!this.isAccountAuthenticated()) { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13) + .fontColor([MUTED]) + } + } } .width('100%') .constraintSize({ minHeight: 72 }) @@ -100,6 +111,11 @@ export struct AccountProfilePanel { .backgroundColor(CARD) .borderRadius(MobileDesignGeometry.settingsCardRadius) .margin({ bottom: 24 }) + .onClick(() => { + if (!this.isAccountAuthenticated()) { + this.onOpenAccount(); + } + }) } @Builder @@ -442,6 +458,16 @@ export struct AccountProfilePanel { return `${identifier.slice(0, 18)}…`; } + private compactAccountStatus(): string { + if (this.isAccountAuthenticated()) { + return RemoteI18n.t('remote.settings.accountSignedIn'); + } + if (this.controlTargetType === 'room') { + return RemoteI18n.t('remote.settings.accountTemporary'); + } + return RemoteI18n.t('remote.settings.accountNotSignedIn'); + } + private accountStatusBody(): string { if (!this.isAccountAuthenticated()) { return RemoteI18n.t('remote.settings.accountNotSignedInBody'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index eed17c9867..8be421a15f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -41,6 +41,7 @@ export struct AppSidebarSurface { this.generalPageState.conversation.activeSession.sessionId : '', connectionState: this.remotePageState.connectionState, accountUserId: this.remotePageState.accountUserId, + controlTargetType: this.remotePageState.controlTargetType, showViewSettingsButton: false, showWorkspaceSection: true, showConversationSection: true, @@ -162,10 +163,16 @@ export struct AppSettingsSurface { selectedGeneralChatModelId: this.generalPageState.conversation.selectedModelId, accountUsername: this.remotePageState.accountUsername, authenticatedUserId: this.remotePageState.accountUserId, + fallbackUserId: this.remotePageState.userId, + desktopName: this.remotePageState.desktopName, + desktopId: this.remotePageState.desktopId, deviceId: this.deviceId, + controlTargetType: this.remotePageState.controlTargetType, controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, connectionState: this.remotePageState.connectionState, onOpenAccount: this.actions.onSettings.openAccount, + onDisconnect: this.actions.onSettings.disconnect, + onClearPairing: this.actions.onSettings.clearPairing, onReconnect: this.actions.onSettings.reconnect, cloudLogout: this.actions.onSettings.cloudLogout, cloudListDevices: this.actions.onSettings.cloudListDevices, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index e731b606f2..f5bdbb777b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -46,6 +46,7 @@ export struct AppSidebar { @Param selectedSessionId: string = ''; @Param connectionState: string = 'idle'; @Param accountUserId: string = ''; + @Param controlTargetType: string = 'none'; @Param showCollapseButton: boolean = false; @Param showViewSettingsButton: boolean = false; @Param showWorkspaceSection: boolean = false; @@ -87,8 +88,8 @@ export struct AppSidebar { @Builder private SidebarContent() { Column() { - if (this.isAccountAuthenticated()) { - this.AuthenticatedHeader() + if (this.shouldShowPrimaryNavigation()) { + this.PrimaryNavigationHeader() } else { this.SignedOutHeader() } @@ -116,10 +117,10 @@ export struct AppSidebar { .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off) - // Signed-out actions form a conventional footer and retain a fade. - // Authenticated navigation uses two independent floating controls, so - // the scroll remains visible all the way to the bottom behind them. - if (!this.isAccountAuthenticated()) { + // Pre-connection actions form a conventional footer and retain a fade. + // Once either account or QR access unlocks the workspace, navigation + // uses two floating controls and the scroll stays visible behind them. + if (!this.shouldShowPrimaryNavigation()) { Column() .width('100%') .height(this.usesCompactFooter() ? 92 : 120) @@ -130,8 +131,8 @@ export struct AppSidebar { }) } - if (this.isAccountAuthenticated()) { - this.AuthenticatedFooter() + if (this.shouldShowPrimaryNavigation()) { + this.PrimaryNavigationFooter() } else { this.SignedOutFooter() } @@ -213,7 +214,7 @@ export struct AppSidebar { } @Builder - private AuthenticatedHeader() { + private PrimaryNavigationHeader() { Row() { Text('OpenBitFun') .fontSize(MobileDesignTypography.headlineMedium.size) @@ -326,7 +327,7 @@ export struct AppSidebar { } @Builder - private AuthenticatedFooter() { + private PrimaryNavigationFooter() { Row() { Button() { Row({ space: 8 }) { @@ -743,12 +744,16 @@ export struct AppSidebar { return AdaptiveSheetOptions.fromPlacement(this.sessionDetailsPlacement); } - private isAccountAuthenticated(): boolean { - return this.accountUserId.trim().length > 0; + private shouldShowPrimaryNavigation(): boolean { + return SidebarConnectionActionPolicy.shouldShowPrimaryNavigation( + this.accountUserId, + this.controlTargetType, + this.connectionState + ); } private usesCompactFooter(): boolean { - return this.isAccountAuthenticated() || + return this.shouldShowPrimaryNavigation() || !SidebarConnectionActionPolicy.shouldShowScan(this.connectionState); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index efce0fb9ec..d1446d1ec3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -16,7 +16,6 @@ import { LINE, MEDIA_SCRIM, MUTED, - SCRIM, SHADOW_SUBTLE, SOFT, STATUS_SUCCESS, @@ -77,9 +76,11 @@ export struct ComposerBar { default_models: {} }; @Param selectedModelId: string = ''; - @Local showModelSelectorSheet: boolean = false; @Local showModelSelectorPopover: boolean = false; @Local isPreparingModelSelectorPopover: boolean = false; + @Local keepExpandedForModelSelection: boolean = false; + private restoreInputFocusAfterModelSelection: boolean = false; + private restoreInputFocusTimerId: number = -1; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; @Event onSend: () => void = () => {}; @@ -97,6 +98,10 @@ export struct ComposerBar { clearTimeout(this.sendTransitionTimerId); this.sendTransitionTimerId = -1; } + if (this.restoreInputFocusTimerId >= 0) { + clearTimeout(this.restoreInputFocusTimerId); + this.restoreInputFocusTimerId = -1; + } } @Monitor('chatInput') @@ -125,7 +130,6 @@ export struct ComposerBar { bottom: this.presentation === ComposerPresentation.Floating ? 16 : 12 }) .backgroundColor(TRANSPARENT) - .bindSheet($$this.showModelSelectorSheet, this.ModelSelector(true), this.modelSelectorSheetOptions()) } @Builder @@ -168,6 +172,9 @@ export struct ComposerBar { this.ModelControl() } Blank() + if (this.shouldShowSupplementalVoice()) { + this.SupplementalVoiceButton() + } this.PrimaryActionButton() } .width('100%') @@ -225,7 +232,7 @@ export struct ComposerBar { .accessibilityText(`${RemoteI18n.t('chat.selectModel')} · ${this.displaySelectedModel()}`) .bindPopup(this.showModelSelectorPopover, { builder: () => { - this.ModelSelector(false) + this.ModelSelector() }, placement: Placement.Top, popupColor: TRANSPARENT, @@ -236,43 +243,25 @@ export struct ComposerBar { onStateChange: (event) => { if (!event.isVisible) { this.showModelSelectorPopover = false; + this.restoreComposerFocusAfterModelSelection(); } } }) - .onClick(() => { - if (this.presentation === ComposerPresentation.Floating) { - this.toggleModelSelectorPopover(); - } else { - this.showModelSelectorSheet = true; + .onTouch((event: TouchEvent) => { + if (event.type === TouchType.Down) { + // Capture focus before the sheet/popover moves it away. Choosing a + // model is part of the current edit, not the end of that edit. + this.beginModelSelectionFromComposer(); } }) + .onClick(() => { + this.toggleModelSelectorPopover(); + }) } @Builder - ModelSelector(asSheet: boolean) { + ModelSelector() { Column({ space: 10 }) { - if (asSheet) { - Row() { - Text(RemoteI18n.t('chat.selectModel')) - .fontSize(MobileDesignTypography.bodySmall.size) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - Blank() - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(15) - .fontColor([MUTED]) - } - .width(32) - .height(32) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => { - this.closeModelSelector(); - }) - } - .width('100%') - } - List({ space: MobileDesignGeometry.composerModelSelectorRowGap }) { ForEach(this.selectorModels(), (model: ConversationUiModel) => { ListItem() { @@ -286,16 +275,15 @@ export struct ComposerBar { .edgeEffect(EdgeEffect.Spring) .divider(null) } - .width(asSheet ? '100%' : MobileDesignGeometry.composerModelSelectorWidth) + .width(MobileDesignGeometry.composerModelSelectorWidth) .padding({ left: 10, right: 10, top: 10, bottom: 10 }) .backgroundColor(CARD) - .borderRadius(asSheet ? { topLeft: 20, topRight: 20 } : - MobileDesignGeometry.composerModelSelectorRadius) - .border({ width: asSheet ? 0 : 0.5, color: asSheet ? TRANSPARENT : LINE }) + .borderRadius(MobileDesignGeometry.composerModelSelectorRadius) + .border({ width: 0.5, color: LINE }) .shadow({ - radius: asSheet ? 0 : 18, - color: asSheet ? TRANSPARENT : SHADOW_SUBTLE, - offsetY: asSheet ? 0 : 7 + radius: 18, + color: SHADOW_SUBTLE, + offsetY: 7 }) } @@ -334,7 +322,7 @@ export struct ComposerBar { .backgroundColor(this.isSelectedModel(model) ? SOFT : TRANSPARENT) .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .onClick(() => { - this.closeModelSelector(); + this.closeModelSelector(true); this.onSelectModel(model.id); }) } @@ -384,6 +372,7 @@ export struct ComposerBar { .defaultFocus(false) .onFocus(() => { this.inputFocused = true; + this.cancelModelSelectionEditingContinuation(); }) .onBlur(() => { this.inputFocused = false; @@ -397,6 +386,9 @@ export struct ComposerBar { this.inputText = value; this.onChatInputChange(value); }) + if (this.shouldShowSupplementalVoice() && !this.isComposerExpanded()) { + this.SupplementalVoiceButton() + } } .layoutWeight(1) .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) @@ -484,7 +476,7 @@ export struct ComposerBar { return; } if (action === ComposerPrimaryAction.Voice) { - this.onVoiceInput(); + void this.startVoiceInput(); } }) } @@ -504,6 +496,24 @@ export struct ComposerBar { .fontColor([this.primaryActionForegroundColor()]) } + @Builder + SupplementalVoiceButton() { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.mic')) + .fontSize(20) + .fontColor([INK]) + } + .width(COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .opacity(this.canUseSupplementalVoice() ? 0.72 : 0.32) + .accessibilityText(RemoteI18n.t('chat.voiceInput')) + .onClick(() => { + if (this.canUseSupplementalVoice()) { + void this.startVoiceInput(); + } + }) + } + @Builder ListeningWave() { Row({ space: 3 }) { @@ -585,6 +595,19 @@ export struct ComposerBar { ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); } + private shouldShowSupplementalVoice(): boolean { + return ChatComposerPolicy.shouldShowSupplementalVoice( + this.inputText, + this.selectedImages.length, + this.isVoiceListening, + this.capabilities.showVoiceInput + ); + } + + private canUseSupplementalVoice(): boolean { + return ChatComposerPolicy.canUseSupplementalVoice(this.isBusy); + } + // Dictation runs inside this app rather than in a system dialog, so listening // outranks the draft here in a way a running turn does not. private primaryAction(): ComposerPrimaryAction { @@ -631,12 +654,13 @@ export struct ComposerBar { return ChatComposerPolicy.isExpanded( this.inputText, this.inputFocused, - this.isModelSelectorExpanded() + this.isModelSelectorExpanded(), + this.keepExpandedForModelSelection ); } private isModelSelectorExpanded(): boolean { - return this.showModelSelectorSheet || this.showModelSelectorPopover || this.isPreparingModelSelectorPopover; + return this.showModelSelectorPopover || this.isPreparingModelSelectorPopover; } private shouldShowModelControl(): boolean { @@ -663,7 +687,10 @@ export struct ComposerBar { } private modelListHeight(): number { - const visibleRows = Math.min(this.enabledModels().length, 7); + // Five rows fit comfortably above the compact composer while the keyboard + // remains visible. Larger catalogs scroll inside the anchored menu. + const maxVisibleRows = this.presentation === ComposerPresentation.Compact ? 5 : 7; + const visibleRows = Math.min(this.enabledModels().length, maxVisibleRows); return visibleRows * MobileDesignGeometry.composerModelSelectorRowHeight + Math.max(0, visibleRows - 1) * MobileDesignGeometry.composerModelSelectorRowGap; } @@ -677,26 +704,38 @@ export struct ComposerBar { return [selected, ...models.filter((model: ConversationUiModel) => model.id !== selected.id)]; } - private closeModelSelector(): void { - this.showModelSelectorSheet = false; + private closeModelSelector(restoreInputFocus: boolean = false): void { + const shouldRestore = restoreInputFocus && this.restoreInputFocusAfterModelSelection; this.showModelSelectorPopover = false; this.isPreparingModelSelectorPopover = false; + if (shouldRestore) { + this.restoreComposerFocusAfterModelSelection(); + } else if (!restoreInputFocus) { + this.cancelModelSelectionEditingContinuation(); + } } private async toggleModelSelectorPopover(): Promise { if (this.showModelSelectorPopover) { - this.closeModelSelector(); + this.closeModelSelector(true); return; } if (this.isPreparingModelSelectorPopover) { return; } + if (this.presentation === ComposerPresentation.Compact) { + // On a phone this is an in-place upward menu, not a modal sheet. Keep the + // IME visible so model switching does not interrupt prompt composition. + this.showModelSelectorPopover = true; + return; + } + // Keep the expanded composer and its model-control anchor mounted while the // IME releases the window. Opening the popup before this completes makes its // viewport and hit-testing inherit the keyboard-constrained layout. this.isPreparingModelSelectorPopover = true; - await this.releaseKeyboardForModelSelector(); + await this.releaseComposerKeyboard(); if (!this.isPreparingModelSelectorPopover || this.presentation !== ComposerPresentation.Floating) { return; @@ -705,7 +744,7 @@ export struct ComposerBar { this.isPreparingModelSelectorPopover = false; } - private async releaseKeyboardForModelSelector(): Promise { + private async releaseComposerKeyboard(): Promise { const hostContext = this.getUIContext().getHostContext(); if (!hostContext) { this.getUIContext().getFocusController().clearFocus(); @@ -721,14 +760,47 @@ export struct ComposerBar { this.getUIContext().getFocusController().clearFocus(); } - private modelSelectorSheetOptions(): SheetOptions { - return { - height: Math.min(480, 86 + this.modelListHeight()), - backgroundColor: TRANSPARENT, - maskColor: SCRIM, - showClose: false, - dragBar: true - }; + private beginModelSelectionFromComposer(): void { + if (!this.inputFocused) { + return; + } + this.keepExpandedForModelSelection = true; + this.restoreInputFocusAfterModelSelection = true; + } + + private restoreComposerFocusAfterModelSelection(): void { + if (!this.restoreInputFocusAfterModelSelection || this.showModelSelectorPopover || + this.isPreparingModelSelectorPopover) { + return; + } + if (this.restoreInputFocusTimerId >= 0) { + clearTimeout(this.restoreInputFocusTimerId); + } + // Let the selector release its focus scope first. Retaining the expanded + // layout during this handoff removes the collapse/re-expand jump. + this.restoreInputFocusTimerId = setTimeout(() => { + this.restoreInputFocusTimerId = -1; + try { + this.getUIContext().getFocusController().requestFocus(this.inputId); + } catch (_error) { + // If the route disappeared during dismissal, component teardown owns + // the continuation and clears its timer. + } + }, MobileDesignMotion.structure); + } + + private cancelModelSelectionEditingContinuation(): void { + if (this.restoreInputFocusTimerId >= 0) { + clearTimeout(this.restoreInputFocusTimerId); + this.restoreInputFocusTimerId = -1; + } + this.restoreInputFocusAfterModelSelection = false; + this.keepExpandedForModelSelection = false; + } + + private async startVoiceInput(): Promise { + await this.releaseComposerKeyboard(); + this.onVoiceInput(); } private inputPlaceholder(): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index dceeedba5d..c5e60b8d96 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -3,7 +3,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; import { ModelServiceSettingsPolicy } from '../policy/ModelServiceSettingsPolicy'; import { SettingsSheetState } from '../state/SettingsSheetState'; -import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT, STATUS_DANGER } from './Theme'; import { AccountProfilePanel } from './AccountProfilePanel'; import { LanguageSettingsPanel } from './LanguageSettingsPanel'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; @@ -25,11 +25,17 @@ export struct SettingsSheet { @Param selectedGeneralChatModelId: string = ''; @Param accountUsername: string = ''; @Param authenticatedUserId: string = ''; + @Param fallbackUserId: string = ''; + @Param desktopName: string = ''; + @Param desktopId: string = ''; @Param deviceId: string = ''; + @Param controlTargetType: string = 'none'; @Param controlTargetDeviceId: string = ''; @Param connectionState: string = 'idle'; @Event onClose: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; + @Event onDisconnect: () => void = () => {}; + @Event onClearPairing: () => void = () => {}; @Event onReconnect: () => void = () => {}; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = @@ -98,15 +104,21 @@ export struct SettingsSheet { AccountProfilePanel({ accountUsername: this.accountUsername, accountUserId: this.authenticatedUserId, + fallbackUserId: this.fallbackUserId, deviceId: this.deviceId, + controlTargetType: this.controlTargetType, controlTargetDeviceId: this.controlTargetDeviceId, connectionState: this.connectionState, cloudLogout: this.cloudLogout, cloudListDevices: this.cloudListDevices, cloudSelectDevice: this.cloudSelectDevice, onReconnect: this.onReconnect, + onOpenAccount: this.onOpenAccount, onLoggedOut: this.onClose, embeddedInSettings: true, + connection: () => { + this.CurrentConnectionSection() + }, preferences: () => { this.GeneralSettingsSection() }, @@ -131,6 +143,87 @@ export struct SettingsSheet { .height('100%') } + @Builder + private CurrentConnectionSection() { + if (this.hasControlTarget()) { + this.SectionTitle(RemoteI18n.t('remote.settings.currentControl')) + Column() { + Row({ space: 14 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22) + .fontColor([MUTED]) + .opacity(0.68) + } + .width(28) + .height(28) + + Column({ space: 3 }) { + Text(this.connectionTitle()) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .width('100%') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.connectionDetail()) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontColor(MUTED) + .width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: 68 }) + .padding({ left: 18, right: 18, top: 12, bottom: 12 }) + .alignItems(VerticalAlign.Center) + + Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 }) + + Row({ space: 8 }) { + Text(this.connectionSourceLabel()) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontColor(MUTED) + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .backgroundColor(SOFT) + .borderRadius(12) + Blank() + if (!this.isConnectedOrConnecting()) { + Text(RemoteI18n.t('remote.settings.reconnect')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .onClick(this.onReconnect) + } + Text(this.controlTargetType === 'room' ? + RemoteI18n.t('remote.settings.disconnectTemporary') : + RemoteI18n.t('remote.settings.disconnect')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(STATUS_DANGER) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .onClick(() => { + if (this.controlTargetType === 'room') { + this.onClearPairing(); + } else { + this.onDisconnect(); + } + }) + } + .width('100%') + .constraintSize({ minHeight: 54 }) + .padding({ left: 18, right: 10 }) + .alignItems(VerticalAlign.Center) + } + .width('100%') + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .margin({ bottom: 24 }) + } + } + @Builder private GeneralSettingsSection() { this.SectionTitle(RemoteI18n.t('settings.general.section')) @@ -361,6 +454,34 @@ export struct SettingsSheet { }) } + private hasControlTarget(): boolean { + return this.controlTargetType === 'room' || this.controlTargetType === 'account_device'; + } + + private isConnectedOrConnecting(): boolean { + return this.connectionState === 'connected' || this.connectionState === 'reconnecting' || + this.connectionState === 'pairing' || this.connectionState === 'parsing'; + } + + private connectionTitle(): string { + return this.desktopName || this.desktopId || RemoteI18n.t('remote.settings.noDesktop'); + } + + private connectionDetail(): string { + if (this.connectionState === 'connected') { + return RemoteI18n.t('connection.connected'); + } + if (this.connectionState === 'reconnecting') { + return RemoteI18n.t('connection.reconnecting'); + } + return RemoteI18n.t('connection.disconnected'); + } + + private connectionSourceLabel(): string { + return this.controlTargetType === 'room' ? RemoteI18n.t('remote.settings.qrPairing') : + RemoteI18n.t('remote.settings.accountDevice'); + } + private modelServiceStatus(): string { const model = this.selectedGeneralChatModel(); return ModelServiceSettingsPolicy.modelLabel(model, RemoteI18n.t('settings.modelService.notConfigured')); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets index 6d750014af..b893ca6f68 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets @@ -1,6 +1,23 @@ -/** Presentation rules for connection actions in the signed-out sidebar. */ +/** Presentation rules for account-independent sidebar entry and navigation. */ export class SidebarConnectionActionPolicy { static shouldShowScan(connectionState: string): boolean { return connectionState !== 'connected'; } + + /** + * Account identity is optional once a remote control target exists. A QR + * room therefore unlocks the same chat/settings shell as an account device, + * including while that remembered target is temporarily reconnecting. + */ + static shouldShowPrimaryNavigation( + accountUserId: string, + controlTargetType: string, + connectionState: string + ): boolean { + return accountUserId.trim().length > 0 || + controlTargetType === 'room' || + controlTargetType === 'account_device' || + connectionState === 'connected' || + connectionState === 'reconnecting'; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index bf136d1e7a..37ceee34b1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -164,11 +164,52 @@ export class AppRootRuntime extends AppRootRuntimeComposition { async restoreIdentity(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { + // Always restore the install identity first. Account-device startup used to + // return before this happened, so the phone presented a new random device + // id on every launch and could not reliably exclude itself from the list. + await this.remoteConnectionController.restoreWithoutReconnect(this.host.context()); + const controlTargetPreference = this.remoteConnectionController.controlTargetPreference(); + let preferredTarget = this.settingsController.preferredCloudTarget(); + + if (controlTargetPreference === 'room') { + await this.remoteConnectionController.reconnectRestoredPairing(); + if ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected) { + return; + } + // A rotated room may fall back only to the same physical desktop through + // the account path. Never jump to an unrelated remembered account device. + if (preferredTarget?.deviceId !== this.remotePageState.desktopId) { + preferredTarget = await this.settingsController.migrateLegacyPairingTarget( + this.remotePageState.remoteUrl, + this.remotePageState.desktopId, + this.remotePageState.desktopName + ); + } + if (preferredTarget?.deviceId === this.remotePageState.desktopId) { + await this.restoreAccountDeviceTarget(false); + } + return; + } + + if (!preferredTarget && this.settingsController.hasCloudAccountSession()) { + preferredTarget = await this.settingsController.migrateLegacyPairingTarget( + this.remotePageState.remoteUrl, + this.remotePageState.desktopId, + this.remotePageState.desktopName + ); + } + if (preferredTarget) { await this.restoreAccountDeviceTarget(false); return; } - await this.remoteConnectionController.restore(this.host.context()); + if (this.settingsController.hasCloudAccountSession()) { + // Legacy records have no explicit control-target preference. With no + // stable same-device target, leave the user on the hydrated directory + // instead of guessing whether an old QR should supersede the account. + RemoteLogger.info('account session restored without a target preference; skipped ambiguous QR reconnect'); + return; + } + await this.remoteConnectionController.reconnectRestoredPairing(); } /** @@ -182,7 +223,8 @@ export class AppRootRuntime extends AppRootRuntimeComposition { * the device lookup is still in flight. */ private async restoreAccountDeviceTarget(navigateHome: boolean): Promise { - const deviceId = this.remotePageState.controlTargetDeviceId.trim(); + const preferredTarget = this.settingsController.preferredCloudTarget(); + const deviceId = preferredTarget?.deviceId || this.remotePageState.controlTargetDeviceId.trim(); if (deviceId.length === 0) { return; } @@ -190,18 +232,24 @@ export class AppRootRuntime extends AppRootRuntimeComposition { this.remotePageState.setStatusText(RemoteI18n.t('status.restoringConnection')); await this.settingsController.restoreCloudTarget( deviceId, - this.remotePageState.controlTargetDeviceName, + preferredTarget?.deviceName || this.remotePageState.controlTargetDeviceName, navigateHome ); } async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { await this.remoteConnectionController.connect(autoReconnect, accountPassword); - await this.settingsController.persistDelegatedAccountSession(); + if ((this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Connected) { + return; + } + await this.settingsController.persistDelegatedAccountSession( + this.remotePageState.desktopId, + this.remotePageState.desktopName + ); } async reconnect(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { + if (this.settingsController.preferredCloudTarget()) { await this.restoreAccountDeviceTarget(true); return; } @@ -645,11 +693,12 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } async reconnectActiveRemote(): Promise { - if (this.remotePageState.controlTargetType !== 'account_device') { + const preferredTarget = this.settingsController.preferredCloudTarget(); + if (!preferredTarget) { await this.connect(true); return; } - const targetId = this.remotePageState.controlTargetDeviceId; + const targetId = preferredTarget.deviceId; const device = (await this.settingsController.listCloudAccountDevices()) .find((item: CloudAccountDevice): boolean => item.deviceId === targetId); if (!device) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index e66fd51439..7867df838f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -800,6 +800,9 @@ export abstract class AppRootRuntimeComposition { loadRecentWorkspaces: async (): Promise => { await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); }, + saveControlTargetType: async (controlTargetType: string): Promise => { + await this.remoteConnectionController.rememberControlTargetType(controlTargetType); + }, onAccountDevices: (devices: CloudAccountDevice[]): void => { this.deviceDirectoryViewModel.syncDevices(devices); }, @@ -999,6 +1002,10 @@ export abstract class AppRootRuntimeComposition { close: (): void => this.appShellState.leaveSettings(), addConnection: (): void => this.openAddConnectionFromSettings(), disconnect: (): void => { this.disconnect(false); }, + clearPairing: (): void => { + this.appShellState.setSettingsVisible(false); + this.disconnect(true); + }, reconnect: (): void => { this.reconnect(); }, openAccount: (): void => { this.appShellState.openSettings('account'); }, cloudLogin: (relayUrl: string, username: string, password: string): Promise => @@ -1138,7 +1145,8 @@ export abstract class AppRootRuntimeComposition { const result = this.remoteConnectionController.handleDetectedUrl( url, this.settingsController.hasCloudAccountSession(), - this.remotePageState.accountUsername + this.remotePageState.accountUsername, + this.settingsController.cloudRelayEndpoint() ); if (result.action === DetectedUrlAction.USE_CLOUD_DEVICE) { void this.settingsController.restoreCloudTarget( diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets index 953405cc63..e73d647320 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets @@ -72,11 +72,22 @@ export class RemoteActivityViewModel { if (!wasConnected && !this.hooks.isReconnecting()) { return; } + if (!wasConnected) { + const token = this.gate.begin(); + this.recovering = true; + try { + await this.reconnect(token); + } catch (err) { + if (this.gate.isCurrent(token)) { + this.hooks.onStatus(ConnectionErrorPolicy.errorText(err)); + } + } finally { + this.recovering = false; + } + return; + } try { await this.connection.ping(); - if (!wasConnected) { - await this.restoreAfterHealthyPing(); - } } catch (err) { if (await this.hooks.onConnectionError(err)) { return; @@ -90,16 +101,6 @@ export class RemoteActivityViewModel { } } - private async restoreAfterHealthyPing(): Promise { - RemoteLogger.info('heartbeat ping answered, link restored'); - this.hooks.onConnectionState('connected'); - this.hooks.onStatus(RemoteI18n.t('connection.connected')); - if (this.hooks.isRemoteChat() && this.hooks.activeSession().sessionId.length > 0) { - this.hooks.onStartPolling(); - await this.hooks.onPoll(); - } - } - resume(): void { if (!this.hooks.hasRemoteBinding()) { return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index 6a3129946f..7aa6021c5c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -59,6 +59,7 @@ export class RemoteConnectionController { private autoReconnectAttempted: boolean = false; private failureCount: number = 0; private lockUntil: number = 0; + private restoredControlTargetType: string = ''; constructor( pageState: RemotePageState, @@ -106,12 +107,48 @@ export class RemoteConnectionController { return this.deviceId; } + controlTargetPreference(): string { + return this.restoredControlTargetType; + } + + async rememberControlTargetType(controlTargetType: string): Promise { + const normalized = controlTargetType === 'room' || controlTargetType === 'account_device' ? + controlTargetType : ''; + this.restoredControlTargetType = normalized; + try { + await this.identity.saveControlTargetType(normalized); + } catch (err) { + // The live transport is already authoritative. A persistence failure may + // affect the next launch, but must not turn this connection into a failure. + RemoteLogger.warn(`control target preference persistence failed: ${String(err)}`); + } + } + async restore(context: Context): Promise { + await this.restoreWithReconnectPolicy(context, true); + } + + async restoreWithoutReconnect(context: Context): Promise { + await this.restoreWithReconnectPolicy(context, false); + } + + async reconnectRestoredPairing(): Promise { + if (this.shouldAutoReconnect()) { + this.autoReconnectAttempted = true; + this.setState(RemoteConnectionState.Reconnecting); + this.pageState.setStatusText(RemoteI18n.t('status.restoringConnection')); + await this.connect(true); + } + } + + private async restoreWithReconnectPolicy(context: Context, reconnect: boolean): Promise { try { const snapshot: MobileIdentitySnapshot = await this.identity.init(context); this.deviceId = snapshot.installId; this.pageState.setUserId(snapshot.userId || snapshot.installId); this.pageState.setRemoteUrl(snapshot.remoteUrl); + this.restoredControlTargetType = snapshot.controlTargetType === 'room' || + snapshot.controlTargetType === 'account_device' ? snapshot.controlTargetType : ''; this.applyPairingProjection(snapshot.remoteUrl); this.models.setPreferredModelId(await this.identity.getLastModelId()); this.failureCount = snapshot.userIdFailureCount; @@ -121,11 +158,8 @@ export class RemoteConnectionController { await this.identity.clearUserIdProtection(); } this.pageState.setRemoteUrlInputVisible(snapshot.remoteUrl.length > 0); - if (this.shouldAutoReconnect()) { - this.autoReconnectAttempted = true; - this.setState(RemoteConnectionState.Reconnecting); - this.pageState.setStatusText(RemoteI18n.t('status.restoringConnection')); - await this.connect(true); + if (reconnect) { + await this.reconnectRestoredPairing(); } } catch (err) { this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); @@ -137,6 +171,7 @@ export class RemoteConnectionController { if (this.pageState.isBusy) { return; } + const recovering = autoReconnect || this.pageState.connectionState === RemoteConnectionState.Reconnecting; try { RemoteLogger.info(`connect start auto=${autoReconnect ? '1' : '0'}`); this.pageState.setBusy(true); @@ -170,6 +205,7 @@ export class RemoteConnectionController { this.pageState.setAuthenticatedUserId(initialSync.authenticatedUserId); this.pageState.setHostCapabilities(initialSync.capabilities || []); this.pageState.setControlTarget('room', this.pageState.desktopId, this.pageState.desktopName); + await this.rememberControlTargetType('room'); this.sessions.setSessions(initialSync.sessions, initialSync.hasMoreSessions); this.setState(RemoteConnectionState.Connected); this.pageState.setConnectionFailureKind(''); @@ -202,9 +238,12 @@ export class RemoteConnectionController { if (result.shouldShowRemoteUrlInput) { this.pageState.setRemoteUrlInputVisible(true); } - this.setState(RemoteConnectionState.Failed); + const terminalFailure = result.failureKind === 'expired_room' || + result.failureKind === 'invalid_link' || result.failureKind === 'protected_user'; + this.setState(recovering && !terminalFailure ? + RemoteConnectionState.Reconnecting : RemoteConnectionState.Failed); RemoteLogger.error(`connect failed stage=connection message=${ConnectionErrorPolicy.errorText(err)}`); - if (!autoReconnect) { + if (!recovering) { this.openConnectSheet(); } } finally { @@ -221,10 +260,11 @@ export class RemoteConnectionController { } this.setState(RemoteConnectionState.Reconnecting); this.pageState.setStatusText(RemoteI18n.t('status.reconnecting')); - await this.connect(); + await this.connect(true); } async disconnect(clearPairing: boolean): Promise { + const clearingPreferredRoom = clearPairing && this.restoredControlTargetType === 'room'; this.connection.invalidate(); this.stopPolling(); this.stopHeartbeat(); @@ -252,6 +292,9 @@ export class RemoteConnectionController { this.pageState.setAccountPairing(false, ''); this.pageState.setRemoteUrlInputVisible(false); await this.identity.clearPairingInput(); + if (clearingPreferredRoom) { + await this.rememberControlTargetType(''); + } // Unpairing is the user saying they are done with that desktop. Leaving // its sessions on disk would put them back on Remote Home at the next // launch, with nothing behind them to open. @@ -309,7 +352,8 @@ export class RemoteConnectionController { handleDetectedUrl( remoteUrl: string, hasCloudAccountSession: boolean = false, - cloudUsername: string = '' + cloudUsername: string = '', + cloudRelayUrl: string = '' ): DetectedRemoteUrlResult { try { this.applyRemoteUrl(remoteUrl); @@ -320,7 +364,9 @@ export class RemoteConnectionController { hasCloudAccountSession, cloudUsername, descriptor.accountUsername, - RemoteUiState.desktopIdFromRemoteUrl(remoteUrl) + RemoteUiState.desktopIdFromRemoteUrl(remoteUrl), + cloudRelayUrl, + descriptor.relayUrl ); if (action === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD) { this.pageState.setStatusText(RemoteI18n.t('connect.enterAccountToPair')); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index 784f4e4125..7968825cd6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -21,6 +21,8 @@ import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/Genera import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; import { RemoteHeartbeatController } from '../../services/RemoteHeartbeatController'; import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteDescriptorParser } from '../../services/RemoteDescriptorParser'; +import { ConnectScanDecisionPolicy } from '../../services/ConnectScanDecisionPolicy'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { WatchProvisionOutcome } from '../../services/WatchProvisionController'; import { RemotePermissionMode } from '../../model/RemoteModels'; @@ -48,6 +50,7 @@ export interface CloudAccountSettingsHooks { readonly closeConnectSheet: () => void; readonly navigateRemoteHome: () => void; readonly loadRecentWorkspaces: () => Promise; + readonly saveControlTargetType: (controlTargetType: string) => Promise; readonly onAccountDevices: (devices: CloudAccountDevice[]) => void; /** Snapshot the outgoing live device before teardown clears its lists. */ readonly onBeforeControlTargetChange: () => void; @@ -64,6 +67,11 @@ export interface CloudAccountSettingsDependencies { readonly hooks: CloudAccountSettingsHooks; } +export interface PreferredCloudTarget { + readonly deviceId: string; + readonly deviceName: string; +} + /** How often presence is refetched while device rows are on screen. */ const PRESENCE_POLL_INTERVAL_MS: number = 20000; @@ -82,6 +90,9 @@ export class SettingsController { private presenceTicker?: RemoteHeartbeatController; private restoringControlTarget: boolean = false; private accountDeviceSelectionVersion: number = 0; + /** Last account device the user successfully controlled, independent of the live QR projection. */ + private preferredTargetDeviceId: string = ''; + private preferredTargetDeviceName: string = ''; constructor( store: GeneralChatConfigStore, @@ -280,37 +291,75 @@ export class SettingsController { } } - async persistDelegatedAccountSession(): Promise { - if (this.cloudSession) { - return; - } + async persistDelegatedAccountSession(targetDeviceId: string = '', targetDeviceName: string = ''): Promise { const cloud = this.requireCloud(); const delegated = cloud.sessionManager.delegatedAccountSession(); if (!delegated) { + // A room and an account target are independent recovery paths. Choosing + // an ordinary QR room must not erase the last account device; the saved + // control-target type decides which one the next launch tries first. return; } - this.applyCloudAccountSession(delegated.session, delegated.relayUrl, delegated.session.userId); - await cloud.sessionStore.save({ - relayUrl: delegated.relayUrl, - username: delegated.session.userId, - token: delegated.session.token, - userId: delegated.session.userId, - masterKey: Encoding.bytesToBase64(delegated.session.masterKey) - }); - RemoteLogger.info('delegated account session persisted after room pairing'); + const existing = this.cloudSession; + if (existing && (existing.userId !== delegated.session.userId || + !ConnectScanDecisionPolicy.relayEndpointsCompatible(this.cloudRelayUrl, delegated.relayUrl))) { + RemoteLogger.warn('delegated account session ignored because the signed-in account or relay differs'); + if (this.preferredTargetDeviceId.length > 0) { + this.clearPreferredCloudTarget(); + try { + await this.persistCurrentCloudSessionTarget('', ''); + } catch (err) { + RemoteLogger.warn(`account target clear persistence failed: ${String(err)}`); + } + } + return; + } + const session = existing || delegated.session; + const relayUrl = existing && this.cloudRelayUrl.length > 0 ? this.cloudRelayUrl : delegated.relayUrl; + const username = existing ? (this.cloudAccountUsername || session.userId) : delegated.session.userId; + if (!existing) { + this.applyCloudAccountSession(session, relayUrl, username); + } + const persisted: PersistedCloudAccountSession = { + relayUrl, + username, + token: session.token, + userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }; + const targetId = targetDeviceId.trim(); + if (targetId.length > 0) { + persisted.targetDeviceId = targetId; + persisted.targetDeviceName = targetDeviceName.trim() || targetId; + this.setPreferredCloudTarget(targetId, persisted.targetDeviceName); + } + try { + await cloud.sessionStore.save(persisted); + RemoteLogger.info(`delegated account session persisted after room pairing target=${targetId.length > 0 ? '1' : '0'}`); + } catch (err) { + // The transport is already authenticated and useful. Storage failure + // means this launch cannot be resumed after a kill; it must not tear down + // the link the user just established. + RemoteLogger.warn(`delegated account session persistence failed: ${String(err)}`); + } } async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { const cloud = this.requireCloud(); RemoteLogger.info('cloud account UI login requested'); const session = await cloud.client.login(relayUrl, username, password, cloud.hooks.deviceId()); + this.clearPreferredCloudTarget(); this.applyCloudAccountSession(session, relayUrl, username); - await cloud.sessionStore.save({ - relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey) - }); + try { + await cloud.sessionStore.save({ + relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }); + } catch (err) { + RemoteLogger.warn(`cloud account session persistence failed: ${String(err)}`); + } await this.loadGeneralChatAccountModels(session, relayUrl); - RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); + RemoteLogger.info('cloud account session active, refreshing account devices'); RemoteLogger.info(`cloud account login success user=${session.userId}`); return session.userId; } @@ -326,14 +375,20 @@ export class SettingsController { async logoutCloudAccount(): Promise { const cloud = this.requireCloud(); - cloud.hooks.invalidatePreview(); - this.teardownCloudAccountProjection(cloud); + const preserveRoom = this.hasRoomControlTarget(cloud); + if (!preserveRoom) { + cloud.hooks.invalidatePreview(); + } + this.teardownCloudAccountProjection(cloud, preserveRoom); this.store.replaceAccountModels([]); await this.refreshModelCatalog(); await cloud.sessionStore.clear(); await cloud.hooks.clearCachedRemoteData(); - cloud.remoteState.clearControlTarget(); - cloud.remoteState.setHostCapabilities([]); + if (!preserveRoom) { + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setHostCapabilities([]); + await this.saveControlTargetType(''); + } RemoteLogger.info('cloud account logout success'); } @@ -351,6 +406,66 @@ export class SettingsController { return binding; } + cloudRelayEndpoint(): string { + return this.cloudRelayUrl; + } + + preferredCloudTarget(): PreferredCloudTarget | undefined { + const deviceId = this.preferredTargetDeviceId.trim(); + if (deviceId.length === 0) { + return undefined; + } + return { + deviceId, + deviceName: this.preferredTargetDeviceName.trim() || deviceId + }; + } + + /** + * Upgrades an older account record that predates persisted target devices. + * + * An account-auth QR directly identifies a stable account device. An ordinary + * QR may migrate only when the refreshed account roster independently proves + * that the same desktop device exists on the same relay. This prevents a + * stale room from silently switching control to another remembered desktop. + */ + async migrateLegacyPairingTarget( + remoteUrl: string, + deviceId: string, + deviceName: string + ): Promise { + const targetId = deviceId.trim(); + if (!this.cloudSession || targetId.length === 0) { + return undefined; + } + if (this.preferredTargetDeviceId.length > 0) { + return this.preferredTargetDeviceId === targetId ? this.preferredCloudTarget() : undefined; + } + try { + const descriptor = RemoteDescriptorParser.parse(remoteUrl); + const knownAccountDevice = this.knownAccountDevice(targetId); + if ((!descriptor.accountAuth && !knownAccountDevice) || !ConnectScanDecisionPolicy.relayEndpointsCompatible( + this.cloudRelayUrl, + descriptor.relayUrl + )) { + return undefined; + } + const targetName = knownAccountDevice?.deviceName || deviceName.trim() || targetId; + this.setPreferredCloudTarget(targetId, targetName); + this.restorePreferredCloudTargetProjection(); + try { + await this.persistCurrentCloudSessionTarget(targetId, targetName); + } catch (err) { + RemoteLogger.warn(`legacy account target persistence failed: ${String(err)}`); + } + RemoteLogger.info(`legacy QR pairing migrated to account target device=${targetId}`); + return this.preferredCloudTarget(); + } catch (err) { + RemoteLogger.warn(`legacy QR pairing target migration skipped: ${String(err)}`); + return undefined; + } + } + /** * Polls presence for as long as device rows are on screen. * @@ -488,6 +603,7 @@ export class SettingsController { if (targetId.length === 0) { return; } + this.setPreferredCloudTarget(targetId, targetDeviceName.trim() || targetId); const remoteState = this.requireCloud().remoteState; try { const devices = await this.listCloudAccountDevices(); @@ -496,8 +612,9 @@ export class SettingsController { const targetName = target?.deviceName || targetDeviceName || targetId; remoteState.setControlTarget('account_device', targetId, targetName); remoteState.setDesktopIdentity(targetName, targetId); - remoteState.setConnectionState('failed'); + remoteState.setConnectionState('reconnecting'); remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); + this.requireCloud().hooks.startHeartbeat(); return; } await this.selectCloudAccountDevice({ @@ -518,14 +635,9 @@ export class SettingsController { // where the target still matters: without it the reconnect button has // nothing to retry and falls through to the paired-desktop path. remoteState.setControlTarget('account_device', targetId, targetDeviceName.trim() || targetId); - // The caller announces `reconnecting` before this runs, and every surface - // hides its reconnect entry point while a connect is in flight. Failing - // silently would leave that announcement standing forever, with no way - // left to retry it. Only report when nothing downstream already did. - if (remoteState.connectionState !== 'failed') { - remoteState.setConnectionState('failed'); - remoteState.setStatusText(ConnectionErrorPolicy.errorText(err)); - } + remoteState.setConnectionState('reconnecting'); + remoteState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.requireCloud().hooks.startHeartbeat(); } } @@ -603,6 +715,8 @@ export class SettingsController { cloud.remoteState.setAuthenticatedUserId(initialSync.authenticatedUserId); cloud.remoteState.setHostCapabilities(initialSync.capabilities || []); cloud.remoteState.setConnectionState('connected'); + this.setPreferredCloudTarget(deviceId, device.deviceName || deviceId); + await this.saveControlTargetType('account_device'); this.resyncAccountDevicePresence(); cloud.remoteState.setStatusText(RemoteI18n.t('connection.connected')); // Dismissing the picker and going home are the same event: the switch the @@ -614,15 +728,11 @@ export class SettingsController { cloud.hooks.closeConnectSheet(); cloud.hooks.navigateRemoteHome(); } - await cloud.sessionStore.save({ - relayUrl: this.cloudRelayUrl, - username: cloud.remoteState.accountUsername, - token: session.token, - userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey), - targetDeviceId: deviceId, - targetDeviceName: device.deviceName - }); + try { + await this.persistCurrentCloudSessionTarget(deviceId, device.deviceName || deviceId); + } catch (err) { + RemoteLogger.warn(`account target persistence failed device=${deviceId}: ${String(err)}`); + } cloud.hooks.startHeartbeat(); // Same reasoning as the paired-desktop path in RemoteConnectionController: // the cross-workspace scan runs behind a list that is already usable. @@ -757,7 +867,9 @@ export class SettingsController { await this.loadGeneralChatAccountModels(session, persisted.relayUrl); } catch (err) { RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - await cloud.sessionStore.clear(); + // Retain the encrypted record. A temporary keystore/storage failure must + // not turn into an implicit logout and erase the only resumable session. + cloud.remoteState.setStatusText(ConnectionErrorPolicy.errorText(err)); } } @@ -775,9 +887,8 @@ export class SettingsController { return; } const targetName = (persisted.targetDeviceName || '').trim() || targetId; - const remoteState = this.requireCloud().remoteState; - remoteState.setControlTarget('account_device', targetId, targetName); - remoteState.setDesktopIdentity(targetName, targetId); + this.setPreferredCloudTarget(targetId, targetName); + this.restorePreferredCloudTargetProjection(); } private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { @@ -803,34 +914,58 @@ export class SettingsController { private async expireCloudAccountSession(): Promise { const cloud = this.requireCloud(); - cloud.hooks.invalidatePreview(); + const preserveRoom = this.hasRoomControlTarget(cloud); + if (!preserveRoom) { + cloud.hooks.invalidatePreview(); + } // Publish the signed-out projection before awaiting storage. A revoked // credential must never leave stale account devices actionable merely // because preferences or cache cleanup is slow (or fails). - this.teardownCloudAccountProjection(cloud); + this.teardownCloudAccountProjection(cloud, preserveRoom); this.store.replaceAccountModels([]); await this.refreshModelCatalog(); await cloud.sessionStore.clear(); await cloud.hooks.clearCachedRemoteData(); + if (!preserveRoom) { + await this.saveControlTargetType(''); + } RemoteLogger.info('cloud account session expired'); } /** Publishes the complete signed-out projection as one synchronous transition. */ - private teardownCloudAccountProjection(cloud: CloudAccountSettingsDependencies): void { + private teardownCloudAccountProjection( + cloud: CloudAccountSettingsDependencies, + preserveRoom: boolean + ): void { // Invalidate late device-selection completions before touching observable // state. During an in-flight switch the target is deliberately `none`, but // its busy/status/session projection still belongs to the account. this.accountDeviceSelectionVersion += 1; - this.resetAccountDeviceConnection(true); + if (!preserveRoom) { + this.resetAccountDeviceConnection(true); + } this.clearCloudAccountIdentity(cloud); } + private hasRoomControlTarget(cloud: CloudAccountSettingsDependencies): boolean { + return cloud.remoteState.controlTargetType === 'room'; + } + + private async saveControlTargetType(controlTargetType: string): Promise { + try { + await this.requireCloud().hooks.saveControlTargetType(controlTargetType); + } catch (err) { + RemoteLogger.warn(`control target preference persistence failed: ${String(err)}`); + } + } + /** Drops the remaining account identity facts after remote teardown. */ private clearCloudAccountIdentity(cloud: CloudAccountSettingsDependencies): void { this.stopPresencePolling(); this.cloudSession = undefined; this.cloudRelayUrl = ''; this.cloudAccountUsername = ''; + this.clearPreferredCloudTarget(); this.accountDevices = []; this.lastAccountDevicesAt = 0; cloud.hooks.onAccountDevices([]); @@ -882,6 +1017,43 @@ export class SettingsController { cloud.remoteState.setConnectionState('disconnected'); } + private setPreferredCloudTarget(deviceId: string, deviceName: string): void { + this.preferredTargetDeviceId = deviceId.trim(); + this.preferredTargetDeviceName = deviceName.trim() || this.preferredTargetDeviceId; + } + + private clearPreferredCloudTarget(): void { + this.preferredTargetDeviceId = ''; + this.preferredTargetDeviceName = ''; + } + + private restorePreferredCloudTargetProjection(): void { + const target = this.preferredCloudTarget(); + if (!target) { + return; + } + const remoteState = this.requireCloud().remoteState; + remoteState.setControlTarget('account_device', target.deviceId, target.deviceName); + remoteState.setDesktopIdentity(target.deviceName, target.deviceId); + } + + private async persistCurrentCloudSessionTarget(deviceId: string, deviceName: string): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + return; + } + await cloud.sessionStore.save({ + relayUrl: this.cloudRelayUrl, + username: this.cloudAccountUsername || session.userId, + token: session.token, + userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey), + targetDeviceId: deviceId.trim(), + targetDeviceName: deviceName.trim() || deviceId.trim() + }); + } + private requireCloud(): CloudAccountSettingsDependencies { if (!this.cloud) { throw new Error('Cloud account settings dependencies are not configured.'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets index cb88aa0e09..62d544cafa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets @@ -31,6 +31,28 @@ export class ChatComposerPolicy { return text.trim().length === 0 && attachmentCount === 0 && !isBusy; } + /** + * Whether voice needs its secondary slot inside the input field. + * + * The primary slot belongs to Send as soon as the draft carries anything, + * but dictation is still an input method at that point. Keeping the inline + * affordance mounted while busy avoids layout movement; the component dims + * it until recording can start again. + */ + static shouldShowSupplementalVoice( + text: string, + attachmentCount: number, + isVoiceListening: boolean, + showVoiceInput: boolean + ): boolean { + return showVoiceInput && !isVoiceListening && + (text.trim().length > 0 || attachmentCount > 0); + } + + static canUseSupplementalVoice(isBusy: boolean): boolean { + return !isBusy; + } + // Which of the things the one round button on the right is offering. The // composer has a single primary slot rather than a row of buttons, so "which // action" is a decision, not a layout detail — and it is the same decision on @@ -78,9 +100,10 @@ export class ChatComposerPolicy { static isExpanded( text: string, inputFocused: boolean, - modelSelectorOpen: boolean + modelSelectorOpen: boolean, + continuingEditAfterModelSelection: boolean = false ): boolean { - return inputFocused || modelSelectorOpen || text.indexOf('\n') >= 0; + return inputFocused || modelSelectorOpen || continuingEditAfterModelSelection || text.indexOf('\n') >= 0; } static shouldShowAddButton(showAddButton: boolean, supportsAttachments: boolean): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets index e6b5f3a315..75838c7754 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets @@ -1,12 +1,13 @@ import { huks } from '@kit.UniversalKeystoreKit'; import { preferences } from '@kit.ArkData'; import { Encoding } from './Encoding'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { OhosError } from './OhosError'; -const STORE_NAME: string = 'openbitfun_cloud_account'; +const STORE_NAME: string = HarmonyUpgradeIdentityContract.CLOUD_ACCOUNT_STORE; const CIPHER_KEY: string = 'session_cipher'; const IV_KEY: string = 'session_iv'; -const HUKS_ALIAS: string = 'openbitfun_cloud_account_session'; +const HUKS_ALIAS: string = HarmonyUpgradeIdentityContract.CLOUD_ACCOUNT_HUKS_ALIAS; export interface PersistedCloudAccountSession { relayUrl: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets index 02700a17a9..241801fc26 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets @@ -30,13 +30,16 @@ export class ConnectScanDecisionPolicy { hasCloudAccountSession: boolean, cloudUsername: string, qrUsername: string, - desktopId: string + desktopId: string, + cloudRelayUrl: string = '', + qrRelayUrl: string = '' ): string { if (!accountAuth) { return DetectedUrlAction.PAIR_NOW; } if (hasCloudAccountSession && - ConnectScanDecisionPolicy.usernamesCompatible(cloudUsername, qrUsername)) { + ConnectScanDecisionPolicy.usernamesCompatible(cloudUsername, qrUsername) && + ConnectScanDecisionPolicy.relayEndpointsCompatible(cloudRelayUrl, qrRelayUrl)) { return desktopId.trim().length > 0 ? DetectedUrlAction.USE_CLOUD_DEVICE : DetectedUrlAction.SHOW_CLOUD_DEVICES; @@ -51,4 +54,26 @@ export class ConnectScanDecisionPolicy { } return cloudUsername.trim() === qr; } + + static relayEndpointsCompatible(cloudRelayUrl: string, qrRelayUrl: string): boolean { + const cloud = ConnectScanDecisionPolicy.normalizedRelayEndpoint(cloudRelayUrl); + const qr = ConnectScanDecisionPolicy.normalizedRelayEndpoint(qrRelayUrl); + return cloud.length > 0 && cloud === qr; + } + + private static normalizedRelayEndpoint(value: string): string { + let normalized = value.trim().toLowerCase(); + if (normalized.indexOf('wss://') === 0) { + normalized = `https://${normalized.slice(6)}`; + } else if (normalized.indexOf('ws://') === 0) { + normalized = `http://${normalized.slice(5)}`; + } + while (normalized.length > 0 && normalized.charAt(normalized.length - 1) === '/') { + normalized = normalized.slice(0, normalized.length - 1); + } + if (normalized.endsWith('/ws')) { + normalized = normalized.slice(0, normalized.length - 3); + } + return normalized; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets new file mode 100644 index 0000000000..f4f13f4a8d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets @@ -0,0 +1,24 @@ +/** + * Stable HarmonyOS package and storage identifiers retained across the + * product-name change. + * + * These values are not display branding. Changing any of them strands data + * written by an installed release (and changing the bundle id prevents an + * in-place upgrade altogether), so all compatibility-only identifiers live at + * this single boundary instead of leaking through product code. + */ +export class HarmonyUpgradeIdentityContract { + static readonly APP_BUNDLE: string = 'com.bitfun.app'; + static readonly REMOTE_IDENTITY_STORE: string = 'bitfun_remote_identity'; + static readonly CLOUD_ACCOUNT_STORE: string = 'bitfun_cloud_account'; + static readonly CLOUD_ACCOUNT_HUKS_ALIAS: string = 'bitfun_cloud_account_session'; + static readonly APP_LOCALE_STORE: string = 'bitfun_app_locale'; + static readonly REMOTE_CHAT_DATABASE: string = 'bitfun_remote_chat.db'; + static readonly REMOTE_SESSIONS_DATABASE: string = 'bitfun_remote_sessions.db'; + static readonly GENERAL_CHAT_CONFIG_STORE: string = 'bitfun_general_chat_config'; + static readonly GENERAL_CHAT_HUKS_ALIAS: string = 'bitfun_general_chat_api_key'; + static readonly GENERAL_CHAT_DATABASE: string = 'bitfun_general_chat.db'; + static readonly WATCH_HANDOFF_STORE: string = 'bitfun_harmony_handoff_s1'; + static readonly WATCH_PROVISION_REQUEST_KEY: string = 'bitfun.account.provision.request'; + static readonly WATCH_PROVISION_RESPONSE_KEY: string = 'bitfun.account.provision.response'; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/LocalePreferenceStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/LocalePreferenceStore.ets index 2c190cdfc2..0ef7cbb43f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/LocalePreferenceStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/LocalePreferenceStore.ets @@ -2,10 +2,11 @@ import { preferences } from '@kit.ArkData'; import { i18n } from '@kit.LocalizationKit'; import { AppLocale } from '../i18n/AppLocale'; import { RemoteI18n } from '../i18n/RemoteI18n'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { OhosError } from './OhosError'; import { RemoteLogger } from './RemoteLogger'; -const STORE_NAME = 'openbitfun_app_locale'; +const STORE_NAME = HarmonyUpgradeIdentityContract.APP_LOCALE_STORE; const LANGUAGE_KEY = 'language'; export interface LocaleSystemHost { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets index ebf3748596..d42e662cce 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets @@ -1,12 +1,19 @@ import { preferences } from '@kit.ArkData'; import { Encoding } from './Encoding'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { OhosError } from './OhosError'; +import { RemoteDescriptorParser } from './RemoteDescriptorParser'; +import { RemoteLogger } from './RemoteLogger'; import { RemoteModelPreferenceStore } from './RemoteModelController'; -const STORE_NAME = 'openbitfun_remote_identity'; +// Persisted identifiers are compatibility contracts; the product rename must +// not strand an installed phone's pairing snapshot in a second store. +const STORE_NAME = HarmonyUpgradeIdentityContract.REMOTE_IDENTITY_STORE; const INSTALL_ID_KEY = 'mobile_install_id'; const USER_ID_KEY = 'user_id'; const REMOTE_URL_KEY = 'remote_url'; +const PAIRING_SAVED_AT_KEY = 'pairing_saved_at'; +const CONTROL_TARGET_TYPE_KEY = 'control_target_type'; const LAST_MODEL_ID_KEY = 'last_model_id'; const USER_ID_LOCK_UNTIL_KEY = 'user_id_lock_until'; const USER_ID_FAILURE_COUNT_KEY = 'user_id_failure_count'; @@ -15,6 +22,8 @@ export interface MobileIdentitySnapshot { installId: string; userId: string; remoteUrl: string; + pairingSavedAt: number; + controlTargetType: string; userIdFailureCount: number; userIdLockUntil: number; } @@ -33,26 +42,53 @@ export class MobileIdentityStore implements RemoteModelPreferenceStore { if (!existingInstallId) { await this.putString(INSTALL_ID_KEY, installId); } - return { + const snapshot: MobileIdentitySnapshot = { installId, userId: await this.getString(USER_ID_KEY), remoteUrl: await this.getString(REMOTE_URL_KEY), + pairingSavedAt: await this.getNumber(PAIRING_SAVED_AT_KEY), + controlTargetType: await this.getString(CONTROL_TARGET_TYPE_KEY), userIdFailureCount: await this.getNumber(USER_ID_FAILURE_COUNT_KEY), userIdLockUntil: await this.getNumber(USER_ID_LOCK_UNTIL_KEY) }; + if (snapshot.remoteUrl.length > 0) { + RemoteLogger.info( + `pairing snapshot restored room=${this.roomReference(snapshot.remoteUrl)} saved_at=${snapshot.pairingSavedAt}` + ); + } + return snapshot; } async savePairingInput(userId: string, remoteUrl: string): Promise { - await this.putString(USER_ID_KEY, userId.trim()); - await this.putString(REMOTE_URL_KEY, remoteUrl.trim()); + if (!this.preferences) { + throw new Error('Mobile identity store is not initialized.'); + } + const normalizedUrl = remoteUrl.trim(); + const savedAt = Date.now(); + try { + await this.preferences.put(USER_ID_KEY, userId.trim()); + await this.preferences.put(REMOTE_URL_KEY, normalizedUrl); + await this.preferences.put(PAIRING_SAVED_AT_KEY, savedAt); + await this.preferences.flush(); + } catch (err) { + throw OhosError.wrap('mobile identity save pairing snapshot', err); + } + RemoteLogger.info(`pairing snapshot saved room=${this.roomReference(normalizedUrl)} saved_at=${savedAt}`); } async clearPairingInput(): Promise { await this.putString(USER_ID_KEY, ''); await this.putString(REMOTE_URL_KEY, ''); + await this.putNumber(PAIRING_SAVED_AT_KEY, 0); await this.clearUserIdProtection(); } + async saveControlTargetType(controlTargetType: string): Promise { + const normalized = controlTargetType === 'room' || controlTargetType === 'account_device' ? + controlTargetType : ''; + await this.putString(CONTROL_TARGET_TYPE_KEY, normalized); + } + async saveUserIdProtection(failureCount: number, lockUntil: number): Promise { await this.putNumber(USER_ID_FAILURE_COUNT_KEY, failureCount); await this.putNumber(USER_ID_LOCK_UNTIL_KEY, lockUntil); @@ -118,4 +154,12 @@ export class MobileIdentityStore implements RemoteModelPreferenceStore { throw OhosError.wrap('mobile identity put number', err); } } + + private roomReference(remoteUrl: string): string { + try { + return RemoteDescriptorParser.parse(remoteUrl).roomId.slice(0, 8); + } catch (_err) { + return 'invalid'; + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets index 51bf27d4d3..ca23b8271c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets @@ -2,10 +2,11 @@ import { relationalStore } from '@kit.ArkData'; import { ChatMessage } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { OhosError } from './OhosError'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { RemoteChatCacheSlice, RemoteChatCacheStore } from './RemoteChatCache'; const STORE_CONFIG: relationalStore.StoreConfig = { - name: 'openbitfun_remote_chat.db', + name: HarmonyUpgradeIdentityContract.REMOTE_CHAT_DATABASE, // A step above the S1 the general-chat store uses: these transcripts carry // the user's source, file paths and terminal output from a machine they own. securityLevel: relationalStore.SecurityLevel.S2 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets index 0ff4698033..8f0f567d24 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets @@ -4,6 +4,8 @@ import { RelayHttpClient } from './RelayHttpClient'; /** Interactive directory rows must fail fast enough to leave a usable retry. */ export const INTERACTIVE_DIRECTORY_TIMEOUT_MS: number = 15000; +/** Relay room commands may legitimately wait up to 60 s for the desktop. */ +export const ROOM_COMMAND_TIMEOUT_MS: number = 65000; export interface RemoteCommandTransport { send(command: RemoteCommand, timeoutMs?: number): Promise; @@ -17,7 +19,7 @@ export class RoomRemoteCommandTransport implements RemoteCommandTransport { this.client = client; } - async send(command: RemoteCommand, timeoutMs: number = 30000): Promise { + async send(command: RemoteCommand, timeoutMs: number = ROOM_COMMAND_TIMEOUT_MS): Promise { return this.client.sendCommand(command, timeoutMs); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets index 001dc5e446..b128ae4106 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets @@ -4,6 +4,7 @@ import { RemoteDescriptorParser } from './RemoteDescriptorParser'; import { RemotePairingPolicy } from './RemotePairingPolicy'; import { RemoteSessionManager } from './RemoteSessionManager'; import { AsyncLifecycleGate } from './AsyncLifecycleGate'; +import { RemoteLogger } from './RemoteLogger'; export interface RemoteConnectionRequest { remoteUrl: string; @@ -52,8 +53,15 @@ export class RemoteConnectionCoordinator { if (!this.lifecycleGate.isCurrent(token)) { throw new Error('Remote connection was invalidated'); } - await this.identityStore.savePairingInput(identity.userId, request.remoteUrl); - await this.identityStore.clearUserIdProtection(); + try { + await this.identityStore.savePairingInput(identity.userId, request.remoteUrl); + await this.identityStore.clearUserIdProtection(); + } catch (err) { + // Pairing has already succeeded. Keep the live channel usable and make + // the loss of restart durability explicit in logs instead of reporting + // the whole connection as failed. + RemoteLogger.warn(`pairing snapshot persistence failed: ${String(err)}`); + } if (!this.lifecycleGate.isCurrent(token)) { throw new Error('Remote connection was invalidated'); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets index 06dbb35823..d6b1f608af 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets @@ -13,12 +13,16 @@ export class RemoteDescriptorParser { const roomId = params.get('room') || ''; const publicKey = params.get('pk') || ''; const relay = params.get('relay') || ''; + const protocolVersion = (params.get('v') || '1').trim(); const accountAuth = params.get('auth') === 'account'; const accountUsername = (params.get('user') || '').trim(); if (!roomId || !publicKey) { throw new Error(RemoteI18n.t('errors.remoteUrlMissingParams')); } + if (protocolVersion !== '1') { + throw new Error(RemoteI18n.t('errors.remoteUrlInvalid')); + } return { relayUrl: RemoteDescriptorParser.resolveRelayBaseUrl(value, relay), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets index 3d9aa415f9..0f1e6bea20 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets @@ -79,14 +79,17 @@ export class RemotePairingPolicy { shouldPromptForAccount( descriptor: RemoteDescriptor, hasCloudAccountSession: boolean = false, - cloudUsername: string = '' + cloudUsername: string = '', + cloudRelayUrl: string = '' ): boolean { return ConnectScanDecisionPolicy.decide( descriptor.accountAuth, hasCloudAccountSession, cloudUsername, descriptor.accountUsername, - '' + '', + cloudRelayUrl, + descriptor.relayUrl ) === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets index 1ce1d3f6ad..ae9f80929c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets @@ -2,10 +2,11 @@ import { relationalStore } from '@kit.ArkData'; import { RecentWorkspaceEntry, RemoteSession } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { OhosError } from './OhosError'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { RemoteSessionListSlice, RemoteSessionListStore } from './RemoteSessionListCache'; const STORE_CONFIG: relationalStore.StoreConfig = { - name: 'openbitfun_remote_sessions.db', + name: HarmonyUpgradeIdentityContract.REMOTE_SESSIONS_DATABASE, // Session titles are as revealing as the transcripts themselves, so this // matches the level the transcript store uses. securityLevel: relationalStore.SecurityLevel.S2 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 0ba6bbfe19..3b2b91f596 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, PeerDeviceProvisionOutcome, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; @@ -49,52 +49,61 @@ export interface ProvisionedPeerDevice { const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { - private readonly roomClient: RelayHttpClient; + private roomClient?: RelayHttpClient; private transport?: RemoteCommandTransport; private transportGeneration: number = 0; - private crypto?: RemoteCrypto; private workspace?: WorkspaceInfo; private roomRelayUrl: string = ''; + private readonly roomClientFactory: () => RelayHttpClient; private readonly cryptoFactory: () => RemoteCrypto; constructor( - roomClient: RelayHttpClient = new RelayHttpClient(), + roomClientFactory: () => RelayHttpClient = () => new RelayHttpClient(), cryptoFactory: () => RemoteCrypto = () => new RemoteCrypto() ) { - this.roomClient = roomClient; + this.roomClientFactory = roomClientFactory; this.cryptoFactory = cryptoFactory; } reset(): void { this.transportGeneration += 1; - this.crypto = undefined; this.workspace = undefined; this.roomRelayUrl = ''; + const hadTransport = this.transport !== undefined; this.transport?.reset(); this.transport = undefined; - this.roomClient.reset(); + if (!hadTransport) { + this.roomClient?.reset(); + } + this.roomClient = undefined; } async connect(descriptor: RemoteDescriptor, identity: PairIdentity, deviceId: string): Promise { - this.transportGeneration += 1; - // A previous room transport owns this same RelayHttpClient. Reset it before - // binding the newly scanned descriptor; resetting afterwards would erase - // the new room and make every retry fail before the pairing HTTP request. - this.transport?.reset(); - this.crypto = undefined; - this.workspace = undefined; - this.roomRelayUrl = ''; - this.crypto = this.cryptoFactory(); - this.roomClient.bind(descriptor, this.crypto); - this.transport = new RoomRemoteCommandTransport(this.roomClient); - const initialSync = await this.roomClient.pair(deviceId, identity); - // Account inheritance is optional for room pairing. A desktop without an - // account, or an older desktop build, must not make normal QR control fail. - await this.roomClient.requestDelegatedIdentity(); + const generation = this.beginTransportReplacement(); + const crypto = this.cryptoFactory(); + const roomClient = this.roomClientFactory(); + const transport = new RoomRemoteCommandTransport(roomClient); + roomClient.bind(descriptor, crypto); + let initialSync: InitialSyncResponse; + try { + initialSync = await roomClient.pair(deviceId, identity); + this.requireCurrentGeneration(generation); + // Account inheritance is optional for room pairing. A desktop without an + // account, or an older desktop build, must not make normal QR control fail. + await roomClient.requestDelegatedIdentity(); + this.requireCurrentGeneration(generation); + } catch (err) { + transport.reset(); + throw err instanceof Error ? err : new Error(String(err)); + } + + const workspace = RemoteResponseMapper.workspaceFromInitialSync(initialSync); + this.roomClient = roomClient; + this.transport = transport; this.roomRelayUrl = descriptor.relayUrl; - this.workspace = RemoteResponseMapper.workspaceFromInitialSync(initialSync); + this.workspace = workspace; return { - workspace: this.workspace, + workspace, sessions: RemoteResponseMapper.sessions(initialSync.sessions || []), hasMoreSessions: initialSync.has_more_sessions || false, authenticatedUserId: initialSync.authenticated_user_id || '', @@ -102,17 +111,38 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile }; } + private beginTransportReplacement(): number { + this.transportGeneration += 1; + const hadTransport = this.transport !== undefined; + this.transport?.reset(); + this.transport = undefined; + if (!hadTransport) { + this.roomClient?.reset(); + } + this.roomClient = undefined; + this.workspace = undefined; + this.roomRelayUrl = ''; + return this.transportGeneration; + } + + private requireCurrentGeneration(generation: number): void { + if (generation !== this.transportGeneration) { + throw new Error('Remote target changed while the connection was in flight.'); + } + } + delegatedAccountSession(): DelegatedAccountSession | undefined { - if (!this.roomClient.hasDelegatedIdentity() || this.roomClient.delegatedUserId.trim().length === 0 || + const roomClient = this.roomClient; + if (!roomClient || !roomClient.hasDelegatedIdentity() || roomClient.delegatedUserId.trim().length === 0 || this.roomRelayUrl.trim().length === 0) { return undefined; } return { relayUrl: this.roomRelayUrl, session: { - token: this.roomClient.delegatedToken, - userId: this.roomClient.delegatedUserId, - masterKey: Encoding.base64ToBytes(this.roomClient.delegatedMasterKey) + token: roomClient.delegatedToken, + userId: roomClient.delegatedUserId, + masterKey: Encoding.base64ToBytes(roomClient.delegatedMasterKey) } }; } @@ -137,10 +167,11 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile deviceName: string, requestId: string ): Promise { - if (!this.hasRoomChannel()) { + const roomClient = this.roomClient; + if (!this.hasRoomChannel() || !roomClient) { throw new Error('Remote room channel is not connected.'); } - return this.roomClient.provisionPeerDevice(deviceId, deviceName, requestId); + return roomClient.provisionPeerDevice(deviceId, deviceName, requestId); } /** Relay the provisioned credential belongs to — the one this room lives on. */ @@ -154,32 +185,42 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile session: CloudAccountSession, targetDeviceId: string ): Promise { - this.transportGeneration += 1; - this.transport?.reset(); - // Account-device RPC replaces the room channel. Do not leave a failed or - // previous QR room advertised as usable after the transport has changed. - this.crypto = undefined; - this.workspace = undefined; - this.roomRelayUrl = ''; - this.transport = new AccountDeviceCommandTransport(accountClient, relayUrl, session, targetDeviceId); + const generation = this.beginTransportReplacement(); + const transport = new AccountDeviceCommandTransport(accountClient, relayUrl, session, targetDeviceId); let workspaceResponse: WorkspaceInfoResponse; try { - workspaceResponse = await this.send( + workspaceResponse = await this.sendOnTransport( + transport, + generation, RemoteCommandFactory.getWorkspaceInfo(), ACCOUNT_HANDSHAKE_TIMEOUT_MS ); } catch (err) { + transport.reset(); throw RemoteSessionManager.handshakeFailure(err); } - this.workspace = RemoteResponseMapper.workspaceFromResponse(workspaceResponse); - const sessions = await this.listSessions(50, 0, '', ''); - return { - workspace: this.workspace, - sessions: sessions.sessions, - hasMoreSessions: sessions.hasMore, - authenticatedUserId: session.userId, - capabilities: workspaceResponse.capabilities || [] - }; + try { + const workspace = RemoteResponseMapper.workspaceFromResponse(workspaceResponse); + const sessionResponse = await this.sendOnTransport( + transport, + generation, + RemoteCommandFactory.listSessions(workspace.path, 50, 0, '') + ); + const sessions = RemoteResponseMapper.sessions(sessionResponse.sessions || []); + this.requireCurrentGeneration(generation); + this.transport = transport; + this.workspace = workspace; + return { + workspace, + sessions, + hasMoreSessions: sessionResponse.has_more || false, + authenticatedUserId: session.userId, + capabilities: workspaceResponse.capabilities || [] + }; + } catch (err) { + transport.reset(); + throw err instanceof Error ? err : new Error(String(err)); + } } async getWorkspaceInfo(): Promise { @@ -541,14 +582,24 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile * number of hops and never shared a sensible number. */ private async send(command: RemoteCommand, readTimeoutMs?: number): Promise { - command._request_id = `req_${Date.now()}_${Encoding.randomId('harmony').slice(0, 20)}`; if (!this.transport) { throw new Error('Remote transport is not connected.'); } const generation = this.transportGeneration; const transport = this.transport; + return this.sendOnTransport(transport, generation, command, readTimeoutMs, true); + } + + private async sendOnTransport( + transport: RemoteCommandTransport, + generation: number, + command: RemoteCommand, + readTimeoutMs?: number, + requireCommittedTransport: boolean = false + ): Promise { + command._request_id = `req_${Date.now()}_${Encoding.randomId('harmony').slice(0, 20)}`; const response = await transport.send(command, readTimeoutMs); - if (generation !== this.transportGeneration || transport !== this.transport) { + if (generation !== this.transportGeneration || (requireCommittedTransport && transport !== this.transport)) { throw new Error('Remote target changed while the request was in flight.'); } return response; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/VoiceInputLifecycleController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/VoiceInputLifecycleController.ets index bfc0cc7e0b..5611e9aaf5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/VoiceInputLifecycleController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/VoiceInputLifecycleController.ets @@ -33,14 +33,19 @@ export class VoiceInputLifecycleController { if (snapshot.isListening || this.service.isListening()) { return this.stop(snapshot, true); } - if (snapshot.isBusy || snapshot.inputText.trim().length > 0 || snapshot.selectedImageCount > 0) { + if (snapshot.isBusy) { return false; } + // Intermediate recognizer results replace one another. Merge every result + // against the immutable draft captured at start so partial callbacks do + // not duplicate speech and existing text or attachments never block a + // user from continuing the prompt by voice. + const baseDraft = snapshot.inputText; try { this.callbacks.onStatusText(RemoteI18n.t('status.voiceStarting')); await this.service.start(context, { onText: (text: string, isFinal: boolean): void => { - this.callbacks.onInputText(snapshot.routeId, text); + this.callbacks.onInputText(snapshot.routeId, this.mergeDraft(baseDraft, text)); if (isFinal) { this.callbacks.onStatusText(RemoteI18n.t('status.voiceRecognized')); } @@ -61,6 +66,17 @@ export class VoiceInputLifecycleController { } } + private mergeDraft(baseDraft: string, transcript: string): string { + const spoken = transcript.trim(); + if (baseDraft.length === 0 || spoken.length === 0) { + return baseDraft.length === 0 ? spoken : baseDraft; + } + const last = baseDraft.charAt(baseDraft.length - 1); + const first = spoken.charAt(0); + const needsSpace = !/\s/.test(last) && /[\x21-\x7E]/.test(last) && /[A-Za-z0-9]/.test(first); + return `${baseDraft}${needsSpace ? ' ' : ''}${spoken}`; + } + async stop(snapshot: VoiceInputRouteSnapshot, showStatus: boolean): Promise { if (!snapshot.isListening && !this.service.isListening()) { return false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets index 61f51de5ff..7fec0be089 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets @@ -2,6 +2,7 @@ import distributedKVStore from '@ohos.data.distributedKVStore'; import { common } from '@kit.AbilityKit'; import { distributedDeviceManager } from '@kit.DistributedServiceKit'; import { OhosError } from './OhosError'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { RemoteLogger } from './RemoteLogger'; import { WATCH_PROVISION_REQUEST_KEY, @@ -11,11 +12,11 @@ import { /** * Versioned because a store's `securityLevel` is fixed at creation: it can only * ever be raised, and for a store that syncs across devices it cannot be changed - * at all. `openbitfun_harmony_handoff` was created S2 on both devices and had to be + * at all. The pre-release S2 handoff store had to be * abandoned rather than relabelled — see the S1 note on `securityLevel` below. * Must stay identical to the watch's `DistributedHandoffStore`. */ -const STORE_ID: string = 'openbitfun_harmony_handoff_s1'; +const STORE_ID: string = HarmonyUpgradeIdentityContract.WATCH_HANDOFF_STORE; export type WatchProvisionRequestHandler = (payload: string) => void; @@ -24,8 +25,8 @@ export type WatchProvisionRequestHandler = (payload: string) => void; * * `distributedKVStore` isolates stores by bundle name *and* store id and only * replicates between installs of the same app on the same account, which is - * why the phone carries the watch's bundle name (`com.openbitfun.app`) rather than - * a store id of its own. + * why the phone and watch retain one stable compatibility bundle id rather + * than using platform-specific ids. * * The isolation key is really bundle name + **AppID**, and AppID is derived from * the signing certificate — so this project and the watch's must also be built diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets index 41458b326e..ef3b8a9b7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets @@ -14,8 +14,10 @@ * them even when the failure was in key agreement. */ -export const WATCH_PROVISION_REQUEST_KEY: string = 'openbitfun.account.provision.request'; -export const WATCH_PROVISION_RESPONSE_KEY: string = 'openbitfun.account.provision.response'; +import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; + +export const WATCH_PROVISION_REQUEST_KEY: string = HarmonyUpgradeIdentityContract.WATCH_PROVISION_REQUEST_KEY; +export const WATCH_PROVISION_RESPONSE_KEY: string = HarmonyUpgradeIdentityContract.WATCH_PROVISION_RESPONSE_KEY; export const WATCH_PROVISION_PROTOCOL_VERSION: number = 1; /** diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets index e54561143f..13c28dc718 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets @@ -3,15 +3,16 @@ import { huks } from '@kit.UniversalKeystoreKit'; import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { Encoding } from '../Encoding'; +import { HarmonyUpgradeIdentityContract } from '../HarmonyUpgradeIdentityContract'; import { OhosError } from '../OhosError'; import { GeneralChatTokenProvider } from './GeneralChatHttpTransport'; -const STORE_NAME = 'openbitfun_general_chat_config'; +const STORE_NAME = HarmonyUpgradeIdentityContract.GENERAL_CHAT_CONFIG_STORE; const API_URL_KEY = 'api_url'; const MODEL_NAME_KEY = 'model_name'; const API_KEY_CIPHER_KEY = 'api_key_cipher'; const API_KEY_IV_KEY = 'api_key_iv'; -const HUKS_ALIAS = 'openbitfun_general_chat_api_key'; +const HUKS_ALIAS = HarmonyUpgradeIdentityContract.GENERAL_CHAT_HUKS_ALIAS; const SELECTED_MODEL_ID_KEY = 'selected_model_id'; export const GENERAL_CHAT_LOCAL_MODEL_ID = 'local-general-chat'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatLocalRdbStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatLocalRdbStore.ets index b2c38476ce..9d702a9925 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatLocalRdbStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatLocalRdbStore.ets @@ -2,10 +2,11 @@ import { relationalStore } from '@kit.ArkData'; import { ChatMessage, RemoteSession } from '../../model/RemoteModels'; import { Encoding } from '../Encoding'; import { OhosError } from '../OhosError'; +import { HarmonyUpgradeIdentityContract } from '../HarmonyUpgradeIdentityContract'; import { GeneralChatLocalStore } from './GeneralChatPort'; const STORE_CONFIG: relationalStore.StoreConfig = { - name: 'openbitfun_general_chat.db', + name: HarmonyUpgradeIdentityContract.GENERAL_CHAT_DATABASE, securityLevel: relationalStore.SecurityLevel.S1 }; diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index bba1d6d064..371070a8f3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -65,9 +65,9 @@ "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "description": "$string:EntryAbility_desc", - "icon": "$media:openbitfun-app-icon", + "icon": "$media:openbitfun_app_icon", "label": "$string:EntryAbility_label", - "startWindowIcon": "$media:openbitfun-start-window", + "startWindowIcon": "$media:openbitfun_start_window", "startWindowBackground": "$color:start_window_background", "exported": true, "skills": [ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun-app-icon.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_app_icon.png similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun-app-icon.png rename to src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_app_icon.png diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun-start-window.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_start_window.png similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun-start-window.png rename to src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_start_window.png diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json index 78f40ae7c4..ec1b3a06ee 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json @@ -1,3 +1,9 @@ { - "allowToBackupRestore": true -} \ No newline at end of file + "allowToBackupRestore": true, + "excludes": [ + "data/storage/el2/base/preferences/bitfun_remote_identity.xml", + "data/storage/el2/base/preferences/bitfun_cloud_account.xml", + "data/storage/el2/base/haps/entry/preferences/bitfun_remote_identity.xml", + "data/storage/el2/base/haps/entry/preferences/bitfun_cloud_account.xml" + ] +} diff --git a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets index fd88681346..ad9545bde9 100644 --- a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets @@ -1,8 +1,9 @@ import { Want } from '@kit.AbilityKit'; import { Driver, ON, abilityDelegatorRegistry } from '@kit.TestKit'; import { describe, it, expect } from '@ohos/hypium'; +import { HarmonyUpgradeIdentityContract } from '../../../main/ets/services/HarmonyUpgradeIdentityContract'; -const APP_BUNDLE: string = 'com.openbitfun.app'; +const APP_BUNDLE: string = HarmonyUpgradeIdentityContract.APP_BUNDLE; const APP_ABILITY: string = 'EntryAbility'; export default function deviceSmokeTest() { diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index e6cbafeb6d..90f59c0b3d 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -566,6 +566,22 @@ export default function conversationPresentationUnitTest() { expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); }); + it('keeps voice reachable beside Send once a draft has content', 0, () => { + expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, false, true)).assertTrue(); + expect(ChatComposerPolicy.shouldShowSupplementalVoice('', 1, false, true)).assertTrue(); + expect(ChatComposerPolicy.shouldShowSupplementalVoice('', 0, false, true)).assertFalse(); + expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, true, true)).assertFalse(); + expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, false, false)).assertFalse(); + expect(ChatComposerPolicy.canUseSupplementalVoice(false)).assertTrue(); + expect(ChatComposerPolicy.canUseSupplementalVoice(true)).assertFalse(); + }); + + it('keeps the composer expanded while model selection returns to the active edit', 0, () => { + expect(ChatComposerPolicy.isExpanded('', false, false, true)).assertTrue(); + expect(ChatComposerPolicy.isExpanded('', false, false, false)).assertFalse(); + expect(ChatComposerPolicy.isExpanded('', false, true, false)).assertTrue(); + }); + it('names the primary composer action for assistive labels', 0, () => { expect(ChatComposerPolicy.primaryActionAccessibilityKey(ComposerPrimaryAction.Send, false)) .assertEqual('chat.send'); @@ -613,6 +629,13 @@ export default function conversationPresentationUnitTest() { expect(SidebarConnectionActionPolicy.shouldShowScan('connected')).assertFalse(); }); + it('uses the complete chat and settings shell for QR access without an account', 0, () => { + expect(SidebarConnectionActionPolicy.shouldShowPrimaryNavigation('', 'room', 'connected')).assertTrue(); + expect(SidebarConnectionActionPolicy.shouldShowPrimaryNavigation('', 'room', 'disconnected')).assertTrue(); + expect(SidebarConnectionActionPolicy.shouldShowPrimaryNavigation('account-user', 'none', 'idle')).assertTrue(); + expect(SidebarConnectionActionPolicy.shouldShowPrimaryNavigation('', 'none', 'idle')).assertFalse(); + }); + it('merges matching QR targets and keeps unmatched QR targets beside account devices', 0, () => { expect(SidebarDeviceProjectionPolicy.shouldPrependControlTarget( [], 'room', 'desk-qr', true, false @@ -737,14 +760,21 @@ export default function conversationPresentationUnitTest() { }); it('reuses the signed-in cloud session instead of asking for the password again', 0, () => { - expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', 'desktop-1')) + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', 'desktop-1', 'https://relay.example.com', 'https://relay.example.com/')) .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); - expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', '')) + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', '', 'https://relay.example.com', 'wss://relay.example.com/ws')) .assertEqual(DetectedUrlAction.SHOW_CLOUD_DEVICES); - expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', '', 'desktop-1')) + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', '', 'desktop-1', 'https://relay.example.com', 'https://relay.example.com')) .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); }); + it('does not reuse an account session from a different relay', 0, () => { + expect(ConnectScanDecisionPolicy.decide( + true, true, 'alice', 'alice', 'desktop-1', + 'https://relay-a.example.com', 'https://relay-b.example.com' + )).assertEqual(DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD); + }); + it('still asks for a password when the QR belongs to a different account', 0, () => { expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'bob', 'desktop-1')) .assertEqual(DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index da13e81741..ea3dd59409 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -615,6 +615,37 @@ export default function lifecycleUnitTest() { expect(service.cancelRequests).assertEqual(1); expect(clearAllCount).assertEqual(1); }); + + it('continues an existing draft by voice without duplicating partial results', 0, async () => { + const service = new FakeVoiceInputLifecycleService(); + let inputText = 'Review this'; + const inputEvents: string[] = []; + const controller = new VoiceInputLifecycleController(service, { + currentInputText: (): string => inputText, + currentStatusText: (): string => '', + onInputText: (_routeId: string, text: string): void => { + inputText = text; + inputEvents.push(text); + }, + onListening: (_routeId: string, _isListening: boolean): void => {}, + onStatusText: (_statusText: string): void => {}, + onError: (_message: string): void => {} + }); + + expect(await controller.toggle({} as Context, { + routeId: 'GeneralChat', + isListening: false, + isBusy: false, + inputText, + selectedImageCount: 1 + })).assertTrue(); + + service.emitText('implementation', false); + service.emitText('implementation carefully', true); + + expect(inputEvents[0]).assertEqual('Review this implementation'); + expect(inputEvents[1]).assertEqual('Review this implementation carefully'); + }); }); describe('GeneralChatDraftController', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 36d67c13dd..e084103637 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -74,7 +74,7 @@ import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; import { PairIdentity, RelayHttpClient } from '../main/ets/services/RelayHttpClient'; import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; -import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { DelegatedAccountSession, RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; @@ -298,8 +298,13 @@ class InMemorySettingsConfigStore extends GeneralChatConfigStore { class ScriptedAccountSessionManager extends RemoteSessionManager { readonly unreachable: Set = new Set(); readonly dialled: string[] = []; + delegated?: DelegatedAccountSession; resets: number = 0; + delegatedAccountSession(): DelegatedAccountSession | undefined { + return this.delegated; + } + async connectAccountDevice( _accountClient: CloudAccountClient, _relayUrl: string, @@ -367,6 +372,56 @@ class RecordingRoomClient extends RelayHttpClient { } } +/** Holds one room handshake so a newer scan can win the transport generation. */ +class GatedRoomClient extends RelayHttpClient { + private readonly sessionId: string; + private readonly gated: boolean; + private releasePairing: () => void = () => {}; + private markPairingStarted: () => void = () => {}; + private readonly pairingGate: Promise; + readonly pairingStarted: Promise; + + constructor(sessionId: string, gated: boolean) { + super(); + this.sessionId = sessionId; + this.gated = gated; + this.pairingGate = new Promise((resolve: () => void) => { + this.releasePairing = resolve; + }); + this.pairingStarted = new Promise((resolve: () => void) => { + this.markPairingStarted = resolve; + }); + } + + bind(_descriptor: RemoteDescriptor, _crypto: RemoteCrypto): void { + } + + reset(): void { + } + + async pair(_deviceId: string, _identity: PairIdentity): Promise { + this.markPairingStarted(); + if (this.gated) { + await this.pairingGate; + } + return { + resp: 'initial_sync', + has_workspace: true, + path: `/${this.sessionId}`, + project_name: this.sessionId, + sessions: [{ session_id: this.sessionId, name: this.sessionId, agent_type: 'agentic' }] + }; + } + + async requestDelegatedIdentity(_force: boolean = false): Promise { + return false; + } + + release(): void { + this.releasePairing(); + } +} + class RecordingAccountDeviceClient extends CloudAccountClient { async deviceRpc( _relayUrl: string, @@ -439,11 +494,26 @@ class GatedAccountSessionManager extends ScriptedAccountSessionManager { class InMemoryCloudAccountSessionStore extends CloudAccountSessionStore { readonly saved: PersistedCloudAccountSession[] = []; clears: number = 0; + failSaves: boolean = false; + failLoads: boolean = false; + + async init(_context: Context): Promise { + } async save(session: PersistedCloudAccountSession): Promise { + if (this.failSaves) { + throw new Error('storage unavailable'); + } this.saved.push(session); } + async load(): Promise { + if (this.failLoads) { + throw new Error('keystore temporarily unavailable'); + } + return undefined; + } + async clear(): Promise { this.clears += 1; } @@ -466,6 +536,16 @@ class ExpiringAccountClient extends CloudAccountClient { } } +class OfflineAccountClient extends CloudAccountClient { + async listDevices( + _relayUrl: string, + _session: CloudAccountSession, + _selfDeviceId: string = '' + ): Promise { + return [{ deviceId: 'desk-a', deviceName: 'Desktop A', online: false }]; + } +} + class ProvisioningFailureAccountClient extends CloudAccountClient { readonly statusCode: number; @@ -512,11 +592,13 @@ class AccountDeviceSwitchHarness { readonly accountDeviceSnapshots: CloudAccountDevice[][] = []; readonly loadingCatalogTargets: string[] = []; readonly changedWorkspaceSnapshots: string[][] = []; + readonly savedControlTargetTypes: string[] = []; readonly controller: SettingsController; constructor( client: CloudAccountClient = new CloudAccountClient(), - sessionManager: ScriptedAccountSessionManager = new ScriptedAccountSessionManager() + sessionManager: ScriptedAccountSessionManager = new ScriptedAccountSessionManager(), + applyExistingSession: boolean = true ) { this.sessionManager = sessionManager; const hooks: CloudAccountSettingsHooks = { @@ -535,6 +617,9 @@ class AccountDeviceSwitchHarness { closeConnectSheet: (): void => {}, navigateRemoteHome: (): void => {}, loadRecentWorkspaces: async (): Promise => {}, + saveControlTargetType: async (controlTargetType: string): Promise => { + this.savedControlTargetTypes.push(controlTargetType); + }, onAccountDevices: (devices: CloudAccountDevice[]): void => { this.accountDeviceSnapshots.push(devices.slice()); }, @@ -565,11 +650,13 @@ class AccountDeviceSwitchHarness { }, cloud ); - this.controller.applyCloudAccountSession( - { token: 't', userId: 'u', masterKey: new Uint8Array(32) }, - 'https://relay.example.com', - 'someone' - ); + if (applyExistingSession) { + this.controller.applyCloudAccountSession( + { token: 't', userId: 'u', masterKey: new Uint8Array(32) }, + 'https://relay.example.com', + 'someone' + ); + } } device(deviceId: string): CloudAccountDevice { @@ -623,12 +710,28 @@ export default function transportAndGeneralChatUnitTest() { } expect(didThrow).assertEqual(true); }); + + it('accepts legacy links as v1 and rejects unsupported QR protocol versions', 0, () => { + const legacy = RemoteDescriptorParser.parse( + 'https://relay.example.com/#/pair?room=legacy-room&pk=legacy-key' + ); + expect(legacy.roomId).assertEqual('legacy-room'); + let didThrow = false; + try { + RemoteDescriptorParser.parse( + 'https://relay.example.com/#/pair?room=future-room&pk=future-key&v=2' + ); + } catch (_err) { + didThrow = true; + } + expect(didThrow).assertTrue(); + }); }); describe('RemoteSessionManager room reconnect', () => { it('keeps the newly scanned descriptor and its initial sessions after a prior room transport', 0, async () => { const roomClient = new RecordingRoomClient(); - const manager = new RemoteSessionManager(roomClient, () => new RemoteCrypto({ + const manager = new RemoteSessionManager(() => roomClient, () => new RemoteCrypto({ cipher: new FakeRemoteCryptoCipher(), keyPair: { privateKey: new Uint8Array(32), @@ -663,7 +766,7 @@ export default function transportAndGeneralChatUnitTest() { it('replaces room state and loads sessions when switching to an account device', 0, async () => { const roomClient = new RecordingRoomClient(); - const manager = new RemoteSessionManager(roomClient, () => new RemoteCrypto({ + const manager = new RemoteSessionManager(() => roomClient, () => new RemoteCrypto({ cipher: new FakeRemoteCryptoCipher(), keyPair: { privateKey: new Uint8Array(32), @@ -691,6 +794,41 @@ export default function transportAndGeneralChatUnitTest() { expect(result.sessions.length).assertEqual(1); expect(result.sessions[0].id).assertEqual('account-device-session'); }); + + it('does not let a slower old QR handshake replace a newer room', 0, async () => { + const slow = new GatedRoomClient('old-session', true); + const fast = new GatedRoomClient('new-session', false); + const clients: GatedRoomClient[] = [slow, fast]; + let clientIndex = 0; + const manager = new RemoteSessionManager( + (): RelayHttpClient => clients[clientIndex++], + () => new RemoteCrypto({ + cipher: new FakeRemoteCryptoCipher(), + keyPair: { privateKey: new Uint8Array(32), publicKey: new Uint8Array(32) }, + randomBytes: fixedRandomBytes + }) + ); + const identity: PairIdentity = { userId: 'harmony-device' }; + const oldConnect = manager.connect({ + relayUrl: 'https://relay.example.com', roomId: 'old-room', publicKey: 'old-key', + accountAuth: false, accountUsername: '' + }, identity, 'harmony-device'); + await slow.pairingStarted; + const newResult = await manager.connect({ + relayUrl: 'https://relay.example.com', roomId: 'new-room', publicKey: 'new-key', + accountAuth: false, accountUsername: '' + }, identity, 'harmony-device'); + slow.release(); + let oldRejected = false; + try { + await oldConnect; + } catch (_err) { + oldRejected = true; + } + + expect(oldRejected).assertTrue(); + expect(newResult.sessions[0].id).assertEqual('new-session'); + }); }); describe('RemotePairingPolicy', () => { @@ -742,8 +880,8 @@ export default function transportAndGeneralChatUnitTest() { ); expect(policy.shouldPromptForAccount(descriptor)).assertTrue(); - expect(policy.shouldPromptForAccount(descriptor, true, 'alice')).assertFalse(); - expect(policy.shouldPromptForAccount(descriptor, true, 'bob')).assertTrue(); + expect(policy.shouldPromptForAccount(descriptor, true, 'alice', descriptor.relayUrl)).assertFalse(); + expect(policy.shouldPromptForAccount(descriptor, true, 'bob', descriptor.relayUrl)).assertTrue(); }); }); @@ -1267,6 +1405,91 @@ export default function transportAndGeneralChatUnitTest() { }); describe('SettingsController', () => { + it('migrates a legacy account QR on the same relay to a stable device target', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + + const target = await harness.controller.migrateLegacyPairingTarget( + 'https://relay.example.com/#/pair?room=old-room&pk=old-key&auth=account&user=someone', + 'desk-a', + 'Desktop A' + ); + + expect(target?.deviceId).assertEqual('desk-a'); + expect(harness.remoteState.controlTargetType).assertEqual('account_device'); + expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); + }); + + it('migrates an ordinary QR only when the same desktop is visible in the account', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new OfflineAccountClient()); + await harness.controller.listCloudAccountDevices(); + + const target = await harness.controller.migrateLegacyPairingTarget( + 'https://relay.example.com/#/pair?room=old-room&pk=old-key', + 'desk-a', + 'QR name' + ); + + expect(target?.deviceId).assertEqual('desk-a'); + expect(target?.deviceName).assertEqual('Desktop A'); + expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); + }); + + it('does not migrate an ordinary QR to an unknown account device', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + + const target = await harness.controller.migrateLegacyPairingTarget( + 'https://relay.example.com/#/pair?room=old-room&pk=old-key', + 'desk-unknown', + 'Unknown desktop' + ); + + expect(target === undefined).assertTrue(); + expect(harness.sessionStore.saved.length).assertEqual(0); + }); + + it('does not migrate a legacy QR from another relay', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + + const target = await harness.controller.migrateLegacyPairingTarget( + 'https://other-relay.example.com/#/pair?room=old-room&pk=old-key&auth=account&user=someone', + 'desk-a', + 'Desktop A' + ); + + expect(target === undefined).assertTrue(); + expect(harness.sessionStore.saved.length).assertEqual(0); + }); + + it('keeps an offline account target resumable for the heartbeat', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new OfflineAccountClient()); + + await harness.controller.restoreCloudTarget('desk-a', 'Desktop A', false); + + expect(harness.remoteState.controlTargetDeviceId).assertEqual('desk-a'); + expect(harness.remoteState.connectionState).assertEqual('reconnecting'); + expect(harness.heartbeatStarts).assertEqual(1); + }); + + it('keeps a connected device usable when target persistence fails', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + harness.sessionStore.failSaves = true; + + await harness.controller.selectCloudAccountDevice(harness.device('desk-a'), false); + + expect(harness.remoteState.connectionState).assertEqual('connected'); + expect(harness.controller.preferredCloudTarget()?.deviceId).assertEqual('desk-a'); + expect(harness.heartbeatStarts).assertEqual(1); + }); + + it('does not erase the encrypted account record when restore temporarily fails', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), new ScriptedAccountSessionManager(), false); + harness.sessionStore.failLoads = true; + + await harness.controller.initializeCloudAccount({} as Context); + + expect(harness.sessionStore.clears).assertEqual(0); + }); + it('asks for account confirmation when a legacy relay has no provisioning route', 0, async () => { const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(404)); @@ -1455,10 +1678,11 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.timelineResets).assertEqual(1); }); - it('does not leave a separately paired room half-connected after account logout', 0, async () => { + it('keeps a separately paired room connected after account logout', 0, async () => { const harness = new AccountDeviceSwitchHarness(); harness.remoteState.setControlTarget('room', 'room-device', 'Paired Desktop'); harness.remoteState.setConnectionState('connected'); + harness.remoteState.setHostCapabilities(['remote_chat']); harness.remoteState.setActiveSession({ sessionId: 'room-session', title: 'Paired room', workspacePath: '/room', agentType: 'code' }); @@ -1467,11 +1691,92 @@ export default function transportAndGeneralChatUnitTest() { await harness.controller.logoutCloudAccount(); expect(harness.remoteState.accountUserId).assertEqual(''); - expect(harness.remoteState.controlTargetType).assertEqual('none'); - expect(harness.remoteState.connectionState).assertEqual('disconnected'); - expect(harness.remoteState.activeSession.sessionId).assertEqual(''); - expect(harness.remoteState.statusText).assertEqual(''); - expect(harness.timelineResets).assertEqual(1); + expect(harness.remoteState.controlTargetType).assertEqual('room'); + expect(harness.remoteState.controlTargetDeviceId).assertEqual('room-device'); + expect(harness.remoteState.connectionState).assertEqual('connected'); + expect(harness.remoteState.activeSession.sessionId).assertEqual('room-session'); + expect(harness.remoteState.statusText).assertEqual('Room is connected'); + expect(harness.remoteState.hostCapabilities.join(',')).assertEqual('remote_chat'); + expect(harness.timelineResets).assertEqual(0); + expect(harness.sessionManager.resets).assertEqual(0); + expect(harness.savedControlTargetTypes.length).assertEqual(0); + }); + + it('keeps a paired room connected when the phone account expires', 0, async () => { + const client = new ExpiringAccountClient(); + const harness = new AccountDeviceSwitchHarness(client); + await harness.controller.listCloudAccountDevices(); + harness.remoteState.setControlTarget('room', 'room-device', 'Paired Desktop'); + harness.remoteState.setConnectionState('connected'); + harness.remoteState.setStatusText('Room is connected'); + + try { + await harness.controller.listCloudAccountDevices(); + } catch (_err) { + } + + expect(harness.controller.hasCloudAccountSession()).assertFalse(); + expect(harness.remoteState.controlTargetType).assertEqual('room'); + expect(harness.remoteState.connectionState).assertEqual('connected'); + expect(harness.remoteState.statusText).assertEqual('Room is connected'); + expect(harness.sessionManager.resets).assertEqual(0); + }); + + it('promotes a delegated QR pairing to the cold-start account target', 0, async () => { + const manager = new ScriptedAccountSessionManager(); + manager.delegated = { + relayUrl: 'https://relay.example.com', + session: { token: 'delegated', userId: 'u', masterKey: new Uint8Array(32) } + }; + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager, false); + + await harness.controller.persistDelegatedAccountSession('desk-a', 'Desktop A'); + + expect(harness.controller.hasCloudAccountSession()).assertTrue(); + expect(harness.sessionStore.saved.length).assertEqual(1); + expect(harness.sessionStore.saved[0].token).assertEqual('delegated'); + expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); + expect(harness.sessionStore.saved[0].targetDeviceName).assertEqual('Desktop A'); + }); + + it('keeps the phone login while attaching a same-account QR desktop target', 0, async () => { + const manager = new ScriptedAccountSessionManager(); + manager.delegated = { + relayUrl: 'https://relay.example.com', + session: { token: 'delegated', userId: 'u', masterKey: new Uint8Array(32) } + }; + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager); + + await harness.controller.persistDelegatedAccountSession('desk-a', 'Desktop A'); + + expect(harness.sessionStore.saved.length).assertEqual(1); + expect(harness.sessionStore.saved[0].token).assertEqual('t'); + expect(harness.sessionStore.saved[0].username).assertEqual('someone'); + expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); + }); + + it('does not erase a remembered account target after choosing an ordinary room', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + await harness.controller.selectCloudAccountDevice(harness.device('desk-a'), false); + const savesBeforeRoom = harness.sessionStore.saved.length; + + await harness.controller.persistDelegatedAccountSession('desk-b', 'Desktop B'); + + expect(harness.controller.preferredCloudTarget()?.deviceId).assertEqual('desk-a'); + expect(harness.sessionStore.saved.length).assertEqual(savesBeforeRoom); + }); + + it('does not replace a signed-in account with a different delegated QR identity', 0, async () => { + const manager = new ScriptedAccountSessionManager(); + manager.delegated = { + relayUrl: 'https://relay.example.com', + session: { token: 'delegated', userId: 'other-user', masterKey: new Uint8Array(32) } + }; + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager); + + await harness.controller.persistDelegatedAccountSession('desk-a', 'Desktop A'); + + expect(harness.sessionStore.saved.length).assertEqual(0); }); it('migrates the same desktop from a QR room to the account transport', 0, async () => { @@ -1487,6 +1792,7 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.remoteState.connectionState).assertEqual('connected'); expect(harness.sessionStore.saved.length).assertEqual(1); expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); + expect(harness.savedControlTargetTypes.join(',')).assertEqual('account_device'); }); it('puts the phone back on the previous desktop when a device switch fails', 0, async () => { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index be88eae3d9..edc79989ff 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -473,12 +473,25 @@ export const RemoteConnectDialog: React.FC = ({ } else if (payload && !payload.logged_in) { setCustomUrl(''); } + // 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) => { + if (!isOpenRef.current) return; + applyStatus(nextStatus); + if (remotePairingStateName(nextStatus.pairing_state) !== 'connected') { + pendingOwnerRef.current = null; + connectionOwnerRef.current = null; + setConnectionOwner(null); + setConnectionResult(null); + } + }).catch(() => undefined); }, ); return () => { unlisten(); }; - }, []); + }, [applyStatus]); // The account status and pairing status intentionally expose opaque UUIDs // for identity checks. Resolve the persisted, non-secret login hint for the