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 ( )} @@ -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) }