fix(desktop): deliver welcome expiry notice once across windows
This commit is contained in:
1 parent
f0f4bb73fc
commit
5a3c316401
12 files changed
+101
-15
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 清理。
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
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()
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user