@allbluecn/web-app 0.23.1 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +3 -3
  2. package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
  3. package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
  4. package/src/components/desktop/cloud-login.tsx +108 -6
  5. package/src/components/desktop/desktop-state-listener.tsx +76 -0
  6. package/src/components/desktop/shell-widgets.tsx +12 -5
  7. package/src/components/desktop/sync-indicator.tsx +62 -13
  8. package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
  9. package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
  10. package/src/lib/__tests__/session.test.ts +95 -0
  11. package/src/lib/auth/auth.config.ts +13 -0
  12. package/src/lib/changelog/sdk-versions.ts +1 -1
  13. package/src/lib/desktop-bridge-types.ts +36 -0
  14. package/src/lib/session.ts +15 -1
  15. package/src/paraglide/messages/_index.d.ts +11 -0
  16. package/src/paraglide/messages/_index.js +11 -0
  17. package/src/paraglide/messages/desktop_cloud_exchange_failed.d.ts +16 -0
  18. package/src/paraglide/messages/desktop_cloud_exchange_failed.js +29 -0
  19. package/src/paraglide/messages/desktop_state_cloud.d.ts +16 -0
  20. package/src/paraglide/messages/desktop_state_cloud.js +29 -0
  21. package/src/paraglide/messages/desktop_state_guest.d.ts +16 -0
  22. package/src/paraglide/messages/desktop_state_guest.js +29 -0
  23. package/src/paraglide/messages/desktop_state_transferring.d.ts +16 -0
  24. package/src/paraglide/messages/desktop_state_transferring.js +29 -0
  25. package/src/paraglide/messages/sync_bind_failed.d.ts +16 -0
  26. package/src/paraglide/messages/sync_bind_failed.js +29 -0
  27. package/src/paraglide/messages/sync_bind_in_progress.d.ts +16 -0
  28. package/src/paraglide/messages/sync_bind_in_progress.js +29 -0
  29. package/src/paraglide/messages/sync_bind_nonempty.d.ts +16 -0
  30. package/src/paraglide/messages/sync_bind_nonempty.js +29 -0
  31. package/src/paraglide/messages/sync_bind_probe_unreachable.d.ts +16 -0
  32. package/src/paraglide/messages/sync_bind_probe_unreachable.js +29 -0
  33. package/src/paraglide/messages/sync_bind_shell_unsupported.d.ts +16 -0
  34. package/src/paraglide/messages/sync_bind_shell_unsupported.js +29 -0
  35. package/src/paraglide/messages/sync_logout_failed.d.ts +16 -0
  36. package/src/paraglide/messages/sync_logout_failed.js +29 -0
  37. package/src/paraglide/messages/sync_logout_in_progress.d.ts +16 -0
  38. package/src/paraglide/messages/sync_logout_in_progress.js +29 -0
  39. package/src/routeTree.gen.ts +21 -0
  40. package/src/routes/api/sync/__tests__/account-status.test.ts +109 -0
  41. package/src/routes/api/sync/account-status.ts +39 -0
  42. package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
  43. package/src/server/__tests__/remote-auth.test.ts +131 -1
  44. package/src/server/ai-gateway/__tests__/tools-authorization.test.ts +4 -2
  45. package/src/server/ai-gateway/tools/server.ts +7 -0
  46. package/src/server/desktop-bootstrap.ts +19 -3
  47. package/src/server/desktop-session-merge.ts +51 -0
  48. package/src/server/remote-auth.ts +61 -3
  49. package/src/server/serverFns/__tests__/knowledge-readable-kb.test.ts +21 -0
  50. package/src/server/serverFns/__tests__/workspace.test.ts +46 -0
  51. package/src/server/serverFns/auth/session.ts +28 -28
  52. package/src/server/serverFns/harness/attach-to-kb.ts +3 -1
  53. package/src/server/serverFns/knowledge.ts +4 -1
  54. package/src/server/serverFns/sync/transfer.ts +168 -0
  55. package/src/server/serverFns/workspace.ts +15 -0
  56. package/src/server/serverfn-public-allowlist.ts +4 -0
  57. package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
  58. package/src/server/sync/account-status.ts +36 -0
  59. package/src/server/sync/ownership-transfer.ts +291 -0
  60. package/src/server/sync/saas-profile.ts +70 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -60,9 +60,9 @@
