diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cdeaa2..d283c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ 本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 +## 0.0.4 + +### 修复 + +- 修复更新包完成下载与 SHA-512 校验后,Windows 对隐藏暂存目录中的 `cshift-updater.exe` 返回 `EACCES` 导致无法安装的问题。 +- 更新助手增加可见独立运行目录、多次短暂重试和 PowerShell `Start-Process` 回退;所有策略失败时保持旧版本、已下载更新包和回滚数据不变。 +- 新版本确认启动后同时清理更新暂存目录与独立助手目录。 +- 主进程全局捕获并记录索引通信异常;退出阶段的 `EPIPE` 不再触发 Electron 原生 JavaScript 错误弹窗。 + +### 诊断与隐私 + +- 增加按日期滚动、自动清理的 JSONL 本地日志,记录应用、索引器、渲染器和更新器生命周期。 +- 设置页增加“打开日志目录”和“一键导出诊断报告”,报告包含系统环境、关键路径权限、更新/索引状态、迁移阶段汇总和崩溃文件元数据。 +- 日志字段统一脱敏,不记录 API Key、鉴权令牌、搜索关键词或文件正文。 + +### 验证 + +- 增加更新助手主路径 `EACCES` 后切换备用路径,以及全部启动策略失败时保留可诊断错误的单元测试。 +- 延续干净退出、安装版/便携版更新、校验拒绝、失败回滚和托盘空闲性能回归。 + +> 从 `0.0.2` 升级:由于故障发生在旧版更新器启动阶段,`0.0.2` 无法自动安装修复自身的版本,需要手动安装一次 `0.0.4`;从本版开始后续自动更新使用新的回退链路。 + ## 0.0.3 ### 修复 diff --git a/README.md b/README.md index 8954e6e..1568e9c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于: 当前版本完全使用自研索引管线,不调用 Everything。 -当前正式版本:`0.0.3`。安装包与便携包见 +当前正式版本:`0.0.4`。安装包与便携包见 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 ## 已实现能力 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f19214..e4abb39 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属分析与跨盘安全迁移工具。名称索引、内容索引和迁移记录都由项目自身维护,不依赖 Everything。 -当前版本:`0.0.3` +当前版本:`0.0.4` ## 下载与运行 @@ -67,6 +67,14 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属 - 支持断点续传、最多三次自动重试和 SHA-512 双重校验。 - 校验失败拒绝安装;替换失败自动恢复旧版本。 - 新版本确认启动成功后才删除安装包、备份和更新临时目录。 +- 更新助手会在隐藏暂存目录和可见独立运行目录间自动回退,并在 Windows 短暂拒绝执行时重试;所有启动策略都失败时保留旧版本和下载包。 +- `0.0.2` 的更新助手启动失败发生在旧版程序自身,无法通过失败中的更新链路修复;此类用户需手动安装一次 `0.0.4`,之后可继续使用完整自动更新。 + +## 日志与诊断 + +- 主进程、索引器退出、更新下载/校验/启动和渲染进程异常会写入安装或便携目录旁的 `.cdriveshiftai-data\logs`。 +- 设置页提供“打开日志目录”和“导出诊断报告”。诊断报告包含版本、操作系统、权限探测、索引/更新状态、崩溃文件清单和近期日志。 +- 日志与报告不会记录 API Key、搜索关键词或文件正文;故障路径可能会保留,发送前可以自行查看 JSON。 ## 性能策略 diff --git a/docs/RELEASE-0.0.4.md b/docs/RELEASE-0.0.4.md new file mode 100644 index 0000000..90980b4 --- /dev/null +++ b/docs/RELEASE-0.0.4.md @@ -0,0 +1,25 @@ +# CDriveShiftAI 0.0.4 发布说明 + +本版集中修复 Windows 自动更新助手启动失败和托盘退出时索引管道 `EPIPE` 两类问题,并加入可由用户直接发送给开发者的脱敏诊断报告。 + +## 更新行为 + +- 更新包继续支持断点续传、三次下载重试、尺寸与 SHA-512 校验。 +- 更新助手优先从原暂存目录启动;遇到权限或安全软件的短暂拒绝后自动重试。 +- 原路径失败时,复制并校验到同盘可见的 `CDriveShiftAI-Update-Runner` 目录再次启动。 +- Node 直接启动仍失败时,使用隐藏 PowerShell `Start-Process` 做最后一次启动回退;不调用 .NET `InstallUtil.exe`。 +- 任何启动、安装或新版本自检失败都保留旧版本;新版本确认启动后再清理更新包、备份与助手目录。 + +## 故障诊断 + +设置页“应用更新”区域现在提供: + +- 打开日志目录; +- 导出单个 JSON 诊断报告; +- 更新失败时的明确引导。 + +诊断报告包含应用版本、Windows/CPU/内存环境、关键文件权限、更新状态、索引状态、崩溃文件元数据与近期日志。API Key、鉴权信息、搜索词和文件正文不会被写入。 + +## 从旧版升级 + +`0.0.2` 中的失败位于旧版应用启动更新助手的代码内,因此已经下载完成的新版无法反向修复仍在运行的旧代码。看到 `spawn ... cshift-updater.exe EACCES` 的用户需要从 GitHub Release 手动下载并安装一次 `0.0.4`;安装版选择原安装目录覆盖,便携版替换原文件。此后自动更新由新链路接管。 diff --git a/electron/diagnostics.ts b/electron/diagnostics.ts new file mode 100644 index 0000000..faf1261 --- /dev/null +++ b/electron/diagnostics.ts @@ -0,0 +1,199 @@ +import { app } from "electron"; +import { constants as fsConstants } from "node:fs"; +import { access, open, readdir, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AppUpdateInfo } from "./update"; +import type { AppSettings, IndexerStatus, MigrationRecord } from "./types"; + +const LOG_TAIL_BYTES = 256 * 1024; +const MAX_LOG_FILES = 4; + +function sanitizedEndpoint(value: string): string { + try { + const endpoint = new URL(value); + endpoint.username = ""; + endpoint.password = ""; + endpoint.search = ""; + endpoint.hash = ""; + return endpoint.toString().replace(/\/$/, ""); + } catch { + return value.split(/[?#]/, 1)[0].slice(0, 2_048); + } +} + +async function pathProbe(candidate: string): Promise> { + const result: Record = { path: candidate }; + for (const [name, mode] of [ + ["readable", fsConstants.R_OK], + ["writable", fsConstants.W_OK], + ["executable", fsConstants.X_OK] + ] as const) { + try { + await access(candidate, mode); + result[name] = true; + } catch (error) { + const detail = error as NodeJS.ErrnoException; + result[name] = false; + result[`${name}Error`] = detail.code ?? detail.message; + } + } + try { + const details = await stat(candidate); + result.exists = true; + result.size = details.size; + result.modifiedAt = details.mtime.toISOString(); + } catch (error) { + result.exists = false; + result.statError = (error as NodeJS.ErrnoException).code ?? String(error); + } + return result; +} + +async function tailFile(candidate: string): Promise { + const details = await stat(candidate); + const length = Math.min(details.size, LOG_TAIL_BYTES); + const handle = await open(candidate, "r"); + try { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, Math.max(0, details.size - length)); + return buffer.toString("utf8"); + } finally { + await handle.close(); + } +} + +async function recentLogs(logDirectory: string): Promise>> { + try { + const entries = await readdir(logDirectory, { withFileTypes: true }); + const candidates = await Promise.all( + entries + .filter((entry) => entry.isFile() && /\.(?:jsonl|log)(?:\.\d+)?$/i.test(entry.name)) + .map(async (entry) => { + const candidate = path.join(logDirectory, entry.name); + return { name: entry.name, candidate, details: await stat(candidate) }; + }) + ); + candidates.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs); + return Promise.all( + candidates.slice(0, MAX_LOG_FILES).map(async ({ name, candidate, details }) => ({ + name, + size: details.size, + modifiedAt: details.mtime.toISOString(), + tail: await tailFile(candidate) + })) + ); + } catch (error) { + return [{ error: (error as NodeJS.ErrnoException).code ?? String(error) }]; + } +} + +async function crashDumpMetadata(crashDirectory: string): Promise>> { + try { + const entries = await readdir(crashDirectory, { withFileTypes: true }); + const files = await Promise.all( + entries + .filter((entry) => entry.isFile()) + .slice(-20) + .map(async (entry) => { + const details = await stat(path.join(crashDirectory, entry.name)); + return { + name: entry.name, + size: details.size, + modifiedAt: details.mtime.toISOString() + }; + }) + ); + return files.sort((left, right) => + String(right.modifiedAt).localeCompare(String(left.modifiedAt)) + ); + } catch { + return []; + } +} + +export interface DiagnosticInput { + applicationDataRoot: string; + logDirectory: string; + settings: AppSettings; + update?: AppUpdateInfo; + indexer?: IndexerStatus; + migrations: MigrationRecord[]; +} + +export async function createDiagnosticReport(input: DiagnosticInput): Promise { + const helperPath = path.join(process.resourcesPath, "bin", "cshift-updater.exe"); + const crashDirectory = app.getPath("crashDumps"); + const migrationStages = Object.fromEntries( + [...new Set(input.migrations.map((record) => record.stage))].map((stage) => [ + stage, + input.migrations.filter((record) => record.stage === stage).length + ]) + ); + const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + privacy: { + apiKeysIncluded: false, + searchQueriesIncluded: false, + fileContentsIncluded: false, + note: "日志可能包含发生故障的程序路径;发送前可自行查看。" + }, + application: { + name: app.getName(), + version: app.getVersion(), + packaged: app.isPackaged, + pid: process.pid, + execPath: process.execPath, + resourcesPath: process.resourcesPath, + dataRoot: input.applicationDataRoot, + locale: app.getLocale(), + distribution: process.env.PORTABLE_EXECUTABLE_FILE ? "portable" : app.isPackaged ? "installed" : "development" + }, + operatingSystem: { + platform: os.platform(), + release: os.release(), + version: os.version(), + architecture: os.arch(), + cpuModel: os.cpus()[0]?.model, + logicalCpuCount: os.cpus().length, + totalMemoryBytes: os.totalmem(), + freeMemoryBytes: os.freemem(), + uptimeSeconds: os.uptime(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone + }, + settings: { + effectMode: input.settings.effectMode, + launchAtLogin: input.settings.launchAtLogin, + minimizeToTray: input.settings.minimizeToTray, + globalShortcutConfigured: Boolean(input.settings.globalShortcut), + quickSearchShortcutConfigured: Boolean(input.settings.quickSearchShortcut), + mouseQuickSearchButton: input.settings.mouseQuickSearchButton, + mouseQuickSearchHoldMs: input.settings.mouseQuickSearchHoldMs, + ai: { + enabled: input.settings.ai.enabled, + provider: input.settings.ai.provider, + protocol: input.settings.ai.protocol, + baseUrl: sanitizedEndpoint(input.settings.ai.baseUrl), + model: input.settings.ai.model, + hasApiKey: input.settings.ai.hasApiKey, + privacyMode: input.settings.ai.privacyMode + } + }, + update: input.update, + indexer: input.indexer, + migrations: { + total: input.migrations.length, + stages: migrationStages + }, + pathChecks: await Promise.all([ + pathProbe(input.applicationDataRoot), + pathProbe(input.logDirectory), + pathProbe(process.execPath), + pathProbe(helperPath) + ]), + crashDumps: await crashDumpMetadata(crashDirectory), + logs: await recentLogs(input.logDirectory) + }; + return `${JSON.stringify(report, null, 2)}\n`; +} diff --git a/electron/logger.ts b/electron/logger.ts new file mode 100644 index 0000000..593edef --- /dev/null +++ b/electron/logger.ts @@ -0,0 +1,151 @@ +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + statSync, + unlinkSync +} from "node:fs"; +import path from "node:path"; + +const MAX_LOG_BYTES = 5 * 1024 * 1024; +const MAX_LOG_AGE_MS = 14 * 24 * 60 * 60 * 1_000; +const REDACTED_KEYS = /(?:api[-_]?key|authorization|token|secret|password|query|content|preview)/i; + +export type LogLevel = "debug" | "info" | "warn" | "error"; +export type LogFields = Record; + +let logDirectory = ""; +let currentPath = ""; +let currentBytes = 0; + +function sanitize(value: unknown, key = "", depth = 0): unknown { + if (REDACTED_KEYS.test(key)) return "[REDACTED]"; + if (depth > 5) return "[TRUNCATED]"; + if (value instanceof Error) return serializeError(value); + if (typeof value === "string") return value.slice(0, 16_384); + if ( + value == null || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.slice(0, 100).map((item) => sanitize(item, key, depth + 1)); + } + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .slice(0, 100) + .map(([entryKey, entryValue]) => [ + entryKey, + sanitize(entryValue, entryKey, depth + 1) + ]) + ); + } + return String(value).slice(0, 2_048); +} + +export function serializeError(error: unknown): Record { + if (!(error instanceof Error)) return { message: String(error) }; + const details = error as Error & { + code?: unknown; + errno?: unknown; + syscall?: unknown; + path?: unknown; + spawnargs?: unknown; + cause?: unknown; + }; + return { + name: error.name, + message: error.message, + stack: error.stack, + code: details.code, + errno: details.errno, + syscall: details.syscall, + path: details.path, + spawnargs: details.spawnargs, + cause: details.cause instanceof Error ? serializeError(details.cause) : details.cause + }; +} + +function pruneOldLogs(): void { + const cutoff = Date.now() - MAX_LOG_AGE_MS; + try { + for (const entry of readdirSync(logDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !/^main-.*\.jsonl(?:\.\d+)?$/.test(entry.name)) continue; + const candidate = path.join(logDirectory, entry.name); + if (statSync(candidate).mtimeMs < cutoff) unlinkSync(candidate); + } + } catch { + // Logging must never prevent the application from starting. + } +} + +function resolveCurrentPath(): string { + const date = new Date().toISOString().slice(0, 10); + return path.join(logDirectory, `main-${date}.jsonl`); +} + +function rotateIfNeeded(nextBytes: number): void { + const expected = resolveCurrentPath(); + if (currentPath !== expected) { + currentPath = expected; + currentBytes = existsSync(currentPath) ? statSync(currentPath).size : 0; + } + if (currentBytes + nextBytes <= MAX_LOG_BYTES) return; + try { + for (let index = 3; index >= 1; index -= 1) { + const source = index === 1 ? currentPath : `${currentPath}.${index - 1}`; + const destination = `${currentPath}.${index}`; + if (existsSync(source)) { + if (existsSync(destination)) unlinkSync(destination); + renameSync(source, destination); + } + } + } catch { + // If rotation loses a race, append to the existing file instead. + } + currentBytes = existsSync(currentPath) ? statSync(currentPath).size : 0; +} + +export function configureLogger(applicationDataRoot: string): string { + logDirectory = path.join(applicationDataRoot, "logs"); + mkdirSync(logDirectory, { recursive: true }); + currentPath = resolveCurrentPath(); + currentBytes = existsSync(currentPath) ? statSync(currentPath).size : 0; + pruneOldLogs(); + return logDirectory; +} + +export function getLogDirectory(): string { + return logDirectory; +} + +export function writeLog(level: LogLevel, event: string, fields: LogFields = {}): void { + if (!logDirectory) return; + try { + const line = `${JSON.stringify({ + timestamp: new Date().toISOString(), + level, + event: event.slice(0, 160), + pid: process.pid, + fields: sanitize(fields) + })}\n`; + const bytes = Buffer.byteLength(line); + rotateIfNeeded(bytes); + appendFileSync(currentPath, line, { encoding: "utf8", mode: 0o600 }); + currentBytes += bytes; + } catch { + // Error reporting must never become a second application failure. + } +} + +export const logger = { + debug: (event: string, fields?: LogFields) => writeLog("debug", event, fields), + info: (event: string, fields?: LogFields) => writeLog("info", event, fields), + warn: (event: string, fields?: LogFields) => writeLog("warn", event, fields), + error: (event: string, fields?: LogFields) => writeLog("error", event, fields) +}; diff --git a/electron/main.ts b/electron/main.ts index 6713839..6834598 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, clipboard, + crashReporter, dialog, globalShortcut, ipcMain, @@ -15,7 +16,8 @@ import { } from "electron"; import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { access, cp, lstat, readlink, rename, stat } from "node:fs/promises"; +import { access, cp, lstat, readlink, rename, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { analyzeLocally, scanOwnershipMap, summarizeDirectory } from "./analyzer"; @@ -27,6 +29,13 @@ import { type ResolvedAiConnection } from "./ai-client"; import { configureApplicationDataPaths } from "./data-root"; +import { createDiagnosticReport } from "./diagnostics"; +import { + configureLogger, + getLogDirectory, + logger, + serializeError +} from "./logger"; import { MigrationService } from "./migration"; import { SearchService } from "./search"; import { AppStore } from "./store"; @@ -61,7 +70,25 @@ import type { WindowLayoutBounds } from "./types"; -configureApplicationDataPaths(); +const applicationDataRoot = configureApplicationDataPaths(); +configureLogger(applicationDataRoot); +crashReporter.start({ uploadToServer: false, compress: false }); + +process.on("uncaughtException", (error) => { + const details = serializeError(error); + logger.error("process.uncaught_exception", { error: details, isQuitting }); + if ((error as NodeJS.ErrnoException).code === "EPIPE" || isQuitting) return; + if (app.isReady()) { + dialog.showErrorBox( + "CDriveShiftAI 运行错误", + `程序已记录错误。请在设置中导出诊断报告,或打开日志目录后将最新日志发给开发者。\n\n${error.message}` + ); + } +}); + +process.on("unhandledRejection", (reason) => { + logger.error("process.unhandled_rejection", { error: serializeError(reason) }); +}); let mainWindow: BrowserWindow | undefined; let quickSearchWindow: BrowserWindow | undefined; @@ -94,6 +121,19 @@ function trackWindowActivity(window: BrowserWindow): void { window.on("minimize", sync); window.on("restore", sync); window.on("closed", sync); + window.on("unresponsive", () => { + logger.warn("window.unresponsive", { + title: window.getTitle(), + url: window.webContents.getURL() + }); + }); + window.webContents.on("render-process-gone", (_event, details) => { + logger.error("renderer.gone", { + title: window.getTitle(), + reason: details.reason, + exitCode: details.exitCode + }); + }); } const effectColors: Record< @@ -948,6 +988,45 @@ function registerIpc(): void { ipcMain.handle("app:update-state", () => updateService?.getState()); ipcMain.handle("app:update-start", () => updateService?.downloadAndInstall()); ipcMain.handle("app:update-cancel", () => updateService?.cancel()); + ipcMain.handle("diagnostics:open-logs", async () => { + const result = await shell.openPath(getLogDirectory()); + if (result) throw new Error(`无法打开日志目录:${result}`); + }); + ipcMain.handle("diagnostics:export", async () => { + const now = new Date(); + const stamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "-", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0") + ].join(""); + const options = { + title: "导出 CDriveShiftAI 诊断报告", + defaultPath: path.join( + path.dirname(process.execPath), + `CDriveShiftAI-diagnostics-${stamp}.json` + ), + filters: [{ name: "JSON 诊断报告", extensions: ["json"] }] + }; + const selected = mainWindow && !mainWindow.isDestroyed() + ? await dialog.showSaveDialog(mainWindow, options) + : await dialog.showSaveDialog(options); + if (selected.canceled || !selected.filePath) return { cancelled: true }; + const report = await createDiagnosticReport({ + applicationDataRoot, + logDirectory: getLogDirectory(), + settings: store.getSettings(), + update: updateService?.getState(), + indexer: searchService?.getStatus(), + migrations: store.listMigrations() + }); + await writeFile(selected.filePath, report, { encoding: "utf8", mode: 0o600 }); + logger.info("diagnostics.exported", { reportPath: selected.filePath }); + return { cancelled: false, path: selected.filePath }; + }); ipcMain.handle("settings:update", async (_event, patch: unknown) => { if (!patch || typeof patch !== "object") throw new Error("设置内容无效"); const { ai: _ignoredAi, apiKey: _ignoredApiKey, ...safePatch } = patch as Record< @@ -1645,6 +1724,18 @@ if (!singleInstance) { app.quit(); } else if (uninstallRestoreMode) { app.whenReady().then(async () => { + logger.info("application.ready", { + version: app.getVersion(), + packaged: app.isPackaged, + execPath: process.execPath, + resourcesPath: process.resourcesPath, + dataRoot: applicationDataRoot, + platform: process.platform, + architecture: process.arch, + osRelease: os.release(), + logicalCpuCount: os.cpus().length, + totalMemoryBytes: os.totalmem() + }); await store.init(); applyNativeEffect(store.getSettings().effectMode); migrationService = new MigrationService(store, () => undefined); @@ -1660,6 +1751,18 @@ if (!singleInstance) { }); app.whenReady().then(async () => { + logger.info("application.ready", { + version: app.getVersion(), + packaged: app.isPackaged, + execPath: process.execPath, + resourcesPath: process.resourcesPath, + dataRoot: applicationDataRoot, + platform: process.platform, + architecture: process.arch, + osRelease: os.release(), + logicalCpuCount: os.cpus().length, + totalMemoryBytes: os.totalmem() + }); await store.init(); applyNativeEffect(store.getSettings().effectMode); mainWindow = createWindow(); @@ -1751,6 +1854,10 @@ app.on("before-quit", (event) => { isQuitting = true; if (shutdownComplete) return; event.preventDefault(); + logger.info("application.shutdown_started", { + hasIndexer: searchService != null, + pendingWindows: BrowserWindow.getAllWindows().length + }); globalShortcut.unregisterAll(); tray?.destroy(); tray = undefined; @@ -1760,8 +1867,19 @@ app.on("before-quit", (event) => { await searchService?.stop(); } finally { shutdownComplete = true; + logger.info("application.shutdown_completed"); app.quit(); } })(); } }); + +app.on("child-process-gone", (_event, details) => { + logger.warn("application.child_process_gone", { + type: details.type, + reason: details.reason, + exitCode: details.exitCode, + name: details.name, + serviceName: details.serviceName + }); +}); diff --git a/electron/preload.ts b/electron/preload.ts index 84562e6..7a1c292 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -13,6 +13,8 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { getUpdateState: () => ipcRenderer.invoke("app:update-state"), startUpdate: () => ipcRenderer.invoke("app:update-start"), cancelUpdate: () => ipcRenderer.invoke("app:update-cancel"), + openLogDirectory: () => ipcRenderer.invoke("diagnostics:open-logs"), + exportDiagnosticReport: () => ipcRenderer.invoke("diagnostics:export"), getSettings: () => ipcRenderer.invoke("settings:get"), updateSettings: (patch: unknown) => ipcRenderer.invoke("settings:update", patch), checkGlobalShortcut: (shortcut: string, target: string) => diff --git a/electron/search.ts b/electron/search.ts index 1da468c..1fa8d6d 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -21,6 +21,7 @@ import { determineIndexRefreshReason, INDEX_REFRESH_INTERVAL_MS } from "./index-refresh-policy"; +import { logger, serializeError } from "./logger"; interface PendingRequest { resolve: (response: NativeResponse) => void; @@ -96,6 +97,10 @@ export class SearchService { stdio: ["pipe", "pipe", "pipe"] }); this.child = child; + logger.info("indexer.spawn_requested", { + executable, + backgroundMode: this.backgroundMode + }); const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); lines.on("line", (line) => this.handleLine(line)); child.stderr.on("data", (chunk) => { @@ -109,12 +114,26 @@ export class SearchService { // an "error" event in addition to invoking the write callback; without a // listener that EPIPE becomes an uncaught main-process exception. child.stdin.on("error", (error) => { + logger.warn("indexer.stdin_error", { + stopping: this.stopping, + error: serializeError(error) + }); if (this.child === child) this.rejectPending(error); }); child.on("error", (error) => { + logger.error("indexer.process_error", { + stopping: this.stopping, + error: serializeError(error) + }); if (this.child === child) this.rejectPending(error); }); - child.on("exit", (code) => { + child.on("exit", (code, signal) => { + logger.info("indexer.exited", { + code, + signal, + stopping: this.stopping, + restartCount: this.restartCount + }); lines.close(); if (this.child === child) this.child = undefined; this.rejectPending(new Error(`索引进程已退出(${code ?? "unknown"})`)); @@ -175,6 +194,10 @@ export class SearchService { async stop(): Promise { if (this.stopPromise) return this.stopPromise; this.stopping = true; + logger.info("indexer.stop_started", { + hasChild: this.child != null, + pendingRequests: this.pending.size + }); this.stopPromise = (async () => { if (this.dailyRefreshTimer) { clearTimeout(this.dailyRefreshTimer); @@ -199,6 +222,7 @@ export class SearchService { new Promise((resolve) => setTimeout(() => resolve(false), 1_500)) ]); if (!graceful && child.exitCode == null && !child.killed) { + logger.warn("indexer.stop_forced", { childPid: child.pid }); child.kill(); await Promise.race([ exited, @@ -207,6 +231,7 @@ export class SearchService { } if (this.child === child) this.child = undefined; this.rejectPending(new Error("索引服务已停止")); + logger.info("indexer.stop_completed", { childPid: child.pid }); })(); return this.stopPromise; } @@ -623,6 +648,11 @@ export class SearchService { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); + logger.warn("indexer.request_timeout", { + operation: typeof payload.op === "string" ? payload.op : "unknown", + timeout, + stopping: this.stopping + }); reject(new Error("索引请求超时")); }, timeout); this.pending.set(id, { resolve, reject, timer }); @@ -631,11 +661,21 @@ export class SearchService { if (!error) return; clearTimeout(timer); this.pending.delete(id); + logger.warn("indexer.request_write_failed", { + operation: typeof payload.op === "string" ? payload.op : "unknown", + stopping: this.stopping, + error: serializeError(error) + }); reject(error); }); } catch (error) { clearTimeout(timer); this.pending.delete(id); + logger.warn("indexer.request_write_threw", { + operation: typeof payload.op === "string" ? payload.op : "unknown", + stopping: this.stopping, + error: serializeError(error) + }); reject(error instanceof Error ? error : new Error(String(error))); } }); diff --git a/electron/update-launcher.ts b/electron/update-launcher.ts new file mode 100644 index 0000000..bc9a6e5 --- /dev/null +++ b/electron/update-launcher.ts @@ -0,0 +1,249 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { chmod, copyFile, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { logger, serializeError } from "./logger"; + +const RETRY_DELAYS_MS = [0, 300, 900]; + +type SpawnProcess = typeof spawn; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function sha512(candidate: string): Promise { + return new Promise((resolve, reject) => { + const digest = createHash("sha512"); + const stream = createReadStream(candidate); + stream.on("data", (chunk) => digest.update(chunk)); + stream.on("error", reject); + stream.on("end", () => resolve(digest.digest("hex"))); + }); +} + +export async function prepareUpdaterExecutable( + sourcePath: string, + destinationPath: string +): Promise { + await mkdir(path.dirname(destinationPath), { recursive: true }); + await rm(destinationPath, { force: true }); + await copyFile(sourcePath, destinationPath); + await chmod(destinationPath, 0o755).catch(() => undefined); + if (process.platform === "win32") { + await rm(`${destinationPath}:Zone.Identifier`, { force: true }).catch( + () => undefined + ); + } + const [sourceHash, destinationHash] = await Promise.all([ + sha512(sourcePath), + sha512(destinationPath) + ]); + if (sourceHash !== destinationHash) { + await rm(destinationPath, { force: true }).catch(() => undefined); + throw new Error("更新助手复制后校验失败"); + } +} + +function spawnAndConfirm( + executable: string, + args: string[], + spawnProcess: SpawnProcess, + environment?: NodeJS.ProcessEnv +): Promise { + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = spawnProcess(executable, args, { + detached: true, + windowsHide: true, + stdio: "ignore", + env: environment ?? process.env + }); + } catch (error) { + reject(error); + return; + } + const onSpawn = () => { + child.removeListener("error", onError); + resolve(child); + }; + const onError = (error: Error) => { + child.removeListener("spawn", onSpawn); + reject(error); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +} + +function spawnAndWait( + executable: string, + args: string[], + spawnProcess: SpawnProcess, + environment: NodeJS.ProcessEnv +): Promise { + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = spawnProcess(executable, args, { + windowsHide: true, + stdio: "ignore", + env: environment + }); + } catch (error) { + reject(error); + return; + } + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolve(child); + else reject(new Error(`${path.basename(executable)} exited with code ${code}`)); + }); + }); +} + +async function directLaunch( + candidate: string, + planPath: string, + spawnProcess: SpawnProcess, + forcedFailure: boolean, + retryDelaysMs: number[] +): Promise { + let lastError: unknown; + for (let index = 0; index < retryDelaysMs.length; index += 1) { + if (retryDelaysMs[index] > 0) await delay(retryDelaysMs[index]); + try { + if (forcedFailure) { + const simulated = new Error("Simulated primary helper EACCES") as NodeJS.ErrnoException; + simulated.code = "EACCES"; + throw simulated; + } + const child = await spawnAndConfirm( + candidate, + ["--plan", planPath], + spawnProcess + ); + logger.info("update.helper_spawned", { + strategy: "direct", + candidate, + attempt: index + 1, + childPid: child.pid + }); + return child; + } catch (error) { + lastError = error; + logger.warn("update.helper_spawn_failed", { + strategy: "direct", + candidate, + attempt: index + 1, + error: serializeError(error) + }); + } + } + throw lastError; +} + +async function powershellLaunch( + candidate: string, + planPath: string, + spawnProcess: SpawnProcess +): Promise { + const environment = { + ...process.env, + CDRIVESHIFTAI_UPDATE_HELPER: candidate, + CDRIVESHIFTAI_UPDATE_PLAN: planPath + }; + const script = [ + "$ErrorActionPreference='Stop'", + "$plan='\"'+$env:CDRIVESHIFTAI_UPDATE_PLAN.Replace('\"','\\\"')+'\"'", + "$p=Start-Process -FilePath $env:CDRIVESHIFTAI_UPDATE_HELPER -ArgumentList @('--plan',$plan) -WindowStyle Hidden -PassThru", + "if($null -eq $p){exit 7}" + ].join(";"); + const child = await spawnAndWait( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], + spawnProcess, + environment + ); + logger.info("update.helper_spawned", { + strategy: "powershell-start-process", + candidate, + childPid: child.pid + }); + return child; +} + +export interface LaunchUpdaterOptions { + primaryPath: string; + fallbackPath: string; + planPath: string; + spawnProcess?: SpawnProcess; + forcePrimaryFailure?: boolean; + forceAllDirectFailures?: boolean; + retryDelaysMs?: number[]; +} + +export async function launchUpdaterHelper( + options: LaunchUpdaterOptions +): Promise<{ child: ChildProcess; strategy: string; executable: string }> { + const spawnProcess = options.spawnProcess ?? spawn; + const retryDelaysMs = options.retryDelaysMs?.length + ? options.retryDelaysMs + : RETRY_DELAYS_MS; + const failures: unknown[] = []; + for (const [index, candidate] of [options.primaryPath, options.fallbackPath].entries()) { + try { + const child = await directLaunch( + candidate, + options.planPath, + spawnProcess, + options.forceAllDirectFailures === true || + (index === 0 && options.forcePrimaryFailure === true), + retryDelaysMs + ); + child.on("error", (error) => { + logger.error("update.helper_post_spawn_error", { + candidate, + error: serializeError(error) + }); + }); + child.unref(); + return { + child, + strategy: index === 0 ? "primary-direct" : "fallback-direct", + executable: candidate + }; + } catch (error) { + failures.push(error); + } + } + try { + const child = await powershellLaunch( + options.fallbackPath, + options.planPath, + spawnProcess + ); + child.unref(); + return { + child, + strategy: "fallback-powershell", + executable: options.fallbackPath + }; + } catch (error) { + failures.push(error); + logger.error("update.helper_launch_exhausted", { + primaryPath: options.primaryPath, + fallbackPath: options.fallbackPath, + failures: failures.map(serializeError) + }); + const codes = failures + .map((failure) => (failure as NodeJS.ErrnoException)?.code) + .filter(Boolean) + .join(", "); + throw new Error( + `无法启动更新助手${codes ? `(${codes})` : ""};更新包与旧版本均已保留,请导出诊断报告后手动安装` + ); + } +} diff --git a/electron/update.ts b/electron/update.ts index 82584ca..e360f51 100644 --- a/electron/update.ts +++ b/electron/update.ts @@ -1,9 +1,7 @@ import { app } from "electron"; -import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { access, - copyFile, mkdir, open, readFile, @@ -14,6 +12,11 @@ import { } from "node:fs/promises"; import { createReadStream } from "node:fs"; import path from "node:path"; +import { logger, serializeError } from "./logger"; +import { + launchUpdaterHelper, + prepareUpdaterExecutable +} from "./update-launcher"; const RELEASE_API = "https://api.github.com/repos/PuppetWen/CDriveShiftAI/releases/latest"; @@ -112,6 +115,7 @@ interface UpdatePlan { expectedVersion: string; expectedSha512: string; logPath: string; + runnerDirectory?: string; } function versionParts(value: string): number[] { @@ -152,6 +156,13 @@ function updateStagingDirectory(version: string): string { return path.join(parent, ".cdriveshiftai-update", version); } +function updateDistributionParent(): string { + const executable = distributionExecutable(); + return updateDistribution() === "installed" + ? path.dirname(path.dirname(executable)) + : path.dirname(executable); +} + function clone(value: T): T { return structuredClone(value); } @@ -384,6 +395,12 @@ export class UpdateService { try { return await this.performDownloadAndInstall(); } catch (error) { + logger.error("update.failed", { + phase: this.state.phase, + distribution: this.state.distribution, + version: this.state.latestVersion, + error: serializeError(error) + }); if (this.abortController?.signal.aborted) { return this.setState({ phase: "cancelled", @@ -587,7 +604,28 @@ export class UpdateService { throw new Error("更新助手缺失,已保留下载包但不会执行替换"); } const helperPath = path.join(stagingDir, "cshift-updater.exe"); - await copyFile(helperSource, helperPath); + const runnerDirectory = path.join( + updateDistributionParent(), + "CDriveShiftAI-Update-Runner", + this.state.latestVersion! + ); + let fallbackHelperPath = path.join( + runnerDirectory, + "CDriveShiftAI-Update.exe" + ); + await prepareUpdaterExecutable(helperSource, helperPath); + try { + await prepareUpdaterExecutable(helperSource, fallbackHelperPath); + } catch (error) { + logger.warn("update.fallback_helper_prepare_failed", { + fallbackHelperPath, + error: serializeError(error) + }); + // Never execute a fallback copy that did not pass byte-for-byte hashing. + // PowerShell can still provide a separate launch API for the verified + // primary helper if this directory itself could not be created. + fallbackHelperPath = helperPath; + } const plan: UpdatePlan = { schemaVersion: 1, mode: distribution, @@ -606,7 +644,8 @@ export class UpdateService { successMarker: path.join(stagingDir, "update-success.json"), expectedVersion: this.state.latestVersion!, expectedSha512: manifestAsset.sha512, - logPath: path.join(stagingDir, "update.log") + logPath: path.join(stagingDir, "update.log"), + runnerDirectory }; const planPath = path.join(stagingDir, "update-plan.json"); await writeFile(planPath, JSON.stringify(plan, null, 2), "utf8"); @@ -617,16 +656,20 @@ export class UpdateService { ? "即将退出并静默安装;失败时会自动恢复旧版本" : "即将退出并在原路径替换便携版;失败时会自动恢复旧文件" }); - const child = spawn(helperPath, ["--plan", planPath], { - detached: true, - windowsHide: true, - stdio: "ignore" + const launched = await launchUpdaterHelper({ + primaryPath: helperPath, + fallbackPath: fallbackHelperPath, + planPath, + forcePrimaryFailure: + process.env.CDRIVESHIFTAI_UPDATE_FORCE_PRIMARY_EACCES === "1" }); - await new Promise((resolve, reject) => { - child.once("spawn", resolve); - child.once("error", reject); + logger.info("update.install_handoff", { + distribution, + version: this.state.latestVersion, + strategy: launched.strategy, + executable: launched.executable, + childPid: launched.child.pid }); - child.unref(); setTimeout(() => app.quit(), 180); return this.getState(); } @@ -639,6 +682,16 @@ function safeUpdateStaging(candidate: string): boolean { .some((segment) => segment.toLocaleLowerCase() === ".cdriveshiftai-update"); } +function safeUpdateRunner(candidate: string): boolean { + return path + .resolve(candidate) + .split(path.sep) + .some( + (segment) => + segment.toLocaleLowerCase() === "cdriveshiftai-update-runner" + ); +} + export async function completePendingUpdate(): Promise { const markerIndex = process.argv.indexOf("--update-staging"); if (markerIndex < 0) return; @@ -667,6 +720,9 @@ export async function completePendingUpdate(): Promise { const cleanup = async (remaining = 20): Promise => { try { await rm(stagingDir, { recursive: true, force: true }); + if (plan.runnerDirectory && safeUpdateRunner(plan.runnerDirectory)) { + await rm(plan.runnerDirectory, { recursive: true, force: true }); + } } catch { if (remaining > 0) { setTimeout(() => void cleanup(remaining - 1), 1_500); diff --git a/native/indexer/Cargo.lock b/native/indexer/Cargo.lock index c8f05ab..4034e4d 100644 --- a/native/indexer/Cargo.lock +++ b/native/indexer/Cargo.lock @@ -68,7 +68,7 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "cshift-indexer" -version = "0.0.3" +version = "0.0.4" dependencies = [ "memmap2", "notify", diff --git a/native/indexer/Cargo.toml b/native/indexer/Cargo.toml index 7976bc2..bb04b5a 100644 --- a/native/indexer/Cargo.toml +++ b/native/indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-indexer" -version = "0.0.3" +version = "0.0.4" edition = "2021" description = "First-party NTFS MFT indexer for CDriveShiftAI" diff --git a/native/updater/Cargo.lock b/native/updater/Cargo.lock index 6de2a13..1c0d62a 100644 --- a/native/updater/Cargo.lock +++ b/native/updater/Cargo.lock @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "cshift-updater" -version = "0.0.3" +version = "0.0.4" dependencies = [ "serde", "serde_json", diff --git a/native/updater/Cargo.toml b/native/updater/Cargo.toml index a1d063f..1bf0557 100644 --- a/native/updater/Cargo.toml +++ b/native/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-updater" -version = "0.0.3" +version = "0.0.4" edition = "2021" description = "Transactional Windows updater for CDriveShiftAI" diff --git a/package-lock.json b/package-lock.json index 3987007..4e12d22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cdriveshiftai", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cdriveshiftai", - "version": "0.0.3", + "version": "0.0.4", "license": "MIT", "dependencies": { "lucide-react": "^0.536.0", diff --git a/package.json b/package.json index 8cc381c..4d51196 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cdriveshiftai", - "version": "0.0.3", + "version": "0.0.4", "private": true, "description": "CDriveShiftAI - AI-assisted Windows disk organizer and safe cross-drive directory migration tool", "main": "dist-electron/main.js", diff --git a/scripts/smoke-auto-update.mjs b/scripts/smoke-auto-update.mjs index 8f7ed72..35fb920 100644 --- a/scripts/smoke-auto-update.mjs +++ b/scripts/smoke-auto-update.mjs @@ -46,7 +46,12 @@ const distribution = path.join(testRoot, "distribution"); const target = path.join(distribution, "CDriveShiftAI-update-smoke.exe"); const staging = path.join(distribution, ".cdriveshiftai-update", currentVersion); const packagePath = path.join(staging, "CDriveShiftAI-x64-portable.exe"); -const helperPath = path.join(staging, "cshift-updater.exe"); +const runnerDirectory = path.join( + distribution, + "CDriveShiftAI-Update-Runner", + currentVersion +); +const helperPath = path.join(runnerDirectory, "CDriveShiftAI-Update.exe"); const planPath = path.join(staging, "update-plan.json"); const backupPath = path.join(staging, "previous-version.exe"); const successMarker = path.join(staging, "update-success.json"); @@ -136,6 +141,7 @@ async function removeDistributionWithRetry() { } await mkdir(staging, { recursive: true }); +await mkdir(runnerDirectory, { recursive: true }); await copyFile(oldPortable, target); await copyFile(newPortable, packagePath); await copyFile(helperSource, helperPath); @@ -164,7 +170,8 @@ await writeFile( successMarker, expectedVersion: currentVersion, expectedSha512, - logPath + logPath, + runnerDirectory }, null, 2 @@ -258,11 +265,21 @@ try { throw new Error(`Portable target was not replaced: ${afterVersion}`); } for (let attempt = 0; attempt < 40; attempt += 1) { - if (!(await exists(packagePath)) && !(await exists(backupPath))) break; + if ( + !(await exists(packagePath)) && + !(await exists(backupPath)) && + !(await exists(runnerDirectory)) + ) break; await new Promise((resolve) => setTimeout(resolve, 250)); } - if ((await exists(packagePath)) || (await exists(backupPath))) { - throw new Error("Successful update did not delete its package and backup"); + if ( + (await exists(packagePath)) || + (await exists(backupPath)) || + (await exists(runnerDirectory)) + ) { + throw new Error( + "Successful update did not delete its package, backup and runner" + ); } const targetStats = await stat(target); console.log( @@ -275,6 +292,7 @@ try { targetReplacedInPlace: true, packageDeletedAfterStart: true, backupDeletedAfterStart: true, + runnerDeletedAfterStart: true, targetBytes: targetStats.size }, null, diff --git a/src/lib/api.ts b/src/lib/api.ts index 818f55d..0fc9157 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -203,10 +203,10 @@ const browserFallback: CDriveShiftApi = { phase: "downloading", distribution: "portable", currentVersion: "0.0.2", - latestVersion: "0.0.3", + latestVersion: "0.0.4", updateAvailable: true, canAutoUpdate: true, - releaseName: "CDriveShiftAI 0.0.3", + releaseName: "CDriveShiftAI 0.0.4", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", publishedAt: new Date().toISOString(), assets: [], @@ -231,14 +231,14 @@ const browserFallback: CDriveShiftApi = { status: "current", phase: "current", distribution: "development", - currentVersion: "0.0.3", - latestVersion: "0.0.3", + currentVersion: "0.0.4", + latestVersion: "0.0.4", updateAvailable: false, canAutoUpdate: false, - releaseName: "CDriveShiftAI 0.0.3", + releaseName: "CDriveShiftAI 0.0.4", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", assets: [], - message: "当前已是最新版本 0.0.3", + message: "当前已是最新版本 0.0.4", checkedAt: new Date().toISOString() } as const; }, @@ -251,6 +251,12 @@ const browserFallback: CDriveShiftApi = { async cancelUpdate() { return this.checkForUpdates(); }, + async openLogDirectory() { + return unavailable(); + }, + async exportDiagnosticReport() { + return unavailable(); + }, async getSettings() { return mockSettings; }, diff --git a/src/styles.css b/src/styles.css index 86b58d7..ddce39e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4485,6 +4485,47 @@ input:checked + .toggle::after { transform: translateX(13px); } @keyframes update-card-scan { 50%, 100% { transform: translateX(100%); } } +.diagnostic-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 11px; + border: 1px solid var(--border); + border-radius: calc(var(--radius) * .7); + padding: 9px 10px; + background: color-mix(in srgb, var(--surface) 70%, transparent); +} +.diagnostic-actions.has-error { + border-color: color-mix(in srgb, var(--danger) 38%, var(--border)); + background: color-mix(in srgb, var(--danger) 5%, var(--surface)); +} +.diagnostic-actions > span { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} +.diagnostic-actions > span strong { font-size: 10px; } +.diagnostic-actions > span small { + overflow: hidden; + color: var(--muted); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} +.diagnostic-actions button { white-space: nowrap; } +:root[data-effect="aurora"] .diagnostic-actions { + border-width: 2px; + border-radius: 3px; + box-shadow: 3px 3px 0 color-mix(in srgb, #061316 42%, transparent); +} +:root[data-effect="matrix"] .diagnostic-actions { + box-shadow: inset 0 0 26px rgba(52, 195, 255, .035); +} +:root[data-effect="calm"] .diagnostic-actions { + box-shadow: 0 10px 28px rgba(49, 75, 88, .05); +} .effect-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } .effect-card { display: flex; align-items: center; gap: 11px; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: color-mix(in srgb, var(--bg) 20%, transparent); padding: 9px; cursor: pointer; text-align: left; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } .effect-card:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--surface-hover) 74%, transparent); transform: translateY(-1px); } diff --git a/src/types.ts b/src/types.ts index 976afc1..0a6aaa1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -508,12 +508,19 @@ export interface AppUpdateInfo { errorCode?: string; } +export interface DiagnosticExportResult { + cancelled: boolean; + path?: string; +} + export interface CDriveShiftApi { getOverview(): Promise; checkForUpdates(force?: boolean): Promise; getUpdateState(): Promise; startUpdate(): Promise; cancelUpdate(): Promise; + openLogDirectory(): Promise; + exportDiagnosticReport(): Promise; getSettings(): Promise; updateSettings(patch: Partial>): Promise; checkGlobalShortcut( diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index d71f3cb..2b1e829 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -9,6 +9,8 @@ import { Eye, EyeOff, ExternalLink, + FileJson, + FolderOpen, KeyRound, Keyboard, Laptop, @@ -286,6 +288,7 @@ export function SettingsView({ }: SettingsViewProps) { const [draft, setDraft] = useState(settings); const [checkingUpdate, setCheckingUpdate] = useState(false); + const [exportingDiagnostics, setExportingDiagnostics] = useState(false); const [apiKey, setApiKey] = useState(""); const [rebuilding, setRebuilding] = useState(false); const [models, setModels] = useState([]); @@ -740,7 +743,7 @@ export function SettingsView({
当前版本 - v{updateInfo?.currentVersion ?? "0.0.3"} + v{updateInfo?.currentVersion ?? "0.0.4"}
最新版本 @@ -916,6 +919,56 @@ export function SettingsView({
)} +
+ + 运行诊断 + + {updateInfo?.phase === "error" + ? "更新失败已写入日志;导出报告发给开发者即可定位环境与权限问题。" + : "本地记录崩溃、更新和索引生命周期;不会写入 API Key、搜索词或文件正文。"} + + + + +
diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..19e1c6f --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,37 @@ +import { mkdir, readFile, readdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { configureLogger, logger } from "../electron/logger"; + +const fixtureRoot = path.join( + process.cwd(), + ".cdriveshiftai-data", + "test-temp", + "logger-tests" +); + +afterEach(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +describe("structured application logger", () => { + it("redacts credentials, search text and file content", async () => { + await mkdir(fixtureRoot, { recursive: true }); + configureLogger(fixtureRoot); + logger.error("privacy.fixture", { + apiKey: "super-secret-key", + authorization: "Bearer private", + query: "private search", + content: "private file body", + harmless: "visible" + }); + const logDirectory = path.join(fixtureRoot, "logs"); + const [logName] = await readdir(logDirectory); + const logged = await readFile(path.join(logDirectory, logName), "utf8"); + expect(logged).toContain("visible"); + expect(logged).not.toContain("super-secret-key"); + expect(logged).not.toContain("private search"); + expect(logged).not.toContain("private file body"); + expect(logged).toContain("[REDACTED]"); + }); +}); diff --git a/tests/update-launcher.test.ts b/tests/update-launcher.test.ts new file mode 100644 index 0000000..fc82fe7 --- /dev/null +++ b/tests/update-launcher.test.ts @@ -0,0 +1,105 @@ +import { EventEmitter } from "node:events"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + launchUpdaterHelper, + prepareUpdaterExecutable +} from "../electron/update-launcher"; + +const fixtureRoot = path.join( + process.cwd(), + ".cdriveshiftai-data", + "test-temp", + "update-launcher-tests" +); + +function spawnedChild(pid: number) { + const child = new EventEmitter() as EventEmitter & { + pid: number; + unref: () => void; + }; + child.pid = pid; + child.unref = () => undefined; + queueMicrotask(() => child.emit("spawn")); + return child; +} + +function failedChild(code: string) { + const child = new EventEmitter() as EventEmitter & { + pid?: number; + unref: () => void; + }; + child.unref = () => undefined; + queueMicrotask(() => { + const error = new Error(`spawn failed: ${code}`) as NodeJS.ErrnoException; + error.code = code; + child.emit("error", error); + }); + return child; +} + +afterEach(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +describe("update helper launcher", () => { + it("copies the native helper and validates its bytes", async () => { + const source = path.join(fixtureRoot, "source.exe"); + const destination = path.join(fixtureRoot, "runner", "helper.exe"); + await mkdir(fixtureRoot, { recursive: true }); + await writeFile(source, Buffer.from("native-updater-fixture")); + await prepareUpdaterExecutable(source, destination); + expect(await readFile(destination)).toEqual(await readFile(source)); + }); + + it("uses the visible fallback runner when the primary path is denied", async () => { + const calls: string[] = []; + const fakeSpawn = ((executable: string) => { + calls.push(executable); + return spawnedChild(4242); + }) as never; + const result = await launchUpdaterHelper({ + primaryPath: "D:\\tools\\.cdriveshiftai-update\\helper.exe", + fallbackPath: "D:\\tools\\CDriveShiftAI-Update-Runner\\helper.exe", + planPath: "D:\\tools\\.cdriveshiftai-update\\plan.json", + forcePrimaryFailure: true, + retryDelaysMs: [0], + spawnProcess: fakeSpawn + }); + expect(result.strategy).toBe("fallback-direct"); + expect(calls).toEqual(["D:\\tools\\CDriveShiftAI-Update-Runner\\helper.exe"]); + }); + + it("reports a useful error after direct and PowerShell launch methods fail", async () => { + const calls: string[] = []; + const fakeSpawn = ((executable: string) => { + calls.push(executable); + return failedChild("EACCES"); + }) as never; + await expect( + launchUpdaterHelper({ + primaryPath: "primary.exe", + fallbackPath: "fallback.exe", + planPath: "plan.json", + retryDelaysMs: [0], + spawnProcess: fakeSpawn + }) + ).rejects.toThrow(/EACCES.*诊断报告/); + expect(calls).toEqual(["primary.exe", "fallback.exe", "powershell.exe"]); + }); + + it.runIf(process.platform === "win32")( + "passes a plan path containing spaces through the PowerShell fallback", + async () => { + const result = await launchUpdaterHelper({ + primaryPath: process.execPath, + fallbackPath: process.execPath, + planPath: path.join(fixtureRoot, "path with spaces", "update-plan.json"), + forceAllDirectFailures: true, + retryDelaysMs: [0] + }); + expect(result.strategy).toBe("fallback-powershell"); + } + ); +});