Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions scripts/generate-brand-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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),
);

Expand Down
6 changes: 6 additions & 0 deletions scripts/product-identity-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 15 additions & 0 deletions scripts/product-identity-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;`,
Expand Down
99 changes: 97 additions & 2 deletions src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Notify> = OnceLock::new();
static ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID: AtomicU64 = AtomicU64::new(0);
static ACCOUNT_CONTEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -2027,6 +2046,7 @@ fn parse_connection_method(
pub async fn remote_connect_start(
request: StartRemoteConnectRequest,
) -> Result<ConnectionResult, String> {
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)?;
Expand Down Expand Up @@ -2360,13 +2380,25 @@ pub async fn account_finalize_login(request: PendingAccountLoginRequest) -> Resu
pub async fn account_cancel_pending_login(
request: PendingAccountLoginRequest,
) -> Result<bool, String> {
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;
Expand Down Expand Up @@ -2415,6 +2447,12 @@ pub async fn account_login(request: AccountAuthRequest) -> Result<AccountLoginRe
}
};

let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await;
if !account_context_is_current(expected_generation) {
revoke_login_candidate(&client, &relay_url, &session, "account replacement race").await;
return Err("account context changed".to_string());
}
retire_unpaired_room_for_account_boundary("account login or replacement").await;
let Some(mut transition_guard) = cancel_and_wait_if_account_current(expected_generation).await
else {
revoke_login_candidate(&client, &relay_url, &session, "account replacement race").await;
Expand Down Expand Up @@ -2528,6 +2566,8 @@ async fn clear_account_login(revoke_relay_token: bool) {
// Invalidate the active operation first, then wait for it to observe the
// cancellation and release its guard. This ensures no settings apply or
// progress event can happen after logout completes.
let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await;
retire_unpaired_room_for_account_boundary("account logout").await;
let _sync_guard = cancel_and_wait_for_account_auto_sync().await;
clear_account_login_state(revoke_relay_token).await;
}
Expand All @@ -2537,6 +2577,14 @@ async fn clear_account_login_if_current(
expected_token: &str,
revoke_relay_token: bool,
) -> 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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::<u8, &str>(Ok(None)).unwrap());
Expand Down
4 changes: 2 additions & 2 deletions src/apps/mobile/design-system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <emulator-tcp-target> shell aa force-stop com.openbitfun.app
hdc -t <emulator-tcp-target> shell aa force-stop <harmony-bundle-id>
hdc -t <emulator-tcp-target> shell aa start \
-a EntryAbility -b com.openbitfun.app \
-a EntryAbility -b <harmony-bundle-id> \
--ps openbitfunDesignPreview connected-conversation
```

Expand Down
6 changes: 4 additions & 2 deletions src/apps/mobile/harmonyos/AppScope/app.json5
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <serial>`,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target);
- 外屏分辨率 1080×2444;点击用 `hdc -t <id> shell uinput -T -c X Y`。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand All @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', '设备'],
Expand Down Expand Up @@ -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 桌面版'],
Expand All @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading