feat: implement open-external IPC handler and update related UI and tests
This commit is contained in:
1 parent
8c0d05a216
commit
c331d67364
9 files changed
+94
-26
No files matched your search
@@ -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 上下文用量提示
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
+40
-3
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user