From 5a3c316401cd461f0234f64be39023702ccb4ea0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:21:12 +0800 Subject: [PATCH] fix(desktop): deliver welcome expiry notice once across windows --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/client/WelcomePage.tsx | 12 ++++-- apps/desktop/src/main.ts | 17 ++++++-- apps/desktop/src/preload-welcome.ts | 3 +- apps/desktop/src/welcome-api.ts | 6 +++ apps/desktop/src/welcome-window.ts | 5 ++- apps/desktop/tests/preload.e2e.ts | 3 ++ .../tests/welcome-renderer.client.spec.tsx | 39 +++++++++++++++++-- apps/desktop/tests/welcome-startup.spec.ts | 14 ++++++- apps/desktop/tests/welcome-window.spec.ts | 9 ++++- 12 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7dbf8777b4..a9b5df858a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: cb10794ae6e9c5cbdc9e0f58acdefe8a252e35e1 -README.zh.md: 778fe9c6bc8df8a727d2a03a818a44f6fbb2e0f9 +README.md: 4ad377430d679fc505c480f2766b474b060a9f60 +README.zh.md: a36ebf77394e016336018d02f38b6688b2936f7f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cb10794ae6..4ad377430d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -409,6 +409,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. +When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. + An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 778fe9c6bc..a36ebf7739 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -409,6 +409,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 +账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 + 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 054b9b11bf..b24618a0da 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -15,9 +15,7 @@ type Page = 'entry' | 'key' | 'account' */ export function Welcome({ api }: { api: WelcomeApi }) { const { messages: m } = api - const [expired, setExpired] = useState(false) const [expiryNotice, setExpiryNotice] = useState(false) - useEffect(() => { setExpiryNotice(expired) }, [expired]) const [page, setPage] = useState('entry') const pageRef = useRef('entry') const [attempt, setAttempt] = useState(null) @@ -53,9 +51,17 @@ export function Welcome({ api }: { api: WelcomeApi }) { mounted.current = true document.documentElement.lang = api.id document.title = m.welcomeTitle + const takeNotice = (): void => { + void api.takeNotice().then((notice) => { + if (mounted.current && notice === 'session-expired') setExpiryNotice(true) + }).catch((_closedChannel: unknown) => { + // A closed Welcome IPC channel must not interrupt the sign-in page. + }) + } + takeNotice() const stop = api.onAccountState((state) => { revision.current++ - setExpired(state.status === 'signed-out' && state.signOutReason === 'expired') + takeNotice() showAccount(state) }) return () => { mounted.current = false; stop() } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d9c55f44ae..45a3a4a8c4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -34,7 +34,7 @@ import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { openWelcomeWindow } from './welcome-window.ts' -import { WELCOME_IPC, needsWelcome } from './welcome-api.ts' +import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' import { DesktopUpdateJournal } from './update-journal.ts' import { DesktopUpdatePreparationError } from './update-error.ts' @@ -307,6 +307,7 @@ async function main(): Promise { let stopAccount: (() => void) | undefined let openedAttempt: string | undefined let returnedAttempt: string | undefined + let pendingWelcomeNotice: WelcomeNotice | undefined let previousAccountStatus: string | undefined const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) @@ -362,8 +363,13 @@ async function main(): Promise { } if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined) if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { - void readWelcomeState().then((value) => { - if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() } + void readWelcomeState().then(async (value) => { + if (needsWelcome(value) && !quitting) { + if (state.signOutReason === 'expired') pendingWelcomeNotice = 'session-expired' + enteredWorkspace = false + await showWelcome() + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) + } return undefined }).catch(() => undefined) } @@ -905,6 +911,11 @@ async function main(): Promise { } openingWelcome ??= (async () => { welcomeWindow = await openWelcomeWindow(locale, { + takeNotice: () => { + const notice = pendingWelcomeNotice + pendingWelcomeNotice = undefined + return Promise.resolve(notice) + }, startSignIn: async () => { if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') return welcomeBackend.account.start(locale.id) diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts index 490a894394..e9ec1aa910 100644 --- a/apps/desktop/src/preload-welcome.ts +++ b/apps/desktop/src/preload-welcome.ts @@ -3,13 +3,14 @@ import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-acc import { contextBridge, ipcRenderer } from 'electron' import { resolveDesktopLocale } from './locale.ts' -import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts' +import { WELCOME_IPC, type WelcomeApi, type WelcomeNotice, type WelcomeSaveResult } from './welcome-api.ts' const prefix = '--dsh-welcome-locale=' const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length) if (locale === undefined) throw new Error('desktop welcome: missing window locale') const api: WelcomeApi = { ...resolveDesktopLocale(locale), + takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise, startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise, cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise, copySignInLink: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.copyLink, id) as Promise, diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts index 36dd85f17a..015296776a 100644 --- a/apps/desktop/src/welcome-api.ts +++ b/apps/desktop/src/welcome-api.ts @@ -11,13 +11,19 @@ export const WELCOME_IPC = { cancel: 'dsh-welcome:cancel', copyLink: 'dsh-welcome:copy-link', state: 'dsh-welcome:state', + takeNotice: 'dsh-welcome:take-notice', } as const /** Credential writes return a safe outcome without exposing Host diagnostics. */ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false } +/** One-time notification retained by the main process until Welcome receives it. */ +export type WelcomeNotice = 'session-expired' + /** Host-owned operations used by the welcome window. */ export interface WelcomeOperations { + /** @returns the pending notification, clearing it before another renderer can receive it. */ + takeNotice(): Promise /** @returns account state after starting a login attempt. */ startSignIn(): Promise /** @param id - attempt to cancel. @returns the settled state. */ diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts index 0edda21454..a32e1f1c56 100644 --- a/apps/desktop/src/welcome-window.ts +++ b/apps/desktop/src/welcome-window.ts @@ -63,7 +63,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco const disposeHandlers = (): void => { if (!active) return active = false - for (const channel of [WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink]) { + for (const channel of [ + WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink, + ]) { ipcMain.removeHandler(channel) } disposeActiveHandlers = undefined @@ -74,6 +76,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco throw new Error('desktop welcome: rejected action from an unowned frame') } } + ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() }) ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { assertSender(event) if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false } diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts index 1022b3a8b8..d18f475bc1 100644 --- a/apps/desktop/tests/preload.e2e.ts +++ b/apps/desktop/tests/preload.e2e.ts @@ -80,6 +80,9 @@ describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop pr expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh') } else { expect(exposed.has('dshWelcome')).toBe(true) + const bridge = exposed.get('dshWelcome') as { takeNotice(): Promise } + void bridge.takeNotice() + expect(invoke).toHaveBeenCalledWith('dsh-welcome:take-notice') } }) }) diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx index 0778b59805..0d3586ecfe 100644 --- a/apps/desktop/tests/welcome-renderer.client.spec.tsx +++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx @@ -6,15 +6,16 @@ import { Welcome } from '../src/client/WelcomePage.tsx' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDesktopLocale } from '../src/locale.ts' import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' -import type { WelcomeSaveResult } from '../src/welcome-api.ts' +import type { WelcomeSaveResult, WelcomeNotice } from '../src/welcome-api.ts' const html = readFileSync(join(import.meta.dirname, '../renderer/welcome.html'), 'utf8') afterEach(cleanup) -function mount(language = 'zh-CN') { +function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined)) { cleanup() const stopAccount = vi.fn() const api = { + takeNotice, onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount), startSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), cancelSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), @@ -264,7 +265,9 @@ it.each(['copied', 'failed'] as const)('restores the copy action after %s feedba it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Welcome: %s', async (language) => { vi.useFakeTimers() try { - const view = mount(language) + const takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined).mockResolvedValueOnce('session-expired') + const view = mount(language, takeNotice) + await act(async () => {}) const publish = view.api.onAccountState.mock.calls[0]![0] const expired: AccountView = { status: 'signed-out', signOutReason: 'expired', attempt: null, links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } } @@ -277,5 +280,35 @@ it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Wel expect(screen.queryByRole('alert')).toBeNull() await act(async () => { publish(expired) }) expect(screen.queryByRole('alert')).toBeNull() + view.unmount() + mount(language, takeNotice) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() } finally { cleanup(); vi.useRealTimers() } }) + +it('does not infer a notification from a retained expired account snapshot', async () => { + const view = mount() + await act(async () => { + view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', signOutReason: 'expired', attempt: null, + links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } }) + }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it('keeps the entry usable when notification IPC fails', async () => { + const view = mount('en', vi.fn<() => Promise>().mockRejectedValue(new Error('closed'))) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(view.button('#api-key')) + expect(view.input.closest('[hidden]')).toBeNull() +}) + +it('ignores a notification received after its renderer unmounts', async () => { + const pending = Promise.withResolvers() + const view = mount('en', vi.fn<() => Promise>().mockReturnValue(pending.promise)) + view.unmount() + mount('en') + await act(async () => { pending.resolve('session-expired') }) + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 20a5a441f4..737a3167be 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -131,7 +131,8 @@ vi.mock('../src/welcome-window.ts', () => ({ state.welcomeLocale = locale state.operations = operations await state.beforeWelcome() - return { once: vi.fn(), close: state.closeWelcome } + return { once: vi.fn(), close: state.closeWelcome, isDestroyed: () => false, + show: vi.fn(), focus: vi.fn(), webContents: { send: vi.fn() } } }, })) @@ -179,6 +180,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho expect(state.showWorkspace).not.toHaveBeenCalled() state.loadWorkspace.mockClear() expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' }) + expect(await state.operations!.takeNotice()).toBeUndefined() expect(state.dialogLocale!().id).toBe('zh-CN') const attemptId = 'login' as NonNullable['id'] const account: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, @@ -228,9 +230,19 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.advanceTimersByTimeAsync(0) expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + expect(await state.operations!.takeNotice()).toBeUndefined() state.hasApiKey = false state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + expect(await state.operations!.takeNotice()).toBe('session-expired') + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() }) diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts index b357f8dcb5..5ab4a1c031 100644 --- a/apps/desktop/tests/welcome-window.spec.ts +++ b/apps/desktop/tests/welcome-window.spec.ts @@ -40,6 +40,7 @@ function createWindow() { beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() }) const operations = { + takeNotice: async () => undefined, startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), copySignInLink: async () => undefined, @@ -115,8 +116,14 @@ describe('desktop welcome window', () => { const saveApiKey = vi.fn(operations.saveApiKey) const skip = vi.fn(operations.skip) const copySignInLink = vi.fn(operations.copySignInLink) - await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink }) + const takeNotice = vi.fn(async () => 'session-expired' as const) + await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink, takeNotice }) const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const take = electron.handlers.get(WELCOME_IPC.takeNotice)! + await expect(take({ ...own, senderFrame: {} })).rejects.toThrow('unowned frame') + expect(takeNotice).not.toHaveBeenCalled() + expect(await take(own)).toBe('session-expired') + expect(takeNotice).toHaveBeenCalledOnce() const save = electron.handlers.get(WELCOME_IPC.saveApiKey)! await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') await expect(save({ ...own, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame')