diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index 0affc0631c..82585b1fde 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -8012,7 +8012,7 @@ registerUserAccountIpc({
fs.readFileSync(path.join(resolveHermesHome(), 'mercury-relay', 'state.json'), 'utf8'),
// 本机 gateway 插件管理面(§21 DB-T4b):经 dashboard 会话令牌走
// /api/plugins/mercury-relay/*;FastAPI {detail} 即稳定 code。
- callPluginApi: async (apiPath: string, body: Record) => {
+ callPluginApi: async (apiPath: string, body: Record, method: 'GET' | 'POST' = 'POST') => {
let descriptor
try {
@@ -8022,10 +8022,11 @@ registerUserAccountIpc({
}
try {
- return await fetchJson(`${descriptor.baseUrl}/api/plugins/mercury-relay/${apiPath}`, descriptor.token, {
- method: 'POST',
- body
- })
+ return await fetchJson(
+ `${descriptor.baseUrl}/api/plugins/mercury-relay/${apiPath}`,
+ descriptor.token,
+ method === 'GET' ? { method: 'GET' } : { method: 'POST', body }
+ )
} catch (error: any) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 0
let code = status > 0 ? 'server' : 'plugin_unreachable'
diff --git a/apps/desktop/electron/user-account-ipc.test.ts b/apps/desktop/electron/user-account-ipc.test.ts
index 00ef1f15e3..e12a81aefd 100644
--- a/apps/desktop/electron/user-account-ipc.test.ts
+++ b/apps/desktop/electron/user-account-ipc.test.ts
@@ -223,7 +223,7 @@ function register(deps: {
io: UserAccountStoreIo
fetcher: FetchLike
readMachineBindingState: () => string
- callPluginApi?: (path: string, body: Record) => Promise
+ callPluginApi?: (path: string, body: Record, method?: 'GET' | 'POST') => Promise
}): void {
registerUserAccountIpc({
...deps,
@@ -449,12 +449,17 @@ test('update-profile:服务端 403 profile_self_service_disabled 结构化透
// ---------- DB-T4b:绑定打通 + U-6 解绑全清(§21) ----------------------------
-test('binding-status:未绑定 / 已绑定只回 site+installationId / 损坏 fail-closed 标记', async () => {
+test('binding-status:未绑定 / 已绑定只回展示字段 / 损坏 fail-closed 标记', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
- assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), { bound: false })
+ assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), {
+ bound: false,
+ machineId: null,
+ pending: null,
+ bindError: null
+ })
})
test('binding-status:已绑定不含凭据字段;损坏报 stateInvalid', async () => {
@@ -463,12 +468,18 @@ test('binding-status:已绑定不含凭据字段;损坏报 stateInvalid', as
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const bound = await handlers.get('hermes:account:binding-status')!(null)
- assert.deepEqual(bound, { bound: true, site: 'https://r', installationId: 'inst-1' })
+ assert.deepEqual(bound, { bound: true, site: 'https://r', installationId: 'inst-1', machineId: null })
assert.equal(JSON.stringify(bound).includes('SHOULD-NOT-BE-READ'), false)
const { handlers: h2, ipcMain: i2 } = fakeIpcMain()
register({ ipcMain: i2, io: fakeIo().io, fetcher, readMachineBindingState: () => '{broken' })
- assert.deepEqual(await h2.get('hermes:account:binding-status')!(null), { bound: false, stateInvalid: true })
+ assert.deepEqual(await h2.get('hermes:account:binding-status')!(null), {
+ bound: false,
+ stateInvalid: true,
+ machineId: null,
+ pending: null,
+ bindError: null
+ })
})
test('bind-machine:插件收到 site+session_token(Bearer 等价物)+suggested_name;不落 email/password', async () => {
@@ -492,12 +503,16 @@ test('bind-machine:插件收到 site+session_token(Bearer 等价物)+suggested
const result = await handlers.get('hermes:account:bind-machine')!(null)
assert.deepEqual(result, { ok: true, state: 'bound' })
- assert.equal(pluginCalls.length, 1)
- assert.equal(pluginCalls[0].path, 'identity/login')
- assert.equal(pluginCalls[0].body.site, 'https://r')
- assert.equal(pluginCalls[0].body.session_token, 'AT-1')
- assert.equal(pluginCalls[0].body.suggested_name, 'test-machine')
- assert.equal('password' in pluginCalls[0].body, false)
+ // DB-T6:login 已自动绑定一次;手动按钮走的是同一条通路(fake 绑定态恒为未绑定,故再次发起)。
+ assert.equal(pluginCalls.length, 2)
+
+ for (const call of pluginCalls) {
+ assert.equal(call.path, 'identity/login')
+ assert.equal(call.body.site, 'https://r')
+ assert.equal(call.body.session_token, 'AT-1')
+ assert.equal(call.body.suggested_name, 'test-machine')
+ assert.equal('password' in call.body, false)
+ }
})
test('bind-machine:已绑定不出网(already_bound);未登录 not_logged_in', async () => {
@@ -669,8 +684,7 @@ test('unbind-machine:未绑定 not_bound,不出网', async () => {
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
-test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', async () => {
- const { handlers, ipcMain } = fakeIpcMain()
+test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', async () => { const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = loginOkFetcher()
let pluginCalls = 0
@@ -692,3 +706,134 @@ test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', asy
assert.equal(pluginCalls, 0)
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
+
+// ---------- DB-T6:登录后自动绑定 + 机器码/pending 可查 + 失败可见 ------------
+
+test('DB-T6:登录成功即自动绑定,无需点击;bound 后 binding-status 无待办', async () => {
+ const { handlers, ipcMain } = fakeIpcMain()
+ const { io } = fakeIo()
+ const { fetcher } = loginOkFetcher()
+ const pluginCalls: string[] = []
+
+ register({
+ ipcMain,
+ io,
+ fetcher,
+ readMachineBindingState: unbound,
+ callPluginApi: async path => {
+ pluginCalls.push(path)
+
+ return { state: 'bound', created: true }
+ }
+ })
+ const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
+ assert.equal(result.ok, true)
+ assert.deepEqual(pluginCalls, ['identity/login'])
+ assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), {
+ bound: false,
+ machineId: null,
+ pending: null,
+ bindError: null
+ })
+})
+
+test('DB-T6:自动绑定 pending → 确认码随 binding-status 随时可查;logout 清空记录', async () => {
+ const { handlers, ipcMain } = fakeIpcMain()
+ const { io } = fakeIo()
+ const { fetcher } = loginOkFetcher()
+
+ register({
+ ipcMain,
+ io,
+ fetcher,
+ readMachineBindingState: unbound,
+ callPluginApi: async () => ({ state: 'binding_pending', code: 'MR-7K2P-Q9', expires_at: '2026-09-19T12:00:00Z' })
+ })
+ await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
+ const status = await handlers.get('hermes:account:binding-status')!(null)
+ assert.deepEqual(status.pending, { code: 'MR-7K2P-Q9', expiresAt: '2026-09-19T12:00:00Z' })
+ assert.equal(status.bindError, null)
+
+ await handlers.get('hermes:account:logout')!(null)
+ const after = await handlers.get('hermes:account:binding-status')!(null)
+ assert.equal(after.pending, null)
+ assert.equal(after.bindError, null)
+})
+
+test('DB-T6:自动绑定失败绝不阻塞登录;失败码随 binding-status 透出', async () => {
+ const { handlers, ipcMain } = fakeIpcMain()
+ const { io } = fakeIo()
+ const { fetcher } = loginOkFetcher()
+ const { PluginApiError } = await import('./user-account-ipc')
+
+ register({
+ ipcMain,
+ io,
+ fetcher,
+ readMachineBindingState: unbound,
+ callPluginApi: async () => {
+ throw new PluginApiError('plugin_unreachable', 0)
+ }
+ })
+ const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
+ assert.equal(result.ok, true)
+ assert.notEqual(loadUserAccount(io), null)
+ const status = await handlers.get('hermes:account:binding-status')!(null)
+ assert.equal(status.bindError, 'plugin_unreachable')
+ assert.equal(status.pending, null)
+})
+
+test('DB-T6:binding-status 带机器码(插件 GET status 的 relay_machine_id),插件不可达为 null', async () => {
+ const { handlers, ipcMain } = fakeIpcMain()
+ const { io } = fakeIo()
+ const { fetcher } = loginOkFetcher()
+ const seen: { path: string; method?: string }[] = []
+
+ register({
+ ipcMain,
+ io,
+ fetcher,
+ readMachineBindingState: () => BINDING_STATE,
+ callPluginApi: async (path, _body, method) => {
+ seen.push({ path, method })
+
+ return path === 'status' ? { relay_machine_id: 'MR-ABCD-1234' } : { state: 'bound' }
+ }
+ })
+ const status = await handlers.get('hermes:account:binding-status')!(null)
+ assert.equal(status.machineId, 'MR-ABCD-1234')
+ assert.deepEqual(seen, [{ path: 'status', method: 'GET' }])
+})
+
+test('DB-T6:恢复出的会话在首次 status 补一次自动绑定,之后不重复', async () => {
+ const { handlers, ipcMain } = fakeIpcMain()
+ const { io } = fakeIo()
+ const { fetcher } = loginOkFetcher()
+ persistUserAccount(
+ {
+ session: { site: 'https://r', accessToken: 'AT', refreshToken: 'RT', sessionId: 'SID' },
+ profile: { email: 'u@example.com', username: '', nickname: '', phone: '', planId: '', periodEnd: '', dataAsOf: 't' },
+ profileAsOf: 't'
+ },
+ io
+ )
+ const pluginCalls: string[] = []
+
+ register({
+ ipcMain,
+ io,
+ fetcher,
+ readMachineBindingState: unbound,
+ callPluginApi: async path => {
+ pluginCalls.push(path)
+
+ return { state: 'bound', created: true }
+ }
+ })
+ const first = await handlers.get('hermes:account:status')!(null)
+ assert.equal(first.loggedIn, true)
+ assert.deepEqual(pluginCalls, ['identity/login'])
+
+ await handlers.get('hermes:account:status')!(null)
+ assert.deepEqual(pluginCalls, ['identity/login'])
+})
diff --git a/apps/desktop/electron/user-account-ipc.ts b/apps/desktop/electron/user-account-ipc.ts
index 8cd255362b..273032a386 100644
--- a/apps/desktop/electron/user-account-ipc.ts
+++ b/apps/desktop/electron/user-account-ipc.ts
@@ -14,6 +14,10 @@
* fail-closed:锁无法判定时不许登录。
*
* 登出≠解绑:logout 只清用户会话槽,机器绑定(H/R)完全不经过这里。
+ *
+ * DB-T6 自动绑定(2026-09-19 裁定):登录成功 / 恢复会话后若本机未绑定,
+ * main 自动发起一次免口令绑定,无需用户点击。结果(pending 确认码 / 失败
+ * 码)留在 main 内存,binding-status 随时回查;绑定失败绝不阻塞登录。
*/
import { loadMachineBinding, MachineBindingError } from './machine-binding'
@@ -65,9 +69,10 @@ export interface UserAccountIpcDeps {
/**
* 调本机 gateway 的插件管理 API(路径如 'identity/login')。
* 非 2xx 必须抛 PluginApiError(detail 即 code);gateway 不可达抛
- * PluginApiError('plugin_unreachable', 0)。
+ * PluginApiError('plugin_unreachable', 0)。method 默认 POST;GET 时
+ * body 被忽略(如 'status' 查机器码)。
*/
- callPluginApi: (path: string, body: Record) => Promise
+ callPluginApi: (path: string, body: Record, method?: 'GET' | 'POST') => Promise
/** 绑定时的 suggested_name(本机显示名)。 */
machineName: string
}
@@ -84,6 +89,13 @@ function failure(error: unknown): { ok: false; code: string } {
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const { ipcMain, io, fetcher, readMachineBindingState, callPluginApi, machineName } = deps
let cached: StoredUserAccount | null = null
+ /**
+ * 最近一次绑定尝试的结果(DB-T6 自动绑定):pending 确认码 / 失败码,
+ * 供 binding-status 随时回查。只在 main 内存里,随进程退出消失
+ * (pending 码本就 10 分钟 TTL,重启后重试即可)。
+ */
+ let lastBind: { pending?: { code: string; expiresAt: string }; error?: string } | null = null
+ let autoBindAttempted = false
function current(): StoredUserAccount | null {
if (cached) {return cached}
@@ -188,6 +200,8 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
+ // DB-T6:登录成功即自动绑定本机(未绑定时);失败不阻塞登录。
+ await autoBind()
return { ok: true, site, profile }
} catch (error) {
@@ -204,6 +218,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
+ // DB-T6:恢复出的会话若尚未尝试过自动绑定(如进程重启后),补一次。
+ if (!autoBindAttempted) {await autoBind()}
+
return { loggedIn: true, site: account.session.site, offline: false, profile }
} catch (error) {
if (error instanceof RelayAccountError && error.code === 'session_expired') {
@@ -263,27 +280,36 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
})
ipcMain.handle('hermes:account:binding-status', async () => {
+ const machineId = await queryMachineId()
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
- if (error instanceof MachineBindingError) {return { bound: false, stateInvalid: true }}
+ if (error instanceof MachineBindingError) {
+ return { bound: false, stateInvalid: true, machineId, pending: null, bindError: lastBind?.error ?? null }
+ }
throw error
}
- if (!binding) {return { bound: false }}
+ if (!binding) {
+ return { bound: false, machineId, pending: lastBind?.pending ?? null, bindError: lastBind?.error ?? null }
+ }
- return { bound: true, site: binding.site, installationId: binding.installationId }
+ return { bound: true, site: binding.site, installationId: binding.installationId, machineId }
})
/**
- * U-1/RL-A1 免口令绑定:用当前用户会话 access token 让本机插件完成
+ * U-1/RL-A1 免口令绑定主体:用当前用户会话 access token 让本机插件完成
* 绑定(口令永远不再经手)。插件 401(会话失效)并入 withSession 的
* 刷新重试链。binding_pending(双因素)原样透传给 UI。
*/
- ipcMain.handle('hermes:account:bind-machine', async () => {
+ async function performBind(): Promise<
+ | { ok: true; state: 'bound' }
+ | { ok: true; state: 'binding_pending'; code: string; expiresAt: string }
+ | { ok: false; code: string }
+ > {
let existing
try {
@@ -328,6 +354,64 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
} catch (error) {
return failure(error)
}
+ }
+
+ function recordBind(result: Awaited>): void {
+ if (!result.ok && result.code === 'already_bound') {
+ lastBind = null
+
+ return
+ }
+
+ if (result.ok && result.state === 'bound') {
+ lastBind = null
+
+ return
+ }
+
+ if (result.ok) {
+ lastBind = { pending: { code: result.code, expiresAt: result.expiresAt } }
+
+ return
+ }
+
+ lastBind = { error: result.code }
+ }
+
+ /**
+ * DB-T6 自动绑定:登录成功 / 恢复会话后若本机未绑定则自动发起一次。
+ * 绝不阻塞或逆转登录——任何意外都只落成 lastBind.error 供界面展示。
+ */
+ async function autoBind(): Promise {
+ autoBindAttempted = true
+
+ try {
+ recordBind(await performBind())
+ } catch {
+ lastBind = { error: 'server' }
+ }
+ }
+
+ /** 机器码(relay allowlist 展示口径):插件不可达时回 null,不影响状态查询。 */
+ async function queryMachineId(): Promise {
+ try {
+ const status = await callPluginApi('status', {}, 'GET')
+
+ return typeof status?.relay_machine_id === 'string' ? status.relay_machine_id : null
+ } catch {
+ return null
+ }
+ }
+
+ ipcMain.handle('hermes:account:bind-machine', async () => {
+ try {
+ const result = await performBind()
+ recordBind(result)
+
+ return result
+ } catch (error) {
+ return failure(error)
+ }
})
/**
@@ -368,6 +452,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
store(null)
}
+ lastBind = null
+ autoBindAttempted = false
+
return { ok: true }
})
@@ -379,6 +466,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
store(null)
}
+ lastBind = null
+ autoBindAttempted = false
+
return { ok: true }
})
diff --git a/apps/desktop/src/app/chat/sidebar/user-account-card.tsx b/apps/desktop/src/app/chat/sidebar/user-account-card.tsx
index 638a459873..dbb11b0224 100644
--- a/apps/desktop/src/app/chat/sidebar/user-account-card.tsx
+++ b/apps/desktop/src/app/chat/sidebar/user-account-card.tsx
@@ -13,15 +13,18 @@ import { ME_ROUTE } from '@/app/me'
import { navigateToWorkspacePage } from '@/app/routes'
import { AvatarChip } from '@/components/ui/avatar-chip'
import { useI18n } from '@/i18n'
-import { $userAccount, userAccountDisplayName } from '@/store/user-account'
+import { $machineBinding, $userAccount, userAccountDisplayName } from '@/store/user-account'
export function UserAccountCard() {
const { t } = useI18n()
const account = useStore($userAccount)
+ const binding = useStore($machineBinding)
const navigate = useNavigate()
if (account.status !== 'signed_in') {return null}
const name = userAccountDisplayName(account.profile) || account.profile?.email || ''
+ // DB-T6:自动绑定失败或 pending 待确认 → 名后 amber 点,点卡片进 /me 处理。
+ const bindIssue = binding != null && !binding.bound && (binding.bindError != null || binding.pending != null)
return (
)}
+ {bindIssue && (
+
+ )}
{account.profile?.email && name !== account.profile.email && (
{account.profile.email}
diff --git a/apps/desktop/src/app/me/binding-section.tsx b/apps/desktop/src/app/me/binding-section.tsx
index 3e69470f27..f568ad1762 100644
--- a/apps/desktop/src/app/me/binding-section.tsx
+++ b/apps/desktop/src/app/me/binding-section.tsx
@@ -1,9 +1,12 @@
/**
- * binding-section.tsx — `/me` 机器绑定区(§21 DB-T4c,U-1/U-6)。
+ * binding-section.tsx — `/me` 机器绑定区(§21 DB-T4c/DB-T6,U-1/U-6)。
*
- * 未绑定:说明 + 绑定按钮(U-1 免口令,session_token 由 main 侧注入)。
+ * DB-T6 起绑定自动发生:登录成功 / 恢复会话后 main 侧已自动发起免口令
+ * 绑定,本区是结果的展示与补救面——机器码、pending 确认码、失败码都可
+ * 随时回查(binding-status),失败给重试入口;手动按钮与自动绑定走同一
+ * 条 performBind 通路。
* binding_pending:展示确认码 + 有效期,等另一台已绑定设备批准。
- * 已绑定:site + installationId + 解绑入口;解绑走破坏性确认框——
+ * 已绑定:site + installationId + 机器码 + 解绑入口;解绑走破坏性确认框——
* U-6 全清(服务端撤销 + 插件 purge + 删所有会话),成功即落 signed_out,
* 登录门随即落回。state 损坏 fail-closed 提示。
*/
@@ -24,9 +27,7 @@ import {
type BindingCopy = Translations['userAccount']['me']['binding']
-function bindingErrorText(copy: BindingCopy, failure: unknown): string {
- const code = failure instanceof UserAccountFailure ? failure.code : 'server'
-
+function bindingErrorText(copy: BindingCopy, code: string): string {
if (code === 'network') {return copy.errorNetwork}
if (code === 'plugin_unreachable') {return copy.errorPluginUnreachable}
@@ -38,7 +39,6 @@ export function BindingSection() {
const { t } = useI18n()
const copy = t.userAccount.me.binding
const [status, setStatus] = useState(null)
- const [pending, setPending] = useState<{ code: string; expiresAt: string } | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [confirmingUnbind, setConfirmingUnbind] = useState(false)
@@ -66,16 +66,15 @@ export function BindingSection() {
setError(null)
try {
- const result = await userAccountBindMachine()
+ await userAccountBindMachine()
+ } catch {
+ // 失败码已由 main 记录(lastBind),随下面的状态回查一并展示。
+ }
- if (result.state === 'binding_pending') {
- setPending({ code: result.code, expiresAt: result.expiresAt })
- } else {
- setPending(null)
- setStatus(await userAccountBindingStatus())
- }
- } catch (failure) {
- setError(bindingErrorText(copy, failure))
+ try {
+ setStatus(await userAccountBindingStatus())
+ } catch {
+ setError(copy.errorServer)
} finally {
setBusy(false)
}
@@ -84,10 +83,14 @@ export function BindingSection() {
async function unbind() {
// 成功即全清 + signed_out(store 内落定),本页随登录门卸载;失败抛错由确认框内联展示。
await userAccountUnbindMachine().catch((failure: unknown) => {
- throw new Error(bindingErrorText(copy, failure))
+ throw new Error(bindingErrorText(copy, failure instanceof UserAccountFailure ? failure.code : 'server'))
})
}
+ const pending = status && !status.bound ? (status.pending ?? null) : null
+ const bindError = status && !status.bound ? (status.bindError ?? null) : null
+ const machineId = status?.machineId ?? null
+
return (
{copy.title}
@@ -100,11 +103,18 @@ export function BindingSection() {
)}
+ {machineId && (
+
+ {copy.machineLabel}
+ {machineId}
+
+ )}
+
{status && !status.bound && (
-
{copy.unboundBody}
+ {!pending && !bindError &&
{copy.unboundBody}
}
)}
@@ -116,6 +126,8 @@ export function BindingSection() {
)}
+ {bindError && {bindingErrorText(copy, bindError)}
}
+
{status?.bound === true && (
diff --git a/apps/desktop/src/app/me/me-page.test.tsx b/apps/desktop/src/app/me/me-page.test.tsx
index 648e4002d2..d37c7cfc41 100644
--- a/apps/desktop/src/app/me/me-page.test.tsx
+++ b/apps/desktop/src/app/me/me-page.test.tsx
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { UserAccountCard } from '@/app/chat/sidebar/user-account-card'
import { MePage } from '@/app/me'
-import { $userAccount } from '@/store/user-account'
+import { $machineBinding, $userAccount } from '@/store/user-account'
const PROFILE = {
email: 'u@example.com',
@@ -44,6 +44,7 @@ function stubBridge(overrides: Record = {}) {
beforeEach(() => {
$userAccount.set({ status: 'unknown' })
+ $machineBinding.set(null)
})
afterEach(() => {
@@ -128,30 +129,52 @@ describe('MePage', () => {
expect(screen.getByText('inst-1')).toBeTruthy()
})
- it('binding_pending:展示确认码与有效期', async () => {
- stubBridge({
- bindMachine: vi.fn(async () => ({
- ok: true,
- state: 'binding_pending',
- code: 'MR-7K2P-Q9',
- expiresAt: '2026-09-19T12:00:00Z'
+ it('binding_pending:确认码与有效期随状态可查;重试走同一通路', async () => {
+ const api = stubBridge({
+ bindingStatus: vi.fn(async () => ({
+ bound: false,
+ machineId: null,
+ pending: { code: 'MR-7K2P-Q9', expiresAt: '2026-09-19T12:00:00Z' },
+ bindError: null
}))
})
+
signedIn()
render()
- fireEvent.click(await screen.findByText('Bind this machine'))
await screen.findByText(/MR-7K2P-Q9/)
expect(screen.getByText(/2026-09-19T12:00:00Z/)).toBeTruthy()
+ fireEvent.click(screen.getByText('Retry binding'))
+ await waitFor(() => expect(api.bindMachine).toHaveBeenCalled())
})
- it('绑定失败按码上文案(plugin_unreachable)', async () => {
- stubBridge({ bindMachine: vi.fn(async () => ({ ok: false, code: 'plugin_unreachable' })) })
+ it('绑定失败按码上文案(plugin_unreachable),并可重试', async () => {
+ const api = stubBridge({
+ bindingStatus: vi
+ .fn()
+ .mockResolvedValueOnce({ bound: false, machineId: null, pending: null, bindError: null })
+ .mockResolvedValue({ bound: false, machineId: null, pending: null, bindError: 'plugin_unreachable' }),
+ bindMachine: vi.fn(async () => ({ ok: false, code: 'plugin_unreachable' }))
+ })
+
signedIn()
render()
fireEvent.click(await screen.findByText('Bind this machine'))
await screen.findByText('The local machine channel is unavailable. Restart Hermes and try again.')
+ expect(api.bindMachine).toHaveBeenCalled()
+ })
+
+ it('机器码随绑定状态展示(已绑定与未绑定同口径)', async () => {
+ stubBridge({
+ bindingStatus: vi.fn(async () => ({ bound: true, site: 'https://r', installationId: 'inst-1', machineId: 'MR-ABCD-1234' }))
+ })
+ signedIn()
+ render()
+
+ await screen.findByText('inst-1')
+ expect(screen.getByText('Machine ID')).toBeTruthy()
+ expect(screen.getByText('MR-ABCD-1234')).toBeTruthy()
})
it('已绑定:显示 site + installationId;解绑走破坏性确认框,成功落 signed_out', async () => {
@@ -204,4 +227,16 @@ describe('UserAccountCard', () => {
)
expect(screen.getByLabelText('Offline — showing cached data')).toBeTruthy()
})
+
+ it('绑定待处理(失败/pending):名旁标 amber 圆点', () => {
+ stubBridge()
+ signedIn()
+ $machineBinding.set({ bound: false, machineId: null, pending: null, bindError: 'plugin_unreachable' })
+ render(
+
+
+
+ )
+ expect(screen.getByLabelText('Machine binding needs attention — open to review')).toBeTruthy()
+ })
})
diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts
index 4f3cba7732..ca163734f5 100644
--- a/apps/desktop/src/i18n/ar.ts
+++ b/apps/desktop/src/i18n/ar.ts
@@ -3327,6 +3327,7 @@ export const ar = defineLocale({
me: {
cardAria: 'فتح صفحة الحساب',
cardOffline: 'غير متصل — تُعرض بيانات مخزنة',
+ cardBindIssue: 'ربط الجهاز يحتاج إلى متابعة — انقر للعرض',
unavailable: 'بيانات الحساب غير متاحة.',
offlineNotice: asOf => `غير متصل — تُعرض البيانات كما في ${asOf}.`,
nicknameLabel: 'الاسم المستعار',
@@ -3351,8 +3352,10 @@ export const ar = defineLocale({
unboundBody: 'هذا الجهاز غير مرتبط بحسابك بعد. اربطه ليظهر في قائمة أجهزتك ويمكن الوصول إليه من أجهزتك الأخرى.',
bind: 'ربط هذا الجهاز',
binding: 'جارٍ الربط…',
+ retry: 'إعادة محاولة الربط',
siteLabel: 'الخادم',
installationLabel: 'التثبيت',
+ machineLabel: 'معرّف الجهاز',
unbind: 'إلغاء الربط…',
unbindTitle: 'إلغاء ربط هذا الجهاز؟',
unbindBody:
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index de823fe902..d00247ea90 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -4568,6 +4568,7 @@ export const en: Translations = {
me: {
cardAria: 'Open your account page',
cardOffline: 'Offline — showing cached data',
+ cardBindIssue: 'Machine binding needs attention — open to review',
unavailable: 'Account details are unavailable.',
offlineNotice: asOf => `Offline — showing data as of ${asOf}.`,
nicknameLabel: 'Nickname',
@@ -4593,8 +4594,10 @@ export const en: Translations = {
'This machine is not bound to your account yet. Bind it so it appears in your machine list and can be reached from your other devices.',
bind: 'Bind this machine',
binding: 'Binding…',
+ retry: 'Retry binding',
siteLabel: 'Server',
installationLabel: 'Installation',
+ machineLabel: 'Machine ID',
unbind: 'Unbind…',
unbindTitle: 'Unbind this machine?',
unbindBody:
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 4e4eb22772..2e891080f0 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -3741,6 +3741,7 @@ export const ja = defineLocale({
me: {
cardAria: 'アカウントページを開く',
cardOffline: 'オフライン — キャッシュデータを表示中',
+ cardBindIssue: 'マシンバインドに対応が必要です — クリックして確認',
unavailable: 'アカウント情報を利用できません。',
offlineNotice: asOf => `オフライン — ${asOf} 時点のデータを表示しています。`,
nicknameLabel: 'ニックネーム',
@@ -3766,8 +3767,10 @@ export const ja = defineLocale({
'このマシンはまだアカウントにバインドされていません。バインドするとマシン一覧に表示され、他のデバイスから到達できるようになります。',
bind: 'このマシンをバインド',
binding: 'バインド中…',
+ retry: 'バインドを再試行',
siteLabel: 'サーバー',
installationLabel: 'インストール',
+ machineLabel: 'マシン ID',
unbind: 'バインド解除…',
unbindTitle: 'このマシンのバインドを解除しますか?',
unbindBody:
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index 17dcf8ae97..5507226df6 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -3948,6 +3948,7 @@ export const ru = defineLocale({
me: {
cardAria: 'Открыть страницу аккаунта',
cardOffline: 'Офлайн — показаны кэшированные данные',
+ cardBindIssue: 'Привязка машины требует внимания — открыть',
unavailable: 'Данные аккаунта недоступны.',
offlineNotice: asOf => `Офлайн — показаны данные на ${asOf}.`,
nicknameLabel: 'Отображаемое имя',
@@ -3973,8 +3974,10 @@ export const ru = defineLocale({
'Эта машина ещё не привязана к вашему аккаунту. Привяжите её, чтобы она появилась в списке машин и была доступна с других ваших устройств.',
bind: 'Привязать эту машину',
binding: 'Привязка…',
+ retry: 'Повторить привязку',
siteLabel: 'Сервер',
installationLabel: 'Установка',
+ machineLabel: 'ID машины',
unbind: 'Отвязать…',
unbindTitle: 'Отвязать эту машину?',
unbindBody:
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 0b0e94c812..f5bd020e49 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -3890,6 +3890,7 @@ export interface Translations {
me: {
cardAria: string
cardOffline: string
+ cardBindIssue: string
unavailable: string
offlineNotice: (asOf: string) => string
nicknameLabel: string
@@ -3914,8 +3915,10 @@ export interface Translations {
unboundBody: string
bind: string
binding: string
+ retry: string
siteLabel: string
installationLabel: string
+ machineLabel: string
unbind: string
unbindTitle: string
unbindBody: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 6219d1f210..414424cae1 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -3661,6 +3661,7 @@ export const zhHant = defineLocale({
me: {
cardAria: '開啟帳號頁',
cardOffline: '離線——顯示快取資料',
+ cardBindIssue: '機器綁定待處理——點擊查看',
unavailable: '帳號資料不可用。',
offlineNotice: asOf => `離線——顯示 ${asOf} 的快取資料。`,
nicknameLabel: '暱稱',
@@ -3685,8 +3686,10 @@ export const zhHant = defineLocale({
unboundBody: '本機尚未綁定到你的帳號。綁定後才會出現在你的機器清單中,其他裝置才能觸達本機。',
bind: '綁定本機',
binding: '綁定中…',
+ retry: '重試綁定',
siteLabel: '伺服器',
installationLabel: '安裝實例',
+ machineLabel: '機器碼',
unbind: '解綁…',
unbindTitle: '解綁本機?',
unbindBody: '解綁會在伺服器撤銷本機,並清空本機全部憑證——使用者工作階段與機器通道一併刪除,你將立即登出。',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index d7aa8a64b8..f251297c4a 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -4466,6 +4466,7 @@ export const zh = defineLocale({
me: {
cardAria: '打开账号页',
cardOffline: '离线——展示缓存数据',
+ cardBindIssue: '机器绑定待处理——点击查看',
unavailable: '账号资料不可用。',
offlineNotice: asOf => `离线——展示 ${asOf} 的缓存数据。`,
nicknameLabel: '昵称',
@@ -4490,8 +4491,10 @@ export const zh = defineLocale({
unboundBody: '本机尚未绑定到你的账号。绑定后才会出现在你的机器列表中,其他设备才能触达本机。',
bind: '绑定本机',
binding: '绑定中…',
+ retry: '重试绑定',
siteLabel: '服务器',
installationLabel: '安装实例',
+ machineLabel: '机器码',
unbind: '解绑…',
unbindTitle: '解绑本机?',
unbindBody: '解绑会在服务端撤销本机,并清空本机全部凭据——用户会话与机器通道一并删除,你将立即退出登录。',
diff --git a/apps/desktop/src/store/user-account.ts b/apps/desktop/src/store/user-account.ts
index 3d52869447..221b68f071 100644
--- a/apps/desktop/src/store/user-account.ts
+++ b/apps/desktop/src/store/user-account.ts
@@ -58,10 +58,19 @@ export type HermesUserAccountLoginResult =
| { ok: true; site: string; profile: HermesUserAccountProfile }
| { ok: false; code: string; binderEmail?: string | null }
-/** 本机绑定状态(§21 DB-T4b):只含 site + installationId,凭据绝不出 main。 */
+/** 本机绑定状态(§21 DB-T4b/DB-T6):只含展示字段,凭据绝不出 main。 */
export type HermesUserAccountBindingStatus =
- | { bound: false; stateInvalid?: boolean }
- | { bound: true; site: string; installationId: string }
+ | {
+ bound: false
+ stateInvalid?: boolean
+ /** 机器码(relay allowlist 展示口径);插件不可达为 null。 */
+ machineId?: string | null
+ /** DB-T6 自动/手动绑定的 pending 确认码(main 内存,TTL 内可查)。 */
+ pending?: { code: string; expiresAt: string } | null
+ /** 最近一次绑定失败码;null = 无失败记录。 */
+ bindError?: string | null
+ }
+ | { bound: true; site: string; installationId: string; machineId?: string | null }
export type HermesUserAccountBindResult =
| { ok: true; state: 'bound' }
@@ -111,6 +120,8 @@ export async function userAccountLogin(site: string, email: string, password: st
}
$userAccount.set({ status: 'signed_in', site: result.site, offline: false, profile: result.profile, profileAsOf: null })
+ // DB-T6:登录后 main 已自动绑定;把绑定态(机器码/pending/失败码)拉进 store 供卡片角标。
+ void userAccountBindingStatus().catch(() => {})
}
async function runAccountOp(op: () => Promise): Promise {
@@ -139,6 +150,7 @@ export function userAccountResetConfirm(site: string, email: string, code: strin
export async function userAccountLogout(): Promise {
await bridge().logout()
$userAccount.set({ status: 'signed_out' })
+ $machineBinding.set(null)
}
/** 改本人资料(username/nickname/phone)。成功回写 signed_in 快照;失败抛 UserAccountFailure。 */
@@ -198,9 +210,14 @@ export function userAccountDisplayName(profile: HermesUserAccountProfile | null)
return at > 0 ? profile.email.slice(0, at) : profile.email
}
-/** 本机绑定状态(只读插件 state.json 的非敏感二字段)。 */
-export function userAccountBindingStatus(): Promise {
- return bridge().bindingStatus()
+/** 本机绑定状态(只读插件 state.json 的非敏感字段 + main 记录的最近绑定结果)。 */
+export const $machineBinding = atom(null)
+
+export async function userAccountBindingStatus(): Promise {
+ const status = await bridge().bindingStatus()
+ $machineBinding.set(status)
+
+ return status
}
/** 绑定本机到当前账号(§21 U-1 免口令)。失败抛 UserAccountFailure;pending 返回确认码。 */
@@ -226,4 +243,5 @@ export async function userAccountUnbindMachine(): Promise {
if (!result.ok) {throw new UserAccountFailure(result.code ?? 'server')}
$userAccount.set({ status: 'signed_out' })
+ $machineBinding.set(null)
}