@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
@@ -5,8 +5,13 @@
5
5
  // 桌面同步状态指示器(迁移设计 §3.7 最小 UI):顶栏小圆点 + 同步状态。
6
6
  // 仅 DEPLOY_TARGET=desktop 渲染;云端部署零成本(组件返回 null)。
7
7
  // 数据源:desktopBridge.onSyncState(backend parentPort 上报的引擎状态机)。
8
+ // A11 三态徽标:desktopBridge.desktopState 查询 + onDesktopState 订阅(main 侧
9
+ // desktop-state 状态机),展示数据属主三态(guest/transferring/cloud)——位置
10
+ // 跟随既有指示器(本组件扩展,不新造摆放位置),位于同步相位行上方。
8
11
  import { useEffect, useState } from "react"
9
12
  import { getDesktopBridge } from "@/lib/desktop-bridge-types"
13
+ import type { DesktopStateKind } from "@/lib/desktop-bridge-types"
14
+ import { m } from "@/paraglide/messages.js"
10
15
 
11
16
  interface SyncState {
12
17
  phase: "idle" | "pushing" | "pulling"
@@ -21,8 +26,30 @@ const LABELS: Record<SyncState["phase"], string> = {
21
26
  pulling: "拉取中",
22
27
  }
23
28
 
29
+ // 三态徽标形态(跟随既有 LOCAL 徽标 = 圆点 + 文案):guest 灰(本地属主)、
30
+ // transferring 琥珀脉冲(转移进行中)、cloud 绿(云属主,与「已同步」同色系)
31
+ const STATE_DOT: Record<DesktopStateKind, string> = {
32
+ guest: "bg-muted-foreground",
33
+ transferring: "bg-amber-500 animate-pulse",
34
+ cloud: "bg-emerald-500",
35
+ }
36
+
37
+ // 文案在渲染时解析(paraglide 按请求/cookie locale——模块顶层调用会把 locale
38
+ // 固化在 import 时刻,语言切换后不更新);非导出模块函数保 Fast Refresh 纯组件边界
39
+ function stateLabelOf(kind: DesktopStateKind): string {
40
+ switch (kind) {
41
+ case "guest":
42
+ return m.desktop_state_guest()
43
+ case "transferring":
44
+ return m.desktop_state_transferring()
45
+ case "cloud":
46
+ return m.desktop_state_cloud()
47
+ }
48
+ }
49
+
24
50
  export function DesktopSyncIndicator() {
25
51
  const [state, setState] = useState<SyncState | null>(null)
52
+ const [deskState, setDeskState] = useState<DesktopStateKind | null>(null)
26
53
  const [mounted, setMounted] = useState(false)
27
54
 
28
55
  useEffect(() => {
@@ -30,31 +57,53 @@ export function DesktopSyncIndicator() {
30
57
  const bridge = getDesktopBridge()
31
58
  if (!bridge) return
32
59
  const off = bridge.onSyncState((s) => setState(s as SyncState))
33
- return off
60
+ // A11 三态:查询补当前态(页面 reload 后推送事件已错过)+ 订阅变更推送
61
+ void bridge.desktopState
62
+ ?.()
63
+ .then((p) => setDeskState(p.kind))
64
+ .catch(() => null)
65
+ const offState = bridge.onDesktopState?.((p) => setDeskState(p.kind))
66
+ return () => {
67
+ off()
68
+ offState?.()
69
+ }
34
70
  }, [])
35
71
 
36
72
  if (!mounted || !getDesktopBridge()) return null
37
- if (!state) return null
73
+ if (!state && !deskState) return null
38
74
 
39
- const hasError = state.lastError !== null
40
- const busy = state.phase === "pushing" || state.phase === "pulling"
75
+ const hasError = state?.lastError !== null && state?.lastError !== undefined
76
+ const busy = state?.phase === "pushing" || state?.phase === "pulling"
41
77
  const dotColor = hasError ? "bg-destructive" : busy ? "bg-amber-500 animate-pulse" : "bg-emerald-500"
42
78
  const label = hasError
43
79
  ? "同步失败"
44
80
  : busy
45
- ? LABELS[state.phase]
46
- : state.pending > 0
81
+ ? LABELS[state!.phase]
82
+ : state && state.pending > 0
47
83
  ? `待同步 ${state.pending}`
48
84
  : LABELS.idle
49
85
 
50
86
  return (
51
- <div
52
- className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"
53
- title={hasError ? `同步失败:${state.lastError}` : `上次同步 ${state.lastSyncedAt ?? "—"}`}
54
- data-testid="desktop-sync-indicator"
55
- >
56
- <span className={`inline-block size-2 rounded-full ${dotColor}`} aria-hidden />
57
- <span>{label}</span>
87
+ <div className="space-y-0.5" data-testid="desktop-sync-indicator">
88
+ {deskState && (
89
+ <div
90
+ className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"
91
+ data-testid="desktop-state-badge"
92
+ data-desktop-state={deskState}
93
+ >
94
+ <span className={`inline-block size-2 rounded-full ${STATE_DOT[deskState]}`} aria-hidden />
95
+ <span>{stateLabelOf(deskState)}</span>
96
+ </div>
97
+ )}
98
+ {state && (
99
+ <div
100
+ className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"
101
+ title={hasError ? `同步失败:${state.lastError}` : `上次同步 ${state.lastSyncedAt ?? "—"}`}
102
+ >
103
+ <span className={`inline-block size-2 rounded-full ${dotColor}`} aria-hidden />
104
+ <span>{label}</span>
105
+ </div>
106
+ )}
58
107
  </div>
59
108
  )
60
109
  }
@@ -19,6 +19,10 @@ vi.mock('@/components/ui/sidebar', () => ({
19
19
  }))
20
20
  vi.mock('@/components/ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
21
21
  vi.mock('@/server/serverFns/auth/session', () => ({ logoutRemoteFn: vi.fn() }))
22
+ // A9:user-menu 反向转移登出链路新增 import——拦住 serverFn 模块图(勿真触
23
+ // desktop-bootstrap/@allbluecn/database 模块级单例,照本文件 session mock 先例)
24
+ vi.mock('@/server/serverFns/sync/transfer', () => ({ reverseTransferOwnershipFn: vi.fn() }))
25
+ vi.mock('@/lib/desktop-bridge-types', () => ({ getDesktopBridge: () => null }))
22
26
  vi.mock('@/components/shared-kit', () => ({
23
27
  UserAvatar: ({ name }: { name?: string }) => <div data-testid="avatar">{name}</div>,
24
28
  }))
@@ -23,7 +23,11 @@ import {
23
23
  Settings,
24
24
  Users,
25
25
  } from "lucide-react";
26
+ import type { QueryClient } from "@tanstack/react-query";
26
27
  import { logoutRemoteFn } from '@/server/serverFns/auth/session'
28
+ import { reverseTransferOwnershipFn } from '@/server/serverFns/sync/transfer'
29
+ import { getDesktopBridge } from '@/lib/desktop-bridge-types'
30
+ import type { DesktopBridgeApi } from '@/lib/desktop-bridge-types'
27
31
  import { toast } from '@/components/ui/toast'
28
32
 
29
33
  import {
@@ -49,6 +53,56 @@ import type { AvatarConfig } from "@allbluecn/kernel";
49
53
  import { SparklesText } from "@/components/magicui/sparkles-text";
50
54
  import { m } from "@/paraglide/messages.js";
51
55
 
56
+ /**
57
+ * 桌面云态登出(桌面本地优先 P2-A9 反向转移编排,裁定顺序):登出 = 把云身份名下
58
+ * 数据翻回 guest(数据保留本机),双会话清理后回到 guest 态。
59
+ * ① 置转移锁(transferLock,复用 bind 锁文件——引擎每 tick 判定同源,「转移进行中
60
+ * 引擎勿 tick」;旧壳无此 IPC 优雅降级:反向转移单事务自身原子,锁为双保险);
61
+ * ② reverseTransferOwnershipFn(serverFn:校验 remoteAuth/会话本人 → 单事务
62
+ * cloud→guest + outbox/row_state 同事务清空防反向 op 上云 + 重建 guest 行;
63
+ * 事务提交后 server 侧已清 remote-auth——崩窗口收敛,A8b 发现 2 的镜像);
64
+ * ③ 双会话清理:logoutRemoteFn(幂等 re-clear remote-auth)+ clearCloudSession
65
+ * (清 cloud-session.json,引擎 guest 守卫回 idle)→ 清锁(先清云会话再解锁,
66
+ * 引擎恢复 tick 前云会话已不可用);
67
+ * ④ queryClient.clear() + 整页 reload——reload 后 getSessionFn 回落 guest 会话。
68
+ * 失败路径(锁失败 / 转移失败):解锁 + toast sync_logout_failed + 不 reload(留在
69
+ * 云态可重试;转移失败单事务已回滚,数据仍是云属主,重试幂等)。模块内局部函数
70
+ * (非导出):本文件唯一导出是 UserMenu 组件,勿破坏 Fast Refresh 纯组件边界。
71
+ */
72
+ async function desktopCloudLogout(
73
+ bridge: DesktopBridgeApi,
74
+ cloudUserId: string,
75
+ queryClient: QueryClient,
76
+ ): Promise<void> {
77
+ toast.info(m.sync_logout_in_progress());
78
+ // ① 转移锁
79
+ let lock: { ok: boolean; error?: string } = { ok: true };
80
+ try {
81
+ lock = bridge.transferLock ? await bridge.transferLock("begin") : { ok: true };
82
+ } catch {
83
+ lock = { ok: false };
84
+ }
85
+ if (!lock.ok) {
86
+ toast.error(m.sync_logout_failed());
87
+ return;
88
+ }
89
+ // ② 反向转移(失败:单事务回滚留在云态,解锁可重试)
90
+ try {
91
+ await reverseTransferOwnershipFn({ data: { cloudUserId } });
92
+ } catch {
93
+ try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
94
+ toast.error(m.sync_logout_failed());
95
+ return;
96
+ }
97
+ // ③ 双会话清理(均幂等 best-effort:serverFn 已清 remote-auth,此处兜底)
98
+ try { await logoutRemoteFn({ data: {} }); } catch { /* 尽力而为 */ }
99
+ try { await bridge.clearCloudSession?.(); } catch { /* 尽力而为 */ }
100
+ try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
101
+ // ④ 缓存清空 + 整页刷新(reload 后 desktop-state 回 guest)
102
+ queryClient.clear();
103
+ window.location.reload();
104
+ }
105
+
52
106
  export function UserMenu() {
53
107
  const { isMobile } = useSidebar();
54
108
  const { data: clientSession } = useSession();
@@ -83,6 +137,15 @@ export function UserMenu() {
83
137
  window.location.reload();
84
138
  return;
85
139
  }
140
+ // 桌面云态(A9):桌面壳存在且无本地 authjs 会话(clientSession 为空)=当前
141
+ // 会话是 remote-auth 回落的轻量云身份,登出走反向转移编排(数据翻回 guest)。
142
+ // 本地凭据登录(clientSession 非空)与 web 照旧走 signOut 链——云身份回落仅在
143
+ // authjs 会话无效时生效,clientSession 为空是云态判别式。
144
+ const bridge = getDesktopBridge();
145
+ if (bridge && !clientSession && user?.id) {
146
+ await desktopCloudLogout(bridge, user.id, queryClient);
147
+ return;
148
+ }
86
149
  await signOut();
87
150
  queryClient.resetQueries({ queryKey: ['session'] });
88
151
  await router.navigate({ to: "/login" });
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // getCurrentSession 桌面回落单点测试(桌面本地优先 P2 / A10)。
6
+ // 优先级链钉死(复审③ B1/B2):authjs session(有效则返回)→ desktop 时
7
+ // remoteAuth → null。start-authjs / auth.config / remote-auth 全 mock:
8
+ // 不触真实 JWT 解密、不真读写 DESKTOP_USER_DATA_DIR 下的 remote-auth.json
9
+ // (#578 fs 实现勿真写盘)。desktop 判定走 DEPLOY_TARGET env stub
10
+ // (isDesktopServer 调用时读 env,见 desktop-guest.ts:40)。
11
+ import { afterEach, describe, expect, it, vi } from 'vitest'
12
+ import { getCurrentSession } from '../session'
13
+ import type { Session } from '../session'
14
+
15
+ const { getSessionMock, getAuthConfigMock, readRemoteAuthMock } = vi.hoisted(() => ({
16
+ getSessionMock: vi.fn(),
17
+ getAuthConfigMock: vi.fn(async () => ({})),
18
+ readRemoteAuthMock: vi.fn(),
19
+ }))
20
+
21
+ vi.mock('start-authjs', () => ({
22
+ getSession: getSessionMock,
23
+ }))
24
+
25
+ vi.mock('@/lib/auth/auth.config', () => ({
26
+ getAuthConfig: getAuthConfigMock,
27
+ }))
28
+
29
+ vi.mock('@/server/remote-auth', () => ({
30
+ readRemoteAuth: readRemoteAuthMock,
31
+ }))
32
+
33
+ const AUTHED: Session = {
34
+ user: { id: 'authjs-user-1', name: 'AuthJS 用户' },
35
+ expires: '2026-01-01T00:00:00.000Z',
36
+ }
37
+
38
+ const REMOTE_AUTH = {
39
+ userId: 'cloud-user-abc',
40
+ planName: 'pro',
41
+ features: {},
42
+ sessionToken: 'cloud-session-token',
43
+ }
44
+
45
+ const REQ = () => new Request('http://localhost/api/auth/session')
46
+
47
+ describe('getCurrentSession 桌面回落(A10 优先级链)', () => {
48
+ afterEach(() => {
49
+ vi.unstubAllEnvs()
50
+ vi.clearAllMocks()
51
+ })
52
+
53
+ it('①a authjs session 有效 → 直接返回,desktop 下也不读 remoteAuth', async () => {
54
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
55
+ getSessionMock.mockResolvedValue(AUTHED)
56
+ const session = await getCurrentSession(REQ())
57
+ expect(session?.user?.id).toBe('authjs-user-1')
58
+ expect(readRemoteAuthMock).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('①b+② authjs 无效 + desktop + remoteAuth 有 → 组装轻量云身份(user.id=cloudUserId)', async () => {
62
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
63
+ getSessionMock.mockResolvedValue(null)
64
+ readRemoteAuthMock.mockResolvedValue(REMOTE_AUTH)
65
+ const session = await getCurrentSession(REQ())
66
+ // 审查重点 4 前半:回落 session 的属主必须是云 userId(非 guest / 非 authjs 残影)
67
+ expect(session?.user?.id).toBe('cloud-user-abc')
68
+ // 组装形态与 buildRemoteSession 对齐(RemoteAuthData 实际字段,不虚构)
69
+ expect(session?.user?.name).toBe('cloud-user-abc') // username 缺省回落 userId
70
+ expect(session?.user?.isGuest).toBe(false)
71
+ expect(session?.user?.hasAiKey).toBe(false)
72
+ expect(session?.expires).toBe('never')
73
+ })
74
+
75
+ it('①c authjs 无效 + desktop + remoteAuth 无 → null', async () => {
76
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
77
+ getSessionMock.mockResolvedValue(null)
78
+ readRemoteAuthMock.mockResolvedValue(null)
79
+ expect(await getCurrentSession(REQ())).toBeNull()
80
+ })
81
+
82
+ it('③ 非 desktop 不读 remoteAuth(env 判定先于 fs 读取)', async () => {
83
+ vi.stubEnv('DEPLOY_TARGET', '')
84
+ getSessionMock.mockResolvedValue(null)
85
+ expect(await getCurrentSession(REQ())).toBeNull()
86
+ expect(readRemoteAuthMock).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('无 request → null(既有守卫不回归,不触发任何读取)', async () => {
90
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
91
+ expect(await getCurrentSession()).toBeNull()
92
+ expect(getSessionMock).not.toHaveBeenCalled()
93
+ expect(readRemoteAuthMock).not.toHaveBeenCalled()
94
+ })
95
+ })
@@ -74,6 +74,19 @@ export function invalidateProfileCache(userId: string): void {
74
74
  profileCache.delete(userId)
75
75
  }
76
76
 
77
+ /**
78
+ * 全量清空 profile 缓存(桌面本地优先 A11 缓存三件套之一):身份切换(guest↔
79
+ * cloud)后旧身份的缓存条目必须整体失效——按 userId 逐个 invalidate 需要知道旧
80
+ * id,身份切换场景里调用方(renderer 经 resetAuthProfileCacheFn)恰恰没有可靠
81
+ * 的旧 id,全量 clear 是唯一正确形态。服务端进程内存缓存,清空零风险(miss 后
82
+ * 自然回源)。桌面 guest/remote-auth 回落会话不经过本缓存(buildGuestSession /
83
+ * buildRemoteSession 不查 profileCache),本清理覆盖的是「本地凭据 authjs 会话
84
+ * 与桌面身份切换并存」的混合场景(getCachedProfile(token.id) 消费面)。
85
+ */
86
+ export function resetProfileCache(): void {
87
+ profileCache.clear()
88
+ }
89
+
77
90
  async function queryProfile(userId: string): Promise<CachedProfile | null> {
78
91
  const [dbUser, providerConfigs, sub] = await Promise.all([
79
92
  prisma.user.findUnique({
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
112
112
  "items": [
113
113
  {
114
114
  "name": "@allbluecn/web-app",
115
- "version": "v0.23.1",
115
+ "version": "v0.23.3",
116
116
  "lastUpdate": "Nov 1, 2026",
117
117
  "source": "apps/web/package.json",
118
118
  "docsUrl": "https://github.com/allbluecn/allblue"
@@ -7,6 +7,9 @@
7
7
  export interface DesktopBridgeApi {
8
8
  appInfo: () => Promise<{ version: string; clientId: string; platform: string; backendPort: number | null }>
9
9
  cloudLogin: (email: string, password: string) => Promise<{ ok: boolean; userId?: string; error?: string }>
10
+ /** A10 轻量云身份激活:登录成功后取 cloud-session.json 解出的纯 session
11
+ * token(renderer 再走 exchangeRemoteTokenFn 换权益并写 remote-auth)。 */
12
+ exchangeCloudSession: () => Promise<{ ok: boolean; token?: string; error?: string }>
10
13
  conflictRpc: (
11
14
  method: "list" | "applyLocal" | "keepRemote" | "keepBoth",
12
15
  ...args: unknown[]
@@ -18,6 +21,39 @@ export interface DesktopBridgeApi {
18
21
  onRenamed: (cb: (info: { table: string; from: string; to: string }) => void) => () => void
19
22
  /** macOS 全屏态切换(enter/leave-full-screen);旧壳无此 API 时 undefined */
20
23
  onFullscreenChange?: (cb: (fullscreen: boolean) => void) => () => void
24
+ /** A8b 归属转移 bind-flow(main 侧阶段编排;本段为 add-only 追加,不改既有段落):
25
+ * - bindBegin:停引擎自动 tick(bind 锁文件)+ PGlite 数据目录文件级快照;
26
+ * - bindDone:清快照 + 解锁 + 请求 backend 触发一次 push;
27
+ * - bindFail:停 backend → 还原快照 → 重启 backend(恢复 guest 态)+ 解锁。
28
+ * 转移 serverFn(transferOwnershipFn)由 renderer 自行调用(localhost cookie
29
+ * 过鉴权),不经本桥。旧壳(无此三方法)undefined。 */
30
+ bindBegin?: () => Promise<{ ok: boolean; error?: string }>
31
+ bindDone?: () => Promise<{ ok: boolean; error?: string }>
32
+ bindFail?: () => Promise<{ ok: boolean; error?: string }>
33
+ /** A9 登出反向转移(本段为 add-only 追加,不改既有段落):transferLock 置/清
34
+ * 转移锁——复用 bind 锁文件(引擎每 tick 判定同源,语义「转移进行中,引擎勿
35
+ * tick」);clearCloudSession 清 cloud-session.json(登出后引擎 guest 守卫回
36
+ * idle)。旧壳无此二方法 undefined——renderer 优雅降级(反向转移单事务自身
37
+ * 原子,锁为防 tick 并发的双保险;旧壳无云会话文件可清,调用方 optional-
38
+ * chaining)。 */
39
+ transferLock?: (action: "begin" | "end") => Promise<{ ok: boolean; error?: string }>
40
+ clearCloudSession?: () => Promise<{ ok: boolean; error?: string }>
41
+ /** A11 desktop-state 三态(本段为 add-only 追加,不改既有段落):main 侧状态机
42
+ * (guest | transferring | cloud,数据属主视角)的查询 + 推送双面。查询用于
43
+ * 页面 reload 后补齐错过的推送事件;推送由 main 侧 bind/登出流 dispatch 时
44
+ * 发送。旧壳(无此二方法)undefined——徽标与缓存失效监听 optional-chaining
45
+ * 优雅降级。 */
46
+ desktopState?: () => Promise<DesktopStatePayload>
47
+ onDesktopState?: (cb: (payload: DesktopStatePayload) => void) => () => void
48
+ }
49
+
50
+ /** A11 desktop-state 三态载荷(main 侧 desktop-state.json 持久化态的广播形状):
51
+ * guest 本地访客属主 / transferring 归属转移进行中 / cloud 云账号属主。 */
52
+ export type DesktopStateKind = "guest" | "transferring" | "cloud"
53
+
54
+ export interface DesktopStatePayload {
55
+ kind: DesktopStateKind
56
+ updatedAt: string
21
57
  }
22
58
 
23
59
  declare global {
@@ -18,6 +18,10 @@
18
18
  import { getSession } from 'start-authjs'
19
19
  import type { AuthSession } from 'start-authjs'
20
20
  import { getAuthConfig } from './auth/auth.config'
21
+ // A10(桌面本地优先 P2):单条豁免 import(eslint 文件级放行)——
22
+ // desktop-session-merge 为纯数据组装 + remote-auth 纯 fs 读取(node:fs +
23
+ // env 守卫),无 prisma/server 重依赖链,客户端/云端部署零副作用。
24
+ import { getDesktopRemoteSession } from '@/server/desktop-session-merge'
21
25
 
22
26
  export type SessionUser = NonNullable<AuthSession['user']> & {
23
27
  id?: string
@@ -42,7 +46,17 @@ export type Session = Omit<AuthSession, 'user'> & { user?: SessionUser | null }
42
46
  export async function getCurrentSession(request?: Request): Promise<Session | null> {
43
47
  if (!request) return null
44
48
  try {
45
- return (await getSession(request, await getAuthConfig())) as Session | null
49
+ const session = (await getSession(request, await getAuthConfig())) as Session | null
50
+ // 有效性口径与 getSessionFn 一致:有 user.id 才算有效(session callback
51
+ // 对 DB 无行用户返回 user: null 的残影不算登录态)
52
+ if (session?.user?.id) return session
53
+ // 桌面本地优先(P2 A10):authjs session 无效且为 desktop 部署时,回落
54
+ // remote-auth.json 的轻量云身份(user.id = 云 userId)。本函数是鉴权单点
55
+ // (requireAuth/authContextMiddleware 全链复用),回落在此一处生效、
56
+ // 604 处自动受益;非 desktop 恒 null(desktop 判定先于 fs 读取,零 IO)。
57
+ const remoteSession = await getDesktopRemoteSession()
58
+ if (remoteSession) return remoteSession
59
+ return session
46
60
  } catch (error) {
47
61
  // JWT 解密失败(AUTH_SECRET 轮换 / 跨应用同名 cookie 污染)等效于无 session:
48
62
  // 返回 null 走未登录分支;@auth/core 会自行清理坏 cookie。
@@ -1975,6 +1975,7 @@ export * from "./dashboard_stats_anyhubinstalled2.js";
1975
1975
  export * from "./dashboard_stats_completedtoday1.js";
1976
1976
  export * from "./dashboard_stats_prdtotal1.js";
1977
1977
  export * from "./dashboard_stats_unreadcomments1.js";
1978
+ export * from "./desktop_cloud_exchange_failed.js";
1978
1979
  export * from "./desktop_connection_cancel.js";
1979
1980
  export * from "./desktop_connection_confirm.js";
1980
1981
  export * from "./desktop_connection_description.js";
@@ -2012,6 +2013,9 @@ export * from "./desktop_onboarding_selfhosted_title1.js";
2012
2013
  export * from "./desktop_onboarding_selfhosted_urllabel2.js";
2013
2014
  export * from "./desktop_onboarding_subtitle.js";
2014
2015
  export * from "./desktop_onboarding_title.js";
2016
+ export * from "./desktop_state_cloud.js";
2017
+ export * from "./desktop_state_guest.js";
2018
+ export * from "./desktop_state_transferring.js";
2015
2019
  export * from "./detail_contentplaceholder1.js";
2016
2020
  export * from "./detail_deletedescription1.js";
2017
2021
  export * from "./detail_deletetitle1.js";
@@ -4905,6 +4909,13 @@ export * from "./stylefield_height1.js";
4905
4909
  export * from "./stylefield_selectcolor2.js";
4906
4910
  export * from "./stylefield_width1.js";
4907
4911
  export * from "./summary_months.js";
4912
+ export * from "./sync_bind_failed.js";
4913
+ export * from "./sync_bind_in_progress.js";
4914
+ export * from "./sync_bind_nonempty.js";
4915
+ export * from "./sync_bind_probe_unreachable.js";
4916
+ export * from "./sync_bind_shell_unsupported.js";
4917
+ export * from "./sync_logout_failed.js";
4918
+ export * from "./sync_logout_in_progress.js";
4908
4919
  export * from "./taginput_add1.js";
4909
4920
  export * from "./taginput_placeholder1.js";
4910
4921
  export * from "./taginput_remove1.js";
@@ -1977,6 +1977,7 @@ export * from './dashboard_stats_anyhubinstalled2.js'
1977
1977
  export * from './dashboard_stats_completedtoday1.js'
1978
1978
  export * from './dashboard_stats_prdtotal1.js'
1979
1979
  export * from './dashboard_stats_unreadcomments1.js'
1980
+ export * from './desktop_cloud_exchange_failed.js'
1980
1981
  export * from './desktop_connection_cancel.js'
1981
1982
  export * from './desktop_connection_confirm.js'
1982
1983
  export * from './desktop_connection_description.js'
@@ -2014,6 +2015,9 @@ export * from './desktop_onboarding_selfhosted_title1.js'
2014
2015
  export * from './desktop_onboarding_selfhosted_urllabel2.js'
2015
2016
  export * from './desktop_onboarding_subtitle.js'
2016
2017
  export * from './desktop_onboarding_title.js'
2018
+ export * from './desktop_state_cloud.js'
2019
+ export * from './desktop_state_guest.js'
2020
+ export * from './desktop_state_transferring.js'
2017
2021
  export * from './detail_contentplaceholder1.js'
2018
2022
  export * from './detail_deletedescription1.js'
2019
2023
  export * from './detail_deletetitle1.js'
@@ -4907,6 +4911,13 @@ export * from './stylefield_height1.js'
4907
4911
  export * from './stylefield_selectcolor2.js'
4908
4912
  export * from './stylefield_width1.js'
4909
4913
  export * from './summary_months.js'
4914
+ export * from './sync_bind_failed.js'
4915
+ export * from './sync_bind_in_progress.js'
4916
+ export * from './sync_bind_nonempty.js'
4917
+ export * from './sync_bind_probe_unreachable.js'
4918
+ export * from './sync_bind_shell_unsupported.js'
4919
+ export * from './sync_logout_failed.js'
4920
+ export * from './sync_logout_in_progress.js'
4910
4921
  export * from './taginput_add1.js'
4911
4922
  export * from './taginput_placeholder1.js'
4912
4923
  export * from './taginput_remove1.js'
@@ -0,0 +1,16 @@
1
+ /**
2
+ * | output |
3
+ * | --- |
4
+ * | "Cloud identity activation failed. Please retry." |
5
+ *
6
+ * @param {Desktop_Cloud_Exchange_FailedInputs} inputs
7
+ * @param {{ locale?: "zh" | "en" }} options
8
+ * @returns {LocalizedString}
9
+ */
10
+ export const desktop_cloud_exchange_failed: ((inputs?: Desktop_Cloud_Exchange_FailedInputs, options?: {
11
+ locale?: "zh" | "en";
12
+ }) => LocalizedString) & import("../runtime.js").MessageMetadata<Desktop_Cloud_Exchange_FailedInputs, {
13
+ locale?: "zh" | "en";
14
+ }, {}>;
15
+ export type LocalizedString = import("../runtime.js").LocalizedString;
16
+ export type Desktop_Cloud_Exchange_FailedInputs = {};
@@ -0,0 +1,29 @@
1
+ /* eslint-disable */
2
+ import { getLocale, experimentalStaticLocale } from '../runtime.js';
3
+
4
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5
+
6
+ /** @typedef {{}} Desktop_Cloud_Exchange_FailedInputs */
7
+
8
+ const zh_desktop_cloud_exchange_failed = /** @type {(inputs: Desktop_Cloud_Exchange_FailedInputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`云端身份激活失败,请重试`)
10
+ };
11
+
12
+ const en_desktop_cloud_exchange_failed = /** @type {(inputs: Desktop_Cloud_Exchange_FailedInputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Cloud identity activation failed. Please retry.`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Cloud identity activation failed. Please retry." |
20
+ *
21
+ * @param {Desktop_Cloud_Exchange_FailedInputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ export const desktop_cloud_exchange_failed = /** @type {((inputs?: Desktop_Cloud_Exchange_FailedInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Desktop_Cloud_Exchange_FailedInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_desktop_cloud_exchange_failed(inputs)
28
+ return zh_desktop_cloud_exchange_failed(inputs)
29
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * | output |
3
+ * | --- |
4
+ * | "Cloud synced" |
5
+ *
6
+ * @param {Desktop_State_CloudInputs} inputs
7
+ * @param {{ locale?: "zh" | "en" }} options
8
+ * @returns {LocalizedString}
9
+ */
10
+ export const desktop_state_cloud: ((inputs?: Desktop_State_CloudInputs, options?: {
11
+ locale?: "zh" | "en";
12
+ }) => LocalizedString) & import("../runtime.js").MessageMetadata<Desktop_State_CloudInputs, {
13
+ locale?: "zh" | "en";
14
+ }, {}>;
15
+ export type LocalizedString = import("../runtime.js").LocalizedString;
16
+ export type Desktop_State_CloudInputs = {};
@@ -0,0 +1,29 @@
1
+ /* eslint-disable */
2
+ import { getLocale, experimentalStaticLocale } from '../runtime.js';
3
+
4
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5
+
6
+ /** @typedef {{}} Desktop_State_CloudInputs */
7
+
8
+ const zh_desktop_state_cloud = /** @type {(inputs: Desktop_State_CloudInputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`云端已绑定`)
10
+ };
11
+
12
+ const en_desktop_state_cloud = /** @type {(inputs: Desktop_State_CloudInputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Cloud synced`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Cloud synced" |
20
+ *
21
+ * @param {Desktop_State_CloudInputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ export const desktop_state_cloud = /** @type {((inputs?: Desktop_State_CloudInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Desktop_State_CloudInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_desktop_state_cloud(inputs)
28
+ return zh_desktop_state_cloud(inputs)
29
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * | output |
3
+ * | --- |
4
+ * | "Local mode" |
5
+ *
6
+ * @param {Desktop_State_GuestInputs} inputs
7
+ * @param {{ locale?: "zh" | "en" }} options
8
+ * @returns {LocalizedString}
9
+ */
10
+ export const desktop_state_guest: ((inputs?: Desktop_State_GuestInputs, options?: {
11
+ locale?: "zh" | "en";
12
+ }) => LocalizedString) & import("../runtime.js").MessageMetadata<Desktop_State_GuestInputs, {
13
+ locale?: "zh" | "en";
14
+ }, {}>;
15
+ export type LocalizedString = import("../runtime.js").LocalizedString;
16
+ export type Desktop_State_GuestInputs = {};
@@ -0,0 +1,29 @@
1
+ /* eslint-disable */
2
+ import { getLocale, experimentalStaticLocale } from '../runtime.js';
3
+
4
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5
+
6
+ /** @typedef {{}} Desktop_State_GuestInputs */
7
+
8
+ const zh_desktop_state_guest = /** @type {(inputs: Desktop_State_GuestInputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`本地模式`)
10
+ };
11
+
12
+ const en_desktop_state_guest = /** @type {(inputs: Desktop_State_GuestInputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Local mode`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Local mode" |
20
+ *
21
+ * @param {Desktop_State_GuestInputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ export const desktop_state_guest = /** @type {((inputs?: Desktop_State_GuestInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Desktop_State_GuestInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_desktop_state_guest(inputs)
28
+ return zh_desktop_state_guest(inputs)
29
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * | output |
3
+ * | --- |
4
+ * | "Transferring data…" |
5
+ *
6
+ * @param {Desktop_State_TransferringInputs} inputs
7
+ * @param {{ locale?: "zh" | "en" }} options
8
+ * @returns {LocalizedString}
9
+ */
10
+ export const desktop_state_transferring: ((inputs?: Desktop_State_TransferringInputs, options?: {
11
+ locale?: "zh" | "en";
12
+ }) => LocalizedString) & import("../runtime.js").MessageMetadata<Desktop_State_TransferringInputs, {
13
+ locale?: "zh" | "en";
14
+ }, {}>;
15
+ export type LocalizedString = import("../runtime.js").LocalizedString;
16
+ export type Desktop_State_TransferringInputs = {};