From 82b4303c621c06289eab571cbfac4312ecd8088e Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Thu, 5 Nov 2026 04:08:54 +0800 Subject: [PATCH 1/5] fix(db): apply migrations atomically and report init failures in the UI A failed database initialization was swallowed: the app kept running with every database feature broken and nothing telling the user why (#86). - initDatabase returns its outcome and runs once, and the renderer shows a persistent alert with the error through app:getDatabaseStatus. - Services that need the database don't start when it's unavailable. - runMigrations throws on the first failure instead of logging it and going on to apply later migrations on top of an incomplete schema. - Each migration runs in one transaction. Its statements could otherwise land on different pooled connections, whose stale schema cache made a RENAME after a DROP TABLE fail at random with "there is already another table", leaving the database half migrated: 19 of 640 replays failed before, 0 of 640 after. - The window opens after the migrations, so that no query sees them half done. --- docs/IPC_API.md | 1 + src/core/infrastructure/db/Migration.ts | 160 ++++++++---------- .../db/__tests__/Migration.test.ts | 51 ++++++ .../db/__tests__/initDatabase.test.ts | 76 +++++++++ src/core/infrastructure/db/index.d.ts | 3 +- src/core/infrastructure/db/index.ts | 22 ++- src/main/index.ts | 66 +++++--- .../handlers/__tests__/app.handler.test.ts | 38 +++++ src/main/ipc/handlers/app.handler.ts | 4 + src/main/ipc/handlers/index.ts | 2 +- src/preload/electron.d.ts | 1 + src/preload/index.ts | 1 + .../components/DatabaseStatusAlert.tsx | 52 ++++++ src/renderer/components/Layout.tsx | 2 + .../__tests__/DatabaseStatusAlert.test.tsx | 69 ++++++++ .../components/__tests__/Layout.test.tsx | 3 + src/renderer/electron.d.ts | 1 + src/renderer/locales/ar-SA/common.json | 5 + src/renderer/locales/en-US/common.json | 5 + src/renderer/locales/fa-IR/common.json | 5 + src/renderer/locales/ja-JP/common.json | 5 + src/renderer/locales/ru-RU/common.json | 5 + src/renderer/locales/zh-CN/common.json | 5 + 23 files changed, 465 insertions(+), 117 deletions(-) create mode 100644 src/core/infrastructure/db/__tests__/Migration.test.ts create mode 100644 src/core/infrastructure/db/__tests__/initDatabase.test.ts create mode 100644 src/main/ipc/handlers/__tests__/app.handler.test.ts create mode 100644 src/renderer/components/DatabaseStatusAlert.tsx create mode 100644 src/renderer/components/__tests__/DatabaseStatusAlert.test.tsx diff --git a/docs/IPC_API.md b/docs/IPC_API.md index 0d9ee15..57c17d0 100644 --- a/docs/IPC_API.md +++ b/docs/IPC_API.md @@ -149,6 +149,7 @@ Application information. | Method | Description | |--------|-------------| | `window.api.app.getVersion()` | Get app version from package.json | +| `window.api.app.getDatabaseStatus()` | Wait for the startup database initialization; `success: false` with `error` when it failed | --- diff --git a/src/core/infrastructure/db/Migration.ts b/src/core/infrastructure/db/Migration.ts index ac832c6..aba183c 100644 --- a/src/core/infrastructure/db/Migration.ts +++ b/src/core/infrastructure/db/Migration.ts @@ -75,18 +75,14 @@ const recordMigration = async ( const id = generateUUID(); const now = new Date().toISOString(); - try { - await prisma.$executeRaw` - INSERT INTO _prisma_migrations ( - id, checksum, migration_name, started_at, finished_at, applied_steps_count - ) VALUES ( - ${id}, ${checksum}, ${migrationName}, ${now}, ${now}, 1 - ); - `; - console.log(`Recorded migration: ${migrationName}`); - } catch (error) { - console.error(`Failed to record migration ${migrationName}:`, error); - } + await prisma.$executeRaw` + INSERT INTO _prisma_migrations ( + id, checksum, migration_name, started_at, finished_at, applied_steps_count + ) VALUES ( + ${id}, ${checksum}, ${migrationName}, ${now}, ${now}, 1 + ); + `; + console.log(`Recorded migration: ${migrationName}`); }; // 生成UUID @@ -105,40 +101,37 @@ const calculateChecksum = async (content: string): Promise => { return crypto.default.createHash("sha256").update(content).digest("hex"); }; +// 改表的迁移要复制整张表,页面内容又存在表里,所以比默认的 5 秒宽裕得多 +const MIGRATION_TRANSACTION_OPTIONS = { maxWait: 30_000, timeout: 5 * 60_000 }; + // 应用单个迁移 const applyMigration = async ( prisma: any, migrationDir: string, migrationName: string, -): Promise => { +): Promise => { const sqlFilePath = path.join(migrationDir, migrationName, "migration.sql"); - try { - if (!fs.existsSync(sqlFilePath)) { - console.error(`Migration SQL file not found: ${sqlFilePath}`); - return false; - } + if (!fs.existsSync(sqlFilePath)) { + throw new Error(`Migration SQL file not found: ${sqlFilePath}`); + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + const sqlStatements = sqlContent.split(";").filter((stmt) => stmt.trim()); - const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); - const sqlStatements = sqlContent.split(";").filter((stmt) => stmt.trim()); + const checksum = await calculateChecksum(sqlContent); - // 应用每条SQL语句 + // 放进事务才能让所有语句走同一条连接。否则连接池里其他连接的 schema 缓存可能已过期, + // DROP TABLE 之后的 RENAME 会偶发 "there is already another table"。失败时也不会留下半截结构。 + // 注意事务内 PRAGMA foreign_keys 不生效,改表的迁移不能依赖它。 + await prisma.$transaction(async (tx: any) => { for (const statement of sqlStatements) { - if (statement.trim()) { - await prisma.$executeRawUnsafe(statement.trim()); - } + await tx.$executeRawUnsafe(statement.trim()); } + await recordMigration(tx, migrationName, checksum); + }, MIGRATION_TRANSACTION_OPTIONS); - // 记录迁移 - const checksum = await calculateChecksum(sqlContent); - await recordMigration(prisma, migrationName, checksum); - - console.log(`Successfully applied migration: ${migrationName}`); - return true; - } catch (error) { - console.error(`Failed to apply migration ${migrationName}:`, error); - return false; - } + console.log(`Successfully applied migration: ${migrationName}`); }; // 批量检查迁移状态(性能优化) @@ -167,64 +160,61 @@ const getAppliedMigrations = async (prisma: any): Promise> => { } }; -// 主要的迁移函数(性能优化版) -const runMigrations = async (prisma: any = null): Promise => { +/** + * 应用所有未执行的迁移(性能优化版)。 + * 任何失败都会抛出:数据库结构不完整时应用无法正常工作,调用方需要知道。 + * 遇到第一个失败就停止,因为后面的迁移依赖它的结果。 + */ +const runMigrations = async (prisma: any = null): Promise => { const startTime = Date.now(); console.log("Running database migrations..."); - try { - // 确保_prisma_migrations表存在 - await prisma.$executeRawUnsafe(createMigrationsTableSQL); - - const migrationsDir = getMigrationsDir(); - console.log("Migrations directory:", migrationsDir); - if (!fs.existsSync(migrationsDir)) { - console.error(`Migrations directory not found: ${migrationsDir}`); - return false; - } + // 确保_prisma_migrations表存在 + await prisma.$executeRawUnsafe(createMigrationsTableSQL); + + const migrationsDir = getMigrationsDir(); + console.log("Migrations directory:", migrationsDir); + if (!fs.existsSync(migrationsDir)) { + throw new Error(`Migrations directory not found: ${migrationsDir}`); + } - // 批量获取所有已应用的迁移(性能优化) - const appliedMigrations = await getAppliedMigrations(prisma); - - // 读取所有迁移目录并按名称排序(优化:减少 stat 调用) - const migrationDirs = fs - .readdirSync(migrationsDir, { withFileTypes: true }) - .filter( - (dirent) => - dirent.isDirectory() && dirent.name !== "migration_lock.toml", - ) - .map((dirent) => dirent.name) - .sort(); // 按名称排序,确保按正确顺序应用 - - let migrationsApplied = 0; - - // 应用每个迁移 - for (const migrationName of migrationDirs) { - // 使用批量查询结果检查(避免单独查询) - const isApplied = appliedMigrations.has(migrationName); - - if (!isApplied) { - console.log(`Applying migration: ${migrationName}`); - const success = await applyMigration( - prisma, - migrationsDir, - migrationName, - ); - if (success) migrationsApplied++; - } else { - console.log(`Migration already applied: ${migrationName}`); - } + // 批量获取所有已应用的迁移(性能优化) + const appliedMigrations = await getAppliedMigrations(prisma); + + // 读取所有迁移目录并按名称排序(优化:减少 stat 调用) + const migrationDirs = fs + .readdirSync(migrationsDir, { withFileTypes: true }) + .filter( + (dirent) => + dirent.isDirectory() && dirent.name !== "migration_lock.toml", + ) + .map((dirent) => dirent.name) + .sort(); // 按名称排序,确保按正确顺序应用 + + let migrationsApplied = 0; + + // 应用每个迁移 + for (const migrationName of migrationDirs) { + // 使用批量查询结果检查(避免单独查询) + if (appliedMigrations.has(migrationName)) { + console.log(`Migration already applied: ${migrationName}`); + continue; } - const elapsed = Date.now() - startTime; - console.log( - `Database migration complete. Applied ${migrationsApplied} migrations in ${elapsed}ms`, - ); - return migrationsApplied > 0; - } catch (error) { - console.error("Migration error:", error); - return false; + console.log(`Applying migration: ${migrationName}`); + try { + await applyMigration(prisma, migrationsDir, migrationName); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to apply migration ${migrationName}: ${reason}`); + } + migrationsApplied++; } + + const elapsed = Date.now() - startTime; + console.log( + `Database migration complete. Applied ${migrationsApplied} migrations in ${elapsed}ms`, + ); }; export { runMigrations }; diff --git a/src/core/infrastructure/db/__tests__/Migration.test.ts b/src/core/infrastructure/db/__tests__/Migration.test.ts new file mode 100644 index 0000000..78cc64b --- /dev/null +++ b/src/core/infrastructure/db/__tests__/Migration.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { PrismaClient } from '@prisma/client' +import { runMigrations } from '../Migration.js' + +/** Runs against a real SQLite file: what matters is how the runner reacts to real SQL errors. */ +describe('runMigrations', () => { + let dir: string + let prisma: PrismaClient + + const appliedMigrations = async () => { + const rows = await prisma.$queryRaw<{ migration_name: string }[]>` + SELECT migration_name FROM _prisma_migrations ORDER BY migration_name` + return rows.map((row) => row.migration_name) + } + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + dir = mkdtempSync(path.join(tmpdir(), 'mpd-migration-runner-')) + prisma = new PrismaClient({ datasources: { db: { url: `file:${path.join(dir, 'test.db')}` } } }) + }) + + afterEach(async () => { + await prisma.$disconnect() + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('applies everything on a new database, and nothing the second time', async () => { + await runMigrations(prisma) + const applied = await appliedMigrations() + + expect(applied.length).toBeGreaterThan(0) + await expect(runMigrations(prisma)).resolves.toBeUndefined() + expect(await appliedMigrations()).toEqual(applied) + }) + + it('fails naming the migration, stops there and leaves none of it behind', async () => { + // The first migration creates Provider and then this table, so it fails halfway through. + await prisma.$executeRawUnsafe('CREATE TABLE "Model" ("id" INTEGER)') + + await expect(runMigrations(prisma)).rejects.toThrow(/Failed to apply migration 20250414154412_: .*Model/s) + + expect(await appliedMigrations()).toEqual([]) + const provider = await prisma.$queryRaw` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Provider'` + expect(provider).toEqual([]) + }) +}) diff --git a/src/core/infrastructure/db/__tests__/initDatabase.test.ts b/src/core/infrastructure/db/__tests__/initDatabase.test.ts new file mode 100644 index 0000000..6606df8 --- /dev/null +++ b/src/core/infrastructure/db/__tests__/initDatabase.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const { queryRaw, disconnectClient, runMigrations } = vi.hoisted(() => ({ + queryRaw: vi.fn(), + disconnectClient: vi.fn(), + runMigrations: vi.fn() +})) + +vi.mock('@prisma/client', () => ({ + default: { + PrismaClient: class { + $queryRaw = queryRaw + $disconnect = disconnectClient + } + } +})) +vi.mock('../Migration.js', () => ({ runMigrations })) +vi.mock('electron-is-dev', () => ({ default: true })) + +import { disconnect, initDatabase } from '../index.js' + +describe('initDatabase', () => { + beforeEach(() => { + queryRaw.mockReset().mockResolvedValue([]) + runMigrations.mockReset().mockResolvedValue(undefined) + disconnectClient.mockReset().mockResolvedValue(undefined) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(async () => { + await disconnect() + vi.restoreAllMocks() + }) + + it('reports success once connected and migrated', async () => { + await expect(initDatabase()).resolves.toEqual({ success: true }) + expect(runMigrations).toHaveBeenCalledTimes(1) + }) + + it('reports why the engine could not be loaded', async () => { + queryRaw.mockRejectedValue(new Error('\nPrisma Client could not locate the Query Engine\n')) + + await expect(initDatabase()).resolves.toEqual({ + success: false, + error: 'Prisma Client could not locate the Query Engine' + }) + expect(runMigrations).not.toHaveBeenCalled() + }) + + it('reports a failed migration', async () => { + runMigrations.mockRejectedValue(new Error('no such table')) + + await expect(initDatabase()).resolves.toEqual({ success: false, error: 'no such table' }) + }) + + it('initializes once however many callers ask, so a late caller gets the same outcome', async () => { + queryRaw.mockRejectedValue(new Error('boom')) + + const [first, second] = await Promise.all([initDatabase(), initDatabase()]) + const late = await initDatabase() + + expect(queryRaw).toHaveBeenCalledTimes(1) + expect(first).toEqual({ success: false, error: 'boom' }) + expect(second).toEqual(first) + expect(late).toEqual(first) + }) + + it('initializes again after a disconnect', async () => { + await initDatabase() + await disconnect() + await initDatabase() + + expect(queryRaw).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/core/infrastructure/db/index.d.ts b/src/core/infrastructure/db/index.d.ts index 2db00d6..493d614 100644 --- a/src/core/infrastructure/db/index.d.ts +++ b/src/core/infrastructure/db/index.d.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; export const prisma: PrismaClient; -export function initDatabase(): Promise; +export type DatabaseInitResult = { success: true } | { success: false; error: string }; +export function initDatabase(): Promise; export function disconnect(): Promise; \ No newline at end of file diff --git a/src/core/infrastructure/db/index.ts b/src/core/infrastructure/db/index.ts index a70f9d2..e5eb43f 100644 --- a/src/core/infrastructure/db/index.ts +++ b/src/core/infrastructure/db/index.ts @@ -6,9 +6,12 @@ import { app } from "electron"; import fs from "fs"; import isDev from "electron-is-dev"; +type DatabaseInitResult = { success: true } | { success: false; error: string }; + // 懒加载的 Prisma 实例和数据库 URL let prismaInstance: InstanceType | null = null; let cachedDbUrl: string | null = null; +let initResult: Promise | null = null; // 设置和获取数据库URL(懒加载,确保 app.setName 已执行) function getDatabaseUrl(): string { @@ -56,8 +59,7 @@ const prisma = new Proxy({} as InstanceType, { }, }); -// 初始化数据库,包括运行迁移 -const initDatabase = async (): Promise => { +const connectAndMigrate = async (): Promise => { try { const dbUrl = getDatabaseUrl(); const client = getPrismaClient(); @@ -69,13 +71,23 @@ const initDatabase = async (): Promise => { // 运行迁移,传递现有的prisma实例 await runMigrations(client); - return true; + return { success: true }; } catch (error) { console.error("Database initialization error:", error); - return false; + // Prisma 的错误信息首尾带空行 + return { success: false, error: (error instanceof Error ? error.message : String(error)).trim() }; } }; +/** + * 初始化数据库,包括运行迁移。只执行一次,重复调用得到同一个结果, + * 所以渲染进程无论何时加载,都能等到并拿到这次初始化的结果。 + */ +const initDatabase = (): Promise => { + initResult ??= connectAndMigrate(); + return initResult; +}; + /** * 关闭数据库连接 */ @@ -84,8 +96,10 @@ const disconnect = async (): Promise => { await prismaInstance.$disconnect(); prismaInstance = null; cachedDbUrl = null; + initResult = null; console.log("Database connection has been closed"); } }; export { prisma, disconnect, initDatabase }; +export type { DatabaseInitResult }; diff --git a/src/main/index.ts b/src/main/index.ts index 18ddec4..76bab7a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -433,11 +433,14 @@ app.whenReady().then(async () => { // 初始化事件桥接器(立即执行,不耗时) eventBridge.initialize(); - // 立即创建并显示窗口(用户体验优先) + // 先完成数据库迁移再创建窗口,否则渲染进程的查询会读到还没迁移完的表。 + // 这里不会抛错,初始化失败时窗口照常打开,由界面提示用户。 + await initDatabase(); + createWindow(); console.log(`[Main] Window created in ${Date.now() - startTime}ms`); - // 后台异步初始化数据库和任务(不阻塞UI) + // 后台异步初始化其余服务(不阻塞UI) initializeBackgroundServices().catch(error => { console.error("[Main] Background services initialization failed:", error); }); @@ -448,6 +451,32 @@ app.whenReady().then(async () => { } }); +/** + * 启动依赖数据库的服务 + */ +async function startDatabaseServices(startTime: number) { + // 注入预设供应商 + console.log("[Main] Injecting preset providers..."); + const presetStartTime = Date.now(); + await presetProviderService.initialize(); + console.log(`[Main] Preset providers injected in ${Date.now() - presetStartTime}ms`); + + // 启动任务逻辑 + console.log("[Main] Starting task logic in background..."); + const taskStartTime = Date.now(); + await startTask(); + console.log(`[Main] Task logic started in ${Date.now() - taskStartTime}ms`); + + console.log( + `[Main] Background services initialized successfully in ${Date.now() - startTime}ms`, + ); + + // 通知渲染进程初始化完成 + if (mainWindow) { + mainWindow.webContents.send('app:ready'); + } +} + /** * 后台异步初始化服务(不阻塞窗口显示) */ @@ -455,10 +484,11 @@ async function initializeBackgroundServices() { try { const startTime = Date.now(); - // 初始化数据库 - console.log("[Main] Initializing database in background..."); - await initDatabase(); - console.log(`[Main] Database initialized in ${Date.now() - startTime}ms`); + // 启动时已完成初始化,这里只取结果。渲染进程通过 app:getDatabaseStatus 拿到错误并提示用户 + const database = await initDatabase(); + if (!database.success) { + console.error("[Main] Database unavailable:", database.error); + } // 恢复认证会话 console.log("[Main] Restoring auth session..."); @@ -469,25 +499,8 @@ async function initializeBackgroundServices() { // SSE connection is managed by renderer's CloudContext via IPC (sseConnect/sseDisconnect) // to avoid duplicate connections from both main process and renderer - // 注入预设供应商 - console.log("[Main] Injecting preset providers..."); - const presetStartTime = Date.now(); - await presetProviderService.initialize(); - console.log(`[Main] Preset providers injected in ${Date.now() - presetStartTime}ms`); - - // 启动任务逻辑 - console.log("[Main] Starting task logic in background..."); - const taskStartTime = Date.now(); - await startTask(); - console.log(`[Main] Task logic started in ${Date.now() - taskStartTime}ms`); - - console.log( - `[Main] Background services initialized successfully in ${Date.now() - startTime}ms`, - ); - - // 通知渲染进程初始化完成 - if (mainWindow) { - mainWindow.webContents.send('app:ready'); + if (database.success) { + await startDatabaseServices(startTime); } // 启动时检测更新(fire-and-forget,不阻塞 UI) @@ -517,7 +530,8 @@ app.on("activate", async () => { if (mainWindow === null) { try { createWindow(); - if (!workerOrchestrator.getStatus()) { + const database = await initDatabase(); + if (database.success && !workerOrchestrator.getStatus()) { await startTask(); } } catch (error) { diff --git a/src/main/ipc/handlers/__tests__/app.handler.test.ts b/src/main/ipc/handlers/__tests__/app.handler.test.ts new file mode 100644 index 0000000..386f74b --- /dev/null +++ b/src/main/ipc/handlers/__tests__/app.handler.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockIpcMain, initDatabase } = vi.hoisted(() => ({ + mockIpcMain: { handle: vi.fn() }, + initDatabase: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: mockIpcMain, + app: { isPackaged: true, getVersion: () => '1.2.3' } +})) +vi.mock('../../../../core/infrastructure/db/index.js', () => ({ initDatabase })) + +import { registerAppHandlers } from '../app.handler.js' + +const handlerOf = (channel: string) => + mockIpcMain.handle.mock.calls.find(([name]) => name === channel)?.[1] + +describe('app handlers', () => { + beforeEach(() => { + mockIpcMain.handle.mockReset() + initDatabase.mockReset() + registerAppHandlers() + }) + + it('returns the app version', () => { + expect(handlerOf('app:getVersion')()).toBe('1.2.3') + }) + + it('passes on the outcome of the database initialization', async () => { + initDatabase.mockResolvedValue({ success: false, error: 'no engine' }) + + await expect(handlerOf('app:getDatabaseStatus')()).resolves.toEqual({ + success: false, + error: 'no engine' + }) + }) +}) diff --git a/src/main/ipc/handlers/app.handler.ts b/src/main/ipc/handlers/app.handler.ts index cafa0e0..9d549e9 100644 --- a/src/main/ipc/handlers/app.handler.ts +++ b/src/main/ipc/handlers/app.handler.ts @@ -1,6 +1,7 @@ import { ipcMain, app } from "electron"; import { createRequire } from "module"; import path from "path"; +import { initDatabase } from "../../../core/infrastructure/db/index.js"; // 使用 createRequire 来支持在 ESM 中导入 JSON const requireJson = createRequire(import.meta.url); @@ -35,4 +36,7 @@ export function registerAppHandlers() { ipcMain.handle("app:getVersion", () => { return getAppVersion(); }); + + // Resolves once startup's database initialization has finished, however early it's called. + ipcMain.handle("app:getDatabaseStatus", () => initDatabase()); } diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 552255c..ffb3043 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -21,7 +21,7 @@ import { registerUpdaterHandlers } from './updater.handler.js'; * - Completion: LLM API calls * - Auth: Authentication (device flow login, logout, state) * - Cloud: Cloud API operations - * - App: Application info (version) + * - App: Application info (version, database status) * - Updater: Auto-update management */ export function registerAllHandlers() { diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index f1c03df..24afec0 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -158,6 +158,7 @@ interface WindowAPI { platform: NodeJS.Platform; app: { getVersion: () => Promise; + getDatabaseStatus: () => Promise<{ success: boolean; error?: string }>; }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index c467359..73e4928 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -247,6 +247,7 @@ contextBridge.exposeInMainWorld("api", { // ==================== App APIs ==================== app: { getVersion: () => ipcRenderer.invoke("app:getVersion"), + getDatabaseStatus: () => ipcRenderer.invoke("app:getDatabaseStatus"), }, }); diff --git a/src/renderer/components/DatabaseStatusAlert.tsx b/src/renderer/components/DatabaseStatusAlert.tsx new file mode 100644 index 0000000..d8d4814 --- /dev/null +++ b/src/renderer/components/DatabaseStatusAlert.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from "react"; +import { Alert, Typography } from "antd"; +import { useTranslation } from "react-i18next"; + +/** + * Stays on screen while the database is unavailable. Pages only see the failure as individual + * requests failing, which doesn't tell the user that the whole app is affected, or why. + */ +const DatabaseStatusAlert: React.FC = () => { + const { t } = useTranslation("common"); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + window.api.app + .getDatabaseStatus() + .then((status) => { + if (mounted && !status.success) setError(status.error ?? ""); + }) + .catch((err: unknown) => { + if (mounted) setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + mounted = false; + }; + }, []); + + if (error === null) return null; + + return ( + + {t("databaseError.description")} +
+ {t("databaseError.details")} + + {error} + +
+ + } + /> + ); +}; + +export default DatabaseStatusAlert; diff --git a/src/renderer/components/Layout.tsx b/src/renderer/components/Layout.tsx index 265ca83..726dda4 100644 --- a/src/renderer/components/Layout.tsx +++ b/src/renderer/components/Layout.tsx @@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"; import { useLanguage } from "../hooks/useLanguage"; import ImgLogo from "../assets/MarkPDFdown.png"; import { CloudContext } from "../contexts/CloudContextDefinition"; +import DatabaseStatusAlert from "./DatabaseStatusAlert"; const { Header, Sider, Content, Footer } = Layout; @@ -322,6 +323,7 @@ const AppLayout: React.FC = () => { ...(isNotMac ? { WebkitAppRegion: 'drag' as const } : {}) }}> + ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +const getDatabaseStatus = vi.fn() + +describe('DatabaseStatusAlert', () => { + beforeEach(() => { + getDatabaseStatus.mockReset() + vi.stubGlobal('api', { app: { getDatabaseStatus } }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('shows nothing when the database is fine', async () => { + getDatabaseStatus.mockResolvedValue({ success: true }) + + const { container } = render() + + await waitFor(() => expect(getDatabaseStatus).toHaveBeenCalled()) + await Promise.resolve() + expect(container).toBeEmptyDOMElement() + }) + + it('shows nothing while the initialization is still running', () => { + getDatabaseStatus.mockReturnValue(new Promise(() => {})) + + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('explains the failure and gives the underlying error', async () => { + getDatabaseStatus.mockResolvedValue({ + success: false, + error: 'Prisma Client could not locate the Query Engine' + }) + + render() + + expect(await screen.findByRole('alert')).toBeInTheDocument() + expect(screen.getByText('databaseError.title')).toBeInTheDocument() + expect(screen.getByText('databaseError.description')).toBeInTheDocument() + expect(screen.getByText('Prisma Client could not locate the Query Engine')).toBeInTheDocument() + }) + + it('still shows the alert when the failure has no message', async () => { + getDatabaseStatus.mockResolvedValue({ success: false }) + + render() + + expect(await screen.findByText('databaseError.title')).toBeInTheDocument() + }) + + it('treats a failed status request as a failure', async () => { + getDatabaseStatus.mockRejectedValue(new Error('No handler registered')) + + render() + + expect(await screen.findByText('No handler registered')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/components/__tests__/Layout.test.tsx b/src/renderer/components/__tests__/Layout.test.tsx index ae52bdc..bf69646 100644 --- a/src/renderer/components/__tests__/Layout.test.tsx +++ b/src/renderer/components/__tests__/Layout.test.tsx @@ -53,6 +53,9 @@ const mockApi = { }, task: { hasRunningTasks: vi.fn() + }, + app: { + getDatabaseStatus: vi.fn().mockResolvedValue({ success: true }) } } diff --git a/src/renderer/electron.d.ts b/src/renderer/electron.d.ts index 44c3b78..0868515 100644 --- a/src/renderer/electron.d.ts +++ b/src/renderer/electron.d.ts @@ -328,6 +328,7 @@ interface ElectronAPI { app: { getVersion: () => Promise>; + getDatabaseStatus: () => Promise; }; } diff --git a/src/renderer/locales/ar-SA/common.json b/src/renderer/locales/ar-SA/common.json index c1e147e..244a7ff 100644 --- a/src/renderer/locales/ar-SA/common.json +++ b/src/renderer/locales/ar-SA/common.json @@ -32,5 +32,10 @@ }, "auth": { "not_signed_in": "غير مسجّل الدخول" + }, + "databaseError": { + "title": "فشلت تهيئة قاعدة البيانات", + "description": "الميزات المحلية مثل مزودي الخدمة والمهام غير متاحة. أعد تشغيل التطبيق. إذا استمرت المشكلة، فأبلغ عنها مع إرفاق التفاصيل أدناه.", + "details": "تفاصيل الخطأ" } } diff --git a/src/renderer/locales/en-US/common.json b/src/renderer/locales/en-US/common.json index b37e30a..2589953 100644 --- a/src/renderer/locales/en-US/common.json +++ b/src/renderer/locales/en-US/common.json @@ -32,5 +32,10 @@ }, "auth": { "not_signed_in": "Not Signed In" + }, + "databaseError": { + "title": "The database failed to initialize", + "description": "Local features such as providers and tasks are unavailable. Restart the app. If this keeps happening, report it with the details below.", + "details": "Error details" } } diff --git a/src/renderer/locales/fa-IR/common.json b/src/renderer/locales/fa-IR/common.json index 96e9613..4a2a41f 100644 --- a/src/renderer/locales/fa-IR/common.json +++ b/src/renderer/locales/fa-IR/common.json @@ -32,5 +32,10 @@ }, "auth": { "not_signed_in": "وارد نشده‌اید" + }, + "databaseError": { + "title": "راه‌اندازی پایگاه داده ناموفق بود", + "description": "قابلیت‌های محلی مانند ارائه‌دهندگان و وظایف در دسترس نیستند. برنامه را دوباره راه‌اندازی کنید. اگر مشکل ادامه داشت، آن را همراه با جزئیات زیر گزارش دهید.", + "details": "جزئیات خطا" } } diff --git a/src/renderer/locales/ja-JP/common.json b/src/renderer/locales/ja-JP/common.json index 4bc5971..bc214a9 100644 --- a/src/renderer/locales/ja-JP/common.json +++ b/src/renderer/locales/ja-JP/common.json @@ -32,5 +32,10 @@ }, "auth": { "not_signed_in": "未ログイン" + }, + "databaseError": { + "title": "データベースの初期化に失敗しました", + "description": "プロバイダーやタスクなどのローカル機能は利用できません。アプリを再起動してください。問題が解決しない場合は、以下の詳細を添えてご報告ください。", + "details": "エラーの詳細" } } diff --git a/src/renderer/locales/ru-RU/common.json b/src/renderer/locales/ru-RU/common.json index 511f104..ca621a4 100644 --- a/src/renderer/locales/ru-RU/common.json +++ b/src/renderer/locales/ru-RU/common.json @@ -32,5 +32,10 @@ }, "auth": { "not_signed_in": "Не авторизован" + }, + "databaseError": { + "title": "Не удалось инициализировать базу данных", + "description": "Локальные функции, такие как провайдеры и задачи, недоступны. Перезапустите приложение. Если проблема повторится, сообщите о ней, приложив сведения ниже.", + "details": "Подробности ошибки" } } diff --git a/src/renderer/locales/zh-CN/common.json b/src/renderer/locales/zh-CN/common.json index bd8c5bf..b6ad814 100644 --- a/src/renderer/locales/zh-CN/common.json +++ b/src/renderer/locales/zh-CN/common.json @@ -32,5 +32,10 @@ }, "auth": { "not_signed_in": "未登录" + }, + "databaseError": { + "title": "数据库初始化失败", + "description": "服务商、任务等本地功能当前不可用。请重启应用;如果问题仍然存在,请附上下方详情反馈给我们。", + "details": "错误详情" } } From f94b0415c4605943d0240296f53059c8dcdcf287 Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Thu, 5 Nov 2026 04:08:54 +0800 Subject: [PATCH 2/5] fix(linux): ship the Prisma engine of every distro and drop sharp Linux packages only held the engine of the Ubuntu build machine, while Prisma picks one from the distro family and libssl of the user's machine. On Fedora, RHEL, openSUSE and any OpenSSL 1.1 distro it found none (#86). - Generate debian/rhel x OpenSSL 1.1/3.0 engines on x64 and both OpenSSL arm64 engines on arm64. binaryTargets comes from PRISMA_BINARY_TARGETS, set by scripts/prisma-cli.mjs, so a package never holds engines of the other arch. - verify:package requires each of them, and verify:package:distros checks in containers that Fedora, Rocky 8 and Ubuntu 20.04 load the engine meant for them. Both CI and the release workflow run it. - Leave the unused engine copies of the Prisma CLI out of the package. - Remove sharp, which segfaults in Electron on Fedora 44: its libvips bundles glib while Electron links the system's. Conversion only used it to read the page image, so it now reads the file. EXIF is no longer stripped from uploaded images as a side effect. --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 4 + AGENTS.md | 5 +- bin/cli.js | 4 +- package-lock.json | 556 +----------------- package.json | 15 +- scripts/__tests__/prisma-engines.test.mjs | 36 ++ .../__tests__/verify-package-arch.test.mjs | 73 ++- .../__tests__/verify-package-distros.test.mjs | 63 ++ scripts/package-load-check.cjs | 16 +- scripts/prisma-cli.mjs | 26 + scripts/prisma-engines.mjs | 27 + scripts/verify-package-arch.mjs | 19 +- scripts/verify-package-distros.mjs | 121 ++++ src/core/application/services/ModelService.ts | 5 +- .../services/__tests__/ModelService.test.ts | 33 +- .../services/__tests__/OllamaProtocol.test.ts | 8 +- src/core/infrastructure/db/schema.prisma | 5 +- 18 files changed, 406 insertions(+), 613 deletions(-) create mode 100644 scripts/__tests__/prisma-engines.test.mjs create mode 100644 scripts/__tests__/verify-package-distros.test.mjs create mode 100644 scripts/prisma-cli.mjs create mode 100644 scripts/prisma-engines.mjs create mode 100644 scripts/verify-package-distros.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb17478..593e3b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,3 +96,6 @@ jobs: - name: Verify packaged native modules run: npm run verify:package -- --platform linux --arch x64 + + - name: Verify the package loads on other distros + run: npm run verify:package:distros diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b390193..8598c17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,10 @@ jobs: - name: Verify packaged native modules run: npm run verify:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} + - name: Verify the package loads on other distros + if: runner.os == 'Linux' + run: npm run verify:package:distros -- --arch ${{ matrix.arch }} + - name: Submit notarization (async) if: matrix.platform == 'mac' continue-on-error: true diff --git a/AGENTS.md b/AGENTS.md index 7d6647f..e6a5432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ npm run build:win # Build Windows NSIS installer npm run build:mac # Build macOS DMG npm run build:linux # Build Linux AppImage npm run verify:package # Check the packaged native modules (arch, presence, load test) +npm run verify:package:distros # Load the Linux package on other distros (needs Docker) # Database npm run generate # Generate Prisma client (required after schema changes) @@ -299,8 +300,10 @@ For detailed instructions on adding new features, including step-by-step develop - **Artifact Naming**: `${productName}-${version}-${arch}.${ext}` (e.g., `MarkPDFdown-1.0.0-x64.exe`) ### Native Modules and Packaging -- **Build on the target architecture**: sharp, `@napi-rs/canvas` and the Prisma engine ship as prebuilt binaries that `npm ci` and `prisma generate` install for the host only, and electron-builder does not fetch those of another arch. Packaging `--x64` on an arm64 machine produces an app that crashes at startup, so `release.yml` runs one job per arch on a native runner. +- **Build on the target architecture**: `@napi-rs/canvas` and the Prisma engine ship as prebuilt binaries that `npm ci` and `prisma generate` install for the host only, and electron-builder does not fetch those of another arch. Packaging `--x64` on an arm64 machine produces an app that crashes at startup, so `release.yml` runs one job per arch on a native runner. - **`npm run verify:package`** (`scripts/verify-package-arch.mjs`) checks an unpacked package: binary architectures, required modules, and, when the host can run the package, a real load test in the packaged Electron. Pass `-- --platform --arch ` for another target. CI fails on any problem. +- **Linux Prisma engines**: Prisma picks its engine at runtime from the user's distro family and libssl, so a Linux package carries one per combination (`scripts/prisma-engines.mjs`). `binaryTargets` comes from `PRISMA_BINARY_TARGETS`, so a list in the schema can't put the other arch's engines in a package: run Prisma through `npm run generate` / `npm run migrate:*` (`scripts/prisma-cli.mjs`). A bare `npx prisma generate` stops with an error about the missing variable. `npm run verify:package:distros` (`scripts/verify-package-distros.mjs`) checks in containers that each distro loads the engine meant for it. +- **No sharp in Electron on Linux**: its libvips bundles and exports glib, while Electron links the system's, so the two mix and sharp segfaults inside `g_object_unref` (seen on Fedora 44, glib 2.88). `RTLD_DEEPBIND` doesn't help. `@napi-rs/canvas` has no such clash. - **New native dependency**: add it to `requiredModules` in the verify script and to `scripts/package-load-check.cjs`. - **Windows is x64 only**: Prisma has no Windows arm64 engine, and an arm64 process can't load the x64 one. Windows on Arm runs the x64 build under emulation. - **`latest-mac.yml`**: each mac job writes its own, and the `upload-manifests` job in `release.yml` merges them, x64 first. diff --git a/bin/cli.js b/bin/cli.js index da3bcff..2be9ba9 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -36,7 +36,9 @@ async function ensurePrismaClient() { shell: true, env: { ...process.env, - DATABASE_URL: 'file:./placeholder.db' + DATABASE_URL: 'file:./placeholder.db', + // schema.prisma 通过该变量读取 binaryTargets;这里只在用户本机运行,生成 native 即可 + PRISMA_BINARY_TARGETS: '["native"]' } }); console.log('✅ Prisma client generated successfully.'); diff --git a/package-lock.json b/package-lock.json index d0f5f8e..2be94fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "markpdfdown", - "version": "0.1.5-tone", + "version": "0.1.0-local", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markpdfdown", - "version": "0.1.5-tone", + "version": "0.1.0-local", "dependencies": { "@ant-design/icons": "^5.6.1", "@prisma/client": "^6.5.0", - "@types/sharp": "^0.31.1", "antd": "^5.24.4", "electron-is-dev": "^3.0.1", "electron-updater": "^6.7.3", @@ -28,7 +27,6 @@ "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "sharp": "^0.34.1", "uuid": "^11.1.0" }, "bin": { @@ -1043,23 +1041,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1810,471 +1791,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -4294,6 +3810,7 @@ "version": "25.0.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4353,15 +3870,6 @@ "@types/node": "*" } }, - "node_modules/@types/sharp": { - "version": "0.31.1", - "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz", - "integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -7065,6 +6573,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -14999,62 +14508,6 @@ "node": ">= 0.4" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -16367,6 +15820,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index 2e8ffd7..a307ad7 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ "build:win": "npm run build && electron-builder --win", "build:linux": "npm run build && electron-builder --linux", "verify:package": "node scripts/verify-package-arch.mjs", - "migrate:dev": "prisma migrate dev --schema=./src/core/infrastructure/db/schema.prisma", - "migrate:reset": "prisma migrate reset --schema=./src/core/infrastructure/db/schema.prisma", - "generate": "prisma generate --schema=./src/core/infrastructure/db/schema.prisma", + "verify:package:distros": "node scripts/verify-package-distros.mjs", + "migrate:dev": "node scripts/prisma-cli.mjs migrate dev --schema=./src/core/infrastructure/db/schema.prisma", + "migrate:reset": "node scripts/prisma-cli.mjs migrate reset --schema=./src/core/infrastructure/db/schema.prisma", + "generate": "node scripts/prisma-cli.mjs generate --schema=./src/core/infrastructure/db/schema.prisma", "logo": "electron-icon-builder --input=./src/renderer/assets/logo.png --output=./public", "test": "vitest run", "test:unit": "vitest run --config vitest.config.ts", @@ -58,7 +59,8 @@ "!**/node_modules/**/{CHANGELOG.md,README.md,readme.md,LICENSE,license,License}", "!**/node_modules/**/*.{ts,tsx,md,markdown}", "!**/node_modules/**/{test,tests,__tests__,__mocks__,docs,doc,example,examples}/**/*", - "!**/node_modules/.cache/**/*" + "!**/node_modules/.cache/**/*", + "!**/node_modules/prisma/libquery_engine-*" ], "extraResources": [ "node_modules/.prisma/**/*", @@ -76,8 +78,7 @@ } ], "asarUnpack": [ - "**/*.{node,dll,metal,exp,lib}", - "**/node_modules/@img/**/*" + "**/*.{node,dll,metal,exp,lib}" ], "directories": { "buildResources": "assets", @@ -162,7 +163,6 @@ "dependencies": { "@ant-design/icons": "^5.6.1", "@prisma/client": "^6.5.0", - "@types/sharp": "^0.31.1", "antd": "^5.24.4", "electron-is-dev": "^3.0.1", "electron-updater": "^6.7.3", @@ -180,7 +180,6 @@ "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "sharp": "^0.34.1", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/scripts/__tests__/prisma-engines.test.mjs b/scripts/__tests__/prisma-engines.test.mjs new file mode 100644 index 0000000..5149c28 --- /dev/null +++ b/scripts/__tests__/prisma-engines.test.mjs @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { binaryTargetsForHost, linuxEngineTargets } from '../prisma-engines.mjs'; + +describe('binaryTargetsForHost', () => { + it.each([ + ['darwin', 'x64'], + ['darwin', 'arm64'], + ['win32', 'x64'], + ])('keeps %s-%s on its single native engine', (platform, arch) => { + expect(binaryTargetsForHost(platform, arch)).toEqual(['native']); + }); + + it('gives Linux x64 every distro family and libssl version', () => { + expect(binaryTargetsForHost('linux', 'x64')).toEqual([ + 'native', + 'debian-openssl-1.1.x', + 'debian-openssl-3.0.x', + 'rhel-openssl-1.1.x', + 'rhel-openssl-3.0.x', + ]); + }); + + it('gives Linux arm64 only arm64 engines, which an x64 target would fail the arch check with', () => { + expect(binaryTargetsForHost('linux', 'arm64')).toEqual([ + 'native', + 'linux-arm64-openssl-1.1.x', + 'linux-arm64-openssl-3.0.x', + ]); + }); +}); + +describe('linuxEngineTargets', () => { + it('has nothing for an arch without a Linux package', () => { + expect(linuxEngineTargets('ia32')).toEqual([]); + }); +}); diff --git a/scripts/__tests__/verify-package-arch.test.mjs b/scripts/__tests__/verify-package-arch.test.mjs index de8ee14..180a85e 100644 --- a/scripts/__tests__/verify-package-arch.test.mjs +++ b/scripts/__tests__/verify-package-arch.test.mjs @@ -95,8 +95,6 @@ describe('verifyBinaries', () => { /** The native files of an app package, with one header per file. */ const putModules = (target, header) => { - put(`app.asar.unpacked/node_modules/@img/sharp-${target}/lib/sharp-${target}.node`, header); - put(`app.asar.unpacked/node_modules/@img/sharp-libvips-${target}/lib/libvips-cpp.8.17.3.dylib`, header); put(`app.asar.unpacked/node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-${target}/skia.${target}.node`, header); put('node_modules/.prisma/client/libquery_engine-darwin.dylib.node', header); }; @@ -115,12 +113,12 @@ describe('verifyBinaries', () => { it('accepts a package whose binaries all match', () => { putModules('darwin-x64', macho('x64')); - put('app.asar.unpacked/node_modules/@img/sharp-darwin-x64/package.json', '{}'); + put('app.asar.unpacked/node_modules/@napi-rs/canvas/package.json', '{}'); const { problems, binaries } = verifyMac('x64'); expect(problems).toEqual([]); - expect(binaries).toHaveLength(5); + expect(binaries).toHaveLength(3); }); it('rejects the package from issue #77: an x64 app holding only arm64 modules', () => { @@ -128,14 +126,8 @@ describe('verifyBinaries', () => { const { problems } = verifyMac('x64'); - expect(problems.filter((p) => p.startsWith('Wrong architecture'))).toHaveLength(4); - expect(problems).toEqual( - expect.arrayContaining([ - 'Missing native module for mac-x64: sharp', - 'Missing native module for mac-x64: libvips', - 'Missing native module for mac-x64: @napi-rs/canvas', - ]), - ); + expect(problems.filter((p) => p.startsWith('Wrong architecture'))).toHaveLength(2); + expect(problems).toContain('Missing native module for mac-x64: @napi-rs/canvas'); }); it('rejects a stray binary of another arch, even when all required modules are fine', () => { @@ -177,23 +169,68 @@ describe('verifyBinaries', () => { expect(verifyMac('x64').problems).toEqual([`Missing file: ${executable}`]); }); - it('knows libvips lives in the sharp package on Windows', () => { + it('accepts a Windows package, whose engine has no lib prefix', () => { writeFileSync(executable, pe(0x8664)); - put('app.asar.unpacked/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node', pe(0x8664)); - put('app.asar.unpacked/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll', pe(0x8664)); put('app.asar.unpacked/node_modules/@napi-rs/canvas-win32-x64-msvc/skia.win32-x64-msvc.node', pe(0x8664)); put('node_modules/.prisma/client/query_engine-windows.dll.node', pe(0x8664)); expect(verifyBinaries({ resourcesDir, executable, platform: 'win', arch: 'x64' }).problems).toEqual([]); }); - it('finds the versioned libvips on Linux', () => { + it('accepts a Linux arm64 package with both of its engines', () => { writeFileSync(executable, elf(183)); - put('app.asar.unpacked/node_modules/@img/sharp-linux-arm64/lib/sharp-linux-arm64.node', elf(183)); - put('app.asar.unpacked/node_modules/@img/sharp-libvips-linux-arm64/lib/libvips-cpp.so.8.17.3', elf(183)); put('app.asar.unpacked/node_modules/@napi-rs/canvas-linux-arm64-gnu/skia.linux-arm64-gnu.node', elf(183)); + put('node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-1.1.x.so.node', elf(183)); put('node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so.node', elf(183)); expect(verifyBinaries({ resourcesDir, executable, platform: 'linux', arch: 'arm64' }).problems).toEqual([]); }); + + describe('Linux Prisma engines', () => { + const X64_ENGINES = ['debian-openssl-1.1.x', 'debian-openssl-3.0.x', 'rhel-openssl-1.1.x', 'rhel-openssl-3.0.x']; + + const putLinuxX64Modules = (engines) => { + writeFileSync(executable, elf(62)); + put('app.asar.unpacked/node_modules/@napi-rs/canvas-linux-x64-gnu/skia.linux-x64-gnu.node', elf(62)); + for (const engine of engines) { + put(`node_modules/.prisma/client/libquery_engine-${engine}.so.node`, elf(62)); + } + }; + + const verifyLinuxX64 = () => verifyBinaries({ resourcesDir, executable, platform: 'linux', arch: 'x64' }); + + it('accepts a package with the engine of every distro family and libssl version', () => { + putLinuxX64Modules(X64_ENGINES); + + expect(verifyLinuxX64().problems).toEqual([]); + }); + + it('rejects the package from issue #86: only the engine of the build machine', () => { + putLinuxX64Modules(['debian-openssl-3.0.x']); + + expect(verifyLinuxX64().problems).toEqual([ + 'Missing native module for linux-x64: Prisma query engine (debian-openssl-1.1.x)', + 'Missing native module for linux-x64: Prisma query engine (rhel-openssl-1.1.x)', + 'Missing native module for linux-x64: Prisma query engine (rhel-openssl-3.0.x)', + ]); + }); + + it('does not count an engine outside the generated client', () => { + putLinuxX64Modules(X64_ENGINES.slice(1)); + put('app.asar.unpacked/node_modules/prisma/libquery_engine-debian-openssl-1.1.x.so.node', elf(62)); + + expect(verifyLinuxX64().problems).toEqual([ + 'Missing native module for linux-x64: Prisma query engine (debian-openssl-1.1.x)', + ]); + }); + + it('rejects an engine of the other arch', () => { + putLinuxX64Modules(X64_ENGINES); + put('node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so.node', elf(183)); + + expect(verifyLinuxX64().problems).toEqual([ + 'Wrong architecture: node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so.node is elf/arm64, expected elf/x64', + ]); + }); + }); }); diff --git a/scripts/__tests__/verify-package-distros.test.mjs b/scripts/__tests__/verify-package-distros.test.mjs new file mode 100644 index 0000000..794abde --- /dev/null +++ b/scripts/__tests__/verify-package-distros.test.mjs @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { linuxEngineTargets } from '../prisma-engines.mjs'; +import { DISTROS, checkDistroResult, dockerRunArgs } from '../verify-package-distros.mjs'; + +const ENGINE = 'rhel-openssl-3.0.x'; +const LOADED = ` load ok Prisma query engine (libquery_engine-${ENGINE}.so.node)\n`; + +describe('DISTROS', () => { + it.each(['x64', 'arm64'])('only expects engines that the %s package carries', (arch) => { + for (const { engine } of DISTROS) { + expect(linuxEngineTargets(arch)).toContain(engine[arch]); + } + }); + + it('covers every x64 engine except the one of the Ubuntu build machine', () => { + const covered = new Set(DISTROS.map(({ engine }) => engine.x64)); + expect([...covered].sort()).toEqual( + linuxEngineTargets('x64').filter((target) => target !== 'debian-openssl-3.0.x').sort(), + ); + }); +}); + +describe('dockerRunArgs', () => { + it('mounts the package read-only and runs the load check after installing the libraries', () => { + const args = dockerRunArgs({ + image: 'fedora:44', + install: 'dnf install -y gtk3', + appDir: '/repo/release/linux-unpacked', + executableName: 'markpdfdown', + loadCheckScript: '/repo/scripts/package-load-check.cjs', + }); + + expect(args.slice(0, 2)).toEqual(['run', '--rm']); + expect(args).toContain('/repo/release/linux-unpacked:/package:ro'); + expect(args).toContain('/repo/scripts/package-load-check.cjs:/load-check.cjs:ro'); + expect(args).toContain('fedora:44'); + expect(args.at(-1)).toBe( + 'dnf install -y gtk3 && ELECTRON_RUN_AS_NODE=1 /package/markpdfdown /load-check.cjs /package/resources', + ); + }); +}); + +describe('checkDistroResult', () => { + it('accepts a run that loaded the expected engine', () => { + expect(checkDistroResult({ status: 0, stdout: LOADED }, ENGINE)).toBeNull(); + }); + + it('rejects a run that loaded the engine of another distro', () => { + expect(checkDistroResult({ status: 0, stdout: LOADED }, 'rhel-openssl-1.1.x')).toBe( + 'Prisma did not load the rhel-openssl-1.1.x engine', + ); + }); + + it('reports a failed load check', () => { + expect(checkDistroResult({ status: 1, stdout: '' }, ENGINE)).toBe('load check failed with exit code 1'); + }); + + it('reports a machine without docker', () => { + expect(checkDistroResult({ error: new Error('spawn docker ENOENT'), stdout: null }, ENGINE)).toBe( + 'could not run: spawn docker ENOENT', + ); + }); +}); diff --git a/scripts/package-load-check.cjs b/scripts/package-load-check.cjs index 62f016b..3846bcc 100644 --- a/scripts/package-load-check.cjs +++ b/scripts/package-load-check.cjs @@ -13,12 +13,6 @@ const path = require('node:path'); const appModules = path.join(process.argv[2], 'app.asar', 'node_modules'); const loadFromApp = (name) => require(path.join(appModules, name)); -async function checkSharp() { - const sharp = loadFromApp('sharp'); - await sharp({ create: { width: 1, height: 1, channels: 3, background: '#000' } }).png().toBuffer(); - return `libvips ${sharp.versions.vips}`; -} - async function checkCanvas() { const { createCanvas } = loadFromApp('@napi-rs/canvas'); createCanvas(1, 1).getContext('2d'); @@ -26,6 +20,13 @@ async function checkCanvas() { loadFromApp('pdf-to-png-converter'); } +/** Linux only: the engine Prisma really picked, which verify-package-distros.mjs checks. */ +function loadedPrismaEngine() { + if (process.platform !== 'linux') return undefined; + const engine = fs.readFileSync('/proc/self/maps', 'utf8').match(/libquery_engine-[^/\s]+\.so\.node/); + return engine?.[0]; +} + async function checkPrisma() { const { PrismaClient } = loadFromApp('@prisma/client'); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markpdfdown-load-check-')); @@ -34,6 +35,7 @@ async function checkPrisma() { }); try { await client.$queryRaw`SELECT 1`; + return loadedPrismaEngine(); } finally { await client.$disconnect(); fs.rmSync(tempDir, { recursive: true, force: true }); @@ -41,7 +43,7 @@ async function checkPrisma() { } async function main() { - const checks = { sharp: checkSharp, '@napi-rs/canvas': checkCanvas, 'Prisma query engine': checkPrisma }; + const checks = { '@napi-rs/canvas': checkCanvas, 'Prisma query engine': checkPrisma }; let failed = false; for (const [name, check] of Object.entries(checks)) { try { diff --git a/scripts/prisma-cli.mjs b/scripts/prisma-cli.mjs new file mode 100644 index 0000000..20b05f8 --- /dev/null +++ b/scripts/prisma-cli.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +/** + * Runs the Prisma CLI with the `binaryTargets` of this host, which schema.prisma reads from + * PRISMA_BINARY_TARGETS. Packages are built on a runner of their target arch, so the host decides. + * + * Usage: node scripts/prisma-cli.mjs generate --schema=./src/core/infrastructure/db/schema.prisma + */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { binaryTargetsForHost } from './prisma-engines.mjs'; + +const PRISMA_CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'node_modules', 'prisma', 'build', 'index.js'); + +const result = spawnSync(process.execPath, [PRISMA_CLI, ...process.argv.slice(2)], { + stdio: 'inherit', + env: { + ...process.env, + PRISMA_BINARY_TARGETS: JSON.stringify(binaryTargetsForHost(process.platform, process.arch)), + }, +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/scripts/prisma-engines.mjs b/scripts/prisma-engines.mjs new file mode 100644 index 0000000..ed6ecbb --- /dev/null +++ b/scripts/prisma-engines.mjs @@ -0,0 +1,27 @@ +/** + * Which Prisma query engines each package carries. Shared by the script that generates the + * client and the one that verifies the package, so the two can't drift apart. + * + * Prisma picks an engine at runtime from the distro family and the libssl version, not from the + * machine that built the app, so a Linux package needs one engine per combination it can meet. + * OpenSSL 1.0 is left out: the distros that ship it have a glibc too old to run Electron at all. + */ +const LINUX_ENGINE_TARGETS = { + x64: ['debian-openssl-1.1.x', 'debian-openssl-3.0.x', 'rhel-openssl-1.1.x', 'rhel-openssl-3.0.x'], + // Prisma has a single arm64 engine per libssl version, whatever the distro family. + arm64: ['linux-arm64-openssl-1.1.x', 'linux-arm64-openssl-3.0.x'], +}; + +/** Engines a Linux package must contain. @returns {string[]} */ +export function linuxEngineTargets(arch) { + return LINUX_ENGINE_TARGETS[arch] ?? []; +} + +/** + * The `binaryTargets` to generate on a given host. macOS and Windows have one engine per arch, + * which `native` already is. `native` stays on Linux too, for developing on any other distro. + * @returns {string[]} + */ +export function binaryTargetsForHost(platform, arch) { + return platform === 'linux' ? ['native', ...linuxEngineTargets(arch)] : ['native']; +} diff --git a/scripts/verify-package-arch.mjs b/scripts/verify-package-arch.mjs index ddeec81..40e33fe 100644 --- a/scripts/verify-package-arch.mjs +++ b/scripts/verify-package-arch.mjs @@ -4,7 +4,8 @@ * Verifies an unpacked electron-builder package (release/mac, release/linux-unpacked, ...): * * 1. Every native binary in the third-party module trees matches the target platform and arch. - * 2. The native modules the app needs at startup are present (sharp, libvips, canvas, Prisma). + * 2. The native modules the app needs at startup are present (canvas, and the Prisma engine, on + * Linux that of every distro family and libssl version). * 3. When the host can execute the package, those modules really load inside the packaged Electron. * This catches what headers cannot, such as a library left inside app.asar where dlopen can't see it. * @@ -22,6 +23,7 @@ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync } import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; +import { linuxEngineTargets } from './prisma-engines.mjs'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const LOAD_CHECK_SCRIPT = path.join(SCRIPT_DIR, 'package-load-check.cjs'); @@ -127,6 +129,16 @@ export function resolvePackageLayout({ releaseDir, platform, arch, productName, }; } +function prismaEngineModules(platform, arch) { + if (platform !== 'linux') { + return [{ label: 'Prisma query engine', pattern: /\/\.prisma\/client\/[^/]*query_engine[^/]*\.node$/ }]; + } + return linuxEngineTargets(arch).map((target) => ({ + label: `Prisma query engine (${target})`, + pattern: new RegExp(`/\\.prisma/client/libquery_engine-${target.replaceAll('.', '\\.')}\\.so\\.node$`), + })); +} + /** * Native modules without which the app can't work. `electron-builder` leaves them out silently, * e.g. when the matching optional dependency wasn't installed on the build machine. @@ -134,11 +146,8 @@ export function resolvePackageLayout({ releaseDir, platform, arch, productName, function requiredModules(platform, arch) { const target = `${PLATFORMS[platform].nodePlatform}-${arch}`; return [ - { label: 'sharp', pattern: new RegExp(`/@img/sharp-${target}/lib/[^/]+\\.node$`) }, - // On Windows libvips ships inside the sharp package rather than in its own package. - { label: 'libvips', pattern: new RegExp(`/@img/sharp-(libvips-)?${target}/lib/libvips[^/]*$`) }, { label: '@napi-rs/canvas', pattern: new RegExp(`/@napi-rs/canvas-${target}[^/]*/[^/]+\\.node$`) }, - { label: 'Prisma query engine', pattern: /\/\.prisma\/client\/[^/]*query_engine[^/]*\.node$/ }, + ...prismaEngineModules(platform, arch), ]; } diff --git a/scripts/verify-package-distros.mjs b/scripts/verify-package-distros.mjs new file mode 100644 index 0000000..3968538 --- /dev/null +++ b/scripts/verify-package-distros.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +/** + * Loads an unpacked Linux package's native modules on other distros, in containers, and checks + * that Prisma picks the engine meant for each. verify-package-arch.mjs only covers the distro of + * the build machine, while Prisma chooses its engine from the distro and libssl of the user's. + * + * Usage: + * npm run verify:package:distros # host arch + * npm run verify:package:distros -- --release-dir out + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { linuxEngineTargets } from './prisma-engines.mjs'; +import { resolvePackageLayout } from './verify-package-arch.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const OUTPUT_LIMIT_BYTES = 64 * 1024 * 1024; + +const RPM_LIBRARIES = 'gtk3 nss mesa-libgbm alsa-lib openssl'; +const dnfInstall = (options = '') => + `dnf install -y -q --setopt=install_weak_deps=False ${options} ${RPM_LIBRARIES}`; +const APT = + 'apt-get update -qq && apt-get install -y -qq --no-install-recommends libgtk-3-0 libnss3 libgbm1 libasound2 openssl'; + +/** One distro per combination of family and libssl that the build machine itself doesn't cover. */ +export const DISTROS = [ + { + image: 'fedora:44', + // Nothing here needs Cisco's OpenH264, and a third-party mirror shouldn't be able to fail the check. + install: dnfInstall('--disablerepo=fedora-cisco-openh264'), + engine: { x64: 'rhel-openssl-3.0.x', arm64: 'linux-arm64-openssl-3.0.x' }, + }, + { image: 'rockylinux:8', install: dnfInstall(), engine: { x64: 'rhel-openssl-1.1.x', arm64: 'linux-arm64-openssl-1.1.x' } }, + // Not debian:11, whose packages have started to disappear from the mirrors now that LTS is over. + { image: 'ubuntu:20.04', install: APT, engine: { x64: 'debian-openssl-1.1.x', arm64: 'linux-arm64-openssl-1.1.x' } }, +]; + +export function dockerRunArgs({ image, install, appDir, executableName, loadCheckScript }) { + return [ + 'run', + '--rm', + '--volume', `${appDir}:/package:ro`, + '--volume', `${loadCheckScript}:/load-check.cjs:ro`, + image, + 'bash', + '-c', + `${install} && ELECTRON_RUN_AS_NODE=1 /package/${executableName} /load-check.cjs /package/resources`, + ]; +} + +/** @returns {string | null} what's wrong with a container's run, null when it's fine */ +export function checkDistroResult({ status, stdout, error }, expectedEngine) { + if (error) return `could not run: ${error.message}`; + if (status !== 0) return `load check failed with exit code ${status}`; + if (!stdout.includes(`libquery_engine-${expectedEngine}.so.node`)) { + return `Prisma did not load the ${expectedEngine} engine`; + } + return null; +} + +function main() { + const { values } = parseArgs({ + options: { + arch: { type: 'string', default: process.arch }, + 'release-dir': { type: 'string', default: 'release' }, + }, + }); + const { arch } = values; + if (linuxEngineTargets(arch).length === 0) throw new Error(`Unsupported --arch "${arch}"`); + + const packageJson = JSON.parse(readFileSync(path.join(SCRIPT_DIR, '..', 'package.json'), 'utf8')); + const executableName = packageJson.name; + const { resourcesDir } = resolvePackageLayout({ + releaseDir: path.resolve(values['release-dir']), + platform: 'linux', + arch, + productName: packageJson.build.productName, + executableName, + }); + if (!existsSync(resourcesDir)) throw new Error(`Package not found at ${resourcesDir}. Was linux-${arch} built?`); + + const problems = []; + for (const { image, install, engine } of DISTROS) { + console.log(`\nChecking ${image}, expecting ${engine[arch]}`); + const result = spawnSync( + 'docker', + dockerRunArgs({ + image, + install, + appDir: path.dirname(resourcesDir), + executableName, + loadCheckScript: path.join(SCRIPT_DIR, 'package-load-check.cjs'), + }), + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: OUTPUT_LIMIT_BYTES }, + ); + if (result.stdout) process.stdout.write(result.stdout); + const problem = checkDistroResult(result, engine[arch]); + if (problem) problems.push(`${image}: ${problem}`); + } + + if (problems.length > 0) { + console.error(`\n${problems.length} problem(s) found:`); + for (const problem of problems) console.error(` - ${problem}`); + process.exit(1); + } + console.log(`\nPackage linux-${arch} loads on ${DISTROS.map(({ image }) => image).join(', ')}.`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/src/core/application/services/ModelService.ts b/src/core/application/services/ModelService.ts index 75ce49d..f6a5b48 100644 --- a/src/core/application/services/ModelService.ts +++ b/src/core/application/services/ModelService.ts @@ -1,6 +1,6 @@ import providerRepository from '../../domain/repositories/ProviderRepository.js'; import { LLMClientFactory, CompletionOptions, Message } from '../../infrastructure/adapters/llm/LLMClient.js'; -import sharp from 'sharp'; +import { promises as fs } from 'fs'; // 根据服务商ID获取LLM客户端 @@ -59,8 +59,7 @@ const completion = async (providerId: number, options: CompletionOptions) => { // 输入图片文件路径,返回图片的base64编码字符串 const getImageBase64 = async (imagePath: string): Promise => { - const image = sharp(imagePath); - const buffer = await image.toBuffer(); + const buffer = await fs.readFile(imagePath); return buffer.toString('base64'); } diff --git a/src/core/application/services/__tests__/ModelService.test.ts b/src/core/application/services/__tests__/ModelService.test.ts index 532e45a..b096b05 100644 --- a/src/core/application/services/__tests__/ModelService.test.ts +++ b/src/core/application/services/__tests__/ModelService.test.ts @@ -9,10 +9,7 @@ const mockLLMClientFactory = { createClient: vi.fn() } -const mockToBuffer = vi.fn().mockResolvedValue(Buffer.from('mock-image-data')) -const mockSharp = vi.fn(() => ({ - toBuffer: mockToBuffer -})) +const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock-image-data')) vi.mock('../../../domain/repositories/ProviderRepository.js', () => ({ default: mockProviderRepository @@ -22,8 +19,8 @@ vi.mock('../../../infrastructure/adapters/llm/LLMClient.js', () => ({ LLMClientFactory: mockLLMClientFactory })) -vi.mock('sharp', () => ({ - default: mockSharp +vi.mock('fs', () => ({ + promises: { readFile: mockReadFile } })) describe('Model Logic', () => { @@ -31,7 +28,7 @@ describe('Model Logic', () => { beforeEach(async () => { vi.clearAllMocks() - mockToBuffer.mockClear() + mockReadFile.mockClear() const module = await import('../ModelService.js') modelLogic = module.default }) @@ -284,11 +281,21 @@ describe('Model Logic', () => { expect(imageContent.image_url.url).toMatch(/^data:image\/jpeg;base64,/) }) - it('should call sharp to process image', async () => { - await modelLogic.transformImageMessage('/path/to/test.jpg') + it('should send the bytes of the image file', async () => { + mockReadFile.mockResolvedValueOnce(Buffer.from('page-image')) + + const result = await modelLogic.transformImageMessage('/path/to/test.jpg') + + expect(mockReadFile).toHaveBeenCalledWith('/path/to/test.jpg') + expect(result[1].content[1].image_url.url).toBe( + `data:image/jpeg;base64,${Buffer.from('page-image').toString('base64')}` + ) + }) + + it('should fail when the image file cannot be read', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')) - expect(mockSharp).toHaveBeenCalledWith('/path/to/test.jpg') - expect(mockToBuffer).toHaveBeenCalled() + await expect(modelLogic.transformImageMessage('/missing.png')).rejects.toThrow('ENOENT') }) it('should handle different image paths', async () => { @@ -299,9 +306,9 @@ describe('Model Logic', () => { ] for (const imagePath of paths) { - mockSharp.mockClear() + mockReadFile.mockClear() await modelLogic.transformImageMessage(imagePath) - expect(mockSharp).toHaveBeenCalledWith(imagePath) + expect(mockReadFile).toHaveBeenCalledWith(imagePath) } }) }) diff --git a/src/core/application/services/__tests__/OllamaProtocol.test.ts b/src/core/application/services/__tests__/OllamaProtocol.test.ts index e324d38..ed37664 100644 --- a/src/core/application/services/__tests__/OllamaProtocol.test.ts +++ b/src/core/application/services/__tests__/OllamaProtocol.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' * Protocol consistency between ModelService (endpoint), OllamaClient (request * body and response parsing) and the Ollama server. * - * Only the database and sharp are mocked. Unit tests that mock either side of + * Only the database and reading the page image are mocked. Unit tests that mock either side of * the HTTP boundary could not catch issue #72: one side asserted /api/generate, * the other returned a /api/chat shaped response. */ @@ -15,10 +15,8 @@ vi.mock('../../../domain/repositories/ProviderRepository.js', () => ({ default: mockProviderRepository })) -vi.mock('sharp', () => ({ - default: vi.fn(() => ({ - toBuffer: vi.fn().mockResolvedValue(Buffer.from('page-image')) - })) +vi.mock('fs', () => ({ + promises: { readFile: vi.fn().mockResolvedValue(Buffer.from('page-image')) } })) const HOST = 'http://ollama.test:11434' diff --git a/src/core/infrastructure/db/schema.prisma b/src/core/infrastructure/db/schema.prisma index 77e5871..12b18ca 100644 --- a/src/core/infrastructure/db/schema.prisma +++ b/src/core/infrastructure/db/schema.prisma @@ -5,7 +5,10 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + // Set by scripts/prisma-cli.mjs, so run Prisma through `npm run generate` / `npm run migrate:*`. + // It can't be a literal list: engines are per arch, and each package must only carry its own. + binaryTargets = env("PRISMA_BINARY_TARGETS") } datasource db { From 0569c1145ed0084fd3c82424f83e05b2e35243d9 Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Thu, 5 Nov 2026 10:06:25 +0800 Subject: [PATCH 3/5] fix(split): re-encode uploaded images to drop their metadata Removing sharp also removed the re-encoding it did on the way out: uploads reached the model provider byte for byte, with their GPS position and device details, and much larger (a 4000x3000 JPEG: 8.48 MB, was 3.29 MB). - ImageSplitter re-encodes uploads through @napi-rs/canvas at sharp's default quality, and fails rather than fall back to the original. The Exif orientation is applied to the pixels, so photos stay upright. - ModelService re-encodes any page file that may still hold metadata. Retrying a task reuses its page files, and those of older versions are plain copies. - Reject truncated images. Unlike sharp, the decoder draws what's there and leaves the rest blank, so a page would silently lose its text. - Pin @napi-rs/canvas to 1.0.11. Up to 1.0.3 it segfaults on damaged images, which would crash the app on each start, and 1.0.12 and 1.0.13 segfault in Electron as soon as they load. The load check now covers both. - Mount all of scripts/ in the distro containers: the load check requires fixtures/count-dark-pixels.cjs, which the PDF rendering check of #92 can share instead of its use of sharp. --- AGENTS.md | 2 + package-lock.json | 110 ++++--- package.json | 2 + .../__tests__/verify-package-distros.test.mjs | 6 +- scripts/fixtures/count-dark-pixels.cjs | 26 ++ scripts/package-load-check.cjs | 20 +- scripts/verify-package-distros.mjs | 9 +- src/core/application/services/ModelService.ts | 6 +- .../adapters/split/ImageReencoder.ts | 80 +++++ .../adapters/split/ImageSplitter.ts | 15 +- .../ImageSplitter.integration.test.ts | 307 ++++++++++++++++++ .../split/__tests__/ImageSplitter.test.ts | 75 +++-- 12 files changed, 569 insertions(+), 89 deletions(-) create mode 100644 scripts/fixtures/count-dark-pixels.cjs create mode 100644 src/core/infrastructure/adapters/split/ImageReencoder.ts create mode 100644 src/core/infrastructure/adapters/split/__tests__/ImageSplitter.integration.test.ts diff --git a/AGENTS.md b/AGENTS.md index e6a5432..19929bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -304,6 +304,8 @@ For detailed instructions on adding new features, including step-by-step develop - **`npm run verify:package`** (`scripts/verify-package-arch.mjs`) checks an unpacked package: binary architectures, required modules, and, when the host can run the package, a real load test in the packaged Electron. Pass `-- --platform --arch ` for another target. CI fails on any problem. - **Linux Prisma engines**: Prisma picks its engine at runtime from the user's distro family and libssl, so a Linux package carries one per combination (`scripts/prisma-engines.mjs`). `binaryTargets` comes from `PRISMA_BINARY_TARGETS`, so a list in the schema can't put the other arch's engines in a package: run Prisma through `npm run generate` / `npm run migrate:*` (`scripts/prisma-cli.mjs`). A bare `npx prisma generate` stops with an error about the missing variable. `npm run verify:package:distros` (`scripts/verify-package-distros.mjs`) checks in containers that each distro loads the engine meant for it. - **No sharp in Electron on Linux**: its libvips bundles and exports glib, while Electron links the system's, so the two mix and sharp segfaults inside `g_object_unref` (seen on Fedora 44, glib 2.88). `RTLD_DEEPBIND` doesn't help. `@napi-rs/canvas` has no such clash. +- **`@napi-rs/canvas` is pinned to an exact version, 1.0.11**: 0.1.x up to 1.0.3 segfault when decoding a truncated image (0.1.88 on 41 of 225 damaged samples), which takes down the main process, and again on each start since the task is picked up again. 1.0.12 and 1.0.13 segfault in Electron 38 as soon as they load, while passing in plain Node. So bump it only with `npm run verify:package`, whose load check covers both. `pdf-to-png-converter` asks for 0.1.x, so the `overrides` entry in `package.json` keeps a single copy in the package. +- **Images leaving the machine carry no metadata**: `ImageSplitter` re-encodes uploads through `ImageReencoder`, dropping Exif (GPS, device) and bringing photos to quality 80, and never falls back to the original. `ModelService` re-encodes any page file that still looks like it has metadata, such as those split by an older version. - **New native dependency**: add it to `requiredModules` in the verify script and to `scripts/package-load-check.cjs`. - **Windows is x64 only**: Prisma has no Windows arm64 engine, and an arm64 process can't load the x64 one. Windows on Arm runs the x64 build under emulation. - **`latest-mac.yml`**: each mac job writes its own, and the `upload-manifests` job in `release.yml` merges them, x64 first. diff --git a/package-lock.json b/package-lock.json index 2be94fd..b3ca921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0-local", "dependencies": { "@ant-design/icons": "^5.6.1", + "@napi-rs/canvas": "1.0.11", "@prisma/client": "^6.5.0", "antd": "^5.24.4", "electron-is-dev": "^3.0.1", @@ -2546,9 +2547,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", - "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.11.tgz", + "integrity": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==", "license": "MIT", "workspaces": [ "e2e/*" @@ -2561,23 +2562,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.88", - "@napi-rs/canvas-darwin-arm64": "0.1.88", - "@napi-rs/canvas-darwin-x64": "0.1.88", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", - "@napi-rs/canvas-linux-arm64-musl": "0.1.88", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", - "@napi-rs/canvas-linux-x64-gnu": "0.1.88", - "@napi-rs/canvas-linux-x64-musl": "0.1.88", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", - "@napi-rs/canvas-win32-x64-msvc": "0.1.88" + "@napi-rs/canvas-android-arm64": "1.0.11", + "@napi-rs/canvas-darwin-arm64": "1.0.11", + "@napi-rs/canvas-darwin-x64": "1.0.11", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.11", + "@napi-rs/canvas-linux-arm64-musl": "1.0.11", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.11", + "@napi-rs/canvas-linux-x64-gnu": "1.0.11", + "@napi-rs/canvas-linux-x64-musl": "1.0.11", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.11", + "@napi-rs/canvas-win32-x64-msvc": "1.0.11" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", - "integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.11.tgz", + "integrity": "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==", "cpu": [ "arm64" ], @@ -2595,9 +2596,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", - "integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.11.tgz", + "integrity": "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==", "cpu": [ "arm64" ], @@ -2615,9 +2616,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", - "integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.11.tgz", + "integrity": "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==", "cpu": [ "x64" ], @@ -2635,9 +2636,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", - "integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.11.tgz", + "integrity": "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==", "cpu": [ "arm" ], @@ -2655,12 +2656,15 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", - "integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.11.tgz", + "integrity": "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2675,12 +2679,15 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", - "integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.11.tgz", + "integrity": "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2695,12 +2702,15 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", - "integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.11.tgz", + "integrity": "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2715,12 +2725,15 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", - "integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.11.tgz", + "integrity": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2735,12 +2748,15 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", - "integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.11.tgz", + "integrity": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2755,9 +2771,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", - "integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz", + "integrity": "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==", "cpu": [ "arm64" ], @@ -2775,9 +2791,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", - "integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.11.tgz", + "integrity": "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index a307ad7..e1cb4cb 100644 --- a/package.json +++ b/package.json @@ -158,10 +158,12 @@ }, "overrides": { "@electron/get": "^4.0.2", + "@napi-rs/canvas": "$@napi-rs/canvas", "global-agent": "^3.0.1" }, "dependencies": { "@ant-design/icons": "^5.6.1", + "@napi-rs/canvas": "1.0.11", "@prisma/client": "^6.5.0", "antd": "^5.24.4", "electron-is-dev": "^3.0.1", diff --git a/scripts/__tests__/verify-package-distros.test.mjs b/scripts/__tests__/verify-package-distros.test.mjs index 794abde..3c6350c 100644 --- a/scripts/__tests__/verify-package-distros.test.mjs +++ b/scripts/__tests__/verify-package-distros.test.mjs @@ -27,15 +27,15 @@ describe('dockerRunArgs', () => { install: 'dnf install -y gtk3', appDir: '/repo/release/linux-unpacked', executableName: 'markpdfdown', - loadCheckScript: '/repo/scripts/package-load-check.cjs', + scriptsDir: '/repo/scripts', }); expect(args.slice(0, 2)).toEqual(['run', '--rm']); expect(args).toContain('/repo/release/linux-unpacked:/package:ro'); - expect(args).toContain('/repo/scripts/package-load-check.cjs:/load-check.cjs:ro'); + expect(args).toContain('/repo/scripts:/scripts:ro'); expect(args).toContain('fedora:44'); expect(args.at(-1)).toBe( - 'dnf install -y gtk3 && ELECTRON_RUN_AS_NODE=1 /package/markpdfdown /load-check.cjs /package/resources', + 'dnf install -y gtk3 && ELECTRON_RUN_AS_NODE=1 /package/markpdfdown /scripts/package-load-check.cjs /package/resources', ); }); }); diff --git a/scripts/fixtures/count-dark-pixels.cjs b/scripts/fixtures/count-dark-pixels.cjs new file mode 100644 index 0000000..b6d9081 --- /dev/null +++ b/scripts/fixtures/count-dark-pixels.cjs @@ -0,0 +1,26 @@ +/** + * Tells whether a decoded image holds what was drawn. Shared by scripts/package-load-check.cjs and + * tests, which have to pass the @napi-rs/canvas they mean to check: the packaged app has its own. + * + * @param {Buffer} image an encoded image, flattened onto white when transparent + * @param {object} canvasModule the @napi-rs/canvas module + * @returns {Promise<{ width: number, dark: number }>} its width and how many of its pixels are dark + */ +async function countDarkPixels(image, canvasModule) { + const decoded = await canvasModule.loadImage(image); + const { width, height } = decoded; + const context = canvasModule.createCanvas(width, height).getContext('2d'); + context.fillStyle = '#fff'; + context.fillRect(0, 0, width, height); + context.drawImage(decoded, 0, 0); + + const { data } = context.getImageData(0, 0, width, height); + let dark = 0; + for (let offset = 0; offset < data.length; offset += 4) { + const luminance = 0.2126 * data[offset] + 0.7152 * data[offset + 1] + 0.0722 * data[offset + 2]; + if (luminance < 128) dark++; + } + return { width, dark }; +} + +module.exports = { countDarkPixels }; diff --git a/scripts/package-load-check.cjs b/scripts/package-load-check.cjs index 3846bcc..8b25eaa 100644 --- a/scripts/package-load-check.cjs +++ b/scripts/package-load-check.cjs @@ -9,13 +9,29 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { countDarkPixels } = require('./fixtures/count-dark-pixels.cjs'); const appModules = path.join(process.argv[2], 'app.asar', 'node_modules'); const loadFromApp = (name) => require(path.join(appModules, name)); async function checkCanvas() { - const { createCanvas } = loadFromApp('@napi-rs/canvas'); - createCanvas(1, 1).getContext('2d'); + const canvasModule = loadFromApp('@napi-rs/canvas'); + const canvas = canvasModule.createCanvas(20, 10); + const context = canvas.getContext('2d'); + context.fillStyle = '#fff'; + context.fillRect(0, 0, 20, 10); + context.fillStyle = '#000'; + context.fillRect(0, 0, 10, 10); + + // ImageReencoder decodes and encodes every uploaded image this way. + const { width, dark } = await countDarkPixels(await canvas.encode('jpeg', 80), canvasModule); + if (width !== 20 || dark < 90) throw new Error(`A re-encoded image lost its pixels: ${dark} dark of ${width} wide`); + + // Some canvas versions segfault on a damaged image, which in the app takes down the main process, + // again on each start once its task is picked up again. Getting past this line is the check. + const encoded = await canvas.encode('jpeg', 80); + await canvasModule.loadImage(encoded.subarray(0, encoded.length >> 1)).catch(() => {}); + // The app reaches canvas through this module, which requires it at load time. loadFromApp('pdf-to-png-converter'); } diff --git a/scripts/verify-package-distros.mjs b/scripts/verify-package-distros.mjs index 3968538..5bc4728 100644 --- a/scripts/verify-package-distros.mjs +++ b/scripts/verify-package-distros.mjs @@ -40,16 +40,17 @@ export const DISTROS = [ { image: 'ubuntu:20.04', install: APT, engine: { x64: 'debian-openssl-1.1.x', arm64: 'linux-arm64-openssl-1.1.x' } }, ]; -export function dockerRunArgs({ image, install, appDir, executableName, loadCheckScript }) { +/** Mounts all of scripts/, since the load check requires files next to it. */ +export function dockerRunArgs({ image, install, appDir, executableName, scriptsDir }) { return [ 'run', '--rm', '--volume', `${appDir}:/package:ro`, - '--volume', `${loadCheckScript}:/load-check.cjs:ro`, + '--volume', `${scriptsDir}:/scripts:ro`, image, 'bash', '-c', - `${install} && ELECTRON_RUN_AS_NODE=1 /package/${executableName} /load-check.cjs /package/resources`, + `${install} && ELECTRON_RUN_AS_NODE=1 /package/${executableName} /scripts/package-load-check.cjs /package/resources`, ]; } @@ -94,7 +95,7 @@ function main() { install, appDir: path.dirname(resourcesDir), executableName, - loadCheckScript: path.join(SCRIPT_DIR, 'package-load-check.cjs'), + scriptsDir: SCRIPT_DIR, }), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: OUTPUT_LIMIT_BYTES }, ); diff --git a/src/core/application/services/ModelService.ts b/src/core/application/services/ModelService.ts index f6a5b48..d4f5894 100644 --- a/src/core/application/services/ModelService.ts +++ b/src/core/application/services/ModelService.ts @@ -1,5 +1,6 @@ import providerRepository from '../../domain/repositories/ProviderRepository.js'; import { LLMClientFactory, CompletionOptions, Message } from '../../infrastructure/adapters/llm/LLMClient.js'; +import { mayHoldMetadata, reencodeImage } from '../../infrastructure/adapters/split/ImageReencoder.js'; import { promises as fs } from 'fs'; @@ -60,7 +61,10 @@ const completion = async (providerId: number, options: CompletionOptions) => { // 输入图片文件路径,返回图片的base64编码字符串 const getImageBase64 = async (imagePath: string): Promise => { const buffer = await fs.readFile(imagePath); - return buffer.toString('base64'); + // ImageSplitter already re-encodes uploads, but retrying a task reuses its page files, and those + // of a task split by an older version are plain copies of what the user uploaded. + const image = mayHoldMetadata(buffer) ? await reencodeImage(buffer) : buffer; + return image.toString('base64'); } // 输入图片文件路径,构造Message diff --git a/src/core/infrastructure/adapters/split/ImageReencoder.ts b/src/core/infrastructure/adapters/split/ImageReencoder.ts new file mode 100644 index 0000000..650eb21 --- /dev/null +++ b/src/core/infrastructure/adapters/split/ImageReencoder.ts @@ -0,0 +1,80 @@ +import { createCanvas, loadImage } from '@napi-rs/canvas'; + +// The default of sharp, which encoded these images until it had to go (see AGENTS.md). +const LOSSY_QUALITY = 80; +const JPEG_END = Buffer.from([0xff, 0xd9]); + +/** + * Where an image's metadata may be: Exif (JPEG APP1, PNG eXIf, WebP EXIF), XMP, IPTC, PNG text. + * Plain bytes, so a chance match in pixel data is possible and only costs a needless re-encode. + */ +const METADATA_MARKERS = ['Exif', 'eXIf', 'EXIF', 'XMP ', 'xpacket', 'Photoshop 3.0', 'tEXt', 'iTXt', 'zTXt']; + +function isJpeg(image: Buffer): boolean { + return image[0] === 0xff && image[1] === 0xd8; +} + +function isWebp(image: Buffer): boolean { + return image.toString('latin1', 0, 4) === 'RIFF' && image.toString('latin1', 8, 12) === 'WEBP'; +} + +function isPng(image: Buffer): boolean { + return image.toString('latin1', 1, 4) === 'PNG'; +} + +/** Skips to the image data first: the end marker may also be that of a thumbnail in the Exif. */ +function jpegHasEnd(image: Buffer): boolean { + const START_OF_SCAN = 0xda; + let offset = 2; + while (offset + 4 <= image.length && image[offset] === 0xff) { + if (image[offset + 1] === START_OF_SCAN) return image.indexOf(JPEG_END, offset) !== -1; + offset += 2 + image.readUInt16BE(offset + 2); + } + return false; +} + +function pngHasEnd(image: Buffer): boolean { + let offset = 8; + while (offset + 8 <= image.length) { + if (image.toString('latin1', offset + 4, offset + 8) === 'IEND') return true; + offset += 12 + image.readUInt32BE(offset); + } + return false; +} + +function isTruncated(image: Buffer): boolean { + if (isJpeg(image)) return !jpegHasEnd(image); + if (isPng(image)) return !pngHasEnd(image); + if (isWebp(image)) return image.length < image.readUInt32LE(4) + 8; + return false; +} + +/** + * Decodes an image and encodes its pixels again, which leaves its metadata (GPS position, device, + * ...) behind and brings an oversized photo down to a normal size. An Exif orientation is applied + * to the pixels first, so that the image stays upright without it. + * + * Keeps the format of the content, whatever the file is called. PNG is the fallback since it's + * lossless and keeps transparency. + * + * @throws Error if the image is truncated or can't be decoded. Callers must not fall back to the + * original. + */ +export async function reencodeImage(source: Buffer): Promise { + // The decoder would draw what's there and leave the rest blank, and a page that silently lost + // its lower half would still convert. + if (isTruncated(source)) throw new Error('The image file is truncated'); + + const image = await loadImage(source); + const canvas = createCanvas(image.width, image.height); + canvas.getContext('2d').drawImage(image, 0, 0); + + if (isJpeg(source)) return canvas.encode('jpeg', LOSSY_QUALITY); + if (isWebp(source)) return canvas.encode('webp', LOSSY_QUALITY); + return canvas.encode('png'); +} + +/** Whether an image has to go through reencodeImage before it may leave the machine. */ +export function mayHoldMetadata(image: Buffer): boolean { + return METADATA_MARKERS.some((marker) => image.includes(marker)); +} diff --git a/src/core/infrastructure/adapters/split/ImageSplitter.ts b/src/core/infrastructure/adapters/split/ImageSplitter.ts index da58818..e35433f 100644 --- a/src/core/infrastructure/adapters/split/ImageSplitter.ts +++ b/src/core/infrastructure/adapters/split/ImageSplitter.ts @@ -4,6 +4,7 @@ import { ISplitter, SplitResult, PageInfo } from '../../../domain/split/ISplitte import { Task } from '../../../../shared/types/index.js'; import { ImagePathUtil } from './ImagePathUtil.js'; import { FileWaitUtil } from './FileWaitUtil.js'; +import { reencodeImage } from './ImageReencoder.js'; /** * Image splitter implementation for single-page image files. @@ -11,10 +12,11 @@ import { FileWaitUtil } from './FileWaitUtil.js'; * Supports: JPG, JPEG, PNG, WebP * * Behavior: - * - Copies image from uploads/{taskId}/{filename} to uploads/{taskId}/split/page-1.png + * - Re-encodes uploads/{taskId}/{filename} into uploads/{taskId}/split/page-1.png * - Always returns single page (images are not split) * - Ignores page_range parameter - * - Preserves image quality (direct file copy, no conversion) + * - The page keeps the image's format despite its name, but none of its metadata: this file is + * what the model provider receives, and uploads may carry a GPS position or device details */ export class ImageSplitter implements ISplitter { private readonly uploadsDir: string; @@ -24,11 +26,11 @@ export class ImageSplitter implements ISplitter { } /** - * "Split" an image file (actually just copy to temp directory as page-1.png). + * "Split" an image file (actually just re-encode it to the temp directory as page-1.png). * * @param task - Task containing image file information * @returns Split result with single page - * @throws Error if file not found or copy fails + * @throws Error if file not found or can't be re-encoded. The original is never used instead. */ async split(task: Task): Promise { if (!task.id) { @@ -46,7 +48,6 @@ export class ImageSplitter implements ISplitter { await FileWaitUtil.waitForFile(sourcePath, this.uploadsDir, taskId, filename, 'ImageSplitter'); try { - // Get file extension (preserve original format) const ext = path.extname(filename).toLowerCase(); if (!ext) { throw new Error(`Image file has no extension: ${filename}`); @@ -56,9 +57,9 @@ export class ImageSplitter implements ISplitter { const taskDir = ImagePathUtil.getTaskDir(taskId); await fs.mkdir(taskDir, { recursive: true }); - // Copy to standard location (page-1.png) + // Write to standard location (page-1.png) const targetPath = ImagePathUtil.getPath(taskId, 1); - await fs.copyFile(sourcePath, targetPath); + await fs.writeFile(targetPath, await reencodeImage(await fs.readFile(sourcePath))); const pageInfo: PageInfo = { page: 1, diff --git a/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.integration.test.ts b/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.integration.test.ts new file mode 100644 index 0000000..1aba945 --- /dev/null +++ b/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.integration.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createCanvas, loadImage } from '@napi-rs/canvas'; +import * as canvasModule from '@napi-rs/canvas'; +import { promises as fs } from 'fs'; +import { createRequire } from 'module'; +import os from 'os'; +import path from 'path'; +import { ImagePathUtil } from '../ImagePathUtil.js'; +import { ImageSplitter } from '../ImageSplitter.js'; +import modelService from '../../../../application/services/ModelService.js'; + +vi.mock('../../../../domain/repositories/ProviderRepository.js', () => ({ default: {} })); +vi.mock('../../../llm/LLMClient.js', () => ({ LLMClientFactory: {} })); + +const require = createRequire(import.meta.url); +const { countDarkPixels } = require('../../../../../../scripts/fixtures/count-dark-pixels.cjs'); + +/** What must never reach a provider: it stands for the device and GPS position in real files. */ +const SECRET = 'LeakyCam GPS 39.9075N 116.3916E'; +const ORIENTATION_ROTATE_90 = 6; + +/** A little-endian TIFF, as Exif is, with the camera make and optionally an orientation. */ +function exifBody(orientation?: number): Buffer { + const tags = orientation ? 2 : 1; + const valueOffset = 8 + 2 + tags * 12 + 4; + const ifd = Buffer.alloc(valueOffset); + ifd.write('II', 0, 'latin1'); + ifd.writeUInt16LE(42, 2); + ifd.writeUInt32LE(8, 4); + ifd.writeUInt16LE(tags, 8); + ifd.writeUInt16LE(0x010f, 10); // Make + ifd.writeUInt16LE(2, 12); // ASCII + ifd.writeUInt32LE(SECRET.length + 1, 14); + ifd.writeUInt32LE(valueOffset, 18); + if (orientation) { + ifd.writeUInt16LE(0x0112, 22); // Orientation + ifd.writeUInt16LE(3, 24); // SHORT + ifd.writeUInt32LE(1, 26); + ifd.writeUInt16LE(orientation, 30); + } + return Buffer.concat([ifd, Buffer.from(`${SECRET}\0`, 'latin1')]); +} + +function withJpegExif(jpeg: Buffer, orientation?: number): Buffer { + const app1 = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), exifBody(orientation)]); + const header = Buffer.from([0xff, 0xe1, 0, 0]); + header.writeUInt16BE(app1.length + 2, 2); + return Buffer.concat([jpeg.subarray(0, 2), header, app1, jpeg.subarray(2)]); +} + +function crc32(data: Buffer): number { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, body: Buffer): Buffer { + const typed = Buffer.concat([Buffer.from(type, 'latin1'), body]); + const length = Buffer.alloc(4); + length.writeUInt32BE(body.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(typed)); + return Buffer.concat([length, typed, crc]); +} + +function withPngExif(png: Buffer): Buffer { + const afterIhdr = 8 + 25; + return Buffer.concat([ + png.subarray(0, afterIhdr), + pngChunk('eXIf', exifBody()), + pngChunk('tEXt', Buffer.from(`Comment\0${SECRET}`, 'latin1')), + png.subarray(afterIhdr), + ]); +} + +/** Our WebP encoder writes a VP8X container, so this only has to flag and append the chunk. */ +function withWebpExif(webp: Buffer): Buffer { + expect(webp.toString('latin1', 12, 16)).toBe('VP8X'); + const body = exifBody(); + const chunkHeader = Buffer.alloc(8); + chunkHeader.write('EXIF', 0, 'latin1'); + chunkHeader.writeUInt32LE(body.length, 4); + const padding = Buffer.alloc(body.length % 2); + const result = Buffer.concat([webp, chunkHeader, body, padding]); + result[20] |= 0x08; // VP8X: has an EXIF chunk + result.writeUInt32LE(result.length - 8, 4); + return result; +} + +/** White with a black left half, so that lost or misplaced pixels show. */ +function halfBlackCanvas(width: number, height: number) { + const canvas = createCanvas(width, height); + const context = canvas.getContext('2d'); + context.fillStyle = '#fff'; + context.fillRect(0, 0, width, height); + context.fillStyle = '#000'; + context.fillRect(0, 0, width / 2, height); + return canvas; +} + +/** Fine detail, which is what makes a high quality JPEG as large as a real photo. */ +function detailedCanvas(width: number, height: number) { + const canvas = createCanvas(width, height); + const context = canvas.getContext('2d'); + const pixels = context.createImageData(width, height); + let seed = 1; + for (let offset = 0; offset < pixels.data.length; offset += 4) { + seed = (seed * 1664525 + 1013904223) >>> 0; + pixels.data[offset] = seed >>> 24; + pixels.data[offset + 1] = (seed >>> 16) & 0xff; + pixels.data[offset + 2] = (seed >>> 8) & 0xff; + pixels.data[offset + 3] = 255; + } + context.putImageData(pixels, 0, 0); + return canvas; +} + +/** + * The real splitter, canvas and message building, from the uploaded file to the request payload: + * this payload is what the model provider receives. + */ +describe('Image metadata on the way to the provider', () => { + const TASK_ID = 'task-1'; + let uploadsDir: string; + + const upload = async (filename: string, content: Buffer) => { + await fs.mkdir(path.join(uploadsDir, TASK_ID), { recursive: true }); + await fs.writeFile(path.join(uploadsDir, TASK_ID, filename), content); + }; + + const payloadOf = async (imagePath: string): Promise => { + const messages = await modelService.transformImageMessage(imagePath); + const url = (messages[1].content as { image_url?: { url: string } }[])[1].image_url!.url; + return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); + }; + + const splitAndSend = async (filename: string, content: Buffer): Promise => { + await upload(filename, content); + const { pages } = await new ImageSplitter(uploadsDir).split({ id: TASK_ID, filename }); + return payloadOf(pages[0].imagePath); + }; + + const expectNoMetadata = (image: Buffer) => { + for (const marker of ['Exif', 'eXIf', 'EXIF', 'tEXt', SECRET]) { + expect(image.includes(marker), `payload holds "${marker}"`).toBe(false); + } + }; + + beforeEach(async () => { + uploadsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'image-splitter-')); + ImagePathUtil.init(uploadsDir); + }); + + afterEach(async () => { + ImagePathUtil.reset(); + await fs.rm(uploadsDir, { recursive: true, force: true }); + }); + + it('should drop the APP1 Exif of a JPEG and keep its pixels', async () => { + const original = withJpegExif(await halfBlackCanvas(200, 100).encode('jpeg', 95)); + expect(original.includes(SECRET)).toBe(true); + + const payload = await splitAndSend('photo.jpg', original); + + expectNoMetadata(payload); + expect(payload.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8])); + const { width, dark } = await countDarkPixels(payload, canvasModule); + expect(width).toBe(200); + expect(dark).toBeGreaterThan(200 * 100 * 0.45); + expect(dark).toBeLessThan(200 * 100 * 0.55); + }); + + it('should drop the eXIf and text chunks of a PNG and keep it lossless', async () => { + const original = withPngExif(await halfBlackCanvas(200, 100).encode('png')); + expect((await loadImage(original)).width).toBe(200); + expect(original.includes('eXIf')).toBe(true); + + const payload = await splitAndSend('screenshot.png', original); + + expectNoMetadata(payload); + expect(payload.subarray(1, 4).toString('latin1')).toBe('PNG'); + expect(await countDarkPixels(payload, canvasModule)).toEqual({ width: 200, dark: 100 * 100 }); + }); + + it('should drop the EXIF chunk of a WebP', async () => { + const original = withWebpExif(await halfBlackCanvas(200, 100).encode('webp', 95)); + expect((await loadImage(original)).width).toBe(200); + + const payload = await splitAndSend('photo.webp', original); + + expectNoMetadata(payload); + expect(payload.toString('latin1', 8, 12)).toBe('WEBP'); + }); + + it('should apply the Exif orientation, which is about to be lost, to the pixels', async () => { + const original = withJpegExif(await halfBlackCanvas(400, 200).encode('jpeg', 95), ORIENTATION_ROTATE_90); + + const payload = await splitAndSend('portrait.jpg', original); + + const decoded = await loadImage(payload); + expect([decoded.width, decoded.height]).toEqual([200, 400]); + expectNoMetadata(payload); + }); + + it('should go by the content rather than the file name', async () => { + const jpegNamedPng = withJpegExif(await halfBlackCanvas(20, 10).encode('jpeg', 95)); + + const payload = await splitAndSend('mislabeled.png', jpegNamedPng); + + expect(payload.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8])); + expectNoMetadata(payload); + }); + + it('should keep the transparency of a PNG', async () => { + const canvas = createCanvas(10, 10); + canvas.getContext('2d').fillRect(0, 0, 5, 10); + + const payload = await splitAndSend('transparent.png', await canvas.encode('png')); + + const decoded = await loadImage(payload); + const context = createCanvas(10, 10).getContext('2d'); + context.drawImage(decoded, 0, 0); + expect(context.getImageData(0, 0, 1, 1).data[3]).toBe(255); + expect(context.getImageData(9, 0, 1, 1).data[3]).toBe(0); + }); + + it('should bring a high quality photo down to a normal size', async () => { + const original = await detailedCanvas(800, 600).encode('jpeg', 97); + + const payload = await splitAndSend('large.jpg', original); + + expect(payload.length).toBeLessThan(original.length * 0.7); + }); + + /** Large enough for the decoder to get going, where it draws what's there instead of failing. */ + const cutInHalf = (image: Buffer) => image.subarray(0, image.length >> 1); + + it.each([ + ['garbage', async () => Buffer.from('not an image')], + ['a JPEG cut off in its header', async () => (await halfBlackCanvas(300, 200).encode('jpeg', 80)).subarray(0, 100)], + ['a WebP cut off in its header', async () => (await halfBlackCanvas(300, 200).encode('webp', 80)).subarray(0, 100)], + ['a PNG cut off in its header', async () => (await halfBlackCanvas(300, 200).encode('png')).subarray(0, 10)], + ['a truncated JPEG', async () => cutInHalf(await detailedCanvas(800, 600).encode('jpeg', 90))], + ['a truncated PNG', async () => cutInHalf(await detailedCanvas(800, 600).encode('png'))], + ['a truncated WebP', async () => cutInHalf(await detailedCanvas(800, 600).encode('webp', 90))], + ])('should fail on %s without writing a page or crashing the process', async (_name, content) => { + await upload('broken.jpg', await content()); + + await expect(new ImageSplitter(uploadsDir).split({ id: TASK_ID, filename: 'broken.jpg' })).rejects.toThrow( + /Failed to process image broken\.jpg/, + ); + + await expect(fs.access(ImagePathUtil.getPath(TASK_ID, 1))).rejects.toThrow(); + }); + + it('should not take the end of a thumbnail in the Exif for the end of a truncated JPEG', async () => { + const jpeg = withJpegExif(await detailedCanvas(800, 600).encode('jpeg', 90)); + const thumbnailEnd = Buffer.from([0xff, 0xd9]); + const app1 = Buffer.concat([Buffer.from([0xff, 0xe1, 0, 8]), Buffer.from('Thmb'), thumbnailEnd]); + const original = cutInHalf(Buffer.concat([jpeg.subarray(0, 2), app1, jpeg.subarray(2)])); + expect(original.includes(thumbnailEnd)).toBe(true); + + await upload('broken.jpg', original); + + await expect(new ImageSplitter(uploadsDir).split({ id: TASK_ID, filename: 'broken.jpg' })).rejects.toThrow( + /truncated/, + ); + }); + + it('should accept a complete JPEG followed by the trailer some cameras add', async () => { + const original = Buffer.concat([await halfBlackCanvas(200, 100).encode('jpeg', 95), Buffer.from('trailer')]); + + const payload = await splitAndSend('camera.jpg', original); + + expect((await loadImage(payload)).width).toBe(200); + }); + + describe('a page file that was not split by this version', () => { + const writePage = async (content: Buffer) => { + const pagePath = ImagePathUtil.getPath(TASK_ID, 1); + await fs.mkdir(path.dirname(pagePath), { recursive: true }); + await fs.writeFile(pagePath, content); + return pagePath; + }; + + it('should strip a copy of the upload, which is what retrying an older task sends', async () => { + const raw = withJpegExif(await halfBlackCanvas(200, 100).encode('jpeg', 95)); + + expectNoMetadata(await payloadOf(await writePage(raw))); + }); + + it('should strip an old copy of a PNG too', async () => { + const raw = withPngExif(await halfBlackCanvas(200, 100).encode('png')); + + expectNoMetadata(await payloadOf(await writePage(raw))); + }); + + it('should send a page without metadata unchanged, as those of PDFs are', async () => { + const clean = await halfBlackCanvas(200, 100).encode('png'); + + expect(await payloadOf(await writePage(clean))).toEqual(clean); + }); + }); +}); diff --git a/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts b/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts index e5c9cb8..1a23d96 100644 --- a/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ImageSplitter } from '../ImageSplitter.js'; import { ImagePathUtil } from '../ImagePathUtil.js'; import { FileWaitUtil } from '../FileWaitUtil.js'; +import { reencodeImage } from '../ImageReencoder.js'; import { promises as fs } from 'fs'; import path from 'path'; @@ -19,11 +20,20 @@ vi.mock('fs', () => ({ stat: vi.fn(), readdir: vi.fn(), mkdir: vi.fn(), - copyFile: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), rm: vi.fn(), }, })); +// The real thing is covered by ImageSplitter.integration.test.ts +vi.mock('../ImageReencoder.js', () => ({ + reencodeImage: vi.fn(), +})); + +const ORIGINAL = Buffer.from('original image'); +const REENCODED = Buffer.from('re-encoded image'); + describe('ImageSplitter', () => { const uploadsDir = '/mock/uploads'; let splitter: ImageSplitter; @@ -40,6 +50,8 @@ describe('ImageSplitter', () => { // Setup default mock for FileWaitUtil (file available immediately) vi.mocked(FileWaitUtil.waitForFile).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(ORIGINAL); + vi.mocked(reencodeImage).mockResolvedValue(REENCODED); }); afterEach(() => { @@ -47,7 +59,7 @@ describe('ImageSplitter', () => { }); describe('split()', () => { - it('should copy JPG image to split directory', async () => { + it('should re-encode JPG image into split directory', async () => { const task = { id: 'task123', filename: 'photo.jpg', @@ -55,7 +67,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -65,13 +77,15 @@ describe('ImageSplitter', () => { expect(result.pages[0].pageSource).toBe(1); expect(result.pages[0].imagePath).toBe(path.join(uploadsDir, 'task123', 'split', 'page-1.png')); - expect(fs.copyFile).toHaveBeenCalledWith( - path.join(uploadsDir, 'task123', 'photo.jpg'), - path.join(uploadsDir, 'task123', 'split', 'page-1.png') + expect(fs.readFile).toHaveBeenCalledWith(path.join(uploadsDir, 'task123', 'photo.jpg')); + expect(reencodeImage).toHaveBeenCalledWith(ORIGINAL); + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(uploadsDir, 'task123', 'split', 'page-1.png'), + REENCODED ); }); - it('should copy PNG image to split directory', async () => { + it('should re-encode PNG image into split directory', async () => { const task = { id: 'task456', filename: 'screenshot.png', @@ -79,14 +93,15 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); expect(result.totalPages).toBe(1); - expect(fs.copyFile).toHaveBeenCalledWith( - path.join(uploadsDir, 'task456', 'screenshot.png'), - path.join(uploadsDir, 'task456', 'split', 'page-1.png') + expect(fs.readFile).toHaveBeenCalledWith(path.join(uploadsDir, 'task456', 'screenshot.png')); + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(uploadsDir, 'task456', 'split', 'page-1.png'), + REENCODED ); }); @@ -98,7 +113,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -113,7 +128,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -128,7 +143,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -143,7 +158,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); await splitter.split(task); @@ -162,7 +177,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -210,15 +225,25 @@ describe('ImageSplitter', () => { await expect(splitter.split(task)).rejects.toThrow(/Image file not found/); }); + it('should not write the original when it cannot be re-encoded', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(reencodeImage).mockRejectedValue(new Error('Unsupported image type')); + + await expect(splitter.split({ id: 'task', filename: 'broken.jpg' })).rejects.toThrow( + /Failed to process image broken\.jpg: Unsupported image type/ + ); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + it('should throw error for permission denied', async () => { const task = { id: 'task123', filename: 'photo.jpg', }; - // FileWaitUtil passes, but file copy fails with permission denied + // FileWaitUtil passes, but writing the page fails with permission denied vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockRejectedValue(new Error('EACCES: permission denied')); + vi.mocked(fs.writeFile).mockRejectedValue(new Error('EACCES: permission denied')); await expect(splitter.split(task)).rejects.toThrow(/Permission denied/); }); @@ -231,7 +256,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockRejectedValue(new Error('ENOSPC: no space left on device')); + vi.mocked(fs.writeFile).mockRejectedValue(new Error('ENOSPC: no space left on device')); await expect(splitter.split(task)).rejects.toThrow(/Not enough disk space/); }); @@ -272,7 +297,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -292,7 +317,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -308,7 +333,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -326,7 +351,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -341,7 +366,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -356,7 +381,7 @@ describe('ImageSplitter', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await splitter.split(task); From 97b8c39a173e0a9e9f4cab1623398cf011dd3a78 Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Thu, 5 Nov 2026 10:06:25 +0800 Subject: [PATCH 4/5] fix(db): explain how to rebuild a database that can't be migrated A database left half migrated by an older version, or one that lost the record of a migration, fails on every start, while the alert only said to restart. initDatabase now also returns the database path when the file is what's failing, and the alert gives it with the steps to back it up and rebuild it. A missing engine keeps the old text, since a new database would fail as well. --- docs/IPC_API.md | 2 +- .../db/__tests__/Migration.test.ts | 30 +++++++++++- .../db/__tests__/initDatabase.test.ts | 9 +++- src/core/infrastructure/db/index.d.ts | 2 +- src/core/infrastructure/db/index.ts | 47 ++++++++++++------- src/preload/electron.d.ts | 2 +- .../components/DatabaseStatusAlert.tsx | 26 +++++++++- .../__tests__/DatabaseStatusAlert.test.tsx | 46 ++++++++++++++++++ src/renderer/electron.d.ts | 2 +- src/renderer/locales/ar-SA/common.json | 9 +++- src/renderer/locales/en-US/common.json | 9 +++- src/renderer/locales/fa-IR/common.json | 9 +++- src/renderer/locales/ja-JP/common.json | 9 +++- src/renderer/locales/ru-RU/common.json | 9 +++- src/renderer/locales/zh-CN/common.json | 9 +++- 15 files changed, 187 insertions(+), 33 deletions(-) diff --git a/docs/IPC_API.md b/docs/IPC_API.md index 57c17d0..f0e9ca6 100644 --- a/docs/IPC_API.md +++ b/docs/IPC_API.md @@ -149,7 +149,7 @@ Application information. | Method | Description | |--------|-------------| | `window.api.app.getVersion()` | Get app version from package.json | -| `window.api.app.getDatabaseStatus()` | Wait for the startup database initialization; `success: false` with `error` when it failed | +| `window.api.app.getDatabaseStatus()` | Wait for the startup database initialization; `success: false` with `error` when it failed, plus `databasePath` when rebuilding the database file can help | --- diff --git a/src/core/infrastructure/db/__tests__/Migration.test.ts b/src/core/infrastructure/db/__tests__/Migration.test.ts index 78cc64b..acffd21 100644 --- a/src/core/infrastructure/db/__tests__/Migration.test.ts +++ b/src/core/infrastructure/db/__tests__/Migration.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'fs' +import { mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import path from 'path' import { PrismaClient } from '@prisma/client' import { runMigrations } from '../Migration.js' +const MIGRATIONS_DIR = path.join(process.cwd(), 'src', 'core', 'infrastructure', 'db', 'migrations') + /** Runs against a real SQLite file: what matters is how the runner reacts to real SQL errors. */ describe('runMigrations', () => { let dir: string @@ -48,4 +50,30 @@ describe('runMigrations', () => { SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'Provider'` expect(provider).toEqual([]) }) + + describe('databases that fail every time, so that restarting does not help', () => { + it('fails on a database left half migrated by an older version, again on a retry', async () => { + await runMigrations(prisma) + const applied = await appliedMigrations() + const rebuildsTask = applied.findLast((name) => + readFileSync(path.join(MIGRATIONS_DIR, name, 'migration.sql'), 'utf8').includes('"new_Task"') + ) + // What rebuilding the table left behind when it stopped between its DROP and its RENAME. + await prisma.$executeRawUnsafe('ALTER TABLE "Task" RENAME TO "new_Task"') + await prisma.$executeRawUnsafe( + `DELETE FROM _prisma_migrations WHERE migration_name >= '${rebuildsTask}'` + ) + + await expect(runMigrations(prisma)).rejects.toThrow(`Failed to apply migration ${rebuildsTask}`) + await expect(runMigrations(prisma)).rejects.toThrow(`Failed to apply migration ${rebuildsTask}`) + }) + + it('fails on an intact database that lost the record of a migration', async () => { + await runMigrations(prisma) + const [first] = await appliedMigrations() + await prisma.$executeRawUnsafe(`DELETE FROM _prisma_migrations WHERE migration_name = '${first}'`) + + await expect(runMigrations(prisma)).rejects.toThrow(`Failed to apply migration ${first}`) + }) + }) }) diff --git a/src/core/infrastructure/db/__tests__/initDatabase.test.ts b/src/core/infrastructure/db/__tests__/initDatabase.test.ts index 6606df8..1b6fc83 100644 --- a/src/core/infrastructure/db/__tests__/initDatabase.test.ts +++ b/src/core/infrastructure/db/__tests__/initDatabase.test.ts @@ -41,6 +41,7 @@ describe('initDatabase', () => { it('reports why the engine could not be loaded', async () => { queryRaw.mockRejectedValue(new Error('\nPrisma Client could not locate the Query Engine\n')) + // No database path: a new database file couldn't fix this, so nobody should be told to make one. await expect(initDatabase()).resolves.toEqual({ success: false, error: 'Prisma Client could not locate the Query Engine' @@ -51,7 +52,11 @@ describe('initDatabase', () => { it('reports a failed migration', async () => { runMigrations.mockRejectedValue(new Error('no such table')) - await expect(initDatabase()).resolves.toEqual({ success: false, error: 'no such table' }) + await expect(initDatabase()).resolves.toEqual({ + success: false, + error: 'no such table', + databasePath: expect.stringMatching(/dev\.db$/) + }) }) it('initializes once however many callers ask, so a late caller gets the same outcome', async () => { @@ -61,7 +66,7 @@ describe('initDatabase', () => { const late = await initDatabase() expect(queryRaw).toHaveBeenCalledTimes(1) - expect(first).toEqual({ success: false, error: 'boom' }) + expect(first).toMatchObject({ success: false, error: 'boom' }) expect(second).toEqual(first) expect(late).toEqual(first) }) diff --git a/src/core/infrastructure/db/index.d.ts b/src/core/infrastructure/db/index.d.ts index 493d614..75f2773 100644 --- a/src/core/infrastructure/db/index.d.ts +++ b/src/core/infrastructure/db/index.d.ts @@ -1,6 +1,6 @@ import { PrismaClient } from '@prisma/client'; export const prisma: PrismaClient; -export type DatabaseInitResult = { success: true } | { success: false; error: string }; +export type DatabaseInitResult = { success: true } | { success: false; error: string; databasePath?: string }; export function initDatabase(): Promise; export function disconnect(): Promise; \ No newline at end of file diff --git a/src/core/infrastructure/db/index.ts b/src/core/infrastructure/db/index.ts index e5eb43f..b62af7b 100644 --- a/src/core/infrastructure/db/index.ts +++ b/src/core/infrastructure/db/index.ts @@ -6,17 +6,23 @@ import { app } from "electron"; import fs from "fs"; import isDev from "electron-is-dev"; -type DatabaseInitResult = { success: true } | { success: false; error: string }; +/** + * databasePath is set when the database file is what's failing, so that rebuilding it can help. + * It isn't when Prisma can't even run a query: without an engine, a new file would fail as well. + */ +type DatabaseInitResult = + | { success: true } + | { success: false; error: string; databasePath?: string }; // 懒加载的 Prisma 实例和数据库 URL let prismaInstance: InstanceType | null = null; -let cachedDbUrl: string | null = null; +let cachedDbPath: string | null = null; let initResult: Promise | null = null; -// 设置和获取数据库URL(懒加载,确保 app.setName 已执行) -function getDatabaseUrl(): string { - if (cachedDbUrl) { - return cachedDbUrl; +// 设置和获取数据库文件路径(懒加载,确保 app.setName 已执行) +function getDatabasePath(): string { + if (cachedDbPath) { + return cachedDbPath; } // 否则,为打包应用生成一个默认路径 @@ -26,25 +32,24 @@ function getDatabaseUrl(): string { if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } - cachedDbUrl = `file:${path.join(dbDir, "app.db")}`; - console.log("Using userData database path:", cachedDbUrl); - return cachedDbUrl; + cachedDbPath = path.join(dbDir, "app.db"); + console.log("Using userData database path:", cachedDbPath); + return cachedDbPath; } // 开发环境回退路径 - cachedDbUrl = `file:${path.join(process.cwd(), "src", "core", "infrastructure", "db", "dev.db")}`; - console.log("Using default development database path:", cachedDbUrl); - return cachedDbUrl; + cachedDbPath = path.join(process.cwd(), "src", "core", "infrastructure", "db", "dev.db"); + console.log("Using default development database path:", cachedDbPath); + return cachedDbPath; } // 获取 Prisma 实例(懒加载) function getPrismaClient(): InstanceType { if (!prismaInstance) { - const dbUrl = getDatabaseUrl(); prismaInstance = new PrismaClient({ datasources: { db: { - url: dbUrl, + url: `file:${getDatabasePath()}`, }, }, }); @@ -60,13 +65,15 @@ const prisma = new Proxy({} as InstanceType, { }); const connectAndMigrate = async (): Promise => { + let rebuildableDatabasePath: string | undefined; try { - const dbUrl = getDatabaseUrl(); + const dbPath = getDatabasePath(); const client = getPrismaClient(); - console.log(`Initializing database(url:${dbUrl})...`); + console.log(`Initializing database(path:${dbPath})...`); // 验证数据库连接 await client.$queryRaw`SELECT 1`; console.log("Database connection established successfully."); + rebuildableDatabasePath = dbPath; // 运行迁移,传递现有的prisma实例 await runMigrations(client); @@ -75,7 +82,11 @@ const connectAndMigrate = async (): Promise => { } catch (error) { console.error("Database initialization error:", error); // Prisma 的错误信息首尾带空行 - return { success: false, error: (error instanceof Error ? error.message : String(error)).trim() }; + return { + success: false, + error: (error instanceof Error ? error.message : String(error)).trim(), + databasePath: rebuildableDatabasePath, + }; } }; @@ -95,7 +106,7 @@ const disconnect = async (): Promise => { if (prismaInstance) { await prismaInstance.$disconnect(); prismaInstance = null; - cachedDbUrl = null; + cachedDbPath = null; initResult = null; console.log("Database connection has been closed"); } diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index 24afec0..67faaa6 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -158,7 +158,7 @@ interface WindowAPI { platform: NodeJS.Platform; app: { getVersion: () => Promise; - getDatabaseStatus: () => Promise<{ success: boolean; error?: string }>; + getDatabaseStatus: () => Promise<{ success: boolean; error?: string; databasePath?: string }>; }; } diff --git a/src/renderer/components/DatabaseStatusAlert.tsx b/src/renderer/components/DatabaseStatusAlert.tsx index d8d4814..74c3a5d 100644 --- a/src/renderer/components/DatabaseStatusAlert.tsx +++ b/src/renderer/components/DatabaseStatusAlert.tsx @@ -5,17 +5,22 @@ import { useTranslation } from "react-i18next"; /** * Stays on screen while the database is unavailable. Pages only see the failure as individual * requests failing, which doesn't tell the user that the whole app is affected, or why. + * When the database file is what's failing, restarting can't help, so it explains how to rebuild it. */ const DatabaseStatusAlert: React.FC = () => { const { t } = useTranslation("common"); const [error, setError] = useState(null); + const [databasePath, setDatabasePath] = useState(); useEffect(() => { let mounted = true; window.api.app .getDatabaseStatus() .then((status) => { - if (mounted && !status.success) setError(status.error ?? ""); + if (mounted && !status.success) { + setError(status.error ?? ""); + setDatabasePath(status.databasePath); + } }) .catch((err: unknown) => { if (mounted) setError(err instanceof Error ? err.message : String(err)); @@ -36,7 +41,24 @@ const DatabaseStatusAlert: React.FC = () => { message={t("databaseError.title")} description={ <> - {t("databaseError.description")} + {databasePath ? ( + <> + {t("databaseError.rebuild.description")} +
    +
  1. {t("databaseError.rebuild.quit")}
  2. +
  3. + {t("databaseError.rebuild.moveFile")} + + {databasePath} + +
  4. +
  5. {t("databaseError.rebuild.reopen")}
  6. +
  7. {t("databaseError.rebuild.stillFailing")}
  8. +
+ + ) : ( + t("databaseError.description") + )}
{t("databaseError.details")} diff --git a/src/renderer/components/__tests__/DatabaseStatusAlert.test.tsx b/src/renderer/components/__tests__/DatabaseStatusAlert.test.tsx index 00bdccd..4e16a3a 100644 --- a/src/renderer/components/__tests__/DatabaseStatusAlert.test.tsx +++ b/src/renderer/components/__tests__/DatabaseStatusAlert.test.tsx @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, cleanup, waitFor } from '@testing-library/react' import DatabaseStatusAlert from '../DatabaseStatusAlert' +import arSA from '../../locales/ar-SA/common.json' +import enUS from '../../locales/en-US/common.json' +import faIR from '../../locales/fa-IR/common.json' +import jaJP from '../../locales/ja-JP/common.json' +import ruRU from '../../locales/ru-RU/common.json' +import zhCN from '../../locales/zh-CN/common.json' vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) @@ -51,6 +57,46 @@ describe('DatabaseStatusAlert', () => { expect(screen.getByText('Prisma Client could not locate the Query Engine')).toBeInTheDocument() }) + it('does not suggest rebuilding the database when a new one would fail as well', async () => { + getDatabaseStatus.mockResolvedValue({ success: false, error: 'no engine' }) + + render() + + await screen.findByRole('alert') + expect(screen.queryByText('databaseError.rebuild.description')).not.toBeInTheDocument() + }) + + it('gives the database path and the steps to rebuild it when the database is what fails', async () => { + getDatabaseStatus.mockResolvedValue({ + success: false, + error: 'Failed to apply migration 20250414154412_', + databasePath: '/home/user/.config/markpdfdown/db/app.db' + }) + + render() + + expect(await screen.findByText('/home/user/.config/markpdfdown/db/app.db')).toBeInTheDocument() + // Restarting is what it would otherwise say, and it can't help here. + expect(screen.queryByText('databaseError.description')).not.toBeInTheDocument() + expect(screen.getByText('databaseError.rebuild.description')).toBeInTheDocument() + expect(screen.getAllByRole('listitem').map((item) => item.firstChild?.textContent)).toEqual([ + 'databaseError.rebuild.quit', + 'databaseError.rebuild.moveFile', + 'databaseError.rebuild.reopen', + 'databaseError.rebuild.stillFailing' + ]) + expect(screen.getByText('Failed to apply migration 20250414154412_')).toBeInTheDocument() + }) + + it('has the rebuild steps in every language', () => { + const expected = Object.keys(enUS.databaseError.rebuild) + + for (const locale of [arSA, enUS, faIR, jaJP, ruRU, zhCN]) { + expect(Object.keys(locale.databaseError.rebuild)).toEqual(expected) + expect(Object.values(locale.databaseError.rebuild).every(Boolean)).toBe(true) + } + }) + it('still shows the alert when the failure has no message', async () => { getDatabaseStatus.mockResolvedValue({ success: false }) diff --git a/src/renderer/electron.d.ts b/src/renderer/electron.d.ts index 0868515..799b930 100644 --- a/src/renderer/electron.d.ts +++ b/src/renderer/electron.d.ts @@ -328,7 +328,7 @@ interface ElectronAPI { app: { getVersion: () => Promise>; - getDatabaseStatus: () => Promise; + getDatabaseStatus: () => Promise<{ success: boolean; error?: string; databasePath?: string }>; }; } diff --git a/src/renderer/locales/ar-SA/common.json b/src/renderer/locales/ar-SA/common.json index 244a7ff..e65e0a0 100644 --- a/src/renderer/locales/ar-SA/common.json +++ b/src/renderer/locales/ar-SA/common.json @@ -36,6 +36,13 @@ "databaseError": { "title": "فشلت تهيئة قاعدة البيانات", "description": "الميزات المحلية مثل مزودي الخدمة والمهام غير متاحة. أعد تشغيل التطبيق. إذا استمرت المشكلة، فأبلغ عنها مع إرفاق التفاصيل أدناه.", - "details": "تفاصيل الخطأ" + "details": "تفاصيل الخطأ", + "rebuild": { + "description": "تعذّرت ترقية قاعدة البيانات المحلية إلى هذا الإصدار، لذا فإن الميزات المحلية مثل مزودي الخدمة والمهام غير متاحة، ولن تحل إعادة التشغيل المشكلة. يمكنك إنشاء نسخة احتياطية ثم إعادة إنشاء قاعدة البيانات:", + "quit": "أغلق التطبيق بالكامل.", + "moveFile": "انقل ملف قاعدة البيانات أدناه إلى مكان آخر كنسخة احتياطية، مع أي ملف بجانبه يبدأ اسمه بالطريقة نفسها (مثل -journal):", + "reopen": "افتح التطبيق مرة أخرى، وسينشئ قاعدة بيانات جديدة. يجب إضافة مزودي الخدمة والمهام من جديد، وتبقى البيانات القديمة في النسخة الاحتياطية.", + "stillFailing": "إذا استمر الفشل، فأعد النسخة الاحتياطية إلى مكانها وأبلغنا مع إرفاق التفاصيل أدناه." + } } } diff --git a/src/renderer/locales/en-US/common.json b/src/renderer/locales/en-US/common.json index 2589953..6c03a41 100644 --- a/src/renderer/locales/en-US/common.json +++ b/src/renderer/locales/en-US/common.json @@ -36,6 +36,13 @@ "databaseError": { "title": "The database failed to initialize", "description": "Local features such as providers and tasks are unavailable. Restart the app. If this keeps happening, report it with the details below.", - "details": "Error details" + "details": "Error details", + "rebuild": { + "description": "The local database can't be upgraded to this version, so local features such as providers and tasks are unavailable, and restarting won't fix it. You can back it up and rebuild it:", + "quit": "Quit the app completely.", + "moveFile": "Move the database file below somewhere else as a backup, along with any file next to it whose name starts the same way (such as -journal):", + "reopen": "Open the app again. It creates a new database. Providers and tasks have to be added again; the old data stays in your backup.", + "stillFailing": "If it still fails, move the backup back and report this with the details below." + } } } diff --git a/src/renderer/locales/fa-IR/common.json b/src/renderer/locales/fa-IR/common.json index 4a2a41f..db6444c 100644 --- a/src/renderer/locales/fa-IR/common.json +++ b/src/renderer/locales/fa-IR/common.json @@ -36,6 +36,13 @@ "databaseError": { "title": "راه‌اندازی پایگاه داده ناموفق بود", "description": "قابلیت‌های محلی مانند ارائه‌دهندگان و وظایف در دسترس نیستند. برنامه را دوباره راه‌اندازی کنید. اگر مشکل ادامه داشت، آن را همراه با جزئیات زیر گزارش دهید.", - "details": "جزئیات خطا" + "details": "جزئیات خطا", + "rebuild": { + "description": "پایگاه داده محلی به این نسخه ارتقا نمی‌یابد، بنابراین قابلیت‌های محلی مانند ارائه‌دهندگان و وظایف در دسترس نیستند و راه‌اندازی مجدد مشکل را حل نمی‌کند. می‌توانید از آن پشتیبان بگیرید و آن را دوباره بسازید:", + "quit": "برنامه را کاملاً ببندید.", + "moveFile": "فایل پایگاه داده زیر را به‌عنوان پشتیبان به جای دیگری منتقل کنید، همراه با هر فایل کنار آن که نامش به همین شکل شروع می‌شود (مانند -journal):", + "reopen": "برنامه را دوباره باز کنید تا پایگاه داده جدیدی بسازد. ارائه‌دهندگان و وظایف باید دوباره اضافه شوند و داده‌های قبلی در پشتیبان می‌مانند.", + "stillFailing": "اگر همچنان ناموفق بود، پشتیبان را به جای اصلی برگردانید و با پیوست کردن جزئیات زیر به ما گزارش دهید." + } } } diff --git a/src/renderer/locales/ja-JP/common.json b/src/renderer/locales/ja-JP/common.json index bc214a9..175c9af 100644 --- a/src/renderer/locales/ja-JP/common.json +++ b/src/renderer/locales/ja-JP/common.json @@ -36,6 +36,13 @@ "databaseError": { "title": "データベースの初期化に失敗しました", "description": "プロバイダーやタスクなどのローカル機能は利用できません。アプリを再起動してください。問題が解決しない場合は、以下の詳細を添えてご報告ください。", - "details": "エラーの詳細" + "details": "エラーの詳細", + "rebuild": { + "description": "ローカルデータベースをこのバージョンにアップグレードできないため、プロバイダーやタスクなどのローカル機能は利用できません。アプリを再起動しても解決しません。次の手順でバックアップしてから再構築できます:", + "quit": "アプリを完全に終了します。", + "moveFile": "下記のデータベースファイルをバックアップとして別の場所に移動します。同じフォルダーにある、同じ名前で始まるファイル(-journal など)も一緒に移動してください:", + "reopen": "アプリをもう一度開くと、新しいデータベースが作成されます。プロバイダーとタスクは追加し直す必要があります。元のデータはバックアップに残ります。", + "stillFailing": "それでも失敗する場合は、バックアップを元の場所に戻し、下記の詳細を添えてご報告ください。" + } } } diff --git a/src/renderer/locales/ru-RU/common.json b/src/renderer/locales/ru-RU/common.json index ca621a4..4b8d42b 100644 --- a/src/renderer/locales/ru-RU/common.json +++ b/src/renderer/locales/ru-RU/common.json @@ -36,6 +36,13 @@ "databaseError": { "title": "Не удалось инициализировать базу данных", "description": "Локальные функции, такие как провайдеры и задачи, недоступны. Перезапустите приложение. Если проблема повторится, сообщите о ней, приложив сведения ниже.", - "details": "Подробности ошибки" + "details": "Подробности ошибки", + "rebuild": { + "description": "Локальную базу данных не удаётся обновить до этой версии, поэтому локальные функции, такие как провайдеры и задачи, недоступны, и перезапуск не поможет. Вы можете сделать резервную копию и пересоздать базу:", + "quit": "Полностью закройте приложение.", + "moveFile": "Переместите указанный ниже файл базы данных в другое место как резервную копию, вместе с файлами рядом с ним, имя которых начинается так же (например, -journal):", + "reopen": "Снова откройте приложение. Оно создаст новую базу данных. Провайдеров и задачи придётся добавить заново; прежние данные останутся в резервной копии.", + "stillFailing": "Если ошибка повторится, верните резервную копию на место и сообщите нам о проблеме, приложив сведения ниже." + } } } diff --git a/src/renderer/locales/zh-CN/common.json b/src/renderer/locales/zh-CN/common.json index b6ad814..03cc0ee 100644 --- a/src/renderer/locales/zh-CN/common.json +++ b/src/renderer/locales/zh-CN/common.json @@ -36,6 +36,13 @@ "databaseError": { "title": "数据库初始化失败", "description": "服务商、任务等本地功能当前不可用。请重启应用;如果问题仍然存在,请附上下方详情反馈给我们。", - "details": "错误详情" + "details": "错误详情", + "rebuild": { + "description": "本地数据库无法升级到当前版本,服务商、任务等本地功能当前不可用,重启应用也无法解决。可以按以下步骤备份后重建数据库:", + "quit": "完全退出应用。", + "moveFile": "把下面的数据库文件移到其他位置作为备份,同目录下文件名以它开头的文件(如 -journal)也一并移走:", + "reopen": "重新打开应用,它会创建新的数据库。服务商和任务记录需要重新添加,原有数据仍保留在备份中。", + "stillFailing": "如果仍然失败,请把备份移回原位,并附上下方详情反馈给我们。" + } } } From 8b76507b7b375cd605029b942a3319808f745cd0 Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Thu, 5 Nov 2026 10:06:25 +0800 Subject: [PATCH 5/5] docs: replace sharp and bare prisma commands --- README.ar.md | 2 +- README.fa.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- docs/CONVERTER_WORKER_DESIGN.md | 4 ++-- docs/TASK_STATE_DESIGN.md | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.ar.md b/README.ar.md index 7a15b8c..782c577 100644 --- a/README.ar.md +++ b/README.ar.md @@ -119,7 +119,7 @@ src/ - **أداة البناء**: Vite 6 - **واجهة المستخدم**: Ant Design 5 - **قاعدة البيانات**: Prisma ORM + SQLite -- **معالجة PDF**: pdf-lib، pdf-to-png-converter، Sharp +- **معالجة PDF**: pdf-lib، pdf-to-png-converter، @napi-rs/canvas - **Markdown**: react-markdown، remark-gfm، remark-math، rehype-katex، rehype-prism-plus - **الاختبار**: Vitest + Testing Library diff --git a/README.fa.md b/README.fa.md index d4c1833..3465b0a 100644 --- a/README.fa.md +++ b/README.fa.md @@ -119,7 +119,7 @@ src/ - **ابزار ساخت**: Vite 6 - **رابط کاربری**: Ant Design 5 - **پایگاه داده**: Prisma ORM + SQLite -- **پردازش PDF**: pdf-lib، pdf-to-png-converter، Sharp +- **پردازش PDF**: pdf-lib، pdf-to-png-converter، @napi-rs/canvas - **Markdown**: react-markdown، remark-gfm، remark-math، rehype-katex، rehype-prism-plus - **تست**: Vitest + Testing Library diff --git a/README.ja.md b/README.ja.md index abc5bf6..f05238a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -117,7 +117,7 @@ src/ - **ビルドツール**:Vite 6 - **UI**:Ant Design 5 - **データベース**:Prisma ORM + SQLite -- **PDF処理**:pdf-lib、pdf-to-png-converter、Sharp +- **PDF処理**:pdf-lib、pdf-to-png-converter、@napi-rs/canvas - **Markdown**:react-markdown、remark-gfm、remark-math、rehype-katex、rehype-prism-plus - **テスト**:Vitest + Testing Library diff --git a/README.md b/README.md index 984c414..a095e6d 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ src/ - **Build Tool**: Vite 6 - **UI**: Ant Design 5 - **Database**: Prisma ORM + SQLite -- **PDF Processing**: pdf-lib, pdf-to-png-converter, Sharp +- **PDF Processing**: pdf-lib, pdf-to-png-converter, @napi-rs/canvas - **Markdown**: react-markdown, remark-gfm, remark-math, rehype-katex, rehype-prism-plus - **Testing**: Vitest + Testing Library diff --git a/README.ru.md b/README.ru.md index 6be2e74..e84d638 100644 --- a/README.ru.md +++ b/README.ru.md @@ -117,7 +117,7 @@ src/ - **Инструмент сборки**: Vite 6 - **UI**: Ant Design 5 - **База данных**: Prisma ORM + SQLite -- **Обработка PDF**: pdf-lib, pdf-to-png-converter, Sharp +- **Обработка PDF**: pdf-lib, pdf-to-png-converter, @napi-rs/canvas - **Markdown**: react-markdown, remark-gfm, remark-math, rehype-katex, rehype-prism-plus - **Тестирование**: Vitest + Testing Library diff --git a/README.zh-CN.md b/README.zh-CN.md index e062f24..3e1127c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,7 +117,7 @@ src/ - **构建工具**:Vite 6 - **UI**:Ant Design 5 - **数据库**:Prisma ORM + SQLite -- **PDF 处理**:pdf-lib、pdf-to-png-converter、Sharp +- **PDF 处理**:pdf-lib、pdf-to-png-converter、@napi-rs/canvas - **Markdown**:react-markdown、remark-gfm、remark-math、rehype-katex、rehype-prism-plus - **测试**:Vitest + Testing Library diff --git a/docs/CONVERTER_WORKER_DESIGN.md b/docs/CONVERTER_WORKER_DESIGN.md index 4175361..591c37e 100644 --- a/docs/CONVERTER_WORKER_DESIGN.md +++ b/docs/CONVERTER_WORKER_DESIGN.md @@ -71,7 +71,7 @@ model TaskDetail { **执行命令**: ```bash -npx prisma migrate dev --name add_taskdetail_metrics +npm run migrate:dev -- --name add_taskdetail_metrics ``` **SQL 内容** (自动生成): @@ -1523,7 +1523,7 @@ export class MockLLMClient extends LLMClient { ### Phase 1: 数据库准备 ✅ - [ ] 更新 `schema.prisma`(添加 TaskDetail 新字段) -- [ ] 执行 `npx prisma migrate dev --name add_taskdetail_metrics` +- [ ] 执行 `npm run migrate:dev -- --name add_taskdetail_metrics` - [ ] 验证迁移成功 - [ ] 更新 TypeScript 类型定义 diff --git a/docs/TASK_STATE_DESIGN.md b/docs/TASK_STATE_DESIGN.md index a6d74c3..c31fbe4 100644 --- a/docs/TASK_STATE_DESIGN.md +++ b/docs/TASK_STATE_DESIGN.md @@ -294,10 +294,10 @@ model TaskDetail { ```bash # 添加新字段到 Task 表 -npx prisma migrate dev --name add_task_worker_fields +npm run migrate:dev -- --name add_task_worker_fields # 添加新字段到 TaskDetail 表 -npx prisma migrate dev --name add_taskdetail_worker_fields +npm run migrate:dev -- --name add_taskdetail_worker_fields ``` ---