From 44069eaacfee22e0bc8ee953eaf9ec81d0f6b6e6 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Fri, 31 Jul 2026 03:47:19 -0400 Subject: [PATCH] feat: add transactional automatic updates --- .gitignore | 2 +- README.md | 15 +- build/installer.nsh | 30 ++ electron/main.ts | 73 ++- electron/preload.ts | 12 + electron/search.ts | 74 ++- electron/store.ts | 12 + electron/types.ts | 11 + electron/update.ts | 654 ++++++++++++++++++++++-- native/indexer/Cargo.lock | 2 +- native/indexer/Cargo.toml | 8 +- native/indexer/src/main.rs | 356 +++++++++++-- native/updater/Cargo.lock | 265 ++++++++++ native/updater/Cargo.toml | 16 + native/updater/src/main.rs | 594 +++++++++++++++++++++ package-lock.json | 4 +- package.json | 12 +- scripts/build-native.ps1 | 23 +- scripts/create-update-manifest.mjs | 45 ++ scripts/smoke-auto-update.mjs | 279 ++++++++++ scripts/smoke-installed-update.mjs | 267 ++++++++++ scripts/smoke-packaged-search-state.mjs | 50 ++ scripts/visual-smoke.mjs | 23 + src/App.tsx | 4 +- src/components/Sidebar.tsx | 40 +- src/lib/api.ts | 71 ++- src/styles.css | 392 +++++++++++++- src/types.ts | 45 ++ src/views/SettingsView.tsx | 357 ++++++++++++- tests/update-download.test.ts | 130 +++++ 30 files changed, 3694 insertions(+), 172 deletions(-) create mode 100644 native/updater/Cargo.lock create mode 100644 native/updater/Cargo.toml create mode 100644 native/updater/src/main.rs create mode 100644 scripts/create-update-manifest.mjs create mode 100644 scripts/smoke-auto-update.mjs create mode 100644 scripts/smoke-installed-update.mjs create mode 100644 tests/update-download.test.ts diff --git a/.gitignore b/.gitignore index 7f236ee..79ed3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ dist-electron/ release*/ artifacts/ .devtools/ -native/indexer/target/ +native/*/target/ .cdriveshiftai-data/ *.log .DS_Store diff --git a/README.md b/README.md index 6387746..05792b8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于: 当前版本完全使用自研索引管线,不调用 Everything。 -当前正式版本:`0.0.1`。安装包与便携包见 +当前正式版本:`0.0.2`。安装包与便携包见 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 ## 已实现能力 @@ -108,9 +108,20 @@ AI 是可选的二次判断层,支持三类真实协议: - 首次运行安装包时可自行选择安装路径; - 使用相同 `appId` 的后续安装包会读取已安装项的 `InstallLocation`,沿用原路径覆盖升级; - 每次打开主界面时只检查一次官方 GitHub Release,不在托盘后台循环联网; -- 设置入口右侧绿点表示当前没有发现更新,红点表示存在更高版本;设置页可查看版本、发布时间和安装包/便携包下载入口; +- 设置入口右侧绿点表示当前没有发现更新,红点表示存在更高版本;主题化悬浮提示会显示当前版与最新版; +- 安装版支持静默原路径升级,便携版支持原文件自替换;下载中断后按已有字节断点续传并最多自动重试三次; +- 更新包由主程序和独立更新助手分别执行 SHA-512 校验,文件大小或摘要不符时拒绝安装; +- 替换前在同一磁盘的安装目录外备份旧程序;只有新版本成功启动并回报正确版本后才删除更新包与备份,失败时自动恢复旧版本; +- 设置页使用与方块、科技、晶境主题一致的分段进度界面,显示下载量、速度、重试次数以及下载、校验、备份、替换和清理阶段; - 当前安装包未使用商业 Authenticode 证书,Windows 可能显示 SmartScreen“未知发布者”提示。 +### 全局快捷唤起 + +- 主窗口与独立极速搜索分别使用 Windows 全局快捷键,录入时会试注册并检查应用内重复、系统或其他程序占用;冲突配置不会保存; +- 独立极速搜索也可通过鼠标后退侧键、前进侧键或中键长按唤起,时长可在 0.5–10 秒之间配置,也可以完全关闭; +- 鼠标快捷操作使用 Windows Raw Input 被动监听,只在达到长按阈值时触发,不拦截原程序的短按前进、后退或中键行为; +- 所有快捷设置在控件失焦或选择完成后自动保存并立即生效,并提供实际唤起测试按钮。 + ## 安全边界 - 盘符根目录、Windows 目录、系统卷信息、回收站、默认/公共用户目录和关键 Microsoft 系统数据会被硬性拦截; diff --git a/build/installer.nsh b/build/installer.nsh index 8d714eb..77a67b1 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -7,3 +7,33 @@ ${EndIf} ${EndIf} !macroend + +; CDriveShiftAI deliberately keeps its indexes, settings and migration journal +; beside the application instead of placing them on C:. electron-builder's +; default updater removes the complete old $INSTDIR, so preserve that one data +; directory outside $INSTDIR while the old application files are replaced. +!macro customRemoveFiles + StrCpy $R8 "$INSTDIR.cdriveshiftai-data-preserved" + IfFileExists "$R8\*.*" 0 preserve_data + Abort "A previous CDriveShiftAI data-preservation directory still exists: $R8" + + preserve_data: + IfFileExists "$INSTDIR\.cdriveshiftai-data\*.*" 0 remove_application + ClearErrors + Rename "$INSTDIR\.cdriveshiftai-data" "$R8" + IfErrors 0 remove_application + Abort "CDriveShiftAI could not preserve .cdriveshiftai-data before updating." + + remove_application: + SetOutPath "$TEMP" + RMDir /r "$INSTDIR" + + IfFileExists "$R8\*.*" 0 remove_files_done + CreateDirectory "$INSTDIR" + ClearErrors + Rename "$R8" "$INSTDIR\.cdriveshiftai-data" + IfErrors 0 remove_files_done + Abort "CDriveShiftAI could not restore .cdriveshiftai-data after updating." + + remove_files_done: +!macroend diff --git a/electron/main.ts b/electron/main.ts index e7e02dc..66c10a1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -31,7 +31,11 @@ import { MigrationService } from "./migration"; import { SearchService } from "./search"; import { AppStore } from "./store"; import { createTrayMenuIcon, type TrayIconKind } from "./tray-icons"; -import { checkForUpdates } from "./update"; +import { + completePendingUpdate, + UpdateService, + type AppUpdateInfo +} from "./update"; import { getDriveInfo, getLocalDriveRoots, @@ -63,6 +67,7 @@ let mainWindow: BrowserWindow | undefined; let quickSearchWindow: BrowserWindow | undefined; let uninstallRestoreWindow: BrowserWindow | undefined; let searchService: SearchService | undefined; +let updateService: UpdateService | undefined; let tray: Tray | undefined; let isQuitting = false; const store = new AppStore(); @@ -350,6 +355,31 @@ function emitSettingsChanged(settings: AppSettings): void { } } +function emitUpdateState(state: AppUpdateInfo): void { + for (const window of [mainWindow, quickSearchWindow]) { + if (window && !window.isDestroyed()) { + window.webContents.send("app:update-status", state); + } + } +} + +function assertUpdateCanInstall(): Promise { + const busyStages = new Set([ + "preflight", + "copying", + "verifying", + "switching", + "rolling-back" + ]); + const busy = store.listMigrations().find((record) => busyStages.has(record.stage)); + if (busy) { + return Promise.reject( + new Error("当前有迁移或恢复事务正在执行;完成后才能更新程序") + ); + } + return Promise.resolve(); +} + function sendNavigation( window: BrowserWindow, event: { view: string; path?: string; focus?: "ai-settings" | "search-input" } @@ -885,8 +915,11 @@ function registerIpc(): void { ipcMain.handle("settings:get", () => store.getSettings()); ipcMain.handle("app:update-check", (_event, force?: unknown) => - checkForUpdates(force === true) + updateService?.check(force === true) ); + ipcMain.handle("app:update-state", () => updateService?.getState()); + ipcMain.handle("app:update-start", () => updateService?.downloadAndInstall()); + ipcMain.handle("app:update-cancel", () => updateService?.cancel()); 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< @@ -907,6 +940,15 @@ function registerIpc(): void { const settings = await store.updateSettings( safePatch as Partial> ); + if ( + previous.mouseQuickSearchButton !== settings.mouseQuickSearchButton || + previous.mouseQuickSearchHoldMs !== settings.mouseQuickSearchHoldMs + ) { + await searchService?.configureMouseShortcut( + settings.mouseQuickSearchButton, + settings.mouseQuickSearchHoldMs + ); + } emitSettingsChanged(settings); createTray(); return settings; @@ -944,6 +986,25 @@ function registerIpc(): void { } return true; }); + ipcMain.handle("shortcut:mouse-status", () => + searchService?.getMouseShortcutStatus() ?? { + available: false, + button: store.getSettings().mouseQuickSearchButton, + holdMs: store.getSettings().mouseQuickSearchHoldMs, + message: "鼠标监听尚未启动" + } + ); + ipcMain.handle("shortcut:mouse-test", () => { + const status = searchService?.getMouseShortcutStatus(); + if (!status || !status.available) { + throw new Error(status?.message ?? "鼠标监听尚未启动"); + } + if (status.button === "disabled") { + throw new Error("请先选择一个鼠标按键"); + } + createQuickSearchWindow(); + return true; + }); ipcMain.handle("app:navigate", (_event, raw: unknown) => { if (!raw || typeof raw !== "object") throw new Error("导航请求无效"); @@ -1574,7 +1635,9 @@ if (!singleInstance) { await store.init(); applyNativeEffect(store.getSettings().effectMode); mainWindow = createWindow(); + await completePendingUpdate(); createTray(); + const currentSettings = store.getSettings(); searchService = new SearchService( (status) => { for (const window of [mainWindow, quickSearchWindow]) { @@ -1589,6 +1652,11 @@ if (!singleInstance) { window.webContents.send("content-indexer:status", status); } } + }, + () => createQuickSearchWindow(), + { + button: currentSettings.mouseQuickSearchButton, + holdMs: currentSettings.mouseQuickSearchHoldMs } ); syncSearchBackgroundMode(); @@ -1597,6 +1665,7 @@ if (!singleInstance) { mainWindow.webContents.send("migration:progress", { record, message }); } }); + updateService = new UpdateService(emitUpdateState, assertUpdateCanInstall); registerIpc(); const shortcutFailures = applyGlobalShortcuts(store.getSettings()); if (shortcutFailures.length > 0) { diff --git a/electron/preload.ts b/electron/preload.ts index 10fffc4..84562e6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -4,17 +4,23 @@ import type { IndexerStatus, MigrationRecord } from "./types"; +import type { AppUpdateInfo } from "./update"; contextBridge.exposeInMainWorld("cDriveShiftAI", { getOverview: () => ipcRenderer.invoke("system:overview"), checkForUpdates: (force?: boolean) => ipcRenderer.invoke("app:update-check", force === true), + getUpdateState: () => ipcRenderer.invoke("app:update-state"), + startUpdate: () => ipcRenderer.invoke("app:update-start"), + cancelUpdate: () => ipcRenderer.invoke("app:update-cancel"), getSettings: () => ipcRenderer.invoke("settings:get"), updateSettings: (patch: unknown) => ipcRenderer.invoke("settings:update", patch), checkGlobalShortcut: (shortcut: string, target: string) => ipcRenderer.invoke("shortcut:check", shortcut, target), testGlobalShortcut: (target: string) => ipcRenderer.invoke("shortcut:test", target), + getMouseShortcutStatus: () => ipcRenderer.invoke("shortcut:mouse-status"), + testMouseShortcut: () => ipcRenderer.invoke("shortcut:mouse-test"), listAiModels: (input: unknown) => ipcRenderer.invoke("ai:list-models", input), testAiConnection: (input: unknown) => ipcRenderer.invoke("ai:test", input), saveAiDraft: (input: unknown) => ipcRenderer.invoke("ai:save-draft", input), @@ -123,5 +129,11 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { listener(settings); ipcRenderer.on("settings:changed", handler); return () => ipcRenderer.removeListener("settings:changed", handler); + }, + onUpdateStatus: (listener: (status: AppUpdateInfo) => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateInfo) => + listener(status); + ipcRenderer.on("app:update-status", handler); + return () => ipcRenderer.removeListener("app:update-status", handler); } }); diff --git a/electron/search.ts b/electron/search.ts index ea34458..ded2cc3 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -9,6 +9,8 @@ import type { ContentSearchResult, DirectorySizeResult, IndexerStatus, + MouseShortcutButton, + MouseShortcutStatus, NativeResponse, SearchFilters, SearchResult @@ -53,11 +55,21 @@ export class SearchService { private dailyRefreshTimer?: NodeJS.Timeout; private lastFullRefreshRequestedAt = 0; private backgroundMode = false; + private mouseShortcutStatus: MouseShortcutStatus; constructor( private readonly onStatus: (status: IndexerStatus) => void, - private readonly onContentStatus: (status: ContentIndexerStatus) => void - ) {} + private readonly onContentStatus: (status: ContentIndexerStatus) => void, + private readonly onMouseShortcutHold: () => void, + initialMouseShortcut: { button: MouseShortcutButton; holdMs: number } + ) { + this.mouseShortcutStatus = { + available: false, + button: initialMouseShortcut.button, + holdMs: initialMouseShortcut.holdMs, + message: "鼠标全局监听正在启动" + }; + } async start(): Promise { const executable = app.isPackaged @@ -133,7 +145,9 @@ export class SearchService { contentCacheDir, background: this.backgroundMode, forceRebuild, - rebuildReason + rebuildReason, + mouseButton: this.mouseShortcutStatus.button, + mouseHoldMs: this.mouseShortcutStatus.holdMs }, 90_000 ); @@ -157,6 +171,41 @@ export class SearchService { return structuredClone(this.status); } + getMouseShortcutStatus(): MouseShortcutStatus { + return structuredClone(this.mouseShortcutStatus); + } + + async configureMouseShortcut( + button: MouseShortcutButton, + holdMs: number + ): Promise { + this.mouseShortcutStatus = { + ...this.mouseShortcutStatus, + button, + holdMs, + message: + button === "disabled" + ? "鼠标快捷操作已关闭" + : this.mouseShortcutStatus.available + ? "全局鼠标监听可用;短按不会被拦截" + : "鼠标监听暂不可用" + }; + if (this.child) { + const response = (await this.request( + { op: "setMouseShortcut", mouseButton: button, mouseHoldMs: holdMs }, + 3_000 + )) as NativeResponse & { available?: boolean }; + this.mouseShortcutStatus.available = response.available === true; + this.mouseShortcutStatus.message = + button === "disabled" + ? "鼠标快捷操作已关闭" + : response.available + ? "全局鼠标监听可用;短按不会被拦截" + : "Windows Raw Input 监听不可用"; + } + return this.getMouseShortcutStatus(); + } + setBackgroundMode(background: boolean): void { if (this.backgroundMode === background) return; this.backgroundMode = background; @@ -543,6 +592,25 @@ export class SearchService { ); return; } + if (response.event === "mouseShortcutHold") { + this.onMouseShortcutHold(); + return; + } + if (response.event === "mouseShortcutStatus") { + const event = response as NativeResponse & { + available?: boolean; + errorCode?: number; + }; + this.mouseShortcutStatus.available = event.available === true; + this.mouseShortcutStatus.message = event.available + ? this.mouseShortcutStatus.button === "disabled" + ? "鼠标快捷操作已关闭" + : "全局鼠标监听可用;短按不会被拦截" + : `Windows Raw Input 监听不可用${ + event.errorCode ? `(错误 ${event.errorCode})` : "" + }`; + return; + } if (response.id == null) return; const pending = this.pending.get(response.id); if (!pending) return; diff --git a/electron/store.ts b/electron/store.ts index 4d3f74d..a0fde05 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -24,6 +24,8 @@ const defaults: StoreShape = { minimizeToTray: true, globalShortcut: "CommandOrControl+Alt+Space", quickSearchShortcut: "CommandOrControl+Alt+F", + mouseQuickSearchButton: "back", + mouseQuickSearchHoldMs: 3_000, indexRoots: ["*"], excludedPaths: [ "C:\\Windows\\WinSxS", @@ -463,6 +465,16 @@ function mergeSettings(input?: Partial): AppSettings { typeof input?.quickSearchShortcut === "string" ? input.quickSearchShortcut.trim().slice(0, 128) : defaults.settings.quickSearchShortcut, + mouseQuickSearchButton: ["disabled", "back", "forward", "middle"].includes( + input?.mouseQuickSearchButton ?? "" + ) + ? input!.mouseQuickSearchButton! + : defaults.settings.mouseQuickSearchButton, + mouseQuickSearchHoldMs: + typeof input?.mouseQuickSearchHoldMs === "number" && + Number.isFinite(input.mouseQuickSearchHoldMs) + ? Math.round(Math.min(10_000, Math.max(500, input.mouseQuickSearchHoldMs))) + : defaults.settings.mouseQuickSearchHoldMs, indexRoots: Array.isArray(input?.indexRoots) ? input.indexRoots : defaults.settings.indexRoots, excludedPaths: Array.isArray(input?.excludedPaths) ? input.excludedPaths diff --git a/electron/types.ts b/electron/types.ts index dd5615a..2208f4e 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -287,6 +287,8 @@ export interface AppSettings { minimizeToTray: boolean; globalShortcut: string; quickSearchShortcut: string; + mouseQuickSearchButton: MouseShortcutButton; + mouseQuickSearchHoldMs: number; indexRoots: string[]; excludedPaths: string[]; ai: { @@ -301,6 +303,15 @@ export interface AppSettings { }; } +export type MouseShortcutButton = "disabled" | "back" | "forward" | "middle"; + +export interface MouseShortcutStatus { + available: boolean; + button: MouseShortcutButton; + holdMs: number; + message: string; +} + export type ShortcutTarget = "main" | "quick-search"; export interface ShortcutCheckResult { diff --git a/electron/update.ts b/electron/update.ts index 628971a..82584ca 100644 --- a/electron/update.ts +++ b/electron/update.ts @@ -1,8 +1,40 @@ import { app } from "electron"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + access, + copyFile, + mkdir, + open, + readFile, + rename, + rm, + stat, + writeFile +} from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import path from "node:path"; const RELEASE_API = "https://api.github.com/repos/PuppetWen/CDriveShiftAI/releases/latest"; const CACHE_DURATION_MS = 15 * 60_000; +const DOWNLOAD_RETRIES = 3; +const MANIFEST_NAME = "update-manifest.json"; + +export type UpdatePhase = + | "idle" + | "checking" + | "current" + | "available" + | "downloading" + | "verifying" + | "ready" + | "installing" + | "cancelled" + | "error" + | "unavailable"; + +export type UpdateDistribution = "installed" | "portable" | "development"; export interface AppUpdateAsset { name: string; @@ -10,17 +42,32 @@ export interface AppUpdateAsset { downloadUrl: string; } +export interface AppUpdateProgress { + transferred: number; + total: number; + percent: number; + bytesPerSecond: number; + retryAttempt: number; + maxRetries: number; +} + export interface AppUpdateInfo { status: "current" | "available" | "unavailable"; + phase: UpdatePhase; + distribution: UpdateDistribution; currentVersion: string; latestVersion?: string; updateAvailable: boolean; + canAutoUpdate: boolean; releaseName?: string; releaseUrl?: string; publishedAt?: string; assets: AppUpdateAsset[]; + selectedAsset?: AppUpdateAsset; + progress?: AppUpdateProgress; message: string; checkedAt: string; + errorCode?: string; } interface GitHubRelease { @@ -37,7 +84,35 @@ interface GitHubRelease { }>; } -let cached: { at: number; result: AppUpdateInfo } | undefined; +interface ManifestAsset { + name: string; + size: number; + sha512: string; +} + +interface UpdateManifest { + schemaVersion: 1; + version: string; + assets: { + installer: ManifestAsset; + portable: ManifestAsset; + }; +} + +interface UpdatePlan { + schemaVersion: 1; + mode: "installed" | "portable"; + parentPid: number; + packagePath: string; + targetPath: string; + installedDir?: string; + stagingDir: string; + backupPath: string; + successMarker: string; + expectedVersion: string; + expectedSha512: string; + logPath: string; +} function versionParts(value: string): number[] { return value @@ -58,41 +133,158 @@ function compareVersions(first: string, second: string): number { return 0; } -export async function checkForUpdates(force = false): Promise { - const now = Date.now(); - if (!force && cached && now - cached.at < CACHE_DURATION_MS) { - return structuredClone(cached.result); - } +function updateDistribution(): UpdateDistribution { + if (!app.isPackaged) return "development"; + return process.env.PORTABLE_EXECUTABLE_FILE ? "portable" : "installed"; +} + +function distributionExecutable(): string { + return path.resolve(process.env.PORTABLE_EXECUTABLE_FILE || process.execPath); +} - const currentVersion = app.getVersion(); - const checkedAt = new Date().toISOString(); +function updateStagingDirectory(version: string): string { + const executable = distributionExecutable(); + const mode = updateDistribution(); + const parent = + mode === "installed" + ? path.dirname(path.dirname(executable)) + : path.dirname(executable); + return path.join(parent, ".cdriveshiftai-update", version); +} + +function clone(value: T): T { + return structuredClone(value); +} + +async function exists(candidate: string): Promise { try { - const response = await fetch(RELEASE_API, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": `CDriveShiftAI/${currentVersion}`, - "X-GitHub-Api-Version": "2022-11-28" + await access(candidate); + return true; + } catch { + return false; + } +} + +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"))); + }); +} + +function githubHeaders(version: string): Record { + return { + Accept: "application/vnd.github+json", + "User-Agent": `CDriveShiftAI/${version}`, + "X-GitHub-Api-Version": "2022-11-28" + }; +} + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); }, - signal: AbortSignal.timeout(6_000) - }); - if (!response.ok) { - throw new Error(`GitHub Release 返回 HTTP ${response.status}`); - } - const release = (await response.json()) as GitHubRelease; - if (release.draft || release.prerelease || !release.tag_name) { - throw new Error("尚未找到可用的正式版本"); - } - const latestVersion = release.tag_name.replace(/^v/i, ""); - const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; - const result: AppUpdateInfo = { - status: updateAvailable ? "available" : "current", + { once: true } + ); + }); +} + +function safeManifest(value: unknown): UpdateManifest | undefined { + if (!value || typeof value !== "object") return undefined; + const input = value as Partial; + if ( + input.schemaVersion !== 1 || + typeof input.version !== "string" || + !input.assets + ) { + return undefined; + } + const validAsset = (asset: ManifestAsset | undefined) => + asset && + typeof asset.name === "string" && + path.basename(asset.name) === asset.name && + !/[\u0000-\u001f]/.test(asset.name) && + Number.isSafeInteger(asset.size) && + asset.size > 0 && + typeof asset.sha512 === "string" && + /^[a-f0-9]{128}$/i.test(asset.sha512); + if (!validAsset(input.assets.installer) || !validAsset(input.assets.portable)) { + return undefined; + } + return input as UpdateManifest; +} + +export class UpdateService { + private cached?: { at: number; result: AppUpdateInfo }; + private state: AppUpdateInfo; + private manifest?: UpdateManifest; + private abortController?: AbortController; + private lastProgressAt = 0; + + constructor( + private readonly onState: (state: AppUpdateInfo) => void, + private readonly canInstall: () => Promise + ) { + const currentVersion = app.getVersion(); + this.state = { + status: "current", + phase: "idle", + distribution: updateDistribution(), currentVersion, - latestVersion, - updateAvailable, - releaseName: release.name || `CDriveShiftAI ${latestVersion}`, - releaseUrl: release.html_url, - publishedAt: release.published_at, - assets: (release.assets ?? []) + updateAvailable: false, + canAutoUpdate: false, + assets: [], + message: "尚未检查更新", + checkedAt: new Date(0).toISOString() + }; + } + + getState(): AppUpdateInfo { + return clone(this.state); + } + + private setState(patch: Partial): AppUpdateInfo { + this.state = { ...this.state, ...patch }; + const result = this.getState(); + this.onState(result); + return result; + } + + async check(force = false): Promise { + const now = Date.now(); + if (!force && this.cached && now - this.cached.at < CACHE_DURATION_MS) { + this.state = clone(this.cached.result); + return this.getState(); + } + + const currentVersion = app.getVersion(); + const checkedAt = new Date().toISOString(); + this.setState({ + phase: "checking", + message: "正在检查 GitHub Release…", + errorCode: undefined + }); + try { + const response = await fetch(RELEASE_API, { + headers: githubHeaders(currentVersion), + signal: AbortSignal.timeout(10_000) + }); + if (!response.ok) { + throw new Error(`GitHub Release 返回 HTTP ${response.status}`); + } + const release = (await response.json()) as GitHubRelease; + if (release.draft || release.prerelease || !release.tag_name) { + throw new Error("尚未找到可用的正式版本"); + } + const assets = (release.assets ?? []) .filter( (asset) => typeof asset.name === "string" && @@ -102,24 +294,388 @@ export async function checkForUpdates(force = false): Promise { name: asset.name!, size: Number(asset.size) || 0, downloadUrl: asset.browser_download_url! - })), - message: updateAvailable - ? `发现新版本 ${latestVersion}` - : `当前已是最新版本 ${currentVersion}`, - checkedAt + })); + const latestVersion = release.tag_name.replace(/^v/i, ""); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(latestVersion)) { + throw new Error("Release 版本号格式无效"); + } + const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; + const manifestReleaseAsset = assets.find( + (asset) => asset.name.toLocaleLowerCase() === MANIFEST_NAME + ); + let manifest: UpdateManifest | undefined; + if (updateAvailable && manifestReleaseAsset) { + const manifestResponse = await fetch(manifestReleaseAsset.downloadUrl, { + headers: githubHeaders(currentVersion), + signal: AbortSignal.timeout(10_000) + }); + if (manifestResponse.ok) { + manifest = safeManifest(await manifestResponse.json()); + } + } + if (manifest && manifest.version !== latestVersion) manifest = undefined; + const distribution = updateDistribution(); + const manifestAsset = + distribution === "portable" + ? manifest?.assets.portable + : manifest?.assets.installer; + const selectedAsset = manifestAsset + ? assets.find((asset) => asset.name === manifestAsset.name) + : undefined; + const canAutoUpdate = + updateAvailable && + app.isPackaged && + distribution !== "development" && + Boolean(manifest && manifestAsset && selectedAsset); + this.manifest = manifest; + const result: AppUpdateInfo = { + status: updateAvailable ? "available" : "current", + phase: updateAvailable ? "available" : "current", + distribution, + currentVersion, + latestVersion, + updateAvailable, + canAutoUpdate, + releaseName: release.name || `CDriveShiftAI ${latestVersion}`, + releaseUrl: release.html_url, + publishedAt: release.published_at, + assets, + selectedAsset, + message: updateAvailable + ? canAutoUpdate + ? `发现新版本 ${latestVersion},可自动下载并更新` + : `发现新版本 ${latestVersion},但该 Release 缺少自动更新清单` + : `当前已是最新版本 ${currentVersion}`, + checkedAt + }; + this.state = result; + this.cached = { at: now, result: clone(result) }; + this.onState(this.getState()); + return this.getState(); + } catch (error) { + return this.setState({ + status: "unavailable", + phase: "unavailable", + currentVersion, + updateAvailable: false, + canAutoUpdate: false, + assets: [], + selectedAsset: undefined, + progress: undefined, + message: `暂时无法检查更新:${ + error instanceof Error ? error.message : String(error) + }`, + checkedAt, + errorCode: "CHECK_FAILED" + }); + } + } + + cancel(): AppUpdateInfo { + this.abortController?.abort(new Error("用户已取消下载")); + return this.setState({ + phase: "cancelled", + message: "已暂停更新;下次继续时会从已下载位置续传", + errorCode: undefined + }); + } + + async downloadAndInstall(): Promise { + try { + return await this.performDownloadAndInstall(); + } catch (error) { + if (this.abortController?.signal.aborted) { + return this.setState({ + phase: "cancelled", + message: "已暂停更新;下次继续时会从已下载位置续传", + errorCode: undefined + }); + } + return this.setState({ + phase: "error", + message: `自动更新未完成:${ + error instanceof Error ? error.message : String(error) + }`, + errorCode: "UPDATE_FAILED" + }); + } finally { + this.abortController = undefined; + } + } + + private async performDownloadAndInstall(): Promise { + if (this.state.phase === "downloading" || this.state.phase === "verifying") { + return this.getState(); + } + if (!this.state.updateAvailable || !this.state.canAutoUpdate) { + const checked = await this.check(true); + if (!checked.updateAvailable || !checked.canAutoUpdate) return checked; + } + await this.canInstall(); + const distribution = updateDistribution(); + const manifestAsset = + distribution === "portable" + ? this.manifest?.assets.portable + : this.manifest?.assets.installer; + const selectedAsset = this.state.selectedAsset; + const latestVersion = this.state.latestVersion; + if (!manifestAsset || !selectedAsset || !latestVersion) { + throw new Error("Release 自动更新资产不完整"); + } + const controller = new AbortController(); + this.abortController = controller; + const stagingDir = updateStagingDirectory(latestVersion); + await mkdir(stagingDir, { recursive: true }); + const partialPath = path.join(stagingDir, `${manifestAsset.name}.part`); + const packagePath = path.join(stagingDir, manifestAsset.name); + let lastError: unknown; + for (let attempt = 1; attempt <= DOWNLOAD_RETRIES; attempt += 1) { + try { + await this.downloadAttempt( + selectedAsset.downloadUrl, + partialPath, + manifestAsset.size, + attempt, + controller.signal + ); + lastError = undefined; + break; + } catch (error) { + lastError = error; + if (controller.signal.aborted) throw error; + if (attempt < DOWNLOAD_RETRIES) { + this.setState({ + phase: "downloading", + message: `下载中断,${Math.min(2 ** (attempt - 1), 4)} 秒后进行第 ${ + attempt + 1 + } 次尝试…` + }); + await delay(Math.min(2 ** (attempt - 1), 4) * 1_000, controller.signal); + } + } + } + if (lastError) { + return this.setState({ + phase: "error", + message: `更新包下载失败:${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + errorCode: "DOWNLOAD_FAILED" + }); + } + + this.setState({ + phase: "verifying", + message: "正在校验更新包完整性与 SHA-512…", + progress: { + transferred: manifestAsset.size, + total: manifestAsset.size, + percent: 100, + bytesPerSecond: 0, + retryAttempt: this.state.progress?.retryAttempt ?? 1, + maxRetries: DOWNLOAD_RETRIES + } + }); + const partialStats = await stat(partialPath); + const actualDigest = await sha512(partialPath); + if ( + partialStats.size !== manifestAsset.size || + actualDigest.toLocaleLowerCase() !== manifestAsset.sha512.toLocaleLowerCase() + ) { + await rm(partialPath, { force: true }); + return this.setState({ + phase: "error", + message: "更新包校验失败,已拒绝安装并删除损坏文件", + errorCode: "CHECKSUM_MISMATCH" + }); + } + await rm(packagePath, { force: true }); + await rename(partialPath, packagePath); + this.setState({ + phase: "ready", + message: "更新包校验通过,正在准备安全替换…", + errorCode: undefined + }); + return this.install(packagePath, manifestAsset); + } + + private async downloadAttempt( + url: string, + partialPath: string, + total: number, + attempt: number, + signal: AbortSignal + ): Promise { + let transferred = 0; + if (await exists(partialPath)) { + transferred = (await stat(partialPath)).size; + if (transferred > total) { + await rm(partialPath, { force: true }); + transferred = 0; + } + } + const headers: Record = githubHeaders(app.getVersion()); + if (transferred > 0) headers.Range = `bytes=${transferred}-`; + const response = await fetch(url, { headers, signal, redirect: "follow" }); + if (response.status === 416 && transferred === total) return; + if (!response.ok && response.status !== 206) { + throw new Error(`HTTP ${response.status}`); + } + if (transferred > 0 && response.status !== 206) { + await rm(partialPath, { force: true }); + transferred = 0; + } + if (!response.body) throw new Error("下载响应没有数据流"); + const file = await open(partialPath, transferred > 0 ? "a" : "w"); + const reader = response.body.getReader(); + const startedAt = Date.now(); + const startedBytes = transferred; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (signal.aborted) throw signal.reason; + await file.write(value); + transferred += value.byteLength; + const now = Date.now(); + if (now - this.lastProgressAt >= 120 || transferred >= total) { + this.lastProgressAt = now; + const elapsed = Math.max(0.25, (now - startedAt) / 1_000); + this.setState({ + phase: "downloading", + message: + attempt > 1 + ? `正在断点续传(第 ${attempt}/${DOWNLOAD_RETRIES} 次尝试)` + : "正在下载更新包…", + progress: { + transferred, + total, + percent: Math.min(100, (transferred / total) * 100), + bytesPerSecond: Math.max(0, (transferred - startedBytes) / elapsed), + retryAttempt: attempt, + maxRetries: DOWNLOAD_RETRIES + }, + errorCode: undefined + }); + } + } + } finally { + await file.close(); + } + if (transferred !== total) { + throw new Error(`文件大小不完整(${transferred}/${total} 字节)`); + } + } + + private async install( + packagePath: string, + manifestAsset: ManifestAsset + ): Promise { + await this.canInstall(); + const distribution = updateDistribution(); + if (distribution === "development") { + throw new Error("开发模式不能执行自更新"); + } + const targetPath = distributionExecutable(); + const stagingDir = path.dirname(packagePath); + const helperSource = path.join( + process.resourcesPath, + "bin", + "cshift-updater.exe" + ); + if (!(await exists(helperSource))) { + throw new Error("更新助手缺失,已保留下载包但不会执行替换"); + } + const helperPath = path.join(stagingDir, "cshift-updater.exe"); + await copyFile(helperSource, helperPath); + const plan: UpdatePlan = { + schemaVersion: 1, + mode: distribution, + parentPid: process.pid, + packagePath, + targetPath: + distribution === "installed" + ? path.join(path.dirname(targetPath), "CDriveShiftAI.exe") + : targetPath, + installedDir: distribution === "installed" ? path.dirname(targetPath) : undefined, + stagingDir, + backupPath: path.join( + stagingDir, + distribution === "installed" ? "previous-version" : "previous-version.exe" + ), + successMarker: path.join(stagingDir, "update-success.json"), + expectedVersion: this.state.latestVersion!, + expectedSha512: manifestAsset.sha512, + logPath: path.join(stagingDir, "update.log") }; - cached = { at: now, result }; - return structuredClone(result); - } catch (error) { - return { - status: "unavailable", - currentVersion, - updateAvailable: false, - assets: [], - message: `暂时无法检查更新:${ - error instanceof Error ? error.message : String(error) - }`, - checkedAt + const planPath = path.join(stagingDir, "update-plan.json"); + await writeFile(planPath, JSON.stringify(plan, null, 2), "utf8"); + this.setState({ + phase: "installing", + message: + distribution === "installed" + ? "即将退出并静默安装;失败时会自动恢复旧版本" + : "即将退出并在原路径替换便携版;失败时会自动恢复旧文件" + }); + const child = spawn(helperPath, ["--plan", planPath], { + detached: true, + windowsHide: true, + stdio: "ignore" + }); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + child.unref(); + setTimeout(() => app.quit(), 180); + return this.getState(); + } +} + +function safeUpdateStaging(candidate: string): boolean { + const resolved = path.resolve(candidate); + return resolved + .split(path.sep) + .some((segment) => segment.toLocaleLowerCase() === ".cdriveshiftai-update"); +} + +export async function completePendingUpdate(): Promise { + const markerIndex = process.argv.indexOf("--update-staging"); + if (markerIndex < 0) return; + const stagingDir = process.argv[markerIndex + 1]; + if (!stagingDir || !safeUpdateStaging(stagingDir)) return; + try { + const plan = JSON.parse( + await readFile(path.join(stagingDir, "update-plan.json"), "utf8") + ) as UpdatePlan; + if ( + plan.schemaVersion !== 1 || + path.resolve(plan.stagingDir) !== path.resolve(stagingDir) || + plan.expectedVersion !== app.getVersion() + ) { + return; + } + await writeFile( + plan.successMarker, + JSON.stringify({ + version: app.getVersion(), + startedAt: new Date().toISOString(), + pid: process.pid + }), + "utf8" + ); + const cleanup = async (remaining = 20): Promise => { + try { + await rm(stagingDir, { recursive: true, force: true }); + } catch { + if (remaining > 0) { + setTimeout(() => void cleanup(remaining - 1), 1_500); + } + } }; + setTimeout(() => void cleanup(), 3_000); + } catch { + // The helper treats a missing success marker as a failed update and + // restores the previous executable/application directory. } } diff --git a/native/indexer/Cargo.lock b/native/indexer/Cargo.lock index 840cca1..ab5fad2 100644 --- a/native/indexer/Cargo.lock +++ b/native/indexer/Cargo.lock @@ -68,7 +68,7 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "cshift-indexer" -version = "0.0.1" +version = "0.0.2" dependencies = [ "memmap2", "notify", diff --git a/native/indexer/Cargo.toml b/native/indexer/Cargo.toml index a45fd8e..5668a7d 100644 --- a/native/indexer/Cargo.toml +++ b/native/indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-indexer" -version = "0.0.1" +version = "0.0.2" edition = "2021" description = "First-party NTFS MFT indexer for CDriveShiftAI" @@ -15,8 +15,12 @@ regex = "1.11.1" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.52.0", features = [ "Win32_Foundation", + "Win32_Graphics_Gdi", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO", - "Win32_System_Threading" + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_Input", + "Win32_UI_WindowsAndMessaging" ] } diff --git a/native/indexer/src/main.rs b/native/indexer/src/main.rs index 96eb956..b88a02e 100644 --- a/native/indexer/src/main.rs +++ b/native/indexer/src/main.rs @@ -1,8 +1,8 @@ +use memmap2::{Mmap, MmapOptions}; use notify::{ event::{ModifyKind, RemoveKind}, Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher, }; -use memmap2::{Mmap, MmapOptions}; use regex::RegexBuilder; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; @@ -12,8 +12,8 @@ use std::env; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{mpsc, Arc, Mutex, RwLock}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex, OnceLock, RwLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -157,6 +157,8 @@ struct Request { background: Option, force_rebuild: Option, rebuild_reason: Option, + mouse_button: Option, + mouse_hold_ms: Option, limit: Option, } @@ -211,6 +213,270 @@ impl Output { } } +#[cfg(windows)] +struct MouseShortcutState { + output: Output, + pressed: AtomicBool, + sequence: AtomicU64, + button: AtomicU64, + hold_ms: AtomicU64, +} + +#[cfg(windows)] +static MOUSE_SHORTCUT_STATE: OnceLock = OnceLock::new(); + +#[cfg(windows)] +fn mouse_button_code(button: &str) -> u64 { + match button { + "back" => 1, + "forward" => 2, + "middle" => 3, + _ => 0, + } +} + +#[cfg(windows)] +fn mouse_button_name(button: u64) -> &'static str { + match button { + 1 => "back", + 2 => "forward", + 3 => "middle", + _ => "disabled", + } +} + +#[cfg(windows)] +fn configure_mouse_shortcut(button: &str, hold_ms: u64) { + let Some(state) = MOUSE_SHORTCUT_STATE.get() else { + return; + }; + state.pressed.store(false, Ordering::SeqCst); + state.sequence.fetch_add(1, Ordering::SeqCst); + state + .button + .store(mouse_button_code(button), Ordering::SeqCst); + state + .hold_ms + .store(hold_ms.clamp(500, 10_000), Ordering::SeqCst); +} + +#[cfg(not(windows))] +fn configure_mouse_shortcut(_button: &str, _hold_ms: u64) {} + +#[cfg(windows)] +fn begin_mouse_shortcut_hold(button: u64) { + let Some(state) = MOUSE_SHORTCUT_STATE.get() else { + return; + }; + if button == 0 || state.button.load(Ordering::SeqCst) != button { + return; + } + if state.pressed.swap(true, Ordering::SeqCst) { + return; + } + let sequence = state.sequence.fetch_add(1, Ordering::SeqCst) + 1; + let hold_ms = state.hold_ms.load(Ordering::SeqCst); + let output = state.output.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(hold_ms)); + let Some(current) = MOUSE_SHORTCUT_STATE.get() else { + return; + }; + if current.pressed.load(Ordering::SeqCst) + && current.sequence.load(Ordering::SeqCst) == sequence + && current.button.load(Ordering::SeqCst) == button + { + output.send(&json!({ + "event": "mouseShortcutHold", + "button": mouse_button_name(button), + "holdMs": hold_ms + })); + current.pressed.store(false, Ordering::SeqCst); + } + }); +} + +#[cfg(windows)] +fn end_mouse_shortcut_hold(button: u64) { + let Some(state) = MOUSE_SHORTCUT_STATE.get() else { + return; + }; + if state.button.load(Ordering::SeqCst) != button { + return; + } + state.pressed.store(false, Ordering::SeqCst); + state.sequence.fetch_add(1, Ordering::SeqCst); +} + +#[cfg(windows)] +unsafe extern "system" fn mouse_shortcut_window_proc( + window: windows_sys::Win32::Foundation::HWND, + message: u32, + wparam: windows_sys::Win32::Foundation::WPARAM, + lparam: windows_sys::Win32::Foundation::LPARAM, +) -> windows_sys::Win32::Foundation::LRESULT { + use windows_sys::Win32::UI::Input::{ + GetRawInputData, RAWINPUT, RAWINPUTHEADER, RID_INPUT, RIM_TYPEMOUSE, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + DefWindowProcW, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_4_UP, RI_MOUSE_BUTTON_5_DOWN, + RI_MOUSE_BUTTON_5_UP, RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_MIDDLE_BUTTON_UP, WM_INPUT, + }; + + if message == WM_INPUT { + let mut raw = std::mem::MaybeUninit::::zeroed(); + let mut size = std::mem::size_of::() as u32; + let copied = GetRawInputData( + lparam, + RID_INPUT, + raw.as_mut_ptr().cast(), + &mut size, + std::mem::size_of::() as u32, + ); + if copied != u32::MAX { + let raw = raw.assume_init(); + if raw.header.dwType == RIM_TYPEMOUSE { + let flags = raw.data.mouse.Anonymous.Anonymous.usButtonFlags; + if flags & RI_MOUSE_BUTTON_4_DOWN as u16 != 0 { + begin_mouse_shortcut_hold(1); + } + if flags & RI_MOUSE_BUTTON_4_UP as u16 != 0 { + end_mouse_shortcut_hold(1); + } + if flags & RI_MOUSE_BUTTON_5_DOWN as u16 != 0 { + begin_mouse_shortcut_hold(2); + } + if flags & RI_MOUSE_BUTTON_5_UP as u16 != 0 { + end_mouse_shortcut_hold(2); + } + if flags & RI_MOUSE_MIDDLE_BUTTON_DOWN as u16 != 0 { + begin_mouse_shortcut_hold(3); + } + if flags & RI_MOUSE_MIDDLE_BUTTON_UP as u16 != 0 { + end_mouse_shortcut_hold(3); + } + } + } + } + DefWindowProcW(window, message, wparam, lparam) +} + +#[cfg(windows)] +fn start_mouse_shortcut_listener(output: Output) { + use windows_sys::Win32::Foundation::GetLastError; + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::UI::Input::{RegisterRawInputDevices, RAWINPUTDEVICE, RIDEV_INPUTSINK}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DispatchMessageW, GetMessageW, RegisterClassW, TranslateMessage, + HWND_MESSAGE, MSG, WNDCLASSW, + }; + + let listener_output = output.clone(); + if MOUSE_SHORTCUT_STATE + .set(MouseShortcutState { + output, + pressed: AtomicBool::new(false), + sequence: AtomicU64::new(0), + button: AtomicU64::new(1), + hold_ms: AtomicU64::new(3_000), + }) + .is_err() + { + return; + } + + thread::spawn(move || unsafe { + let module = GetModuleHandleW(std::ptr::null()); + let class_name: Vec = "CDriveShiftAI.MouseBackListener\0".encode_utf16().collect(); + let window_class = WNDCLASSW { + style: 0, + lpfnWndProc: Some(mouse_shortcut_window_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: module, + hIcon: 0, + hCursor: 0, + hbrBackground: 0, + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + }; + if RegisterClassW(&window_class) == 0 { + listener_output.send(&json!({ + "event": "mouseShortcutStatus", + "available": false, + "errorCode": GetLastError() + })); + return; + } + + let window = CreateWindowExW( + 0, + class_name.as_ptr(), + class_name.as_ptr(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + 0, + module, + std::ptr::null(), + ); + if window == 0 { + listener_output.send(&json!({ + "event": "mouseShortcutStatus", + "available": false, + "errorCode": GetLastError() + })); + return; + } + + let mouse = RAWINPUTDEVICE { + usUsagePage: 0x01, + usUsage: 0x02, + dwFlags: RIDEV_INPUTSINK, + hwndTarget: window, + }; + if RegisterRawInputDevices(&mouse, 1, std::mem::size_of::() as u32) == 0 { + listener_output.send(&json!({ + "event": "mouseShortcutStatus", + "available": false, + "errorCode": GetLastError() + })); + return; + } + + listener_output.send(&json!({ + "event": "mouseShortcutStatus", + "available": true, + "button": "back", + "holdMs": 3000 + })); + let mut message: MSG = std::mem::zeroed(); + loop { + let result = GetMessageW(&mut message, 0, 0, 0); + if result > 0 { + TranslateMessage(&message); + DispatchMessageW(&message); + continue; + } + if result <= 0 { + listener_output.send(&json!({ + "event": "mouseShortcutStatus", + "available": false, + "errorCode": if result < 0 { GetLastError() } else { 0 }, + "messageLoopResult": result + })); + break; + } + } + }); +} + +#[cfg(not(windows))] +fn start_mouse_shortcut_listener(_output: Output) {} + fn now_iso_like() -> String { let seconds = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -244,9 +510,7 @@ fn wait_while_backgrounded(backgrounded: &AtomicBool, stopping: &AtomicBool) -> #[cfg(windows)] fn trim_process_working_set() { - use windows_sys::Win32::System::Threading::{ - GetCurrentProcess, SetProcessWorkingSetSize, - }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, SetProcessWorkingSetSize}; unsafe { // usize::MAX asks Windows to discard reclaimable working-set pages. // The mapped cache stays valid and pages back on demand. @@ -372,9 +636,7 @@ impl SearchIndex { entries, }; for entry_index in 0..index.entries.len() { - if entry_index % 4_096 == 0 - && wait_while_backgrounded(backgrounded, stopping) - { + if entry_index % 4_096 == 0 && wait_while_backgrounded(backgrounded, stopping) { return None; } let (signature, path_hash) = { @@ -385,9 +647,7 @@ impl SearchIndex { ) }; index.name_signatures.push(signature); - index - .path_positions - .push((path_hash, entry_index as u32)); + index.path_positions.push((path_hash, entry_index as u32)); } index .path_positions @@ -407,9 +667,7 @@ impl SearchIndex { let mut paths = Vec::with_capacity(capacity); let mut indexed_entries = Vec::with_capacity(entries.len()); for (entry_index, entry) in entries.into_iter().enumerate() { - if entry_index % 4_096 == 0 - && wait_while_backgrounded(backgrounded, stopping) - { + if entry_index % 4_096 == 0 && wait_while_backgrounded(backgrounded, stopping) { return None; } let offset = paths.len() as u64; @@ -468,7 +726,11 @@ impl SearchIndex { *live = false; } self.live_count = self.live_count.saturating_sub(1); - if self.entries.get(index).is_some_and(|entry| entry.is_delta()) { + if self + .entries + .get(index) + .is_some_and(|entry| entry.is_delta()) + { self.delta_positions.remove(&hash); } } @@ -491,7 +753,11 @@ impl SearchIndex { .map(|(index, _)| index) .collect(); for index in removed { - if self.entries.get(index).is_some_and(|entry| entry.is_delta()) { + if self + .entries + .get(index) + .is_some_and(|entry| entry.is_delta()) + { if let Some(path_value) = self.path(index) { let hash = normalized_path_hash(path_value); self.delta_positions.remove(&hash); @@ -579,14 +845,12 @@ impl SearchIndex { }; let first_token_lower = tokens[0].to_lowercase(); - let query_signature = if !regex_mode - && !match_path - && first_token_lower.chars().count() >= 3 - { - name_signature(&first_token_lower) - } else { - 0 - }; + let query_signature = + if !regex_mode && !match_path && first_token_lower.chars().count() >= 3 { + name_signature(&first_token_lower) + } else { + 0 + }; let mut matches = Vec::with_capacity(limit.saturating_mul(2)); for index in 0..self.entries.len() { @@ -594,10 +858,9 @@ impl SearchIndex { continue; } if query_signature != 0 - && self - .name_signatures - .get(index) - .map_or(true, |candidate| candidate & query_signature != query_signature) + && self.name_signatures.get(index).map_or(true, |candidate| { + candidate & query_signature != query_signature + }) { continue; } @@ -2135,11 +2398,8 @@ fn start_initial_cache_load(state: Arc, output: Output) { } thread::spawn(move || { let loaded = (|| -> io::Result<(SearchIndex, usize, String)> { - let (cached_root, mut index) = load_cache_index( - &state.cache_path, - &state.backgrounded, - &state.stopping, - )?; + let (cached_root, mut index) = + load_cache_index(&state.cache_path, &state.backgrounded, &state.stopping)?; if normalized(&cached_root) != normalized(&state.roots.join("|")) { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -2350,11 +2610,8 @@ fn start_scan(state: Arc, output: Output) { let cache_error = save_result.err(); let mut index = if cache_error.is_none() { drop(entries); - match load_cache_index( - &state.cache_path, - &state.backgrounded, - &state.stopping, - ) { + match load_cache_index(&state.cache_path, &state.backgrounded, &state.stopping) + { Ok((_cached_root, index)) => index, Err(_) => { state.scanning.store(false, Ordering::SeqCst); @@ -2435,6 +2692,7 @@ fn run_server() -> io::Result<()> { let output = Output { lock: Arc::new(Mutex::new(())), }; + start_mouse_shortcut_listener(output.clone()); let stdin = io::stdin(); let mut state: Option> = None; @@ -2452,6 +2710,10 @@ fn run_server() -> io::Result<()> { }; match request.op.as_str() { "init" => { + configure_mouse_shortcut( + request.mouse_button.as_deref().unwrap_or("back"), + request.mouse_hold_ms.unwrap_or(3_000), + ); let requested_root = request.root.unwrap_or_else(|| "*".to_string()); #[cfg(windows)] let roots = if requested_root == "*" { @@ -2501,9 +2763,7 @@ fn run_server() -> io::Result<()> { content_scanning: AtomicBool::new(false), watching: AtomicBool::new(false), loading: AtomicBool::new(false), - backgrounded: Arc::new(AtomicBool::new( - request.background.unwrap_or(false), - )), + backgrounded: Arc::new(AtomicBool::new(request.background.unwrap_or(false))), stopping: Arc::new(AtomicBool::new(false)), delta_lock: Mutex::new(()), }); @@ -2540,6 +2800,18 @@ fn run_server() -> io::Result<()> { ); } } + "setMouseShortcut" => { + let button = request.mouse_button.as_deref().unwrap_or("disabled"); + let hold_ms = request.mouse_hold_ms.unwrap_or(3_000).clamp(500, 10_000); + configure_mouse_shortcut(button, hold_ms); + output.send(&json!({ + "id": request.id, + "ok": true, + "available": cfg!(windows), + "button": button, + "holdMs": hold_ms + })); + } "query" => { let Some(shared) = &state else { output.send( diff --git a/native/updater/Cargo.lock b/native/updater/Cargo.lock new file mode 100644 index 0000000..77edcc4 --- /dev/null +++ b/native/updater/Cargo.lock @@ -0,0 +1,265 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cshift-updater" +version = "0.0.2" +dependencies = [ + "serde", + "serde_json", + "sha2", + "windows-sys", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/updater/Cargo.toml b/native/updater/Cargo.toml new file mode 100644 index 0000000..3c373b2 --- /dev/null +++ b/native/updater/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cshift-updater" +version = "0.0.2" +edition = "2021" +description = "Transactional Windows updater for CDriveShiftAI" + +[dependencies] +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.114" +sha2 = "0.10.8" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52.0", features = [ + "Win32_Foundation", + "Win32_System_Threading" +] } diff --git a/native/updater/src/main.rs b/native/updater/src/main.rs new file mode 100644 index 0000000..1c17bb6 --- /dev/null +++ b/native/updater/src/main.rs @@ -0,0 +1,594 @@ +use serde::Deserialize; +use sha2::{Digest, Sha512}; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const DATA_DIRECTORY: &str = ".cdriveshiftai-data"; +const START_TIMEOUT: Duration = Duration::from_secs(90); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdatePlan { + schema_version: u32, + mode: String, + parent_pid: u32, + package_path: PathBuf, + target_path: PathBuf, + installed_dir: Option, + staging_dir: PathBuf, + backup_path: PathBuf, + success_marker: PathBuf, + expected_version: String, + expected_sha512: String, + log_path: PathBuf, +} + +struct Logger { + path: PathBuf, +} + +impl Logger { + fn line(&self, message: impl AsRef) { + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + let _ = writeln!(file, "{}", message.as_ref()); + } + } +} + +fn canonical_or_absolute(path: &Path) -> io::Result { + if path.exists() { + path.canonicalize() + } else if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) { + if parent.exists() { + parent + .canonicalize() + .map(|canonical_parent| canonical_parent.join(file_name)) + } else if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + env::current_dir().map(|cwd| cwd.join(path)) + } + } else if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + env::current_dir().map(|cwd| cwd.join(path)) + } +} + +fn validate_plan(plan: &UpdatePlan) -> Result<(), String> { + if plan.schema_version != 1 { + return Err("unsupported update plan schema".to_string()); + } + if plan.mode != "installed" && plan.mode != "portable" { + return Err("unsupported update mode".to_string()); + } + if plan.expected_version.trim().is_empty() + || !plan + .expected_sha512 + .chars() + .all(|value| value.is_ascii_hexdigit()) + || plan.expected_sha512.len() != 128 + { + return Err("invalid expected version or SHA-512".to_string()); + } + let staging = canonical_or_absolute(&plan.staging_dir).map_err(|error| error.to_string())?; + let staging_has_marker = staging.components().any(|component| { + component + .as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(".cdriveshiftai-update") + }); + if !staging_has_marker { + return Err("staging directory is outside the updater boundary".to_string()); + } + for candidate in [ + &plan.package_path, + &plan.backup_path, + &plan.success_marker, + &plan.log_path, + ] { + let absolute = canonical_or_absolute(candidate).map_err(|error| error.to_string())?; + if !absolute.starts_with(&staging) { + return Err(format!( + "update plan path is outside staging: {}", + candidate.display() + )); + } + } + if plan.target_path.starts_with(&staging) { + return Err("update target must be outside staging".to_string()); + } + let staging_base = staging + .parent() + .and_then(Path::parent) + .ok_or_else(|| "invalid staging directory structure".to_string())?; + if plan.mode == "installed" { + let installed = canonical_or_absolute( + plan.installed_dir + .as_ref() + .ok_or_else(|| "installed update is missing installedDir".to_string())?, + ) + .map_err(|error| error.to_string())?; + if installed.parent() != Some(staging_base) + || canonical_or_absolute(&plan.target_path) + .map_err(|error| error.to_string())? + .parent() + != Some(installed.as_path()) + { + return Err("installed update target is outside its application directory".to_string()); + } + } else { + let target = canonical_or_absolute(&plan.target_path).map_err(|error| error.to_string())?; + if target.parent() != Some(staging_base) { + return Err("portable update target is outside its distribution directory".to_string()); + } + } + if plan.mode == "installed" && plan.installed_dir.is_none() { + return Err("installed update is missing installedDir".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn wait_for_process(pid: u32, timeout: Duration) { + use windows_sys::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0}; + use windows_sys::Win32::System::Threading::{OpenProcess, WaitForSingleObject}; + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + unsafe { + let process = OpenProcess(SYNCHRONIZE_ACCESS, 0, pid); + if process == 0 { + return; + } + let result = WaitForSingleObject(process, timeout.as_millis().min(u32::MAX as u128) as u32); + CloseHandle(process); + if result != WAIT_OBJECT_0 { + thread::sleep(Duration::from_secs(2)); + } + } +} + +#[cfg(not(windows))] +fn wait_for_process(_pid: u32, _timeout: Duration) {} + +fn sha512(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let mut digest = Sha512::new(); + let mut buffer = vec![0_u8; 1024 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn remove_entry(path: &Path) -> io::Result<()> { + if !path.exists() { + return Ok(()); + } + let metadata = fs::symlink_metadata(path)?; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } +} + +fn copy_tree(source: &Path, destination: &Path, skip_data: bool) -> io::Result<()> { + fs::create_dir_all(destination)?; + for item in fs::read_dir(source)? { + let item = item?; + if skip_data + && item + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(DATA_DIRECTORY) + { + continue; + } + let source_path = item.path(); + let destination_path = destination.join(item.file_name()); + let metadata = fs::symlink_metadata(&source_path)?; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + copy_tree(&source_path, &destination_path, false)?; + } else { + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&source_path, &destination_path)?; + } + } + Ok(()) +} + +fn clear_application_directory(directory: &Path) -> io::Result<()> { + if !directory.exists() { + fs::create_dir_all(directory)?; + return Ok(()); + } + for item in fs::read_dir(directory)? { + let item = item?; + if item + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(DATA_DIRECTORY) + { + continue; + } + remove_entry(&item.path())?; + } + Ok(()) +} + +fn preserved_data_path(installed: &Path) -> PathBuf { + PathBuf::from(format!( + "{}.cdriveshiftai-data-preserved", + installed.display() + )) +} + +fn move_directory_with_retry(source: &Path, destination: &Path) -> Result<(), String> { + let mut last_error = None; + for _ in 0..80 { + match fs::rename(source, destination) { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error); + thread::sleep(Duration::from_millis(250)); + } + } + } + Err(last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "directory move failed".to_string())) +} + +fn updater_preserved_data_path(plan: &UpdatePlan) -> PathBuf { + plan.staging_dir.join("preserved-application-data") +} + +fn preserve_application_data(plan: &UpdatePlan, installed: &Path) -> Result<(), String> { + let data = installed.join(DATA_DIRECTORY); + if !data.exists() { + return Ok(()); + } + let preserved = updater_preserved_data_path(plan); + if preserved.exists() { + return Err(format!( + "updater data-preservation directory already exists: {}", + preserved.display() + )); + } + move_directory_with_retry(&data, &preserved) +} + +fn ensure_preserved_data_restored(plan: &UpdatePlan, installed: &Path) -> Result<(), String> { + let updater_preserved = updater_preserved_data_path(plan); + let data = installed.join(DATA_DIRECTORY); + if updater_preserved.exists() { + if data.exists() { + return Err(format!( + "both active and updater-preserved application data exist: {}", + updater_preserved.display() + )); + } + fs::create_dir_all(installed).map_err(|error| error.to_string())?; + move_directory_with_retry(&updater_preserved, &data)?; + } + + let preserved = preserved_data_path(installed); + if !preserved.exists() { + return Ok(()); + } + if data.exists() { + return Err(format!( + "both active and preserved application data exist: {}", + preserved.display() + )); + } + fs::create_dir_all(installed).map_err(|error| error.to_string())?; + fs::rename(&preserved, &data).map_err(|error| error.to_string()) +} + +fn launch_updated(plan: &UpdatePlan) -> io::Result { + Command::new(&plan.target_path) + .arg("--updated") + .arg("--update-staging") + .arg(&plan.staging_dir) + .arg("--update-version") + .arg(&plan.expected_version) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() +} + +fn wait_for_success(child: &mut Child, marker: &Path) -> bool { + let started = Instant::now(); + while started.elapsed() < START_TIMEOUT { + if marker.exists() { + return true; + } + match child.try_wait() { + Ok(Some(_)) => return marker.exists(), + Ok(None) => {} + Err(_) => return false, + } + thread::sleep(Duration::from_millis(300)); + } + false +} + +fn launch_restored(plan: &UpdatePlan, reason: &str) { + let _ = Command::new(&plan.target_path) + .arg("--update-failed") + .arg(reason) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); +} + +fn restore_installed(plan: &UpdatePlan, logger: &Logger) -> Result<(), String> { + let installed = plan + .installed_dir + .as_ref() + .ok_or_else(|| "missing installed directory".to_string())?; + logger.line("restoring installed application backup"); + clear_application_directory(installed).map_err(|error| error.to_string())?; + ensure_preserved_data_restored(plan, installed)?; + copy_tree(&plan.backup_path, installed, false).map_err(|error| error.to_string()) +} + +fn update_installed(plan: &UpdatePlan, logger: &Logger) -> Result<(), String> { + let installed = plan + .installed_dir + .as_ref() + .ok_or_else(|| "missing installed directory".to_string())?; + remove_entry(&plan.backup_path).map_err(|error| error.to_string())?; + logger.line("creating application backup outside the install directory"); + copy_tree(installed, &plan.backup_path, true).map_err(|error| error.to_string())?; + logger.line("moving application data outside the old install directory"); + preserve_application_data(plan, installed)?; + + logger.line("starting silent NSIS update"); + let status = match Command::new(&plan.package_path) + .arg("/S") + .arg("--updated") + .arg(format!("/D={}", installed.display())) + .status() + { + Ok(status) => status, + Err(error) => { + restore_installed(plan, logger)?; + return Err(error.to_string()); + } + }; + if !status.success() || !plan.target_path.exists() { + restore_installed(plan, logger)?; + return Err(format!("installer exited with {}", status)); + } + ensure_preserved_data_restored(plan, installed)?; + + let mut child = launch_updated(plan).map_err(|error| error.to_string())?; + if wait_for_success(&mut child, &plan.success_marker) { + logger.line("new installed version reported a successful start"); + return Ok(()); + } + logger.line("new installed version did not report success; rolling back"); + let _ = child.kill(); + let _ = child.wait(); + restore_installed(plan, logger)?; + Err("new installed version failed its startup acknowledgement".to_string()) +} + +fn update_portable(plan: &UpdatePlan, logger: &Logger) -> Result<(), String> { + remove_entry(&plan.backup_path).map_err(|error| error.to_string())?; + logger.line("backing up portable executable"); + fs::rename(&plan.target_path, &plan.backup_path).map_err(|error| error.to_string())?; + let replacement = plan.target_path.with_extension("update-new"); + let replace_result = (|| -> io::Result<()> { + remove_entry(&replacement)?; + fs::copy(&plan.package_path, &replacement)?; + fs::rename(&replacement, &plan.target_path)?; + Ok(()) + })(); + if let Err(error) = replace_result { + let _ = remove_entry(&plan.target_path); + let _ = fs::rename(&plan.backup_path, &plan.target_path); + return Err(format!("portable replacement failed: {error}")); + } + + let mut child = match launch_updated(plan) { + Ok(child) => child, + Err(error) => { + let _ = remove_entry(&plan.target_path); + let _ = fs::rename(&plan.backup_path, &plan.target_path); + return Err(format!("new portable executable could not start: {error}")); + } + }; + if wait_for_success(&mut child, &plan.success_marker) { + logger.line("new portable version reported a successful start"); + return Ok(()); + } + logger.line("new portable version did not report success; rolling back"); + let _ = child.kill(); + let _ = child.wait(); + remove_entry(&plan.target_path).map_err(|error| error.to_string())?; + fs::rename(&plan.backup_path, &plan.target_path).map_err(|error| error.to_string())?; + Err("new portable version failed its startup acknowledgement".to_string()) +} + +fn run(plan_path: &Path) -> Result<(), String> { + let plan_text = fs::read_to_string(plan_path).map_err(|error| error.to_string())?; + let plan: UpdatePlan = serde_json::from_str(&plan_text).map_err(|error| error.to_string())?; + validate_plan(&plan)?; + let logger = Logger { + path: plan.log_path.clone(), + }; + logger.line(format!( + "starting {} update to {}", + plan.mode, plan.expected_version + )); + wait_for_process(plan.parent_pid, Duration::from_secs(120)); + let actual_sha512 = sha512(&plan.package_path).map_err(|error| error.to_string())?; + if !actual_sha512.eq_ignore_ascii_case(&plan.expected_sha512) { + return Err("helper SHA-512 verification rejected the package".to_string()); + } + logger.line("helper SHA-512 verification passed"); + let result = if plan.mode == "installed" { + update_installed(&plan, &logger) + } else { + update_portable(&plan, &logger) + }; + match result { + Ok(()) => { + let _ = remove_entry(&plan.package_path); + let _ = remove_entry(&plan.backup_path); + let _ = remove_entry(plan_path); + logger.line("update completed and package/backup were removed"); + Ok(()) + } + Err(error) => { + logger.line(format!("update failed: {error}")); + launch_restored(&plan, &error); + Err(error) + } + } +} + +fn main() -> ExitCode { + let arguments = env::args_os().collect::>(); + let plan_path = arguments + .windows(2) + .find(|pair| pair[0] == "--plan") + .map(|pair| PathBuf::from(&pair[1])); + let Some(plan_path) = plan_path else { + eprintln!("usage: cshift-updater.exe --plan "); + return ExitCode::from(2); + }; + match run(&plan_path) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn fixture(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + env::temp_dir().join(format!( + "cdriveshiftai-updater-{name}-{}-{nonce}", + std::process::id() + )) + } + + #[test] + fn sha512_matches_known_value() { + let root = fixture("sha"); + fs::create_dir_all(&root).unwrap(); + let candidate = root.join("package.exe"); + fs::write(&candidate, b"abc").unwrap(); + assert_eq!( + sha512(&candidate).unwrap(), + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a\ + 2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + .replace(' ', "") + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn application_cleanup_preserves_data_directory() { + let root = fixture("preserve"); + let data = root.join(DATA_DIRECTORY); + fs::create_dir_all(&data).unwrap(); + fs::write(data.join("state.json"), b"important").unwrap(); + fs::write(root.join("old-app.exe"), b"old").unwrap(); + clear_application_directory(&root).unwrap(); + assert!(!root.join("old-app.exe").exists()); + assert_eq!(fs::read(data.join("state.json")).unwrap(), b"important"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn installed_update_moves_and_restores_application_data_atomically() { + let root = fixture("installed-data"); + let installed = root.join("CDriveShiftAI"); + let staging = root.join(".cdriveshiftai-update").join("0.0.2"); + let data = installed.join(DATA_DIRECTORY); + fs::create_dir_all(&data).unwrap(); + fs::create_dir_all(&staging).unwrap(); + fs::write(data.join("state.json"), b"migration journal").unwrap(); + let plan = UpdatePlan { + schema_version: 1, + mode: "installed".to_string(), + parent_pid: 1, + package_path: staging.join("installer.exe"), + target_path: installed.join("CDriveShiftAI.exe"), + installed_dir: Some(installed.clone()), + staging_dir: staging.clone(), + backup_path: staging.join("previous-version"), + success_marker: staging.join("success.json"), + expected_version: "0.0.2".to_string(), + expected_sha512: "a".repeat(128), + log_path: staging.join("update.log"), + }; + preserve_application_data(&plan, &installed).unwrap(); + assert!(!data.exists()); + assert_eq!( + fs::read(updater_preserved_data_path(&plan).join("state.json")).unwrap(), + b"migration journal" + ); + ensure_preserved_data_restored(&plan, &installed).unwrap(); + assert_eq!( + fs::read(data.join("state.json")).unwrap(), + b"migration journal" + ); + assert!(!updater_preserved_data_path(&plan).exists()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn plan_rejects_files_outside_staging() { + let root = fixture("boundary"); + let staging = root.join(".cdriveshiftai-update").join("0.0.2"); + fs::create_dir_all(&staging).unwrap(); + let plan = UpdatePlan { + schema_version: 1, + mode: "portable".to_string(), + parent_pid: 1, + package_path: root.join("outside.exe"), + target_path: root.join("CDriveShiftAI.exe"), + installed_dir: None, + staging_dir: staging.clone(), + backup_path: staging.join("backup.exe"), + success_marker: staging.join("success.json"), + expected_version: "0.0.2".to_string(), + expected_sha512: "a".repeat(128), + log_path: staging.join("update.log"), + }; + assert!(validate_plan(&plan).is_err()); + let _ = fs::remove_dir_all(root); + } +} diff --git a/package-lock.json b/package-lock.json index dbd1431..e73159d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cdriveshiftai", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cdriveshiftai", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "lucide-react": "^0.536.0", diff --git a/package.json b/package.json index 94bb8a0..34301a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cdriveshiftai", - "version": "0.0.1", + "version": "0.0.2", "private": true, "description": "CDriveShiftAI - AI-assisted Windows disk organizer and safe cross-drive directory migration tool", "main": "dist-electron/main.js", @@ -17,12 +17,16 @@ "build:app": "npm run build:native && npm run build:main && npm run build:web", "dist": "npm run prepare:electron && npm run build:app && electron-builder --win nsis", "dist:portable": "npm run prepare:electron && npm run build:app && electron-builder --win portable", - "dist:all": "npm run prepare:electron && npm run build:app && electron-builder --win nsis portable", + "dist:all": "npm run prepare:electron && npm run build:app && electron-builder --win nsis portable && node scripts/create-update-manifest.mjs", "test": "vitest run", "test:indexer": "node scripts/smoke-indexer.mjs", "test:index-cache": "node scripts/smoke-index-cache-restart.mjs", "test:packaged-index-cache": "node scripts/smoke-packaged-index-cache.mjs", "test:background-cpu": "node scripts/smoke-background-cpu.mjs", + "test:auto-update": "node scripts/smoke-auto-update.mjs", + "test:auto-update-rejection": "node scripts/smoke-auto-update.mjs --tamper", + "test:auto-update-rollback": "node scripts/smoke-auto-update.mjs --invalid-start", + "test:installed-update": "node scripts/smoke-installed-update.mjs", "test:portable": "node scripts/smoke-portable-distribution.mjs", "test:ownership-map": "npm run build:main && node scripts/smoke-ownership-map.mjs", "test:ownership-state": "node scripts/smoke-ownership-persistence.mjs", @@ -69,6 +73,10 @@ "from": "native/indexer/target/release/cshift-indexer.exe", "to": "bin/cshift-indexer.exe" }, + { + "from": "native/updater/target/release/cshift-updater.exe", + "to": "bin/cshift-updater.exe" + }, { "from": "build/icon.png", "to": "assets/icon.png" diff --git a/scripts/build-native.ps1 b/scripts/build-native.ps1 index 26ffc0c..5eba6c6 100644 --- a/scripts/build-native.ps1 +++ b/scripts/build-native.ps1 @@ -16,7 +16,10 @@ if (-not $visualStudioPath) { } $developerCommand = Join-Path $visualStudioPath "Common7\Tools\VsDevCmd.bat" -$manifestPath = Join-Path $workspacePath "native\indexer\Cargo.toml" +$manifestPaths = @( + (Join-Path $workspacePath "native\indexer\Cargo.toml"), + (Join-Path $workspacePath "native\updater\Cargo.toml") +) $cargoPath = (Get-Command cargo.exe -ErrorAction Stop).Source & cmd.exe /d /c "`"$developerCommand`" -arch=x64 -host_arch=x64 >nul 2>nul && set" | @@ -50,13 +53,15 @@ if ($sdkPath) { $env:INCLUDE = (($sdkIncludes + @($env:INCLUDE)) | Where-Object { $_ }) -join ";" } -$cargoArguments = if ($Test) { - @("test", "--manifest-path", $manifestPath) -} else { - @("build", "--manifest-path", $manifestPath, "--release") -} +foreach ($manifestPath in $manifestPaths) { + $cargoArguments = if ($Test) { + @("test", "--manifest-path", $manifestPath) + } else { + @("build", "--manifest-path", $manifestPath, "--release") + } -& $cargoPath +1.75.0 @cargoArguments -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE + & $cargoPath +1.75.0 @cargoArguments + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } } diff --git a/scripts/create-update-manifest.mjs b/scripts/create-update-manifest.mjs new file mode 100644 index 0000000..156ffbd --- /dev/null +++ b/scripts/create-update-manifest.mjs @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import packageJson from "../package.json" with { type: "json" }; + +const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const output = path.join(workspace, "release-ready"); + +async function sha512(candidate) { + 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"))); + }); +} + +async function describe(name) { + const candidate = path.join(output, name); + const details = await stat(candidate); + return { + name, + size: details.size, + sha512: await sha512(candidate) + }; +} + +const manifest = { + schemaVersion: 1, + version: packageJson.version, + assets: { + installer: await describe("CDriveShiftAI-x64.exe"), + portable: await describe("CDriveShiftAI-x64-portable.exe") + } +}; + +await writeFile( + path.join(output, "update-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8" +); +console.log(JSON.stringify(manifest, null, 2)); diff --git a/scripts/smoke-auto-update.mjs b/scripts/smoke-auto-update.mjs new file mode 100644 index 0000000..b9a90ab --- /dev/null +++ b/scripts/smoke-auto-update.mjs @@ -0,0 +1,279 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { + access, + appendFile, + copyFile, + mkdir, + readFile, + rm, + stat, + writeFile +} from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") { + throw new Error("The transactional updater smoke test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const tamperPackage = process.argv.includes("--tamper"); +const invalidStartupPackage = process.argv.includes("--invalid-start"); +const suppliedOldPortable = process.argv + .slice(2) + .find((argument) => !argument.startsWith("--")); +const testRoot = path.join( + workspace, + ".cdriveshiftai-data", + "test-temp", + "update-smoke-0.0.2" +); +const oldPortable = + suppliedOldPortable ?? + path.join(testRoot, "download", "CDriveShiftAI-x64-portable.exe"); +const distribution = path.join(testRoot, "distribution"); +const target = path.join(distribution, "CDriveShiftAI-update-smoke.exe"); +const staging = path.join(distribution, ".cdriveshiftai-update", "0.0.2"); +const packagePath = path.join(staging, "CDriveShiftAI-x64-portable.exe"); +const helperPath = path.join(staging, "cshift-updater.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"); +const logPath = path.join(staging, "update.log"); +const newPortable = path.join( + workspace, + "release-ready", + "CDriveShiftAI-x64-portable.exe" +); +const helperSource = path.join( + workspace, + "release-ready", + "win-unpacked", + "resources", + "bin", + "cshift-updater.exe" +); + +async function exists(candidate) { + try { + await access(candidate); + return true; + } catch { + return false; + } +} + +async function sha512(candidate) { + const digest = createHash("sha512"); + digest.update(await readFile(candidate)); + return digest.digest("hex"); +} + +async function fileVersion(candidate) { + const command = spawn( + "powershell.exe", + [ + "-NoProfile", + "-Command", + "(Get-Item -LiteralPath $env:CSHIFT_SMOKE_VERSION_PATH).VersionInfo.FileVersion" + ], + { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, CSHIFT_SMOKE_VERSION_PATH: candidate } + } + ); + let output = ""; + let errorOutput = ""; + command.stdout.on("data", (chunk) => { + output += chunk.toString("utf8"); + }); + command.stderr.on("data", (chunk) => { + errorOutput += chunk.toString("utf8"); + }); + const [code] = await once(command, "exit"); + if (code !== 0) { + throw new Error( + `Could not read the portable executable version: ${errorOutput.trim()}` + ); + } + return output.trim(); +} + +async function stopSmokeApplication() { + const processName = path.basename(target); + const killer = spawn( + "taskkill.exe", + ["/IM", processName, "/T", "/F"], + { windowsHide: true, stdio: "ignore" } + ); + await once(killer, "exit").catch(() => undefined); +} + +async function removeDistributionWithRetry() { + let lastError; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await rm(distribution, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError; +} + +await mkdir(staging, { recursive: true }); +await copyFile(oldPortable, target); +await copyFile(newPortable, packagePath); +await copyFile(helperSource, helperPath); +const beforeVersion = await fileVersion(target); +if (beforeVersion !== "0.0.1") { + throw new Error(`Expected a 0.0.1 portable fixture, received ${beforeVersion}`); +} +if (invalidStartupPackage) { + await writeFile(packagePath, Buffer.from("MZ-invalid-CDriveShiftAI-update")); +} +const expectedSha512 = await sha512(packagePath); +if (tamperPackage) { + await appendFile(packagePath, Buffer.from([0xde, 0xad, 0xbe, 0xef])); +} +await writeFile( + planPath, + JSON.stringify( + { + schemaVersion: 1, + mode: "portable", + parentPid: 0, + packagePath, + targetPath: target, + stagingDir: staging, + backupPath, + successMarker, + expectedVersion: "0.0.2", + expectedSha512, + logPath + }, + null, + 2 + ), + "utf8" +); + +let helper; +try { + helper = spawn(helperPath, ["--plan", planPath], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"] + }); + let helperError = ""; + helper.stderr.on("data", (chunk) => { + helperError += chunk.toString("utf8"); + }); + let timeout; + const result = await Promise.race([ + once(helper, "exit").then(([code]) => ({ code, timedOut: false })), + new Promise((resolve) => + { + timeout = setTimeout( + () => resolve({ code: null, timedOut: true }), + 120_000 + ); + } + ) + ]); + clearTimeout(timeout); + if (result.timedOut) { + helper.kill(); + throw new Error("Updater helper timed out"); + } + if (tamperPackage) { + if (result.code === 0) { + throw new Error("Tampered update package was incorrectly accepted"); + } + const afterRejectedVersion = await fileVersion(target); + if ( + afterRejectedVersion !== "0.0.1" || + (await exists(backupPath)) + ) { + throw new Error( + `Checksum rejection modified the old version: ${afterRejectedVersion}` + ); + } + console.log( + JSON.stringify( + { + result: "ok", + mode: "checksum-rejection", + oldVersionPreserved: true, + replacementRefused: true, + helperError: helperError.trim() + }, + null, + 2 + ) + ); + } else if (invalidStartupPackage) { + if (result.code === 0) { + throw new Error("Invalid replacement unexpectedly started"); + } + const restoredVersion = await fileVersion(target); + if (restoredVersion !== "0.0.1" || (await exists(backupPath))) { + throw new Error( + `Failed startup did not restore the old portable version: ${restoredVersion}` + ); + } + console.log( + JSON.stringify( + { + result: "ok", + mode: "startup-rollback", + oldVersionRestored: true, + failedReplacementRemoved: true, + helperError: helperError.trim() + }, + null, + 2 + ) + ); + } else if (result.code !== 0) { + throw new Error( + `Updater helper exited with ${result.code}: ${helperError.trim()}` + ); + } else { + const afterVersion = await fileVersion(target); + if (afterVersion !== "0.0.2") { + 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; + 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"); + } + const targetStats = await stat(target); + console.log( + JSON.stringify( + { + result: "ok", + mode: "portable", + beforeVersion, + afterVersion, + targetReplacedInPlace: true, + packageDeletedAfterStart: true, + backupDeletedAfterStart: true, + targetBytes: targetStats.size + }, + null, + 2 + ) + ); + } +} finally { + await stopSmokeApplication(); + if (helper && helper.exitCode == null) helper.kill(); + await removeDistributionWithRetry(); +} diff --git a/scripts/smoke-installed-update.mjs b/scripts/smoke-installed-update.mjs new file mode 100644 index 0000000..04c9059 --- /dev/null +++ b/scripts/smoke-installed-update.mjs @@ -0,0 +1,267 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { + access, + copyFile, + mkdir, + readFile, + rm, + stat, + writeFile +} from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") { + throw new Error("The installed updater smoke test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const testRoot = path.join( + workspace, + ".cdriveshiftai-data", + "test-temp", + "installed-update-smoke" +); +const oldInstaller = path.join( + testRoot, + "download", + "CDriveShiftAI-x64.exe" +); +const installDirectory = path.join(testRoot, "installation"); +const installedExecutable = path.join(installDirectory, "CDriveShiftAI.exe"); +const dataDirectory = path.join(installDirectory, ".cdriveshiftai-data"); +const sentinel = path.join(dataDirectory, "update-sentinel.txt"); +const staging = path.join(testRoot, ".cdriveshiftai-update", "0.0.2"); +const packagePath = path.join(staging, "CDriveShiftAI-x64.exe"); +const helperPath = path.join(staging, "cshift-updater.exe"); +const planPath = path.join(staging, "update-plan.json"); +const backupPath = path.join(staging, "previous-version"); +const successMarker = path.join(staging, "update-success.json"); +const logPath = path.join(staging, "update.log"); +const newInstaller = path.join( + workspace, + "release-ready", + "CDriveShiftAI-x64.exe" +); +const helperSource = path.join( + workspace, + "release-ready", + "win-unpacked", + "resources", + "bin", + "cshift-updater.exe" +); + +async function exists(candidate) { + try { + await access(candidate); + return true; + } catch { + return false; + } +} + +async function sha512(candidate) { + const digest = createHash("sha512"); + digest.update(await readFile(candidate)); + return digest.digest("hex"); +} + +async function run(executable, argumentList, timeoutMs = 120_000) { + const child = spawn(executable, argumentList, { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + let timeout; + const result = await Promise.race([ + once(child, "exit").then(([code]) => ({ code, timedOut: false })), + new Promise((resolve) => { + timeout = setTimeout( + () => resolve({ code: null, timedOut: true }), + timeoutMs + ); + }) + ]); + clearTimeout(timeout); + if (result.timedOut) { + child.kill(); + throw new Error(`${path.basename(executable)} timed out`); + } + return { code: result.code, stdout, stderr }; +} + +async function fileVersion(candidate) { + const command = + "(Get-Item -LiteralPath $env:CSHIFT_SMOKE_VERSION_PATH).VersionInfo.FileVersion"; + const child = spawn( + "powershell.exe", + ["-NoProfile", "-Command", command], + { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, CSHIFT_SMOKE_VERSION_PATH: candidate } + } + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString("utf8"); + }); + const [code] = await once(child, "exit"); + if (code !== 0) throw new Error(`Could not read version for ${candidate}`); + return output.trim(); +} + +async function stopInstalledProcesses() { + const script = ` + $root = [System.IO.Path]::GetFullPath($env:CSHIFT_SMOKE_INSTALL_PATH) + Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and + [System.IO.Path]::GetFullPath($_.ExecutablePath).StartsWith( + $root + [System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::OrdinalIgnoreCase + ) + } | ForEach-Object { + Invoke-CimMethod -InputObject $_ -MethodName Terminate | Out-Null + } + `; + const child = spawn( + "powershell.exe", + ["-NoProfile", "-Command", script], + { + windowsHide: true, + stdio: "ignore", + env: { + ...process.env, + CSHIFT_SMOKE_INSTALL_PATH: installDirectory + } + } + ); + await once(child, "exit").catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 500)); +} + +async function cleanup() { + await stopInstalledProcesses(); + const uninstaller = path.join( + installDirectory, + "Uninstall CDriveShiftAI.exe" + ); + if (await exists(uninstaller)) { + await run(uninstaller, ["/S"], 60_000).catch(() => undefined); + } + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await rm(installDirectory, { recursive: true, force: true }); + await rm(path.join(testRoot, ".cdriveshiftai-update"), { + recursive: true, + force: true + }); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } +} + +await cleanup(); +await mkdir(staging, { recursive: true }); +let completed = false; +try { + const install = await run(oldInstaller, [ + "/S", + `/D=${installDirectory}` + ]); + if (install.code !== 0 || !(await exists(installedExecutable))) { + throw new Error( + `0.0.1 test installation failed (${install.code}): ${install.stderr}` + ); + } + const beforeVersion = await fileVersion(installedExecutable); + if (beforeVersion !== "0.0.1") { + throw new Error(`Expected installed 0.0.1, received ${beforeVersion}`); + } + + await mkdir(dataDirectory, { recursive: true }); + await writeFile(sentinel, "preserve-index-settings-migrations", "utf8"); + await copyFile(newInstaller, packagePath); + await copyFile(helperSource, helperPath); + const expectedSha512 = await sha512(packagePath); + await writeFile( + planPath, + JSON.stringify( + { + schemaVersion: 1, + mode: "installed", + parentPid: 0, + packagePath, + targetPath: installedExecutable, + installedDir: installDirectory, + stagingDir: staging, + backupPath, + successMarker, + expectedVersion: "0.0.2", + expectedSha512, + logPath + }, + null, + 2 + ), + "utf8" + ); + + const update = await run(helperPath, ["--plan", planPath]); + if (update.code !== 0) { + throw new Error( + `Installed update helper failed (${update.code}): ${update.stderr}` + ); + } + const afterVersion = await fileVersion(installedExecutable); + const sentinelValue = await readFile(sentinel, "utf8"); + if ( + afterVersion !== "0.0.2" || + sentinelValue !== "preserve-index-settings-migrations" + ) { + throw new Error( + `Installed update lost its version or data: ${afterVersion}, ${sentinelValue}` + ); + } + if ( + (await exists(packagePath)) || + (await exists(backupPath)) || + (await exists(path.join(staging, "preserved-application-data"))) + ) { + throw new Error("Installed update left package, backup or preserved data behind"); + } + const targetStats = await stat(installedExecutable); + completed = true; + console.log( + JSON.stringify( + { + result: "ok", + mode: "installed", + beforeVersion, + afterVersion, + silentInstallPathPreserved: true, + applicationDataPreservedAcrossOldUninstaller: true, + packageAndBackupDeletedAfterStart: true, + targetBytes: targetStats.size + }, + null, + 2 + ) + ); +} finally { + await cleanup(); + if (!completed) { + console.error("Installed update smoke cleanup completed after a failure"); + } +} diff --git a/scripts/smoke-packaged-search-state.mjs b/scripts/smoke-packaged-search-state.mjs index 83da470..162d96c 100644 --- a/scripts/smoke-packaged-search-state.mjs +++ b/scripts/smoke-packaged-search-state.mjs @@ -58,6 +58,7 @@ let migrationReapplyVerified = false; let migrationRoundTripVerified = false; let basicSettingsAutoSaveVerified = false; let shortcutBlurAutoSaveVerified = false; +let mouseShortcutConfigurationVerified = false; let aiSettingsAutoSaveVerified = false; let windowBoundsRestoredVerified = false; let expectedWindowBounds; @@ -473,6 +474,54 @@ async function runApplication(debugPort, verifyRestored) { } shortcutBlurAutoSaveVerified = true; + const mouseButtonChanged = await evaluate(`(() => { + const select = document.querySelector(".mouse-shortcut-controls select"); + if (!(select instanceof HTMLSelectElement)) return false; + select.value = "forward"; + select.dispatchEvent(new Event("change", { bubbles: true })); + return true; + })()`); + if (!mouseButtonChanged) { + throw new Error("Mouse shortcut button selector was unavailable"); + } + await waitFor( + 'window.cDriveShiftAI.getSettings().then((settings) => settings.mouseQuickSearchButton === "forward")' + ); + const mouseHoldChanged = await evaluate(`(() => { + const input = document.querySelector(".mouse-hold-input input"); + if (!(input instanceof HTMLInputElement)) return false; + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + setter?.call(input, "1.5"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.focus(); + input.blur(); + return true; + })()`); + if (!mouseHoldChanged) { + throw new Error("Mouse shortcut hold input was unavailable"); + } + await waitFor( + 'window.cDriveShiftAI.getSettings().then((settings) => settings.mouseQuickSearchHoldMs === 1500)' + ); + const mouseStatus = await evaluate( + "window.cDriveShiftAI.getMouseShortcutStatus()" + ); + if ( + mouseStatus?.available !== true || + mouseStatus?.button !== "forward" || + mouseStatus?.holdMs !== 1_500 + ) { + throw new Error( + `Mouse shortcut configuration did not reach the native listener: ${JSON.stringify( + mouseStatus + )}` + ); + } + mouseShortcutConfigurationVerified = true; + const pickerOpened = await evaluate(`(() => { const trigger = document.querySelector(".provider-picker-trigger"); if (!(trigger instanceof HTMLButtonElement)) return false; @@ -779,6 +828,7 @@ try { migrationRoundTripVerified, basicSettingsAutoSaveVerified, shortcutBlurAutoSaveVerified, + mouseShortcutConfigurationVerified, aiSettingsAutoSaveVerified, windowBoundsRestoredVerified }, null, 2) diff --git a/scripts/visual-smoke.mjs b/scripts/visual-smoke.mjs index b121de4..bc2faa8 100644 --- a/scripts/visual-smoke.mjs +++ b/scripts/visual-smoke.mjs @@ -151,6 +151,29 @@ try { await wait(350); await capture(`cdriveshiftai-${requestedView}.png`); + if (requestedView === "settings") { + const updateDot = await evaluate(`(() => { + const button = document.querySelector(".brand-update-button"); + if (!(button instanceof HTMLButtonElement)) return null; + const bounds = button.getBoundingClientRect(); + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2 + }; + })()`); + if (!updateDot) throw new Error("Brand update status dot was not rendered"); + if (await evaluate('document.querySelector(".settings-update-dot") !== null')) { + throw new Error("Legacy settings update dot is still rendered"); + } + await send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: updateDot.x, + y: updateDot.y + }); + await waitFor('document.querySelector(".themed-tooltip") !== null'); + await capture("cdriveshiftai-settings-version-tooltip.png"); + } + if (requestedView === "search") { await evaluate(`(() => { const input = document.querySelector(".search-input-wrap input"); diff --git a/src/App.tsx b/src/App.tsx index 096ed31..d1b70f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -151,12 +151,14 @@ export default function App() { } }); const offSettings = api.onSettingsChanged(setSettings); + const offUpdate = api.onUpdateStatus(setUpdateInfo); void api.checkForUpdates().then(setUpdateInfo).catch(() => undefined); return () => { offIndexer(); offMigration(); offNavigation(); offSettings(); + offUpdate(); }; }, [notify]); @@ -322,7 +324,7 @@ export default function App() { indexer={indexer} collapsed={sidebarCollapsed} onToggle={toggleSidebar} - updateAvailable={updateInfo?.updateAvailable === true} + updateInfo={updateInfo} />
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a804176..f1f2cfa 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,7 +12,8 @@ import { Sparkles } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import type { IndexerStatus, ViewId } from "../types"; +import type { AppUpdateInfo, IndexerStatus, ViewId } from "../types"; +import { ThemedTooltip } from "./ThemedTooltip"; const items: Array<{ id: ViewId; label: string; icon: LucideIcon }> = [ { id: "overview", label: "空间总览", icon: LayoutDashboard }, @@ -29,7 +30,7 @@ interface SidebarProps { indexer: IndexerStatus; collapsed: boolean; onToggle: () => void; - updateAvailable: boolean; + updateInfo?: AppUpdateInfo; } export function Sidebar({ @@ -38,9 +39,19 @@ export function Sidebar({ indexer, collapsed, onToggle, - updateAvailable + updateInfo }: SidebarProps) { const ready = indexer.state === "ready"; + const updateStatus = updateInfo?.status ?? "checking"; + const updateTooltip = + updateStatus === "available" + ? `发现新版本 v${updateInfo?.latestVersion ?? "未知"};当前为 v${updateInfo?.currentVersion ?? "未知"}。点击进入设置更新。` + : updateStatus === "unavailable" + ? `暂时无法检查版本:${updateInfo?.message ?? "请稍后重试"}。点击进入设置查看。` + : updateStatus === "current" + ? `当前版本 v${updateInfo?.currentVersion ?? "未知"},已是最新版本。` + : "正在检查 GitHub Release 版本…"; + return (