60
60
  "dependencies": {
61
61
  "@ag-ui/core": "1.0.0",
62
62
  "@allbluecn/collab-core": "0.2.0",
63
- "@allbluecn/database": "0.12.1",
63
+ "@allbluecn/database": "0.12.2",
64
64
  "@allbluecn/kernel": "0.12.0",
65
- "@allbluecn/sync-core": "0.5.4",
65
+ "@allbluecn/sync-core": "0.6.0",
66
66
  "@ariakit/react": "^0.4.28",
67
67
  "@auth/core": "^0.41.2",
68
68
  "@auth/prisma-adapter": "^2.11.2",
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ //
3
+ // DesktopStateListener 身份切换三件套(桌面本地优先 A11,设计 D1;审查修复轮 1):
4
+ // 只喂状态机真实迁移序列(身份切换必经 transferring——直接相邻 guest↔cloud 推送
5
+ // 对在状态机里不可能产生,初版测试喂非法直达序列是假阳性)。断言语义:
6
+ // - bind 全程 guest→transferring→cloud:transferring 过境不触发、完成时触发一次
7
+ // - 登出全程 cloud→transferring→guest:触发一次
8
+ // - bind 失败 guest→transferring→guest / 登出失败 cloud→transferring→cloud /
9
+ // rebind 成功 cloud→transferring→cloud:首尾同态,全程不触发
10
+ // - rebind 失败回 guest cloud→transferring→guest:触发(属主确已翻转)
11
+ // - 基线竞态:晚到的查询响应不得把推送已建立的基线回退成旧值
12
+ // - 基线为 transferring:不记录,首个非转移态只建立基线
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14
+ import { act, cleanup, render } from '@testing-library/react'
15
+
16
+ type Kind = 'guest' | 'transferring' | 'cloud'
17
+ type StatePayload = { kind: Kind; updatedAt: string }
18
+
19
+ const resetAuthProfileCacheFn = vi.hoisted(() => vi.fn(async () => ({ success: true })))
20
+ const queryClear = vi.hoisted(() => vi.fn())
21
+
22
+ vi.mock('@/server/serverFns/auth/session', () => ({ resetAuthProfileCacheFn }))
23
+ vi.mock('@/lib/query-client-seam', () => ({ getQueryClient: () => ({ clear: queryClear }) }))
24
+
25
+ let stateListener: ((p: StatePayload) => void) | null = null
26
+ // null = 桥无 desktopState 查询方法;'deferred' = 挂起的手工 resolve 查询
27
+ let desktopStateImpl: (() => Promise<StatePayload>) | null = null
28
+ let noBridge = false
29
+
30
+ vi.mock('@/lib/desktop-bridge-types', () => ({
31
+ getDesktopBridge: () => {
32
+ if (noBridge) return null
33
+ // desktopStateImpl = null 模拟「旧壳无查询方法」(onDesktopState 仍在);
34
+ // noBridge 才是无桥(浏览器)
35
+ const bridge: Record<string, unknown> = {
36
+ onDesktopState: (cb: (p: StatePayload) => void) => {
37
+ stateListener = cb
38
+ return () => {
39
+ stateListener = null
40
+ }
41
+ },
42
+ }
43
+ if (desktopStateImpl) bridge.desktopState = desktopStateImpl
44
+ return bridge
45
+ },
46
+ }))
47
+
48
+ import { DesktopStateListener } from '../desktop-state-listener'
49
+
50
+ const payload = (kind: Kind): StatePayload => ({ kind, updatedAt: '2026-11-01T00:00:00Z' })
51
+
52
+ // jsdom 的 location.reload 是 [Unforgeable](不可 spy),整对象替换为带 mock 的
53
+ // 桩(同仓首例;jsdom 实测 defineProperty 可行)
54
+ const reload = vi.hoisted(() => vi.fn())
55
+
56
+ beforeEach(() => {
57
+ stateListener = null
58
+ desktopStateImpl = async () => payload('guest')
59
+ resetAuthProfileCacheFn.mockClear()
60
+ queryClear.mockClear()
61
+ reload.mockClear()
62
+ Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true })
63
+ })
64
+
65
+ afterEach(() => {
66
+ desktopStateImpl = null
67
+ noBridge = false
68
+ cleanup()
69
+ vi.restoreAllMocks()
70
+ // reload 桩经 defineProperty 替换的是 location 对象本身,无需恢复(每文件独立 jsdom 实例)
71
+ })
72
+
73
+ async function renderListener(): Promise<void> {
74
+ render(<DesktopStateListener />)
75
+ // 初始查询建立基线(异步 then)——微任务排空后再收推送
76
+ await act(async () => {
77
+ await Promise.resolve()
78
+ await Promise.resolve()
79
+ })
80
+ }
81
+
82
+ /** 推送一条状态并排空微任务(fireIdentityInvalidation 的 await 链) */
83
+ async function push(kind: Kind): Promise<void> {
84
+ await act(async () => {
85
+ stateListener?.(payload(kind))
86
+ await Promise.resolve()
87
+ })
88
+ }
89
+
90
+ const expectNoFire = (): void => {
91
+ expect(resetAuthProfileCacheFn).not.toHaveBeenCalled()
92
+ expect(queryClear).not.toHaveBeenCalled()
93
+ expect(reload).not.toHaveBeenCalled()
94
+ }
95
+
96
+ describe('DesktopStateListener 真实迁移序列(审查修复轮 1)', () => {
97
+ it('bind 全程 guest→transferring→cloud:过境不触发、完成触发一次三件套', async () => {
98
+ await renderListener()
99
+ await push('transferring')
100
+ expectNoFire()
101
+ await push('cloud')
102
+ expect(resetAuthProfileCacheFn).toHaveBeenCalledTimes(1)
103
+ expect(queryClear).toHaveBeenCalledTimes(1)
104
+ expect(reload).toHaveBeenCalledTimes(1)
105
+ })
106
+
107
+ it('登出全程 cloud→transferring→guest:触发一次', async () => {
108
+ desktopStateImpl = async () => payload('cloud')
109
+ await renderListener()
110
+ await push('transferring')
111
+ expectNoFire()
112
+ await push('guest')
113
+ expect(queryClear).toHaveBeenCalledTimes(1)
114
+ expect(reload).toHaveBeenCalledTimes(1)
115
+ })
116
+
117
+ it('bind 失败 guest→transferring→guest:首尾同态全程不触发', async () => {
118
+ await renderListener()
119
+ await push('transferring')
120
+ await push('guest')
121
+ expectNoFire()
122
+ })
123
+
124
+ it('登出失败 cloud→transferring→cloud:首尾同态全程不触发', async () => {
125
+ desktopStateImpl = async () => payload('cloud')
126
+ await renderListener()
127
+ await push('transferring')
128
+ await push('cloud')
129
+ expectNoFire()
130
+ })
131
+
132
+ it('rebind 成功 cloud→transferring→cloud:首尾同态全程不触发', async () => {
133
+ desktopStateImpl = async () => payload('cloud')
134
+ await renderListener()
135
+ await push('transferring')
136
+ await push('cloud')
137
+ expectNoFire()
138
+ })
139
+
140
+ it('rebind 失败回 guest cloud→transferring→guest:触发(属主确已翻转)', async () => {
141
+ desktopStateImpl = async () => payload('cloud')
142
+ await renderListener()
143
+ await push('transferring')
144
+ await push('guest')
145
+ expect(queryClear).toHaveBeenCalledTimes(1)
146
+ expect(reload).toHaveBeenCalledTimes(1)
147
+ })
148
+
149
+ it('基线竞态守卫:晚到查询响应不回退推送已建立的基线', async () => {
150
+ // 查询挂起期间推送 cloud 先到建立基线;查询随后以旧值 guest 晚到——
151
+ // 守卫下 last 保持 cloud,此后 guest 推送判异触发(无守卫则会 guest==guest 不触发)
152
+ let resolveQuery!: (p: StatePayload) => void
153
+ desktopStateImpl = () => new Promise<StatePayload>((res) => { resolveQuery = res })
154
+ render(<DesktopStateListener />)
155
+ await push('cloud')
156
+ await act(async () => {
157
+ resolveQuery(payload('guest'))
158
+ await Promise.resolve()
159
+ await Promise.resolve()
160
+ })
161
+ expectNoFire() // 晚到基线被守卫忽略,也未误触发
162
+ await push('guest')
163
+ expect(queryClear).toHaveBeenCalledTimes(1)
164
+ expect(reload).toHaveBeenCalledTimes(1)
165
+ })
166
+
167
+ it('基线查询返回 transferring:不记录,首个非转移态只建立基线不触发', async () => {
168
+ desktopStateImpl = async () => payload('transferring')
169
+ await renderListener()
170
+ await push('cloud')
171
+ expectNoFire() // cloud 是基线建立(transferring 过境后首个非转移态)
172
+ await push('guest')
173
+ expect(queryClear).toHaveBeenCalledTimes(1)
174
+ expect(reload).toHaveBeenCalledTimes(1)
175
+ })
176
+
177
+ it('无 desktopBridge:零订阅零行为(浏览器/云端部署零成本)', async () => {
178
+ noBridge = true
179
+ const render1 = render(<DesktopStateListener />)
180
+ render1.unmount()
181
+ expect(stateListener).toBeNull()
182
+ })
183
+ })
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ //
3
+ // DesktopSyncIndicator 三态徽标(桌面本地优先 A11):desktopState 查询建立初始
4
+ // 徽标 + onDesktopState 推送更新;三态文案走 i18n(desktop_state_*);旧壳
5
+ // (无 desktopState/onDesktopState)与无桥(浏览器)优雅降级;同步相位行回归。
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
8
+ import { m } from '@/paraglide/messages.js'
9
+
10
+ type Kind = 'guest' | 'transferring' | 'cloud'
11
+ type StatePayload = { kind: Kind; updatedAt: string }
12
+
13
+ // 可控 fake bridge(组件仅消费 getDesktopBridge 值导入,类型导入被擦除不受 mock 影响)
14
+ let syncListener: ((s: unknown) => void) | null = null
15
+ let stateListener: ((p: StatePayload) => void) | null = null
16
+ let desktopStateImpl: (() => Promise<StatePayload>) | undefined
17
+ let onDesktopStateImpl: ((cb: (p: StatePayload) => void) => () => void) | undefined
18
+
19
+ vi.mock('@/lib/desktop-bridge-types', () => ({
20
+ getDesktopBridge: () => bridgeRef.current,
21
+ }))
22
+
23
+ const bridgeRef = {
24
+ current: {
25
+ onSyncState: (cb: (s: unknown) => void) => {
26
+ syncListener = cb
27
+ return () => {
28
+ syncListener = null
29
+ }
30
+ },
31
+ } as unknown as NonNullable<ReturnType<typeof import('@/lib/desktop-bridge-types').getDesktopBridge>>,
32
+ }
33
+
34
+ function makeBridge(overrides: Partial<Record<'desktopState' | 'onDesktopState', undefined>> = {}) {
35
+ return {
36
+ onSyncState: (cb: (s: unknown) => void) => {
37
+ syncListener = cb
38
+ return () => {
39
+ syncListener = null
40
+ }
41
+ },
42
+ desktopState: overrides.desktopState === undefined ? desktopStateImpl : undefined,
43
+ onDesktopState: overrides.onDesktopState === undefined ? onDesktopStateImpl : undefined,
44
+ } as unknown as NonNullable<ReturnType<typeof import('@/lib/desktop-bridge-types').getDesktopBridge>>
45
+ }
46
+
47
+ import { DesktopSyncIndicator } from '../sync-indicator'
48
+
49
+ beforeEach(() => {
50
+ syncListener = null
51
+ stateListener = null
52
+ desktopStateImpl = async () => ({ kind: 'guest', updatedAt: '2026-11-01T00:00:00Z' })
53
+ onDesktopStateImpl = (cb: (p: StatePayload) => void) => {
54
+ stateListener = cb
55
+ return () => {
56
+ stateListener = null
57
+ }
58
+ }
59
+ })
60
+
61
+ afterEach(() => {
62
+ bridgeRef.current = undefined as never
63
+ cleanup()
64
+ vi.clearAllMocks()
65
+ })
66
+
67
+ describe('DesktopSyncIndicator 三态徽标(A11)', () => {
68
+ it('初始查询建立基线:guest 徽标 + i18n 文案', async () => {
69
+ bridgeRef.current = makeBridge()
70
+ render(<DesktopSyncIndicator />)
71
+ const badge = await screen.findByTestId('desktop-state-badge')
72
+ expect(badge.getAttribute('data-desktop-state')).toBe('guest')
73
+ expect(badge.textContent).toContain(m.desktop_state_guest())
74
+ })
75
+
76
+ it('事件推送 transferring:徽标随推送更新(过境态)', async () => {
77
+ bridgeRef.current = makeBridge()
78
+ render(<DesktopSyncIndicator />)
79
+ await screen.findByTestId('desktop-state-badge')
80
+ await act(async () => {
81
+ stateListener?.({ kind: 'transferring', updatedAt: '2026-11-01T00:00:01Z' })
82
+ })
83
+ const badge = screen.getByTestId('desktop-state-badge')
84
+ expect(badge.getAttribute('data-desktop-state')).toBe('transferring')
85
+ expect(badge.textContent).toContain(m.desktop_state_transferring())
86
+ })
87
+
88
+ it('事件推送 cloud:徽标迁云态(身份切换终态)', async () => {
89
+ bridgeRef.current = makeBridge()
90
+ render(<DesktopSyncIndicator />)
91
+ await screen.findByTestId('desktop-state-badge')
92
+ await act(async () => {
93
+ stateListener?.({ kind: 'cloud', updatedAt: '2026-11-01T00:00:02Z' })
94
+ })
95
+ const badge = screen.getByTestId('desktop-state-badge')
96
+ expect(badge.getAttribute('data-desktop-state')).toBe('cloud')
97
+ expect(badge.textContent).toContain(m.desktop_state_cloud())
98
+ })
99
+
100
+ it('旧壳降级:无 desktopState/onDesktopState 时不渲染徽标、同步行不受影响', async () => {
101
+ bridgeRef.current = makeBridge({ desktopState: undefined, onDesktopState: undefined })
102
+ render(<DesktopSyncIndicator />)
103
+ await waitFor(() => {
104
+ expect(screen.queryByTestId('desktop-state-badge')).toBeNull()
105
+ })
106
+ // 同步相位行仍工作(既有行为回归哨兵)
107
+ await act(async () => {
108
+ syncListener?.({ phase: 'pushing', pending: 3, lastError: null, lastSyncedAt: null })
109
+ })
110
+ expect(screen.getByTestId('desktop-sync-indicator').textContent).toContain('推送中')
111
+ })
112
+
113
+ it('无 desktopBridge(浏览器/云端部署):组件整体返回 null', () => {
114
+ bridgeRef.current = undefined as never
115
+ const { container } = render(<DesktopSyncIndicator />)
116
+ expect(container.textContent).toBe('')
117
+ })
118
+
119
+ it('三态齐备时同步相位行与徽标共存(双行)', async () => {
120
+ bridgeRef.current = makeBridge()
121
+ render(<DesktopSyncIndicator />)
122
+ await screen.findByTestId('desktop-state-badge')
123
+ await act(async () => {
124
+ syncListener?.({ phase: 'idle', pending: 0, lastError: null, lastSyncedAt: '2026-11-01' })
125
+ })
126
+ const wrapper = screen.getByTestId('desktop-sync-indicator')
127
+ expect(wrapper.textContent).toContain(m.desktop_state_guest())
128
+ expect(wrapper.textContent).toContain('已同步')
129
+ })
130
+ })
@@ -5,20 +5,38 @@
5
5
  // 桌面云端登录桥(迁移设计 §3.6):表单调用 desktopBridge.cloudLogin
