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
57 changes: 56 additions & 1 deletion src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use bitfun_core::service::remote_connect::{
bot::{self, weixin, BotConfig},
lan, session_store, sync_state, AccountClient, AccountPairingVerification, AccountSession,
ConnectionMethod, ConnectionResult, DelegatedIdentityAuthorization, DeviceIdentity,
PairingState, RemoteConnectConfig, RemoteConnectService,
PairingState, ProvisionedDeviceAuthorization, RemoteConnectConfig, RemoteConnectService,
};
use bitfun_core::service::session::{DialogTurnData, SessionMetadata};
use bitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind};
Expand Down Expand Up @@ -1220,6 +1220,61 @@ async fn register_delegated_identity_providers() {
})
.await;

// Room-channel provider that adds a keyboard-less device (a watch) to
// this account. Same lease discipline as delegation above; the errors
// are returned rather than swallowed because a provisioning failure is
// shown to someone standing there waiting for it.
let account_context = get_account_context().clone();
service
.set_peer_device_provisioner(move |device_id, device_name, request_id| {
let account_context = account_context.clone();
Box::pin(async move {
// Minted by the device being provisioned so a retry anywhere
// along the chain replays one idempotent relay request.
let request_id = uuid::Uuid::parse_str(&request_id)
.map_err(|_| "Request id must be a UUID".to_string())?;
let generation = account_context_generation();
if !account_context_is_current(generation) {
return Err("Desktop account changed; try again".to_string());
}
let account_lease = lock_account_sync(generation)
.await
.map_err(|_| "Desktop account changed; try again".to_string())?;
let context = account_context
.read()
.await
.clone()
.ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?;
if !account_context_matches(generation, &context.session.token).await {
return Err("Desktop account changed; try again".to_string());
}
let provisioned = AccountClient::new()
.provision_device_token(
&context.relay_url,
&context.session,
&device_id,
&device_name,
request_id,
)
.await
.map_err(|e| {
log::warn!("Provision device token failed: {e}");
format!("Could not add the device to your account: {e}")
})?;
if !account_context_matches(generation, &context.session.token).await {
return Err("Desktop account changed; try again".to_string());
}
Ok(ProvisionedDeviceAuthorization::with_host_lease(
provisioned.token,
provisioned.user_id,
context.session.master_key,
provisioned.device_id,
account_lease,
))
})
})
.await;

// Account-mode mobile pairing: QR prefill + password verification.
register_account_pairing_context(service).await;

Expand Down
2 changes: 1 addition & 1 deletion src/apps/mobile/harmonyos/AppScope/app.json5
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"app": {
"bundleName": "com.example.bitfun_mobile",
"bundleName": "com.bitfun.app",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0",
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.example.bitfun_mobile`**,不是 `com.bitfun.mobile`
- bundle 名是 **`com.bitfun.app`**,和手表端共用一个 bundle。2026-08-10 从脚手架遗留的 `com.example.bitfun_mobile` 改过来的,原因是 `distributedKVStore` 按 bundleName + storeId 隔离,跨设备同步的前提是同一个 app —— bundle 不一致时手机和手表各自建的是两个互不相干的库,手机↔手表的凭证交接物理上跑不通。改动的代价是已装的旧包等于另一个 app,数据不通、要重新登录
- `hdc` 必须带 `-t <serial>`,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target);
- 外屏分辨率 1080×2444;点击用 `hdc -t <id> shell uinput -T -c X Y`。

Expand Down
14 changes: 14 additions & 0 deletions src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,20 @@ const ZH_CN_MESSAGES: [string, string][] = [
['errors.voiceInputUnavailable', '语音识别暂不可用,请稍后重试。'],
['errors.voiceInputFailed', '语音识别失败({0}),请稍后重试。'],

['watchProvision.title', '把这块手表加入你的 BitFun 账号?'],
['watchProvision.deviceId', '设备编号 {0}'],
['watchProvision.body', '同意后,这块手表可以用你的账号连接桌面端,有效期 30 天。请确认这台设备就在你手里。'],
['watchProvision.approve', '允许'],
['watchProvision.reject', '拒绝'],
['watchProvision.gotIt', '知道了'],
['watchProvision.working', '正在从桌面端申请授权...'],
['watchProvision.doneBody', '{0} 已加入账号,手表上可以直接使用了。'],
['watchProvision.rejected', '已在手机上拒绝。'],
['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'],
['watchProvision.errors.noDesktop', '需要先在手机上扫码连接桌面端,才能给手表授权。'],
['watchProvision.errors.desktopUnreachable', '桌面端未在线或版本过旧,请更新桌面端后重试。'],
['watchProvision.errors.handoffFailed', '授权已完成,但没能把凭证发给手表,请在手表上重试一次。'],

['ui.emptyMessage', '(空消息)'],
['ui.desktopProcessing', '桌面端正在处理...'],
['ui.seedConnected', '{0} 已连接。你可以继续发送指令,桌面端会在 BitFun 中执行。'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export interface RemoteCommand {
answers?: Object;
image_contexts?: RemoteImageContext[];
images?: ImageAttachment[];
// provision_peer_device. `request_id` is minted by the device being
// provisioned, unlike `_request_id`, which the phone stamps on every command
// for its own correlation logging.
device_id?: string;
device_name?: string;
request_id?: string;
}

export interface PairChallengeResponse {
Expand Down Expand Up @@ -250,6 +256,18 @@ export interface DelegatedIdentityResponse extends CommandStatusResponse {
device_id?: string;
}

/**
* Answer to `provision_peer_device`. Shaped like a delegated identity but
* carrying a 30-day *full* device credential, and `device_id` names the device
* that was just registered rather than the desktop that registered it.
*/
export interface PeerDeviceProvisionedResponse extends CommandStatusResponse {
token?: string;
user_id?: string;
master_key?: string;
device_id?: string;
}

export interface SessionListResult {
sessions: RemoteSession[];
hasMore: boolean;
Expand Down
16 changes: 16 additions & 0 deletions src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppRootPresentation } from './components/AppRootPresentation';
import { WatchProvisionCard } from './components/WatchProvisionCard';
import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter';
import { AppRootRuntime } from './runtime/AppRootRuntime';

Expand Down Expand Up @@ -41,6 +42,21 @@ struct AppRoot {
deviceId: this.runtime.remoteConnectionController.getDeviceId(),
actions: this.runtime.presentationActions
})

// Sits above every route on purpose: a watch waiting for approval must
// not be hidden behind whatever screen the phone happens to be on.
WatchProvisionCard({
state: this.runtime.watchProvisionState,
onApprove: () => {
void this.runtime.watchProvisionController.approve();
},
onReject: () => {
void this.runtime.watchProvisionController.reject();
},
onDismiss: () => {
this.runtime.watchProvisionController.dismiss();
}
})
}
.width('100%')
.height('100%')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { RemoteI18n } from '../../i18n/RemoteI18n';
import { WatchProvisionPhase, WatchProvisionState } from '../state/WatchProvisionState';
import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme';

/**
* The one place a watch can be added to the account. It is deliberately modal:
* granting a device a 30-day credential should interrupt, not sit in a corner
* waiting to be noticed.
*/
@ComponentV2
export struct WatchProvisionCard {
@Param state: WatchProvisionState = new WatchProvisionState();
@Event onApprove: () => void = () => {};
@Event onReject: () => void = () => {};
@Event onDismiss: () => void = () => {};

build() {
if (this.state.visible()) {
Stack() {
Text('')
.width('100%')
.height('100%')
.backgroundColor(MODAL_SCRIM)
.onClick(() => {
// Tapping away while the desktop is minting would leave the watch
// with no answer and no card to explain it.
if (this.state.dismissible()) {
this.onDismiss();
}
})

Column({ space: 18 }) {
Text(RemoteI18n.t('watchProvision.title'))
.fontSize(23)
.fontWeight(FontWeight.Bold)
.fontColor(INK)
.width('100%')

Column({ space: 6 }) {
Text(this.state.deviceName)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor(INK)
.width('100%')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(RemoteI18n.f('watchProvision.deviceId', this.state.deviceIdLabel))
.fontSize(14)
.fontColor(MUTED)
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(SOFT)
.borderRadius(20)

if (this.state.phase === WatchProvisionPhase.Asking) {
Text(RemoteI18n.t('watchProvision.body'))
.fontSize(16)
.lineHeight(23)
.fontColor(MUTED)
.width('100%')
} else if (this.state.phase === WatchProvisionPhase.Working) {
Row({ space: 10 }) {
LoadingProgress()
.width(20)
.height(20)
.color(MUTED)
Text(RemoteI18n.t('watchProvision.working'))
.fontSize(16)
.lineHeight(23)
.fontColor(MUTED)
.layoutWeight(1)
}
.width('100%')
} else {
Text(this.state.message)
.fontSize(16)
.lineHeight(23)
.fontColor(this.state.phase === WatchProvisionPhase.Done ? GREEN : RED)
.width('100%')
}

if (this.state.phase === WatchProvisionPhase.Asking) {
Row({ space: 12 }) {
Button(RemoteI18n.t('watchProvision.reject'))
.layoutWeight(1)
.height(56)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(INK)
.backgroundColor(SOFT)
.borderRadius(28)
.onClick(this.onReject)
Button(RemoteI18n.t('watchProvision.approve'))
.layoutWeight(1)
.height(56)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(PRIMARY_ACTION_TEXT)
.backgroundColor(PRIMARY_ACTION)
.borderRadius(28)
.onClick(this.onApprove)
}
.width('100%')
} else if (this.state.phase !== WatchProvisionPhase.Working) {
Button(RemoteI18n.t('watchProvision.gotIt'))
.width('100%')
.height(56)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(PRIMARY_ACTION_TEXT)
.backgroundColor(PRIMARY_ACTION)
.borderRadius(28)
.onClick(this.onDismiss)
}
}
.width('82%')
.constraintSize({ maxWidth: 520 })
.padding({ left: 26, right: 26, top: 28, bottom: 26 })
.backgroundColor(CARD)
.borderRadius(34)
.border({ width: 1, color: LINE })
}
.width('100%')
.height('100%')
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,26 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
await this.settingsController.initializeCloudAccount(this.host.context());
await this.settingsController.refreshModelCatalog();
await this.restoreIdentity();
await this.startWatchProvisioning();
}

/**
* Only ask for distributed data sync once the phone actually has a desktop
* to relay to. A fresh install has nothing to provision a watch with, and a
* permission prompt at first launch would have no explanation behind it.
*/
private async startWatchProvisioning(): Promise<void> {
if (!this.hasRemoteBindingForResume()) {
return;
}
await this.watchProvisionController.start(this.host.context());
}

onPageShow(): void {
RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`);
this.remoteActivityViewModel.resume();
// Idempotent: covers the pairing that happened after the last cold start.
void this.startWatchProvisioning();
}

