From 423557f0372d543091d0c6694a5853831cac8da4 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 29 Sep 2026 19:31:48 +0800 Subject: [PATCH 1/3] fix(mobile): make account sign-in recovery reliable --- .../pages/viewmodel/SettingsController.ets | 8 +- .../ets/services/CloudAccountSessionStore.ets | 26 +++- .../core/persistence/AndroidSecureStore.kt | 31 ++++- .../services/relay-service/src/identity.rs | 129 ++++++++++++++++-- 4 files changed, 178 insertions(+), 16 deletions(-) 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 c6208f9908..4b4742ba65 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 @@ -161,7 +161,13 @@ export class SettingsController { const generation = ++this.accountLoginVersion; const cloud = this.requireCloud(); RemoteLogger.info('cloud account UI login requested'); - const deviceSecret = await cloud.sessionStore.deviceSecret(); + let deviceSecret: Uint8Array; + try { + deviceSecret = await cloud.sessionStore.deviceSecret(); + } catch (err) { + RemoteLogger.error(`cloud account device identity failed: ${err instanceof Error ? err.message : String(err)}`); + throw err instanceof Error ? err : new Error(String(err)); + } if (generation !== this.accountLoginVersion) { deviceSecret.fill(0); throw new Error('Account login cancelled'); } let session: CloudAccountSession; try { 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 e8b6325476..fa6758c704 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 @@ -4,6 +4,7 @@ import { preferences } from '@kit.ArkData'; import { Encoding } from './Encoding'; import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { OhosError } from './OhosError'; +import { RemoteLogger } from './RemoteLogger'; const STORE_NAME: string = HarmonyUpgradeIdentityContract.CLOUD_ACCOUNT_STORE; const CIPHER_KEY: string = 'github_device_session_v1_cipher'; @@ -49,10 +50,33 @@ export class CloudAccountSessionStore { const previous = await this.load(); const secret = previous ? Encoding.base64ToBytes(previous.masterKey) : Encoding.randomBytes(32); if (secret.length !== 32) throw new Error('Stored device identity is invalid.'); - await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret)); + try { + await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret)); + } catch (err) { + // A reinstall clears Preferences but HarmonyOS may retain the HUKS alias. + // Only rotate the alias when there is no prior session record: existing + // encrypted account data must never be made unreadable as recovery. + if (previous) throw err instanceof Error ? err : new Error(OhosError.reason(err)); + RemoteLogger.warn('cloud account device identity write failed on fresh install; recreating local key'); + await this.recreateKeyForFreshInstall(); + await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret)); + } return secret; } + private async recreateKeyForFreshInstall(): Promise { + try { + await huks.deleteKeyItem(HUKS_ALIAS, {}); + } catch (err) { + const message = OhosError.reason(err); + if (message.indexOf('12000011') < 0 && message.indexOf('-13') < 0 && + message.toLowerCase().indexOf('does not exist') < 0) { + throw err instanceof Error ? err : new Error(OhosError.reason(err)); + } + } + await this.ensureKey(); + } + async loadProfile(userId: string): Promise { const text = await this.readSealed(`github_profile_${userId}_v1_cipher`, `github_profile_${userId}_v1_iv`); return readCachedGitHubProfile(userId, text || ''); diff --git a/src/apps/mobile/shared/core-persistence/src/androidMain/kotlin/com/openbitfun/mobile/core/persistence/AndroidSecureStore.kt b/src/apps/mobile/shared/core-persistence/src/androidMain/kotlin/com/openbitfun/mobile/core/persistence/AndroidSecureStore.kt index 97f765b52e..233fb42045 100644 --- a/src/apps/mobile/shared/core-persistence/src/androidMain/kotlin/com/openbitfun/mobile/core/persistence/AndroidSecureStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/androidMain/kotlin/com/openbitfun/mobile/core/persistence/AndroidSecureStore.kt @@ -4,6 +4,7 @@ import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import android.util.Log import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -31,11 +32,17 @@ private class AndroidSecureStore( } override fun write(key: String, value: ByteArray) { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, secretKey()) - val packed = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." + - Base64.encodeToString(cipher.doFinal(value), Base64.NO_WRAP) - check(preferences.edit().putString(key, packed).commit()) { "Secure value could not be persisted." } + try { + writeWithKey(key, value) + } catch (cause: Throwable) { + // Android may retain a Keystore alias while uninstalling the app + // clears SharedPreferences. Replacing the alias is safe only when + // this namespace has no encrypted records to preserve. + if (preferences.all.isNotEmpty()) throw cause + Log.w(TAG, "Secure store key failed on a fresh install; recreating it", cause) + deleteKeyAlias() + writeWithKey(key, value) + } } override fun delete(key: String) { @@ -59,9 +66,23 @@ private class AndroidSecureStore( return generator.generateKey() } + private fun writeWithKey(key: String, value: ByteArray) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val packed = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." + + Base64.encodeToString(cipher.doFinal(value), Base64.NO_WRAP) + check(preferences.edit().putString(key, packed).commit()) { "Secure value could not be persisted." } + } + + private fun deleteKeyAlias() { + val keyStore = KeyStore.getInstance(KEY_STORE).also { it.load(null) } + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) + } + companion object { private const val KEY_STORE = "AndroidKeyStore" private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG = "OpenBitFunSecureStore" } } diff --git a/src/crates/services/relay-service/src/identity.rs b/src/crates/services/relay-service/src/identity.rs index 50ca6881f7..d2ec6b6745 100644 --- a/src/crates/services/relay-service/src/identity.rs +++ b/src/crates/services/relay-service/src/identity.rs @@ -3,7 +3,11 @@ use axum::http::StatusCode; use serde::Deserialize; -use std::{sync::Arc, time::Duration}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; pub(crate) const IDENTITY_ME_URL: &str = "https://auth.openbitfun.com/api/v1/me"; @@ -27,6 +31,63 @@ struct IdentityResponse { user: VerifiedIdentity, } +/// A completed poll response kept long enough to make the authority's +/// single-use transaction safe to retry after a lost HTTP response. +struct CompletedPoll { + payload: serde_json::Value, + expires_at: i64, +} + +/// Terminal sign-in outcomes are keyed by both transaction id and secret. +/// A different secret must never overwrite the valid client's replay entry. +fn completed_authorizations() -> &'static Mutex> { + static COMPLETED: OnceLock>> = OnceLock::new(); + COMPLETED.get_or_init(|| Mutex::new(HashMap::new())) +} + +const POLL_REPLAY_SECS: i64 = 900; + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn replay_completed_poll( + transaction_id: &str, + transaction_secret: &str, +) -> Option { + let key = (transaction_id.to_string(), transaction_secret.to_string()); + let mut completed = completed_authorizations() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + completed.retain(|_, poll| poll.expires_at > now_secs()); + completed.get(&key).map(|poll| poll.payload.clone()) +} + +fn remember_completed_poll( + transaction_id: &str, + transaction_secret: &str, + payload: &serde_json::Value, +) { + if payload.get("status").and_then(|status| status.as_str()) == Some("pending") { + return; + } + let key = (transaction_id.to_string(), transaction_secret.to_string()); + let mut completed = completed_authorizations() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + completed.retain(|_, poll| poll.expires_at > now_secs()); + completed.insert( + key, + CompletedPoll { + payload: payload.clone(), + expires_at: now_secs() + POLL_REPLAY_SECS, + }, + ); +} + impl IdentityVerifier { pub(crate) async fn start_auth( &self, @@ -55,14 +116,20 @@ impl IdentityVerifier { { return Err(StatusCode::BAD_REQUEST); } - self.auth_request( - "auth/desktop/poll", - serde_json::json!({ - "transactionId": transaction_id, - "transactionSecret": transaction_secret, - }), - ) - .await + if let Some(payload) = replay_completed_poll(transaction_id, transaction_secret) { + return Ok(payload); + } + let payload = self + .auth_request( + "auth/desktop/poll", + serde_json::json!({ + "transactionId": transaction_id, + "transactionSecret": transaction_secret, + }), + ) + .await?; + remember_completed_poll(transaction_id, transaction_secret, &payload); + Ok(payload) } async fn auth_request( @@ -279,6 +346,50 @@ mod tests { task.abort(); } } + + #[tokio::test] + async fn poll_replays_a_completed_transaction_after_a_mismatched_secret() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let upstream_calls = Arc::new(AtomicUsize::new(0)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let transaction_id = format!("replayed-{}", listener.local_addr().unwrap().port()); + let url = format!("http://{}/me", listener.local_addr().unwrap()); + let counter = upstream_calls.clone(); + let app = Router::new().route( + "/auth/desktop/poll", + axum::routing::post(move || { + let counter = counter.clone(); + async move { + if counter.fetch_add(1, Ordering::SeqCst) == 0 { + axum::Json(serde_json::json!({ + "status": "authorized", + "tokens": { "accessToken": "token-1" }, + })) + } else { + axum::Json(serde_json::json!({ "status": "consumed" })) + } + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = IdentityVerifier::with_url(&url).unwrap(); + + let first = client.poll_auth(&transaction_id, "secret-1").await.unwrap(); + let repeated = client.poll_auth(&transaction_id, "secret-1").await.unwrap(); + let other_secret = client.poll_auth(&transaction_id, "secret-2").await.unwrap(); + let repeated_after_other_secret = + client.poll_auth(&transaction_id, "secret-1").await.unwrap(); + + assert_eq!(first["status"], "authorized"); + assert_eq!(repeated, first); + assert_eq!(other_secret["status"], "consumed"); + assert_eq!(repeated_after_other_secret, first); + assert_eq!(upstream_calls.load(Ordering::SeqCst), 2); + task.abort(); + } } fn identity_request_permit() -> Result { From 5e1e9f23cfbfb0fd79e4c9ea37ba57ad2fee77d9 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 29 Sep 2026 19:37:20 +0800 Subject: [PATCH 2/3] fix(harmonyos): adapt welcome sheet to system insets --- .../ui/theme/generated/MobileDesignTokens.kt | 4 +- .../preview/generated/mobile-design-data.js | 6 +- .../design-system/tokens/mobile-tokens.json | 6 +- .../main/ets/generated/MobileDesignTokens.ets | 3 +- .../src/main/ets/pages/components/Theme.ets | 1 + .../main/ets/pages/components/WelcomeHome.ets | 62 ++++++++++++++++--- .../ets/services/WindowSystemBarService.ets | 23 +++++++ .../main/resources/base/element/color.json | 4 ++ .../main/resources/dark/element/color.json | 6 +- .../GeneratedMobileDesignTokens.swift | 3 +- 10 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt index 6f8faac6a2..28936f7881 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt @@ -12,6 +12,7 @@ internal object MobileDesignColors { val StartWindowBackground = Color(0xFFFFFFFF) val Transparent = Color(0x00000000) val PageBg = Color(0xFFFFFFFF) + val WelcomePageBg = Color(0xFFFFFFFF) val PageBgFade = Color(0x00FFFFFF) val PageBgOverlay = Color(0xB8FFFFFF) val Ink = Color(0xFF171717) @@ -75,6 +76,7 @@ internal object MobileDesignColors { val StartWindowBackground = Color(0xFF000000) val Transparent = Color(0x00000000) val PageBg = Color(0xFF151514) + val WelcomePageBg = Color(0xFF252522) val PageBgFade = Color(0x00151514) val PageBgOverlay = Color(0xB8151514) val Ink = Color(0xFFF4F3EF) @@ -127,7 +129,7 @@ internal object MobileDesignColors { val CodeProperty = Color(0xFF9CC8D0) val CodeTargetBg = Color(0xFF5A4E24) val BrandDot = Color(0xFF16B9CE) - val WelcomeDock = Color(0xFF171917) + val WelcomeDock = Color(0xFF000000) val WelcomeButton = Color(0xFFFFFFFF) val WelcomeButtonLabel = Color(0xFF171917) val WelcomeSecondary = Color(0xFFB9BCB9) diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index d9080f6bb6..11baa9add1 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -18,6 +18,10 @@ export const mobileTokens = { "light": "#FFFFFF", "dark": "#151514" }, + "welcome_page_bg": { + "light": "#FFFFFF", + "dark": "#252522" + }, "page_bg_fade": { "light": "#00FFFFFF", "dark": "#00151514" @@ -228,7 +232,7 @@ export const mobileTokens = { }, "welcome_dock": { "light": "#171917", - "dark": "#171917" + "dark": "#000000" }, "welcome_button": { "light": "#FFFFFF", diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json index 5d18b06fc9..d81f3b2d6f 100644 --- a/src/apps/mobile/design-system/tokens/mobile-tokens.json +++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json @@ -17,6 +17,10 @@ "light": "#FFFFFF", "dark": "#151514" }, + "welcome_page_bg": { + "light": "#FFFFFF", + "dark": "#252522" + }, "page_bg_fade": { "light": "#00FFFFFF", "dark": "#00151514" @@ -227,7 +231,7 @@ }, "welcome_dock": { "light": "#171917", - "dark": "#171917" + "dark": "#000000" }, "welcome_button": { "light": "#FFFFFF", diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets index b4508bbff4..8626cd781e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets @@ -14,6 +14,7 @@ export class MobileDesignColors { static readonly startWindowBackground: MobileColorPair = new MobileColorPair("#FFFFFF", "#000000"); static readonly transparent: MobileColorPair = new MobileColorPair("#00000000", "#00000000"); static readonly pageBg: MobileColorPair = new MobileColorPair("#FFFFFF", "#151514"); + static readonly welcomePageBg: MobileColorPair = new MobileColorPair("#FFFFFF", "#252522"); static readonly pageBgFade: MobileColorPair = new MobileColorPair("#00FFFFFF", "#00151514"); static readonly pageBgOverlay: MobileColorPair = new MobileColorPair("#B8FFFFFF", "#B8151514"); static readonly ink: MobileColorPair = new MobileColorPair("#171717", "#F4F3EF"); @@ -66,7 +67,7 @@ export class MobileDesignColors { static readonly codeProperty: MobileColorPair = new MobileColorPair("#466D78", "#9CC8D0"); static readonly codeTargetBg: MobileColorPair = new MobileColorPair("#FFF1BE", "#5A4E24"); static readonly brandDot: MobileColorPair = new MobileColorPair("#16B9CE", "#16B9CE"); - static readonly welcomeDock: MobileColorPair = new MobileColorPair("#171917", "#171917"); + static readonly welcomeDock: MobileColorPair = new MobileColorPair("#171917", "#000000"); static readonly welcomeButton: MobileColorPair = new MobileColorPair("#FFFFFF", "#FFFFFF"); static readonly welcomeButtonLabel: MobileColorPair = new MobileColorPair("#171917", "#171917"); static readonly welcomeSecondary: MobileColorPair = new MobileColorPair("#B9BCB9", "#B9BCB9"); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 3fb5f1ea60..5b356d8209 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -1,4 +1,5 @@ export const PAGE_BG: ResourceColor = $r('app.color.page_bg'); +export const WELCOME_PAGE_BG: ResourceColor = $r('app.color.welcome_page_bg'); /** PAGE_BG at zero alpha, per theme — the transparent end of a fade-out. */ export const PAGE_BG_FADE: ResourceColor = $r('app.color.page_bg_fade'); /** diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 10b2a4982d..7e638a136b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -1,8 +1,9 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignTypography, MobileDesignGeometry as G } from '../../generated/MobileDesignTokens'; import { MotionPreference } from '../../services/MotionPreference'; +import { WindowSystemBarService } from '../../services/WindowSystemBarService'; import { WelcomeBrandFlow } from './WelcomeBrandFlow'; -import { INK, PAGE_BG, MUTED, TRANSPARENT } from './Theme'; +import { INK, PAGE_BG, WELCOME_PAGE_BG, MUTED, TRANSPARENT } from './Theme'; /** A welcome composition with the C staggered-slide phrase timeline. */ @ComponentV2 @@ -13,10 +14,22 @@ export struct WelcomeHome { @Local elapsed: number = 0; @Local pageHeight: number = 700; @Local pageWidth: number = 390; + @Local topInset: number = 0; private timer: number = -1; private started: number = 0; + private immersiveCompact: boolean = false; - aboutToDisappear(): void { this.stop(); } + aboutToDisappear(): void { + this.stop(); + if (this.immersiveCompact) { + const context = this.getUIContext().getHostContext(); + if (context) { + void WindowSystemBarService.setLayoutFullScreen(context, false); + } + this.immersiveCompact = false; + this.topInset = 0; + } + } private stop(): void { if (this.timer >= 0) clearInterval(this.timer); this.timer = -1; } private play(): void { this.stop(); @@ -57,7 +70,6 @@ export struct WelcomeHome { private landscape(): boolean { return this.pageWidth >= 840 && this.pageWidth / Math.max(1, this.pageHeight) >= 1.2; } - build() { Scroll() { Column() { @@ -80,21 +92,52 @@ export struct WelcomeHome { }.width('100%').constraintSize({ minHeight: Math.max(540, this.pageHeight - G.welcomeHeaderHeight) }) .justifyContent(FlexAlign.Center).padding({ bottom: 64 }) } else { - Column() { this.Hero() } - .justifyContent(FlexAlign.Center).width('100%') - .height(Math.max(300, this.pageHeight - G.welcomeHeaderHeight - 228)) - .padding({ bottom: 24 }) - this.Actions() + Column() { + Column() { this.Hero() } + .justifyContent(FlexAlign.Center).width('100%') + .layoutWeight(1) + .constraintSize({ minHeight: 300 }) + .padding({ bottom: 24 }) + this.Actions() + }.width('100%').height(Math.max(0, + this.pageHeight - this.topInset - G.welcomeHeaderHeight)) } }.width('100%') - }.width('100%').height('100%').scrollBar(BarState.Off).backgroundColor(PAGE_BG) + }.width('100%').height('100%').scrollBar(BarState.Off).backgroundColor(WELCOME_PAGE_BG) + .padding({ top: this.topInset }) .onSizeChange((_old: SizeOptions, size: SizeOptions) => { this.pageHeight = Number(size.height); this.pageWidth = Number(size.width); + this.updateWindowLayout(); }) .onVisibleAreaChange([0], (visible: boolean) => { if (visible) this.play(); else this.stop(); }) } + private updateWindowLayout(): void { + const compact = this.pageWidth < 600; + if (compact === this.immersiveCompact) { + return; + } + const context = this.getUIContext().getHostContext(); + if (!context) { + return; + } + this.immersiveCompact = compact; + if (!compact) { + this.topInset = 0; + void WindowSystemBarService.setLayoutFullScreen(context, false); + return; + } + void WindowSystemBarService.setLayoutFullScreen(context, true).then(async (): Promise => { + if (this.immersiveCompact) { + const topInsetPx: number = await WindowSystemBarService.topSystemInsetPx(context); + if (this.immersiveCompact) { + this.topInset = this.getUIContext().px2vp(topInsetPx); + } + } + }); + } + @Builder private Hero() { Column({ space: this.wide() ? 16 : 20 }) { @@ -126,6 +169,7 @@ export struct WelcomeHome { .padding({ left: G.welcomeGutter, right: G.welcomeGutter, top: this.wide() ? 0 : G.welcomeGutter, bottom: this.wide() ? 0 : G.welcomeDockBottom }) .backgroundColor(this.wide() ? TRANSPARENT : $r('app.color.welcome_dock')) + .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets new file mode 100644 index 0000000000..53dc37dca1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -0,0 +1,23 @@ +import { Context } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; + +/** Platform window adapter for surfaces that need to draw behind system bars. */ +export class WindowSystemBarService { + static async setLayoutFullScreen(context: Context, enabled: boolean): Promise { + try { + const appWindow = await window.getLastWindow(context); + await appWindow.setWindowLayoutFullScreen(enabled); + } catch (_err) { + // Window layout is best effort and must not block page rendering. + } + } + + static async topSystemInsetPx(context: Context): Promise { + try { + const appWindow = await window.getLastWindow(context); + return appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; + } catch (_err) { + return 0; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 94428cf3ce..b48b8b2d8c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -12,6 +12,10 @@ "name": "page_bg", "value": "#FFFFFF" }, + { + "name": "welcome_page_bg", + "value": "#FFFFFF" + }, { "name": "page_bg_fade", "value": "#00FFFFFF" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 918fb093dd..44c8bff439 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -12,6 +12,10 @@ "name": "page_bg", "value": "#151514" }, + { + "name": "welcome_page_bg", + "value": "#252522" + }, { "name": "page_bg_fade", "value": "#00151514" @@ -222,7 +226,7 @@ }, { "name": "welcome_dock", - "value": "#171917" + "value": "#000000" }, { "name": "welcome_button", diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift index 719dc875da..4c04d1102b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift @@ -25,6 +25,7 @@ enum MobileDesignColors { static let startWindowBackground = dynamic(light: 0xFFFFFFFF, dark: 0xFF000000) static let transparent = dynamic(light: 0x00000000, dark: 0x00000000) static let pageBg = dynamic(light: 0xFFFFFFFF, dark: 0xFF151514) + static let welcomePageBg = dynamic(light: 0xFFFFFFFF, dark: 0xFF252522) static let pageBgFade = dynamic(light: 0x00FFFFFF, dark: 0x00151514) static let pageBgOverlay = dynamic(light: 0xB8FFFFFF, dark: 0xB8151514) static let ink = dynamic(light: 0xFF171717, dark: 0xFFF4F3EF) @@ -77,7 +78,7 @@ enum MobileDesignColors { static let codeProperty = dynamic(light: 0xFF466D78, dark: 0xFF9CC8D0) static let codeTargetBg = dynamic(light: 0xFFFFF1BE, dark: 0xFF5A4E24) static let brandDot = dynamic(light: 0xFF16B9CE, dark: 0xFF16B9CE) - static let welcomeDock = dynamic(light: 0xFF171917, dark: 0xFF171917) + static let welcomeDock = dynamic(light: 0xFF171917, dark: 0xFF000000) static let welcomeButton = dynamic(light: 0xFFFFFFFF, dark: 0xFFFFFFFF) static let welcomeButtonLabel = dynamic(light: 0xFF171917, dark: 0xFF171917) static let welcomeSecondary = dynamic(light: 0xFFB9BCB9, dark: 0xFFB9BCB9) From a21107e041660753b9b792a12a7710e737c61eb3 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 29 Sep 2026 19:47:46 +0800 Subject: [PATCH 3/3] fix(harmonyos): wait for account store initialization --- .../src/main/ets/pages/runtime/AppRootRuntime.ets | 5 ++++- .../main/ets/pages/viewmodel/SettingsController.ets | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 985d2f7cb8..52d7eeefb3 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 @@ -41,6 +41,10 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } async aboutToAppear(onAccountRestored?: (signedIn: boolean) => void): Promise { + // Account login can be tapped while the rest of the cold-start work is + // still running. Initialize its secure store first so the login path never + // races the asynchronous cache and onboarding setup below. + await this.settingsController.initializeCloudAccount(this.host.context()); await this.localeController.initialize(this.host.context()); if (this.host.offerTaskNotifications) { await this.taskCompletionNotificationPort.offerOnboarding( @@ -51,7 +55,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { await this.remoteChatCache.init(this.host.context()); await this.remoteSessionListCache.init(this.host.context()); await this.restoreCachedRemoteSessions(); - await this.settingsController.initializeCloudAccount(this.host.context()); if (onAccountRestored) onAccountRestored(this.settingsController.hasCloudAccountSession()); if (this.settingsController.hasCloudAccountSession()) { try { 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 4b4742ba65..df6018e575 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 @@ -64,6 +64,7 @@ export interface PreferredCloudTarget { /** Owns the authenticated account and remote device selection. */ export class SettingsController { private readonly cloud?: CloudAccountSettingsDependencies; + private cloudInitialization?: Promise; private cloudSession?: CloudAccountSession; private cloudRelayUrl: string = ''; private selectedRelayUrl: string = DEFAULT_CLOUD_RELAY_URL; @@ -89,6 +90,13 @@ export class SettingsController { } async initializeCloudAccount(context: Context): Promise { + if (!this.cloudInitialization) { + this.cloudInitialization = this.initializeCloudAccountInternal(context); + } + await this.cloudInitialization; + } + + private async initializeCloudAccountInternal(context: Context): Promise { const cloud = this.requireCloud(); await cloud.sessionStore.init(context); await this.restoreCloudAccountSession(); @@ -160,6 +168,9 @@ export class SettingsController { const relayUrl = this.selectedRelayUrl; const generation = ++this.accountLoginVersion; const cloud = this.requireCloud(); + if (this.cloudInitialization) { + await this.cloudInitialization; + } RemoteLogger.info('cloud account UI login requested'); let deviceSecret: Uint8Array; try {