feat: implement open-external IPC handler and update related UI and tests

This commit is contained in:
hy committed 2026-09-03 15:42:22 +08:00
1 parent 8c0d05a216
commit c331d67364
9 files changed
+94 -26

No files matched your search

+1 -1
View File
@@ -322,7 +322,7 @@
- `WB-COMPOSER-012-AC2` 用户主动点击后才开始下载,下载中显示进度且失败后可重试。
- `WB-COMPOSER-012-AC3` 下载完成后可立即重启或稍后处理,稍后处理时入口保留为“重启更新”。
- `WB-COMPOSER-012-AC4` 当前应用检测到本机 SQLite schema version 更高时,不初始化 Workbench 和其他业务服务,只显示独立升级恢复页,并明确展示检测版本与当前支持版本。
- `WB-COMPOSER-012-AC5` 升级恢复页自动检查更新,按状态提供检查、下载、进度、失败重试和重启安装;始终保留官网中文下载页入口与退出入口,更新失败或不可用时不能绕过进入业务界面。页面不展示底层更新错误,统一使用可理解的中文失败提示。
- `WB-COMPOSER-012-AC5` 升级恢复页自动检查更新,按状态提供检查、下载、进度、失败重试和重启安装;始终保留官网中文下载页入口与退出入口。点击官网入口必须通过系统默认浏览器打开(恢复模式也要注册 `openExternal`,不能只渲染链接)。更新失败或不可用时不能绕过进入业务界面。页面不展示底层更新错误,统一使用可理解的中文失败提示。恢复页顶部保留 44px 窗口拖动条(Windows 另有独立 36px 原生标题栏,业务内容从其下方开始),按钮和链接使用 `no-drag`。
#### WB-COMPOSER-008 上下文用量提示
+2 -2
View File
@@ -140,8 +140,8 @@ tests/case/workbench/
- 测试类型:`e2e`
- What:数据库版本高于当前应用支持版本时的仅更新恢复模式。
- Why:旧应用误开新 schema 数据库时,必须先升级应用,不能初始化业务层继续读写。
- How:先启动应用创建正式 schema,再关闭应用,在同一隔离 `userData/app.sqlite` 的合法迁移前缀后写入更高版本记录并重启,通过真实主进程版本检查进入恢复页;继续用更新 fixture 验证自动检查、下载、失败和重启安装。
- Oracle:只显示恢复页及本机数据库版本和当前应用支持版本,Workbench 不存在;恢复页提供下载、重启安装、中文失败提示、官网下载页和退出入口,底层英文错误不直接展示,失败后仍不能进入业务界面。对齐 `WB-COMPOSER-012-AC4`、`WB-COMPOSER-012-AC5`。
- How:先启动应用创建正式 schema,再关闭应用,在同一隔离 `userData/app.sqlite` 的合法迁移前缀后写入更高版本记录并重启,通过真实主进程版本检查进入恢复页;继续用更新 fixture 验证自动检查、下载、失败和重启安装。点击「前往官网下载」后除记录 renderer 调用外,还等待 `openExternalResult` 并断言 IPC 返回成功。
- Oracle:只显示恢复页及本机数据库版本和当前应用支持版本,Workbench 不存在;恢复页提供下载、重启安装、中文失败提示、官网下载页和退出入口,点击官网入口时 `open-external` 必须真正成功;底层英文错误不直接展示,失败后仍不能进入业务界面。对齐 `WB-COMPOSER-012-AC4`、`WB-COMPOSER-012-AC5`。
- Not:启动前数据库 fixture 只制造“未来 schema version”,不绕过或替换生产版本检查;更新 fixture 不证明真实安装包下载、签名、安装成功或安装后数据库兼容。
#### `context-ring-usage`
+2 -1
View File
@@ -3,7 +3,7 @@ import { join } from 'path'
import { fileURLToPath } from 'node:url'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerHandlers } from './ipc/handlers'
import { registerHandlers, registerOpenExternalHandler } from './ipc/handlers'
import { IngestionService } from './services/ingestion/loader'
import { DEFAULT_EMBEDDING_MODEL_NAME, EmbeddingService } from './services/vector/EmbeddingService'
import { EmbeddingRuntimeClient } from './services/vector/runtime/EmbeddingRuntimeClient'
@@ -288,6 +288,7 @@ if (!singleInstanceLock) {
error,
context: { event: 'database-version-too-new' }
})
registerOpenExternalHandler()
createWindow()
appUpdateService.checkForUpdatesOnLaunch()
app.on('activate', () => {
+16 -12
View File
@@ -195,6 +195,21 @@ const normalizeReferenceKindFilters = (
return normalized.length > 0 ? new Set(normalized) : null
}
export function registerOpenExternalHandler(): void {
ipcMain.handle('open-external', async (_event, url: string) => {
try {
const parsed = new URL(String(url))
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { success: false, error: '仅支持 http/https URL' }
}
await shell.openExternal(parsed.toString())
return { success: true }
} catch (error) {
return { success: false, error: getErrorMessage(error) }
}
})
}
export function registerHandlers(services: {
ingestionService: IngestionService
embeddingService: EmbeddingService
@@ -1495,18 +1510,7 @@ export function registerHandlers(services: {
}
)
ipcMain.handle('open-external', async (_event, url: string) => {
try {
const parsed = new URL(String(url))
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { success: false, error: '仅支持 http/https URL' }
}
await shell.openExternal(parsed.toString())
return { success: true }
} catch (error) {
return { success: false, error: getErrorMessage(error) }
}
})
registerOpenExternalHandler()
ipcMain.handle(
'file-watch-start',
+16 -2
View File
@@ -109,9 +109,23 @@ const api: DesktopApi = {
ipcRenderer.invoke('ingest-web', payload),
ingestWebs: (payload): Promise<{ success: boolean; created?: number; error?: string }> =>
ipcRenderer.invoke('ingest-webs', payload),
openExternal: (url: string): Promise<{ success: boolean; error?: string }> => {
openExternal: async (url: string): Promise<{ success: boolean; error?: string }> => {
emitE2EIpcCall('openExternal', String(url || '').trim())
return ipcRenderer.invoke('open-external', url)
try {
const response = (await ipcRenderer.invoke('open-external', url)) as {
success: boolean
error?: string
}
emitE2EIpcCall('openExternalResult', response)
return response
} catch (error) {
const response = {
success: false,
error: error instanceof Error ? error.message : String(error)
}
emitE2EIpcCall('openExternalResult', response)
return response
}
},
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> => {
const safePath = String(filePath || '').trim()
+7 -1
View File
@@ -911,7 +911,8 @@ textarea,
position: relative;
}
.nc-settings-main-titlebar-drag {
.nc-settings-main-titlebar-drag,
.nc-update-required-titlebar-drag {
position: absolute;
top: 0;
right: 0;
@@ -922,6 +923,11 @@ textarea,
-webkit-app-region: drag;
}
.nc-update-required-page button,
.nc-update-required-page a {
-webkit-app-region: no-drag;
}
.nc-settings-header {
position: relative;
z-index: 4;
@@ -43,9 +43,14 @@ const UpdateRequiredPage: React.FC<{ startupStatus: UpdateRequiredStatus }> = ({
return (
<main
className="relative flex h-full min-h-0 items-center justify-center overflow-hidden bg-white px-8 py-16 text-[#37352F]"
className="nc-update-required-page relative flex h-full min-h-0 items-center justify-center overflow-hidden bg-white px-8 py-16 text-[#37352F]"
data-testid={workbenchSelectors.updateRequiredPage}
>
<div
className="nc-update-required-titlebar-drag"
data-testid={workbenchSelectors.updateRequiredDragRegion}
aria-hidden="true"
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[#EEEEEC]" />
<section className="w-full max-w-[560px]" aria-labelledby="update-required-title">
<h1 id="update-required-title" className="m-0 text-[30px] font-semibold tracking-normal">
@@ -90,7 +95,7 @@ const UpdateRequiredPage: React.FC<{ startupStatus: UpdateRequiredStatus }> = ({
<button
type="button"
className="flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-md bg-[#2F3437] px-4 text-[14px] font-medium text-white transition-colors hover:bg-[#1F2325] disabled:cursor-not-allowed disabled:bg-[#D8D8D5]"
className="workbench-no-drag flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-md bg-[#2F3437] px-4 text-[14px] font-medium text-white transition-colors hover:bg-[#1F2325] disabled:cursor-not-allowed disabled:bg-[#D8D8D5]"
disabled={primaryDisabled}
onClick={handlePrimaryAction}
data-testid={workbenchSelectors.updateRequiredPrimaryButton}
@@ -105,7 +110,7 @@ const UpdateRequiredPage: React.FC<{ startupStatus: UpdateRequiredStatus }> = ({
<div className="mt-5 flex items-center justify-between text-[13px]">
<a
className="inline-flex cursor-pointer items-center gap-1 text-[#0B6BCB] hover:text-[#0958A5]"
className="workbench-no-drag inline-flex cursor-pointer items-center gap-1 text-[#0B6BCB] hover:text-[#0958A5]"
href={startupStatus.downloadPageUrl}
target="_blank"
rel="noreferrer"
@@ -120,7 +125,7 @@ const UpdateRequiredPage: React.FC<{ startupStatus: UpdateRequiredStatus }> = ({
</a>
<button
type="button"
className="cursor-pointer border-0 bg-transparent p-0 text-[#787774] hover:text-[#37352F]"
className="workbench-no-drag cursor-pointer border-0 bg-transparent p-0 text-[#787774] hover:text-[#37352F]"
onClick={() => void window.api.appQuit()}
data-testid={workbenchSelectors.updateRequiredQuitButton}
>
+1
View File
@@ -154,6 +154,7 @@ export const workbenchSelectors = {
updateRequiredPrimaryButton: 'workbench-update-required-primary-button',
updateRequiredDownloadPage: 'workbench-update-required-download-page',
updateRequiredQuitButton: 'workbench-update-required-quit-button',
updateRequiredDragRegion: 'workbench-update-required-drag-region',
topbar: 'workbench-topbar',
emptyDragRegion: 'workbench-empty-drag-region',
topbarDragRegion: 'workbench-topbar-drag-region',
@@ -63,6 +63,34 @@ describe('database version update required e2e', () => {
.getByTestId(rendererSelectors.updateRequiredDownloadPage)
.getAttribute('href')) || ''
await page.getByTestId(rendererSelectors.updateRequiredDownloadPage).click()
await page.waitForFunction(() => {
const observer = (
window as Window & {
__NCURATOR_E2E_OBSERVER__?: {
ipcInvokeEntries: Array<{ name: string }>
}
}
).__NCURATOR_E2E_OBSERVER__
return Boolean(observer?.ipcInvokeEntries.some((entry) => entry.name === 'openExternalResult'))
})
const officialDownloadIpc = await page.evaluate(() => {
const observer = (
window as Window & {
__NCURATOR_E2E_OBSERVER__?: {
ipcInvokeEntries: Array<{ name: string; payload?: unknown }>
}
}
).__NCURATOR_E2E_OBSERVER__
const entries = observer?.ipcInvokeEntries || []
const lastCall = [...entries].reverse().find((entry) => entry.name === 'openExternal')
const lastResult = [...entries]
.reverse()
.find((entry) => entry.name === 'openExternalResult')
return {
url: lastCall?.payload,
result: lastResult?.payload
}
})
await primaryButton.click()
const restartModal = page.getByRole('dialog', { name: '更新已准备好' })
@@ -113,6 +141,7 @@ describe('database version update required e2e', () => {
.getByTestId(rendererSelectors.updateRequiredQuitButton)
.isVisible(),
downloadPageUrl,
officialDownloadIpc,
autoCheckReachedAvailable: recoverySnapshot.status === 'available',
userFacingError,
unavailableMessage,
@@ -146,11 +175,19 @@ describe('database version update required e2e', () => {
await runtime.assertThat(
result.downloadPageUrl === OFFICIAL_DOWNLOAD_PAGE_URL &&
result.bodyText.includes('前往官网下载') &&
result.openedOfficialDownloadPage,
result.openedOfficialDownloadPage &&
result.officialDownloadIpc.url === OFFICIAL_DOWNLOAD_PAGE_URL &&
result.officialDownloadIpc.result !== null &&
typeof result.officialDownloadIpc.result === 'object' &&
(result.officialDownloadIpc.result as { success?: boolean }).success === true,
{
name: 'recovery-page-links-to-official-download-page',
message: '备用下载入口应打开包含平台选择的官网中文下载页。',
data: { downloadPageUrl: result.downloadPageUrl }
message:
'备用下载入口应通过 open-external IPC 打开官网中文下载页,恢复模式也必须真正成功。',
data: {
downloadPageUrl: result.downloadPageUrl,
officialDownloadIpc: result.officialDownloadIpc
}
}
)
await runtime.assertThat(