fix(desktop): deliver welcome expiry notice once across windows

This commit is contained in:
lsdsjy committed 2026-09-22 19:21:12 +08:00
1 parent f0f4bb73fc
commit 5a3c316401
12 files changed
+101 -15

No files matched your search

+2 -2
View File
@@ -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
+2
View File
@@ -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.
+2
View File
@@ -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 清理。
+9 -3
View File
@@ -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<Page>('entry')
const pageRef = useRef<Page>('entry')
const [attempt, setAttempt] = useState<AccountView['attempt']>(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() }
+14 -3
View File
@@ -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<void> {
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<void> {
}
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<void> {
}
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)
+2 -1
View File
@@ -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<WelcomeNotice | undefined>,
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
copySignInLink: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.copyLink, id) as Promise<void>,
+6
View File
@@ -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<WelcomeNotice | undefined>
/** @returns account state after starting a login attempt. */
startSignIn(): Promise<AccountView>
/** @param id - attempt to cancel. @returns the settled state. */
+4 -1
View File
@@ -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 }
+3
View File
@@ -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<unknown> }
void bridge.takeNotice()
expect(invoke).toHaveBeenCalledWith('dsh-welcome:take-notice')
}
})
})
@@ -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<WelcomeNotice | undefined>>().mockResolvedValue(undefined)) {
cleanup()
const stopAccount = vi.fn()
const api = {
takeNotice,
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount),
startSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
cancelSignIn: vi.fn(async (): Promise<AccountView> => ({ 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<WelcomeNotice | undefined>>().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<WelcomeNotice | undefined>>().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<WelcomeNotice | undefined>()
const view = mount('en', vi.fn<() => Promise<WelcomeNotice | undefined>>().mockReturnValue(pending.promise))
view.unmount()
mount('en')
await act(async () => { pending.resolve('session-expired') })
expect(screen.queryByRole('alert')).toBeNull()
})
+13 -1
View File
@@ -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<AccountView['attempt']>['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()
})
+8 -1
View File
@@ -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')