onPageHide(): void {
Expand All @@ -43,6 +58,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
}

aboutToDisappear(): void {
this.watchProvisionController.stop();
this.remoteActivityViewModel.invalidate();
this.remoteConnectionCoordinator.invalidate();
this.remotePageState.setBusy(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ import { ConversationIntentDispatcher } from '../actions/ConversationIntentDispa
import { GeneralChatPageState } from '../state/GeneralChatPageState';
import { RemotePageState } from '../state/RemotePageState';
import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState';
import { WatchProvisionState } from '../state/WatchProvisionState';
import { WatchProvisionController } from '../../services/WatchProvisionController';
import { PeerDeviceProvisionOutcome } from '../../services/RelayHttpClient';
import { ConversationViewModel } from '../viewmodel/ConversationViewModel';
import { FilePreviewState } from '../state/FilePreviewState';
import { FilePreviewRequest } from '../../model/FilePreviewTarget';
Expand Down Expand Up @@ -147,6 +150,18 @@ export abstract class AppRootRuntimeComposition {
readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState();
readonly remotePageState: RemotePageState = new RemotePageState();
readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState();
readonly watchProvisionState: WatchProvisionState = new WatchProvisionState();
readonly watchProvisionController: WatchProvisionController =
new WatchProvisionController(this.watchProvisionState, {
// Provisioning only rides the QR-paired room channel: that is the one
// path where the desktop holds the pairing identity that authorizes it.
canProvision: (): boolean =>
(this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected &&
this.sessionManager.hasRoomChannel(),
provision: (deviceId: string, deviceName: string, requestId: string): Promise<PeerDeviceProvisionOutcome> =>
this.sessionManager.provisionPeerDevice(deviceId, deviceName, requestId),
relayUrl: (): string => this.sessionManager.roomRelayEndpoint()
});
readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore();
readonly generalChatController: GeneralChatController =
GeneralChatController.createDefault(this.generalChatConfigStore);
Expand Down
Loading