diff --git a/src/main/index.ts b/src/main/index.ts index 213204e..4b6df15 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,20 +21,23 @@ import { getStore } from './config/store' import { migrateProvidersToConnections } from './config/connectionMigration' import { isSmokeTestRequested, runSmokeTest } from './smokeTest' import { isEvalRequested, runEvalCli } from './eval/run' +import { declareDocumentScheme, registerDocumentProtocolHandler } from './protocol/documentProtocol' import { - registerDocumentProtocolHandler, - registerDocumentScheme -} from './protocol/documentProtocol' -import { - registerPdfjsAssetProtocolHandler, - registerPdfjsAssetScheme + declarePdfjsAssetScheme, + registerPdfjsAssetProtocolHandler } from './protocol/pdfjsAssetProtocol' +import { applyPrivilegedSchemes } from './protocol/privilegedSchemes' import Logger from '../shared/utils/logger' -// Scheme privileges must be registered before the app is ready, so this is a -// module-scope call rather than part of the whenReady sequence. -registerDocumentScheme() -registerPdfjsAssetScheme() +// Scheme privileges must be declared before the app is ready, so this is a +// module-scope sequence rather than part of the whenReady flow. They are +// submitted in one call on purpose: Electron *replaces* the privilege table +// instead of appending to it, so a second `registerSchemesAsPrivileged()` drops +// every scheme registered by the first - which is how `knownote-asset` silently +// broke `knownote-doc` and the PDF reader (#135). +declareDocumentScheme() +declarePdfjsAssetScheme() +applyPrivilegedSchemes() let connectionManager: ConnectionManager | null = null let embeddingService: EmbeddingService | null = null diff --git a/src/main/protocol/documentProtocol.ts b/src/main/protocol/documentProtocol.ts index 667779f..3a42337 100644 --- a/src/main/protocol/documentProtocol.ts +++ b/src/main/protocol/documentProtocol.ts @@ -4,13 +4,14 @@ * 渲染进程不应拿到任意文件路径。它只知道 documentId,主进程按 id 查库、只服务 * 这份来源自己的文件。路径永远来自数据库,不来自请求,因此不存在路径穿越。 * - * scheme 必须在 app ready 之前注册为 privileged;handler 在 ready 之后安装。 + * scheme 必须在 app ready 之前声明为 privileged;handler 在 ready 之后安装。 */ import { net, protocol } from 'electron' import { pathToFileURL } from 'url' import { DOCUMENT_SCHEME, parseDocumentUrl } from '../../shared/utils/documentUrl' import Logger from '../../shared/utils/logger' +import { declarePrivilegedScheme } from './privilegedSchemes' /** * Origins allowed to read a document. The renderer is `file://` in production @@ -22,23 +23,21 @@ const ALLOWED_ORIGINS = new Set(['null', 'http://localhost:5173', 'http://127.0. export { DOCUMENT_SCHEME, documentUrl, parseDocumentUrl } from '../../shared/utils/documentUrl' -/** 必须在 app ready 之前调用一次。 */ -export function registerDocumentScheme(): void { - protocol.registerSchemesAsPrivileged([ - { - scheme: DOCUMENT_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - stream: true, - // The renderer fetches this scheme from a different origin (`file://` in - // production, the dev server in development), so it is a cross-origin - // request and must be CORS-enabled to succeed. - corsEnabled: true - } +/** 声明特权。只入队,提交由 `applyPrivilegedSchemes()` 在 ready 前统一完成(#135)。 */ +export function declareDocumentScheme(): void { + declarePrivilegedScheme({ + scheme: DOCUMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + // The renderer fetches this scheme from a different origin (`file://` in + // production, the dev server in development), so it is a cross-origin + // request and must be CORS-enabled to succeed. + corsEnabled: true } - ]) + }) } /** 在 app ready 之后调用,安装只读的字节服务 handler。 */ diff --git a/src/main/protocol/pdfjsAssetProtocol.ts b/src/main/protocol/pdfjsAssetProtocol.ts index 60a26b9..0febd21 100644 --- a/src/main/protocol/pdfjsAssetProtocol.ts +++ b/src/main/protocol/pdfjsAssetProtocol.ts @@ -5,7 +5,7 @@ * `resources/pdfjs/{cmaps,standard_fonts,wasm}` 下的文件。路径来自这里的 * `pdfjsAssetRoot()`,不来自请求。 * - * 和 `documentProtocol` 一样,scheme 必须在 app ready 之前注册为 privileged; + * 和 `documentProtocol` 一样,scheme 必须在 app ready 之前声明为 privileged; * handler 在 ready 之后安装。 */ @@ -15,6 +15,7 @@ import { pathToFileURL } from 'url' import { PDFJS_ASSET_SCHEME, parsePdfjsAssetUrl } from '../../shared/utils/pdfjsAssets' import { pdfjsAssetRoot } from '../pdfjsAssetPaths' import Logger from '../../shared/utils/logger' +import { declarePrivilegedScheme } from './privilegedSchemes' /** * Origins allowed to read an asset. Same set as `documentProtocol`: the renderer @@ -23,23 +24,21 @@ import Logger from '../../shared/utils/logger' */ const ALLOWED_ORIGINS = new Set(['null', 'http://localhost:5173', 'http://127.0.0.1:5173']) -/** 必须在 app ready 之前调用一次。 */ -export function registerPdfjsAssetScheme(): void { - protocol.registerSchemesAsPrivileged([ - { - scheme: PDFJS_ASSET_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - stream: true, - // The renderer fetches this scheme from a different origin (`file://` in - // production, the dev server in development), so it is a cross-origin - // request and must be CORS-enabled to succeed. - corsEnabled: true - } +/** 声明特权。只入队,提交由 `applyPrivilegedSchemes()` 在 ready 前统一完成(#135)。 */ +export function declarePdfjsAssetScheme(): void { + declarePrivilegedScheme({ + scheme: PDFJS_ASSET_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + // The renderer fetches this scheme from a different origin (`file://` in + // production, the dev server in development), so it is a cross-origin + // request and must be CORS-enabled to succeed. + corsEnabled: true } - ]) + }) } /** 在 app ready 之后调用,安装只读的资源 handler。 */ diff --git a/src/main/protocol/privilegedSchemes.ts b/src/main/protocol/privilegedSchemes.ts new file mode 100644 index 0000000..da527a6 --- /dev/null +++ b/src/main/protocol/privilegedSchemes.ts @@ -0,0 +1,30 @@ +/** + * 自定义 scheme 特权的**单一提交点**。 + * + * `protocol.registerSchemesAsPrivileged()` 是替换而不是追加:第二次调用会整张表覆盖 + * 上一次。之前 `documentProtocol` 和 `pdfjsAssetProtocol` 各调了一次,后注册的 + * `knownote-asset` 顶掉了 `knownote-doc` 的 `supportFetchAPI`,渲染进程的 + * `fetch('knownote-doc://...')` 立刻报 + * `URL scheme "knownote-doc" is not supported` —— PDF Reader 整个打不开(#135)。 + * + * 打包 smoke 当时没发现,是因为它用主进程 `net.fetch` 检查同一个协议:`protocol.handle` + * 在 scheme 没有特权时照样在 main 里工作,只有 renderer 的 `fetch` 会失败。所以这里 + * 也补了 renderer 侧检查。 + * + * 协议模块只声明自己的 scheme;提交集中在 `applyPrivilegedSchemes()`,在 app ready + * 之前调用一次。 + */ + +import { protocol, type CustomScheme } from 'electron' + +const declaredSchemes: CustomScheme[] = [] + +/** 声明一个 scheme 的特权。只入队,不调用 Electron API。 */ +export function declarePrivilegedScheme(scheme: CustomScheme): void { + declaredSchemes.push(scheme) +} + +/** 在 app ready 之前调用一次,把全部声明一次提交。 */ +export function applyPrivilegedSchemes(): void { + protocol.registerSchemesAsPrivileged(declaredSchemes) +} diff --git a/src/main/smokeTest.ts b/src/main/smokeTest.ts index 085f20b..40f0b3a 100644 --- a/src/main/smokeTest.ts +++ b/src/main/smokeTest.ts @@ -18,8 +18,12 @@ * actually broke - loading every external package the main process needs, the * native addons, the app's own vector-store code path and the document importers * (PDF, DOCX, HTML) against the fixtures in test/fixtures - and then exits - * without creating a window, so CI can gate on the exit code. It is driven by + * without showing a window, so CI can gate on the exit code. It is driven by * `scripts/smoke-packaged.mjs`, which passes `--smoke-fixtures=`. + * + * One check does create a hidden window: a custom scheme's `supportFetchAPI` + * privilege is only observable from a renderer, and checking it from the main + * process is what let #135 ship. */ import Logger from '../shared/utils/logger' @@ -27,7 +31,7 @@ import { readFile, readdir } from 'fs/promises' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { net } from 'electron' +import { net, BrowserWindow } from 'electron' import { eq } from 'drizzle-orm' import { closeDatabase, @@ -105,6 +109,39 @@ function assertThrows(run: () => unknown, message: string): void { throw new Error(message) } +/** + * Hidden windows created for renderer-side checks. They are deliberately **not** + * destroyed: destroying the last window fires `window-all-closed`, and this app + * closes the database (and quits) on that event, which would abort every check + * after this one. The process exits through `app.exit()` anyway. + */ +const smokeWindows: BrowserWindow[] = [] + +/** + * Fetch a custom-scheme URL from a real renderer. + * + * `protocol.handle` keeps working in the main process even when its scheme was + * never registered as privileged, so a `net.fetch` check cannot see a missing + * privilege. Only the renderer's `fetch` can - and that is exactly what broke + * when two `registerSchemesAsPrivileged()` calls replaced each other and dropped + * `knownote-doc`, leaving every PDF unopenable (#135). + */ +async function fetchFromRenderer( + url: string +): Promise<{ ok: boolean; status: number; body: string }> { + const window = new BrowserWindow({ show: false }) + smokeWindows.push(window) + await window.loadURL('data:text/html,') + return (await window.webContents.executeJavaScript(`(async () => { + try { + const response = await fetch(${JSON.stringify(url)}); + return { ok: response.ok, status: response.status, body: await response.text() }; + } catch (error) { + return { ok: false, status: 0, body: String((error && error.message) || error) }; + } + })()`)) as { ok: boolean; status: number; body: string } +} + /** * A deterministic stand-in for `EmbeddingService` so the smoke test can drive * the whole index lifecycle (blocks → chunks → embeddings → vectors) without @@ -261,9 +298,17 @@ async function runChecks(): Promise { const unknown = await net.fetch(documentUrl('smoke-missing-doc')) assert(unknown.status === 404, `an unknown document id returned ${unknown.status}, not 404`) + // And the same URL from a renderer: this is the check whose absence let #135 ship. + const rendererFetch = await fetchFromRenderer(documentUrl(protocolDocumentId)) + assert( + rendererFetch.ok && rendererFetch.body === protocolPayload, + `the renderer could not fetch knownote-doc:// (status ${rendererFetch.status}: ${rendererFetch.body})` + ) + database.prepare('DELETE FROM documents WHERE id = ?').run(protocolDocumentId) rmSync(protocolDir, { recursive: true, force: true }) pass('knownote-doc:// serves an owned document and 404s an unknown id') + pass('knownote-doc:// is fetchable from a renderer (scheme privileges registered)') const { version } = database.prepare('SELECT vec_version() AS version').get() as { version: string diff --git a/test/privilegedSchemes.test.ts b/test/privilegedSchemes.test.ts new file mode 100644 index 0000000..afa00f3 --- /dev/null +++ b/test/privilegedSchemes.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +/** + * 自定义 scheme 特权只能提交一次。 + * + * `protocol.registerSchemesAsPrivileged()` 是**替换**而不是追加:第二个调用会把上一张 + * 表整个覆盖。`knownote-asset` 单独注册时顶掉了 `knownote-doc` 的 `supportFetchAPI`, + * 渲染进程再 `fetch('knownote-doc://...')` 就报 `URL scheme ... is not supported`, + * PDF Reader 全挂(#135)。 + * + * 这条护栏把「只能有一个提交点」钉住;真正的行为检查在 `smokeTest.ts` 里,用真实 + * 渲染进程 fetch 两个 scheme。 + */ + +const SCAN_ROOT = 'src' + +const walk = (dir: string, files: string[] = []): string[] => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) walk(path, files) + else if (/\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(path)) files.push(path) + } + return files +} + +test('exactly one call site submits the custom-scheme privileges', () => { + const callSites: string[] = [] + + for (const file of walk(SCAN_ROOT)) { + const matches = readFileSync(file, 'utf8').match(/protocol\.registerSchemesAsPrivileged\s*\(/g) + if (matches) callSites.push(`${file} (${matches.length})`) + } + + assert.equal( + callSites.length, + 1, + 'Electron replaces the whole privilege table on every call, so a second call drops the ' + + 'schemes the first registered (#135). Declare schemes with `declarePrivilegedScheme()` ' + + `and submit them once. Found: ${callSites.join(', ') || 'none'}` + ) + assert.ok( + callSites[0].startsWith(join(SCAN_ROOT, 'main', 'protocol', 'privilegedSchemes.ts')), + `the single call must live in privilegedSchemes.ts, found ${callSites[0]}` + ) +}) + +test('every scheme is declared and submitted before the app is ready', () => { + const index = readFileSync(join(SCAN_ROOT, 'main', 'index.ts'), 'utf8') + + for (const call of [ + 'declareDocumentScheme()', + 'declarePdfjsAssetScheme()', + 'applyPrivilegedSchemes()' + ]) { + assert.ok(index.includes(call), `index.ts stopped calling ${call}`) + } +})