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
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Mobile-Web 账号配对门槛与设备列表跟随设计

Date: 2026-07-20
Scope: `src/mobile-web`, `src/crates/services/services-integrations` (pairing/QR/account), `src/crates/assembly/core` (remote_connect), `src/apps/desktop` (account verifier wiring)

## 背景

1. Mobile-web 设备页在桌面换号后展示「账号授权已过期」死胡同:委派 token 失效后未向配对桌面重新拉取身份。
2. 扫码配对的「用户 ID」原用于 URL 防盗用;桌面已登录 BitFun 账号时,应改为校验与桌面相同的账号密码,且必须是被扫码桌面当前账号。

## 已确认产品规则

| 场景 | Mobile 表单 | 校验 |
|---|---|---|
| 桌面未登录账号 | 仅用户 ID(现状) | `TrustedMobileIdentity`(install_id + user_id) |
| 桌面已登录账号 | 用户名 + 密码 | 与桌面登录同源(challenge + unwrap master_key);`master_key` 必须等于当前桌面会话;密码不落盘;用户名可预填(含首次) |

设备列表:与桌面登录后列表一致;401 时透明刷新委派身份并重试,不再展示授权过期终态。

## 方案

### A. 配对(账号模式)

1. **QR URL**:桌面已登录时追加 `auth=account&user=<username>`(username 来自 credential hint,非机密)。未登录不带这些参数。
2. **Mobile**:解析到 `auth=account` 时显示密码框;用户名优先 URL,其次本地缓存;**禁用无密码自动重连**;密码仅经 ECDH 加密房间通道提交。
3. **`PairingResponse`**:新增可选 `password`;`user_id` 在账号模式下承载提交的 username。
4. **Desktop 校验(verify-only)**:
- 注册 `account_pairing_verifier(username, password) -> Result<canonical_user_id>`。
- 实现:`AccountClient` challenge + unwrap master_key,与当前 `AccountSession.master_key` 比较;一致则返回 `session.user_id`。
- **不调用** `/api/auth/login`,避免刷新/污染桌面 token。
- 账号不一致或密码错误 → 统一拒绝文案(不泄露哪一项失败)。
5. **信任绑定**:校验成功后用 **canonical `user_id`**(非明文 username)写入 `TrustedMobileIdentity`,保证重连时 username 提交仍能对齐。

### B. 设备列表跟随

1. `RelayHttpClient`:`clearDelegatedIdentity()`;`requestDelegatedIdentity({ force })`;`listDevices` / `sendDeviceRpc` 遇 HTTP 401 时清身份 → 强制重拉 → **重试一次**。
2. `DevicesPage`:移除 `tokenExpired` 终态;失败走可重试错误或 `noDelegatedIdentity`。
3. 清理或降级 `devices.tokenExpired` i18n(无引用则删)。

## 错误处理

- 账号模式缺密码 / 校验失败:配对失败 + 既有失败次数锁定。
- 桌面在出码后登出:verifier 不可用 → 拒绝账号模式配对(提示桌面重新登录并刷新二维码)。
- 委派刷新后桌面仍未登录:设备页 `noDelegatedIdentity` + 重试。

## 验证

- `cargo test -p bitfun-services-integrations --features remote-connect --test remote_connect_contracts`(不带 `--features remote-connect` 时整个测试文件被 cfg 门掉,0 测试静默通过)
- `cargo check -p bitfun-desktop`
- `pnpm --dir src/mobile-web run type-check`
- 手工:未登录扫码;已登录扫码(预填用户名+密码);错密码;换号后设备列表刷新无过期页
61 changes: 61 additions & 0 deletions src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,13 @@ async fn register_delegated_identity_providers() {
})
})
.await;

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

// Login/restore may switch accounts; drop any prior URL-bound mobile
// identity so the next pair can bind to the current account user id.
service.clear_trusted_mobile_identity().await;
}

// Global provider for IM bots.
Expand All @@ -418,6 +425,51 @@ async fn register_delegated_identity_providers() {
});
}

/// Wire QR account prefill + verify-only password check for mobile pairing.
async fn register_account_pairing_context(service: &RemoteConnectService) {
// Always enable account mode when logged in; username prefill is best-effort.
let username = load_credential_hint()
.map(|hint| hint.username)
.unwrap_or_default();
service.set_account_pairing_username(Some(username)).await;

let session_arc = get_account_session().clone();
let relay_url_arc = get_account_relay_url().clone();
service
.set_account_pairing_verifier(move |username, password| {
let session_arc = session_arc.clone();
let relay_url_arc = relay_url_arc.clone();
async move {
let session = session_arc
.read()
.await
.clone()
.ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?;
let relay_url = relay_url_arc
.read()
.await
.clone()
.ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?;
AccountClient::new()
.verify_password_for_master_key(
&relay_url,
&username,
&password,
&session.master_key,
)
.await
.map_err(|e| {
// Keep the real cause in desktop logs (network vs bad
// credentials); the mobile only gets the unified message.
log::warn!("Account pairing verification failed: {e}");
"Invalid username or password".to_string()
})?;
Ok(session.user_id)
}
})
.await;
}

pub fn init_on_startup() {
tokio::spawn(async {
// Restore persisted account session (if any) before anything else
Expand Down Expand Up @@ -982,6 +1034,13 @@ pub async fn remote_connect_start(
let holder = get_service_holder();
let guard = holder.read().await;
let service = guard.as_ref().ok_or("service not initialized")?;
// Refresh account pairing context so a newly logged-in session is reflected
// in the QR (`auth=account&user=...`) before the room is created.
if get_account_session().read().await.is_some() {
register_account_pairing_context(service).await;
} else {
service.clear_account_pairing_context().await;
}
service
.start(method)
.await
Expand Down Expand Up @@ -1343,6 +1402,8 @@ pub async fn account_logout() -> Result<(), String> {
// Disconnect device routing before clearing the session.
if let Some(service) = get_service_holder().read().await.as_ref() {
service.stop_device_connection().await;
service.clear_account_pairing_context().await;
service.clear_trusted_mobile_identity().await;
}
// Revoke the token on the relay (best-effort — don't block on failure)
if let Ok((session, relay_url)) = read_account_context().await {
Expand Down
Loading
Loading