6
6
  // (main 侧 desktopLogin 走云端 credentials),成功后引擎自动拾取会话
7
7
  // (makeFetchApi 每次同步重读 cloud-session.json)。
8
+ // P2 A10 轻量云身份激活:登录成功回调链追加 exchangeCloudSession(main 从
9
+ // cloud-session.json 解纯 token)→ exchangeRemoteTokenFn(serverFn 换权益并
10
+ // 写 remote-auth,getCurrentSession 桌面回落由此得云身份)→ queryClient.clear
11
+ // (规格 D1:identity 变化缓存失效)→ 整页 reload 重载 session。exchange 阶段
12
+ // 失败 toast 提示重试、不 reload(登录 cookie 已落盘,同步引擎可用)。
13
+ // P2 A8b 归属转移串联:exchange 成功后 claim 探测(probeCloudAccountStatusFn
14
+ // 代持云会话 token 问 SaaS 账号是否空账号)——空账号进入 bind 流程(bindBegin
15
+ // 停写快照 → transferOwnershipFn 单事务翻转 guest→cloud(身份字段省略,server
16
+ // 侧自取 SaaS profile)→ bindDone 清快照触发 push → reload);非空账号一期不
17
+ // 支持转移(提示后仍 reload,云身份生效、本地数据保留 guest 属主);转移失败
18
+ // bindFail 恢复快照回 guest 态(toast 提示,不 reload)。
8
19
  // 独立于 web 登录页——web 表单走本地 serverFn authjs,二者不混用。
