Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 16 additions & 17 deletions src/main/protocol/documentProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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。 */
Expand Down
33 changes: 16 additions & 17 deletions src/main/protocol/pdfjsAssetProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* `resources/pdfjs/{cmaps,standard_fonts,wasm}` 下的文件。路径来自这里的
* `pdfjsAssetRoot()`,不来自请求。
*
* 和 `documentProtocol` 一样,scheme 必须在 app ready 之前注册为 privileged;
* 和 `documentProtocol` 一样,scheme 必须在 app ready 之前声明为 privileged;
* handler 在 ready 之后安装。
*/

Expand All @@ -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
Expand All @@ -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。 */
Expand Down
30 changes: 30 additions & 0 deletions src/main/protocol/privilegedSchemes.ts
Original file line number Diff line number Diff line change
@@ -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)
}
49 changes: 47 additions & 2 deletions src/main/smokeTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@
* 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=<dir>`.
*
* 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'
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,
Expand Down Expand Up @@ -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,<html><body></body></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
Expand Down Expand Up @@ -261,9 +298,17 @@ async function runChecks(): Promise<string[]> {
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
Expand Down
60 changes: 60 additions & 0 deletions test/privilegedSchemes.test.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
})
Loading