9
20
  import { useEffect, useState } from "react"
21
+ import { useQueryClient } from "@tanstack/react-query"
10
22
  import { getDesktopBridge } from "@/lib/desktop-bridge-types"
23
+ import { exchangeRemoteTokenFn, logoutRemoteFn } from "@/server/serverFns/auth/session"
24
+ import { probeCloudAccountStatusFn, transferOwnershipFn } from "@/server/serverFns/sync/transfer"
11
25
  import { Button } from "@/components/ui/button"
12
26
  import { Input } from "@/components/ui/input"
13
27
  import { Label } from "@/components/ui/label"
28
+ import { toast } from "@/components/ui/toast"
29
+ import { m } from "@/paraglide/messages.js"
14
30
 
15
31
  export function DesktopCloudLogin() {
16
32
  const [email, setEmail] = useState("")
17
33
  const [password, setPassword] = useState("")
18
34
  const [busy, setBusy] = useState(false)
35
+ const [binding, setBinding] = useState(false)
19
36
  const [error, setError] = useState<string | null>(null)
20
37
  const [ok, setOk] = useState(false)
21
38
  const [mounted, setMounted] = useState(false)
39
+ const queryClient = useQueryClient()
22
40
 
23
41
  // 环境自判(与 sync-indicator/conflict-center 同构,mounted 模式保证 SSR/客户端
24
42
  // 首帧一致):仅桌面壳(存在 desktopBridge)时渲染。web/云端部署下本组件由
@@ -30,20 +48,104 @@ export function DesktopCloudLogin() {
30
48
 
31
49
  const submit = async () => {
32
50
  const bridge = getDesktopBridge()
33
- if (!bridge || !email || !password || busy) return
51
+ if (!bridge || !email || !password || busy || binding) return
34
52
  setBusy(true)
35
53
  setError(null)
36
54
  try {
37
55
  const res = await bridge.cloudLogin(email, password)
38
- if (res.ok) {
39
- setOk(true)
40
- } else {
56
+ if (!res.ok) {
41
57
  setError(res.error ?? "登录失败")
58
+ return
59
+ }
60
+ // 激活阶段(token 交换)失败:toast 提示重试,不 reload——登录 cookie
61
+ // 已落盘(同步引擎可用),仅云身份未激活,留在登录表单可重试
62
+ let cloudUserId: string | undefined
63
+ try {
64
+ const ex = await bridge.exchangeCloudSession()
65
+ if (!ex.ok || !ex.token) throw new Error(ex.error ?? "exchange 无 token")
66
+ const activated = await exchangeRemoteTokenFn({ data: { token: ex.token } })
67
+ cloudUserId = activated.userId ?? res.userId
68
+ } catch {
69
+ toast.error(m.desktop_cloud_exchange_failed())
70
+ return
71
+ }
72
+ if (!cloudUserId) {
73
+ // 防御:exchange 与 cloudLogin 均未回 userId(不应发生)——按激活失败处理
74
+ toast.error(m.desktop_cloud_exchange_failed())
75
+ return
76
+ }
77
+
78
+ // claim 探测(A8b):探测异常按非空处理(宁可不转不可错转)。旧壳无
79
+ // bind 三方法时同样只激活云身份不转移(优雅降级,A10 行为)。
80
+ const { bindBegin, bindDone, bindFail } = bridge
81
+ let probe: { empty: boolean; unreachable?: boolean } = { empty: false, unreachable: true }
82
+ if (bindBegin && bindDone && bindFail) {
83
+ try {
84
+ probe = await probeCloudAccountStatusFn()
85
+ } catch {
86
+ probe = { empty: false, unreachable: true }
87
+ }
88
+ }
89
+
90
+ if (!(bindBegin && bindDone && bindFail)) {
91
+ // 旧壳降级(无 bind 三 IPC):只激活云身份不转移(A10 行为),提示后
92
+ // 仍 reload 生效(本地数据保留 guest 属主)。与探测不可达分路如实提示。
93
+ toast.info(m.sync_bind_shell_unsupported())
94
+ setOk(true)
95
+ queryClient.clear()
96
+ window.location.reload()
97
+ return
98
+ }
99
+ if (!probe.empty) {
100
+ // 非空账号:一期不支持转移——云身份已激活,仍 reload 生效(本地数据
101
+ // 保留 guest 属主,二期做云端 bootstrap 下载)。探测不可达与「确认
102
+ // 非空」分路提示(不可达 ≠ 账号有数据,勿误导)。
103
+ toast.info(probe.unreachable ? m.sync_bind_probe_unreachable() : m.sync_bind_nonempty())
104
+ setOk(true)
105
+ queryClient.clear()
106
+ window.location.reload()
107
+ return
108
+ }
109
+
110
+ // 空账号 → 归属转移 bind 流程:main 侧停写快照(bindBegin)→ renderer 调
111
+ // 转移 serverFn(localhost cookie 过鉴权;身份字段省略,server 自取
112
+ // profile)→ 成功 bindDone(清快照 + 触发 push);失败 bindFail(停
113
+ // backend 还原快照重启,回 guest 态)。
114
+ setBinding(true)
115
+ const begin = await bindBegin()
116
+ if (!begin.ok) {
117
+ // 快照失败未进转移面(begin 已自行解锁):提示后留在表单可重试
118
+ toast.error(m.sync_bind_failed())
119
+ return
120
+ }
121
+ try {
122
+ await transferOwnershipFn({ data: { cloudUserId } })
123
+ } catch {
124
+ // 转移失败(单事务已回滚):main 侧恢复快照回 guest 态 + 回滚云身份激活
125
+ // (修复轮 1 发现 2:exchange 已写 remote-auth,不清则 reload/重启后
126
+ // getSessionFn 桌面回落返回云身份,guest 数据全部不可见=「假丢数据」;
127
+ // logoutRemoteFn 桌面守卫内清 remote-auth,cloud-session.json 保留——
128
+ // A11 三态统一治理,不在本轮)。提示后不 reload(本机会话仍是 guest,
129
+ // 表单可重试)。
130
+ await bindFail?.().catch(() => null)
131
+ await logoutRemoteFn({ data: {} }).catch(() => null)
132
+ toast.error(m.sync_bind_failed())
133
+ return
42
134
  }
135
+ // bindDone 失败不回滚:转移已成功 + 云身份已激活,残留快照由下次
136
+ // bindBegin 清理;push 由 bindDone 失败时的下一 60s tick 兜底
137
+ await bindDone?.().catch(() => null)
138
+ // 身份已切换 + 本地属主已翻转:清 react-query 缓存(规格 D1)+ 整页刷新
139
+ // (reload 后 getSessionFn 桌面回落即得云身份)
140
+ setOk(true)
141
+ queryClient.clear()
142
+ window.location.reload()
143
+ return
43
144
  } catch (e) {
44
145
  setError(e instanceof Error ? e.message : String(e))
45
146
  } finally {
46
147
  setBusy(false)
148
+ setBinding(false)
47
149
  }
48
150
  }
49
151
 
@@ -83,8 +185,8 @@ export function DesktopCloudLogin() {
83
185
  />
84
186
  </div>
85
187
  {error && <p className="text-xs text-destructive">{error}</p>}
86
- <Button size="sm" className="w-full" disabled={busy || !email || !password} onClick={() => void submit()}>
87
- {busy ? "登录中…" : "登录云端"}
188
+ <Button size="sm" className="w-full" disabled={busy || binding || !email || !password} onClick={() => void submit()}>
189
+ {binding ? m.sync_bind_in_progress() : busy ? "登录中…" : "登录云端"}
88
190
  </Button>
89
191
  </div>
90
192
  )
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // desktop-state 身份切换监听(桌面本地优先 A11,缓存三件套兜底面):订阅
6
+ // desktopBridge.onDesktopState,身份切换(guest↔cloud)完成时执行设计 D1 三件套
7
+ // ——① resetAuthProfileCacheFn(服务端 auth.config profileCache 模块缓存,renderer
8
+ // 无法直清、serverFn 是唯一载体)② queryClient.clear() ③ window.location.reload()。
9
+ // 分工(幂等共存):A8b/A9 流内 inline 失效(cloud-login / user-menu)是 UX 时序
10
+ // 主路径(紧跟用户操作、先于 toast/reload);本监听是兜底面——覆盖流外身份变化
11
+ // (token 过期回落、未来其他切换路径)。两者都做完整三件套,重复执行无副作用
12
+ // (clear/reload 幂等)。
13
+ // 触发判定(审查修复轮 1 Critical):状态机迁移表钉死身份切换必经 transferring
14
+ // (guest/cloud 态仅 begin 类事件合法),故 transferring 过境透明——last 只记
15
+ // 最近非转移态,新的非转移态到达时与 last 比较判异。七条真实序列的语义:
16
+ // bind 全程 guest→transferring→cloud → 触发(guest≠cloud)
17
+ // 登出全程 cloud→transferring→guest → 触发(cloud≠guest)
18
+ // 登出后重绑 guest→transferring→cloud → 触发(同 bind 全程)
19
+ // rebind 失败回 guest cloud→transferring→guest → 触发(属主确已翻转)
20
+ // bind 失败 guest→transferring→guest → 不触发(首尾同态)
21
+ // 登出失败 cloud→transferring→cloud → 不触发(首尾同态)
22
+ // rebind 成功 cloud→transferring→cloud → 不触发(首尾同态)
23
+ // (直接相邻 guest↔cloud 推送对在状态机里不可能产生——初版 isIdentitySwitch
24
+ // 正是因此不可达,见 task-A11-report.md 修复轮 1。)
25
+ // 基线建立(reload 后首条非转移态查询/推送)只记录不触发——防 reload 循环。
26
+ // 挂载点:shell-widgets(Providers 全页挂载,桌面壳外零成本——无 desktopBridge
27
+ // 即不订阅)。
28
+ import { useEffect } from "react"
29
+ import { getDesktopBridge } from "@/lib/desktop-bridge-types"
30
+ import type { DesktopStateKind } from "@/lib/desktop-bridge-types"
31
+ import { getQueryClient } from "@/lib/query-client-seam"
32
+ import { resetAuthProfileCacheFn } from "@/server/serverFns/auth/session"
33
+
34
+ /**
35
+ * 三件套(顺序即依赖:先清服务端缓存再 reload——reload 后 session 链会回源
36
+ * profile;serverFn 失败不阻断 ②③,profileCache 30s TTL 自愈,缓存卫生尽力
37
+ * 而为、会话切换硬保证)。非导出模块函数:本文件唯一导出是组件(Fast Refresh)。
38
+ */
39
+ async function fireIdentityInvalidation(): Promise<void> {
40
+ try {
41
+ await resetAuthProfileCacheFn({ data: {} })
42
+ } catch {
43
+ // 尽力而为:backend 不可达/非桌面部署时静默——TTL 30s 内自愈
44
+ }
45
+ getQueryClient().clear()
46
+ window.location.reload()
47
+ }
48
+
49
+ export function DesktopStateListener() {
50
+ useEffect(() => {
51
+ const bridge = getDesktopBridge()
52
+ if (!bridge?.onDesktopState) return
53
+ // 基线:null = 未建立(本会话尚无已知非转移态)
54
+ let last: DesktopStateKind | null = null
55
+ // 基线查询(reload 后推送事件已错过,查询补当前态):仅 last===null 且非
56
+ // transferring 时写入——Electron invoke 响应与 push 事件到达顺序无保证,
57
+ // 晚到的查询响应不得把推送已建立的基线回退成旧值(竞态守卫)
58
+ void bridge.desktopState
59
+ ?.()
60
+ .then((p) => {
61
+ if (last === null && p.kind !== "transferring") last = p.kind
62
+ })
63
+ .catch(() => null)
64
+ const off = bridge.onDesktopState((payload) => {
65
+ // transferring 过境透明:不更新 last(prev 恒取最近非转移态)
66
+ if (payload.kind === "transferring") return
67
+ const prev = last
68
+ last = payload.kind
69
+ if (!prev) return // 首个非转移态 = 基线建立,不触发(防 reload 循环)
70
+ if (prev !== payload.kind) void fireIdentityInvalidation()
71
+ })
72
+ return off
73
+ }, [])
74
+
75
+ return null
76
+ }
@@ -3,17 +3,24 @@
3
3
  // Copyright (C) 2026 AllBlue Contributors
4
4
 
5
5
  // 桌面壳挂载组件(迁移设计 §3.7):DEPLOY_TARGET=desktop 时挂载同步状态
6
- // 指示器 + 冲突中心。云端部署零成本(组件内部自判返回 null)。
6
+ // 指示器 + 冲突中心 + desktop-state 身份切换监听。云端部署零成本(组件内部
7
+ // 自判返回 null)。
7
8
  // 云登录入口在侧边栏用户菜单(isLocalGuest → /login)与 /settings/sync
8
9
  // 设置页卡片,不在壳层悬浮表单(2026-10-31 用户裁决移除右下角表单)。
9
10
  import { DesktopSyncIndicator } from "@/components/desktop/sync-indicator"
10
11
  import { DesktopConflictCenter } from "@/components/desktop/conflict-center"
12
+ import { DesktopStateListener } from "@/components/desktop/desktop-state-listener"
11
13
 
12
14
  export function DesktopShellWidgets() {
13
15
  return (
14
- <div className="fixed right-4 bottom-4 z-50 w-80 space-y-3">
15
- <DesktopSyncIndicator />
16
- <DesktopConflictCenter />
17
- </div>
16
+ <>
17
+ {/* 监听器无 UI(订阅身份切换 → 缓存三件套失效),置于悬浮容器外防其
18
+ 占位/参与布局 */}
19
+ <DesktopStateListener />
20
+ <div className="fixed right-4 bottom-4 z-50 w-80 space-y-3">
21
+ <DesktopSyncIndicator />
22
+ <DesktopConflictCenter />
23
+ </div>
24
+ </>
18
25
  )
19
26
  }