From b8a5bfc04285dd156330657837146daee38ac991 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Sun, 2 Aug 2026 02:48:36 -0400 Subject: [PATCH] feat: add live search and proxy-aware updates --- CHANGELOG.md | 20 +++++++ README.md | 4 +- README.zh-CN.md | 6 +- build/installer.nsh | 39 ++++++++++++ docs/RELEASE-0.0.5.md | 33 +++++++++++ electron/diagnostics.ts | 1 + electron/main.ts | 76 ++++++++++++++++++++++-- electron/preload.ts | 11 +++- electron/search.ts | 20 +++++++ electron/store.ts | 9 ++- electron/types.ts | 7 +++ electron/update.ts | 79 ++++++++++++++++++++++--- native/indexer/Cargo.lock | 2 +- native/indexer/Cargo.toml | 2 +- native/indexer/src/main.rs | 6 ++ native/updater/Cargo.lock | 2 +- native/updater/Cargo.toml | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/smoke-indexer.mjs | 36 ++++++++++- scripts/smoke-tray-idle-performance.mjs | 4 +- src/App.tsx | 5 +- src/lib/api.ts | 16 ++--- src/styles.css | 6 ++ src/types.ts | 14 ++++- src/views/SearchView.tsx | 56 +++++++++++++++++- src/views/SettingsView.tsx | 32 +++++++++- tests/search-index-change.test.ts | 33 +++++++++++ tests/update-download.test.ts | 16 ++++- 29 files changed, 498 insertions(+), 45 deletions(-) create mode 100644 docs/RELEASE-0.0.5.md create mode 100644 tests/search-index-change.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d283c39..8909c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ 本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 +## 0.0.5 + +### 实时搜索与后台资源 + +- 原生索引器在合并并落盘 Windows 文件系统增量后,向当前可见的主搜索页和独立极速搜索窗口发送轻量变更事件;新增、删除、改名和移动会自动重新执行当前查询。 +- 实时刷新继续使用当前组合筛选与排序条件,删除项会从结果中消失,新匹配项按所选顺序插入;变更事件在 180 ms 内合并,避免写入密集场景反复查询。 +- 托盘静默时不轮询、不向界面发送事件,也不执行动态搜索;只有可见且存在有效名称查询时才提高工作量。 + +### 更新与网络 + +- 应用启动以及从最小化或托盘重新打开主窗口时都会检查一次 GitHub Release;检查阶段显示动态状态,新版本可用时左上角状态点变红。 +- 更新检查、清单获取、断点下载统一改用 Electron 的 Windows 系统网络会话,自动遵循系统代理/PAC;FlClash 等工具启用系统代理时会自动使用,没有代理时直连。 +- 设置页显示本次更新链路是 Windows 系统代理还是直连,诊断日志同时记录脱敏后的网络路由。 + +### 启动与安装 + +- 新增“开机启动后最小化”设置。启用后只有 Windows 登录自动启动会直接进入托盘,手动打开仍显示主窗口。 +- 覆盖安装会检查旧安装的桌面快捷方式:如果用户此前没有选择或已经删除桌面快捷方式,重新安装不会擅自创建。 +- 版本更新为 `0.0.5`,继续提供可选安装目录的安装包、原路径运行的便携包和 SHA-512 更新清单。 + ## 0.0.4 ### 修复 diff --git a/README.md b/README.md index 1568e9c..fedacd1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于: 当前版本完全使用自研索引管线,不调用 Everything。 -当前正式版本:`0.0.4`。安装包与便携包见 +当前正式版本:`0.0.5`。安装包与便携包见 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 ## 已实现能力 @@ -110,11 +110,13 @@ AI 是可选的二次判断层,支持三类真实协议: - 首次运行安装包时可自行选择安装路径; - 使用相同 `appId` 的后续安装包会读取已安装项的 `InstallLocation`,沿用原路径覆盖升级; - 每次打开主界面时只检查一次官方 GitHub Release,不在托盘后台循环联网; +- 更新检查、清单与下载使用 Windows 系统网络会话,自动遵循系统代理/PAC;没有代理时使用直连; - 设置入口右侧绿点表示当前没有发现更新,红点表示存在更高版本;主题化悬浮提示会显示当前版与最新版; - 安装版支持静默原路径升级,便携版支持原文件自替换;下载中断后按已有字节断点续传并最多自动重试三次; - 更新包由主程序和独立更新助手分别执行 SHA-512 校验,文件大小或摘要不符时拒绝安装; - 替换前在同一磁盘的安装目录外备份旧程序;只有新版本成功启动并回报正确版本后才删除更新包与备份,失败时自动恢复旧版本; - 设置页使用与方块、科技、晶境主题一致的分段进度界面,显示下载量、速度、重试次数以及下载、校验、备份、替换和清理阶段; +- 覆盖安装会保留用户此前“不创建桌面快捷方式”的选择;登录时启动可单独配置为直接最小化到托盘; - 当前安装包未使用商业 Authenticode 证书,Windows 可能显示 SmartScreen“未知发布者”提示。 ### 全局快捷唤起 diff --git a/README.zh-CN.md b/README.zh-CN.md index e4abb39..391bfec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属分析与跨盘安全迁移工具。名称索引、内容索引和迁移记录都由项目自身维护,不依赖 Everything。 -当前版本:`0.0.4` +当前版本:`0.0.5` ## 下载与运行 @@ -20,6 +20,7 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属 - NTFS 优先读取 MFT;不可用时自动使用多线程目录扫描。 - 支持文件类型、盘符、目录范围、扩展名、大小、日期、大小写、完整单词和正则表达式组合筛选。 - 支持搜索书签、书签文件夹、拖放整理、结果状态恢复和独立极速搜索窗口。 +- 主搜索页与独立极速搜索窗口会接收原生文件系统增量事件;当前查询结果在新增、删除、改名或移动后自动按原筛选和排序刷新。 - 结果支持双击打开、主题化右键菜单、属性、重命名、复制、删除、归属分析和安全迁移。 ### 指定目录内容搜索 @@ -60,6 +61,7 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属 - 独立极速搜索也可以配置鼠标后退键、前进键或中键及长按时间。 - 鼠标监听为被动 Raw Input,不拦截原有短按动作。 - 托盘菜单提供主界面、独立搜索、常用功能、AI 服务、主题、设置和退出入口。 +- 可分别控制“登录时启动”和“开机启动后最小化”;最小化启动只作用于 Windows 登录自动启动。 ## 自动更新 @@ -67,6 +69,8 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属 - 支持断点续传、最多三次自动重试和 SHA-512 双重校验。 - 校验失败拒绝安装;替换失败自动恢复旧版本。 - 新版本确认启动成功后才删除安装包、备份和更新临时目录。 +- 启动应用、从最小化或托盘重新打开主窗口时都会检查一次版本;发现新版本后左上角状态点变红。 +- 更新检查和下载遵循 Windows 系统代理/PAC,FlClash 等工具开启系统代理后无需重复填写;没有代理时使用直连。 - 更新助手会在隐藏暂存目录和可见独立运行目录间自动回退,并在 Windows 短暂拒绝执行时重试;所有启动策略都失败时保留旧版本和下载包。 - `0.0.2` 的更新助手启动失败发生在旧版程序自身,无法通过失败中的更新链路修复;此类用户需手动安装一次 `0.0.4`,之后可继续使用完整自动更新。 diff --git a/build/installer.nsh b/build/installer.nsh index 77a67b1..2cb6a3c 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -1,3 +1,42 @@ +!ifndef BUILD_UNINSTALLER +Var /GLOBAL cdrivePreserveNoDesktopShortcut + +!macro customInit + StrCpy $cdrivePreserveNoDesktopShortcut "false" + ReadRegStr $R7 HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R6 HKEY_LOCAL_MACHINE "${INSTALL_REGISTRY_KEY}" InstallLocation + ${If} $R7 != "" + ${OrIf} $R6 != "" + ReadRegStr $R8 HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" ShortcutName + ${If} $R8 == "" + ReadRegStr $R8 HKEY_LOCAL_MACHINE "${INSTALL_REGISTRY_KEY}" ShortcutName + ${EndIf} + ${If} $R8 == "" + StrCpy $R8 "${SHORTCUT_NAME}" + ${EndIf} + StrCpy $R5 "false" + SetShellVarContext current + ${If} ${FileExists} "$DESKTOP\$R8.lnk" + StrCpy $R5 "true" + ${EndIf} + SetShellVarContext all + ${If} ${FileExists} "$DESKTOP\$R8.lnk" + StrCpy $R5 "true" + ${EndIf} + SetShellVarContext current + ${If} $R5 == "false" + StrCpy $cdrivePreserveNoDesktopShortcut "true" + ${EndIf} + ${EndIf} +!macroend + +!macro customInstall + ${If} $cdrivePreserveNoDesktopShortcut == "true" + Delete "$newDesktopLink" + ${EndIf} +!macroend +!endif + !macro customUnInstall ${IfNot} ${isUpdated} ${IfNot} ${Silent} diff --git a/docs/RELEASE-0.0.5.md b/docs/RELEASE-0.0.5.md new file mode 100644 index 0000000..e046c24 --- /dev/null +++ b/docs/RELEASE-0.0.5.md @@ -0,0 +1,33 @@ +# CDriveShiftAI 0.0.5 发布说明 + +本版集中完善版本检测、系统代理、实时搜索结果和开机启动行为,同时保持托盘静默状态不执行轮询查询。 + +## 版本检测与自动更新 + +- 应用启动时检查一次正式 GitHub Release。 +- 主窗口从最小化、托盘隐藏或无渲染窗口状态恢复时再次检查;同一恢复动作产生的重复事件会自动合并。 +- 检查期间左上角版本点显示动态状态;当前版本为绿色,新版本可用时为红色,网络不可用时为琥珀色。 +- Release API、更新清单和更新包下载统一使用 Electron 默认会话,遵循 Windows 系统代理与 PAC。FlClash 等代理工具开启系统代理时会自动接管;未配置代理时直连。 +- 设置页显示当前检测到的网络路由;日志仅记录脱敏后的协议与端点,不记录代理凭据。 + +安装版仍执行静默原目录覆盖,便携版仍执行原文件替换;断点续传、三次重试、大小与 SHA-512 校验、失败保留旧版本、启动成功后清理更新包等事务保证保持不变。 + +## 极速搜索实时结果 + +- Windows 文件变更监听器完成一批增量更新后发送 `indexChanged` 事件。 +- 可见的主搜索页与独立极速搜索窗口对事件做 180 ms 合并,再使用当前关键词、正则规则、范围、类型、大小、日期和排序条件重新查询。 +- 文件或目录被删除后会从结果中消失;新增、改名和移动后的匹配项会按当前排序进入结果。 +- 托盘静默时不向渲染层发送事件,也不运行动态查询;没有有效名称搜索时界面不会订阅变更。 + +## 启动与安装行为 + +- 新增“开机启动后最小化”。该选项只随 Windows 登录项传入 `--startup-minimized`,手动启动仍正常显示主窗口。 +- 最小化自动启动不创建 Chromium 主窗口,只保留托盘、快捷键与原生索引服务,降低后台内存。 +- 覆盖安装前会检查旧安装与桌面快捷方式。如果旧安装存在但快捷方式不存在,安装完成后继续保持不创建。 + +## 验证 + +- TypeScript/Electron 类型检查与 33 项既有及新增单元测试通过。 +- 原生索引器与更新器共 7 项 Rust 测试通过。 +- 原生索引冒烟测试验证新增文件实时出现、删除文件实时消失,以及原有名称、组合筛选、正则、可执行文件目录和内容搜索。 +- NSIS 安装包与便携包已实际构建,安装脚本通过 `makensis` 编译。 diff --git a/electron/diagnostics.ts b/electron/diagnostics.ts index faf1261..13a7535 100644 --- a/electron/diagnostics.ts +++ b/electron/diagnostics.ts @@ -165,6 +165,7 @@ export async function createDiagnosticReport(input: DiagnosticInput): Promise | undefined; +let visibleUpdateCheckTimer: NodeJS.Timeout | undefined; +let lastVisibleUpdateCheckAt = 0; const store = new AppStore(); let migrationService: MigrationService; const aiVerifications = new Map(); @@ -380,6 +382,7 @@ function createWindow(): BrowserWindow { window.setMenuBarVisibility(false); trackWindowActivity(window); + window.on("restore", () => triggerVisibleUpdateCheck("main-window-restored")); trackWindowBounds(window, "mainWindowBounds"); window.on("close", (event) => { if (isQuitting || !store.getSettings().minimizeToTray) return; @@ -434,6 +437,36 @@ function emitUpdateState(state: AppUpdateInfo): void { } } +function triggerVisibleUpdateCheck(reason: string, delayMs = 0): void { + if (!updateService || isQuitting) return; + const busyPhases = new Set(["checking", "downloading", "verifying", "ready", "installing"]); + if (busyPhases.has(updateService.getState().phase)) return; + if (visibleUpdateCheckTimer) { + clearTimeout(visibleUpdateCheckTimer); + visibleUpdateCheckTimer = undefined; + } + const run = () => { + visibleUpdateCheckTimer = undefined; + if (!updateService || isQuitting) return; + if (busyPhases.has(updateService.getState().phase)) return; + if (Date.now() - lastVisibleUpdateCheckAt < 1_500) return; + lastVisibleUpdateCheckAt = Date.now(); + logger.info("update.visible_check", { reason }); + void updateService.check(true).catch((error) => { + logger.warn("update.visible_check_failed", { + reason, + error: serializeError(error) + }); + }); + }; + if (delayMs > 0) { + visibleUpdateCheckTimer = setTimeout(run, delayMs); + visibleUpdateCheckTimer.unref(); + } else { + run(); + } +} + function assertUpdateCanInstall(): Promise { const busyStages = new Set([ "preflight", @@ -466,11 +499,17 @@ function showMainView( view: "overview" | "search" | "ownership-map" | "analyze" | "migrate" | "history" | "settings", options: { path?: string; focus?: "ai-settings" | "search-input" } = {} ): void { + const shouldCheckForUpdates = + !mainWindow || + mainWindow.isDestroyed() || + !mainWindow.isVisible() || + mainWindow.isMinimized(); if (!mainWindow || mainWindow.isDestroyed()) mainWindow = createWindow(); if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); sendNavigation(mainWindow, { view, ...options }); + if (shouldCheckForUpdates) triggerVisibleUpdateCheck("main-window-restored"); } function createQuickSearchWindow(): BrowserWindow { @@ -1765,9 +1804,7 @@ if (!singleInstance) { }); await store.init(); applyNativeEffect(store.getSettings().effectMode); - mainWindow = createWindow(); await completePendingUpdate(); - createTray(); const currentSettings = store.getSettings(); searchService = new SearchService( (status) => { @@ -1784,13 +1821,24 @@ if (!singleInstance) { } } }, + (event) => { + for (const window of [mainWindow, quickSearchWindow]) { + if ( + window && + !window.isDestroyed() && + window.isVisible() && + !window.isMinimized() + ) { + window.webContents.send("search:index-changed", event); + } + } + }, () => createQuickSearchWindow(), { button: currentSettings.mouseQuickSearchButton, holdMs: currentSettings.mouseQuickSearchHoldMs } ); - syncSearchBackgroundMode(); migrationService = new MigrationService(store, (record, message) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("migration:progress", { record, message }); @@ -1798,8 +1846,16 @@ if (!singleInstance) { }); updateService = new UpdateService(emitUpdateState, assertUpdateCanInstall); registerIpc(); + const startupMinimized = + process.argv.includes("--startup-minimized") && + currentSettings.launchAtLogin && + currentSettings.launchMinimized; + if (!startupMinimized) mainWindow = createWindow(); + createTray(); + syncSearchBackgroundMode(); + triggerVisibleUpdateCheck("application-startup", 2_500); const shortcutFailures = applyGlobalShortcuts(store.getSettings()); - if (shortcutFailures.length > 0) { + if (shortcutFailures.length > 0 && mainWindow && !mainWindow.isDestroyed()) { void dialog.showMessageBox(mainWindow, { type: "warning", title: "全局快捷键未完全启用", @@ -1809,6 +1865,12 @@ if (!singleInstance) { noLink: true }); } + if (shortcutFailures.length > 0 && startupMinimized) { + logger.warn("shortcut.registration_incomplete", { + startupMinimized, + failures: shortcutFailures + }); + } await migrationService.recoverIncomplete(); // Let Chromium finish the first interactive frame before parsing a // multi-million-entry persistent index. Search remains fully accurate once @@ -1836,7 +1898,7 @@ if (!singleInstance) { } app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) mainWindow = createWindow(); + if (BrowserWindow.getAllWindows().length === 0) showMainView("overview"); }); }); } @@ -1852,6 +1914,10 @@ app.on("window-all-closed", () => { app.on("before-quit", (event) => { isQuitting = true; + if (visibleUpdateCheckTimer) { + clearTimeout(visibleUpdateCheckTimer); + visibleUpdateCheckTimer = undefined; + } if (shutdownComplete) return; event.preventDefault(); logger.info("application.shutdown_started", { diff --git a/electron/preload.ts b/electron/preload.ts index 7a1c292..fc38bf3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,7 +2,8 @@ import { contextBridge, ipcRenderer } from "electron"; import type { ContentIndexerStatus, IndexerStatus, - MigrationRecord + MigrationRecord, + SearchIndexChangedEvent } from "./types"; import type { AppUpdateInfo } from "./update"; @@ -92,6 +93,14 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { ipcRenderer.on("indexer:status", handler); return () => ipcRenderer.removeListener("indexer:status", handler); }, + onSearchIndexChanged: (listener: (event: SearchIndexChangedEvent) => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: SearchIndexChangedEvent + ) => listener(payload); + ipcRenderer.on("search:index-changed", handler); + return () => ipcRenderer.removeListener("search:index-changed", handler); + }, onContentIndexerStatus: (listener: (status: ContentIndexerStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: ContentIndexerStatus) => listener(status); diff --git a/electron/search.ts b/electron/search.ts index 1fa8d6d..e7aff7a 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -13,6 +13,7 @@ import type { MouseShortcutButton, MouseShortcutStatus, NativeResponse, + SearchIndexChangedEvent, SearchFilters, SearchResult } from "./types"; @@ -64,6 +65,7 @@ export class SearchService { constructor( private readonly onStatus: (status: IndexerStatus) => void, private readonly onContentStatus: (status: ContentIndexerStatus) => void, + private readonly onIndexChanged: (event: SearchIndexChangedEvent) => void, private readonly onMouseShortcutHold: () => void, initialMouseShortcut: { button: MouseShortcutButton; holdMs: number } ) { @@ -189,6 +191,16 @@ export class SearchService { }, 90_000 ); + if (this.backgroundMode) { + // An indexer that starts directly in tray mode does not observe a later + // foreground-to-background transition. Re-send the background command + // after init so it applies the same process-tree working-set trim used + // when a visible window is closed, without stopping watchers or search. + await this.request( + { op: "setBackground", background: true, processId: process.pid }, + 3_000 + ); + } } async stop(): Promise { @@ -714,6 +726,14 @@ export class SearchService { this.onMouseShortcutHold(); return; } + if (response.event === "indexChanged") { + this.executableCatalogCache = undefined; + this.onIndexChanged({ + changedCount: Math.max(1, response.changedCount ?? 1), + observedAt: new Date().toISOString() + }); + return; + } if (response.event === "mouseShortcutStatus") { const event = response as NativeResponse & { available?: boolean; diff --git a/electron/store.ts b/electron/store.ts index a0fde05..01ffd86 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -21,6 +21,7 @@ const defaults: StoreShape = { settings: { effectMode: "aurora", launchAtLogin: false, + launchMinimized: false, minimizeToTray: true, globalShortcut: "CommandOrControl+Alt+Space", quickSearchShortcut: "CommandOrControl+Alt+F", @@ -575,7 +576,13 @@ export class AppStore { } } - app.setLoginItemSettings({ openAtLogin: this.data.settings.launchAtLogin }); + app.setLoginItemSettings({ + openAtLogin: this.data.settings.launchAtLogin, + args: + this.data.settings.launchAtLogin && this.data.settings.launchMinimized + ? ["--startup-minimized"] + : [] + }); await this.flush(); return this.getSettings(); } diff --git a/electron/types.ts b/electron/types.ts index 2208f4e..bdbf9a1 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -284,6 +284,7 @@ export interface OwnershipMapResult { export interface AppSettings { effectMode: "aurora" | "matrix" | "calm"; launchAtLogin: boolean; + launchMinimized: boolean; minimizeToTray: boolean; globalShortcut: string; quickSearchShortcut: string; @@ -458,4 +459,10 @@ export interface NativeResponse { error?: string; status?: IndexerStatus | ContentIndexerStatus; results?: SearchResult[] | ContentSearchResult[]; + changedCount?: number; +} + +export interface SearchIndexChangedEvent { + changedCount: number; + observedAt: string; } diff --git a/electron/update.ts b/electron/update.ts index e360f51..62ad5b9 100644 --- a/electron/update.ts +++ b/electron/update.ts @@ -1,4 +1,4 @@ -import { app } from "electron"; +import { app, net, session } from "electron"; import { createHash } from "node:crypto"; import { access, @@ -55,7 +55,7 @@ export interface AppUpdateProgress { } export interface AppUpdateInfo { - status: "current" | "available" | "unavailable"; + status: "checking" | "current" | "available" | "unavailable"; phase: UpdatePhase; distribution: UpdateDistribution; currentVersion: string; @@ -71,6 +71,13 @@ export interface AppUpdateInfo { message: string; checkedAt: string; errorCode?: string; + network?: UpdateNetworkRoute; +} + +export interface UpdateNetworkRoute { + mode: "system-proxy" | "direct" | "unavailable"; + label: string; + resolvedAt: string; } interface GitHubRelease { @@ -194,6 +201,22 @@ function githubHeaders(version: string): Record { }; } +export function summarizeProxyRules(rules: string): Pick { + const candidates = rules + .split(";") + .map((item) => item.trim()) + .filter(Boolean); + const proxy = candidates.find((item) => !/^DIRECT$/i.test(item)); + if (!proxy) return { mode: "direct", label: "系统网络 · 直连" }; + const match = /^(PROXY|HTTPS?|SOCKS(?:4|5)?)\s+(.+)$/i.exec(proxy); + if (!match) return { mode: "system-proxy", label: "Windows 系统代理" }; + const endpoint = match[2].replace(/^.*@/, ""); + return { + mode: "system-proxy", + label: `Windows 系统代理 · ${match[1].toUpperCase()} ${endpoint}` + }; +} + function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); @@ -239,6 +262,7 @@ export class UpdateService { private manifest?: UpdateManifest; private abortController?: AbortController; private lastProgressAt = 0; + private networkResolvedAt = 0; constructor( private readonly onState: (state: AppUpdateInfo) => void, @@ -246,7 +270,7 @@ export class UpdateService { ) { const currentVersion = app.getVersion(); this.state = { - status: "current", + status: "checking", phase: "idle", distribution: updateDistribution(), currentVersion, @@ -269,6 +293,39 @@ export class UpdateService { return result; } + private async resolveNetworkRoute(url: string, force = false): Promise { + if (!force && this.state.network && Date.now() - this.networkResolvedAt < 30_000) { + return; + } + const resolvedAt = new Date().toISOString(); + try { + const rules = await session.defaultSession.resolveProxy(url); + const summary = summarizeProxyRules(rules); + this.networkResolvedAt = Date.now(); + this.setState({ network: { ...summary, resolvedAt } }); + logger.info("update.network_route", summary); + } catch (error) { + this.networkResolvedAt = Date.now(); + this.setState({ + network: { + mode: "unavailable", + label: "系统代理检测失败,按 Windows 默认网络继续", + resolvedAt + } + }); + logger.warn("update.proxy_resolution_failed", { error: serializeError(error) }); + } + } + + private async networkFetch( + url: string, + init: Parameters[1], + refreshRoute = false + ): Promise { + await this.resolveNetworkRoute(url, refreshRoute); + return net.fetch(url, init); + } + async check(force = false): Promise { const now = Date.now(); if (!force && this.cached && now - this.cached.at < CACHE_DURATION_MS) { @@ -279,15 +336,16 @@ export class UpdateService { const currentVersion = app.getVersion(); const checkedAt = new Date().toISOString(); this.setState({ + status: "checking", phase: "checking", message: "正在检查 GitHub Release…", errorCode: undefined }); try { - const response = await fetch(RELEASE_API, { + const response = await this.networkFetch(RELEASE_API, { headers: githubHeaders(currentVersion), signal: AbortSignal.timeout(10_000) - }); + }, true); if (!response.ok) { throw new Error(`GitHub Release 返回 HTTP ${response.status}`); } @@ -316,7 +374,7 @@ export class UpdateService { ); let manifest: UpdateManifest | undefined; if (updateAvailable && manifestReleaseAsset) { - const manifestResponse = await fetch(manifestReleaseAsset.downloadUrl, { + const manifestResponse = await this.networkFetch(manifestReleaseAsset.downloadUrl, { headers: githubHeaders(currentVersion), signal: AbortSignal.timeout(10_000) }); @@ -357,7 +415,8 @@ export class UpdateService { ? `发现新版本 ${latestVersion},可自动下载并更新` : `发现新版本 ${latestVersion},但该 Release 缺少自动更新清单` : `当前已是最新版本 ${currentVersion}`, - checkedAt + checkedAt, + network: this.state.network }; this.state = result; this.cached = { at: now, result: clone(result) }; @@ -533,7 +592,11 @@ export class UpdateService { } const headers: Record = githubHeaders(app.getVersion()); if (transferred > 0) headers.Range = `bytes=${transferred}-`; - const response = await fetch(url, { headers, signal, redirect: "follow" }); + const response = await this.networkFetch( + url, + { headers, signal, redirect: "follow" }, + attempt === 1 + ); if (response.status === 416 && transferred === total) return; if (!response.ok && response.status !== 206) { throw new Error(`HTTP ${response.status}`); diff --git a/native/indexer/Cargo.lock b/native/indexer/Cargo.lock index 4034e4d..535f7af 100644 --- a/native/indexer/Cargo.lock +++ b/native/indexer/Cargo.lock @@ -68,7 +68,7 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "cshift-indexer" -version = "0.0.4" +version = "0.0.5" dependencies = [ "memmap2", "notify", diff --git a/native/indexer/Cargo.toml b/native/indexer/Cargo.toml index bb04b5a..b1fd96c 100644 --- a/native/indexer/Cargo.toml +++ b/native/indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-indexer" -version = "0.0.4" +version = "0.0.5" edition = "2021" description = "First-party NTFS MFT indexer for CDriveShiftAI" diff --git a/native/indexer/src/main.rs b/native/indexer/src/main.rs index d3e33e9..56235ab 100644 --- a/native/indexer/src/main.rs +++ b/native/indexer/src/main.rs @@ -2581,6 +2581,12 @@ fn start_watchers(state: Arc, output: Output) { status.message = Some(format!("索引增量日志写入失败:{error}")); update_status(&state, &output, status); } + if !state.backgrounded.load(Ordering::Relaxed) { + output.send(&json!({ + "event": "indexChanged", + "changedCount": deltas.len() + })); + } } drop(watchers); state.watching.store(false, Ordering::SeqCst); diff --git a/native/updater/Cargo.lock b/native/updater/Cargo.lock index 1c0d62a..0862c59 100644 --- a/native/updater/Cargo.lock +++ b/native/updater/Cargo.lock @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "cshift-updater" -version = "0.0.4" +version = "0.0.5" dependencies = [ "serde", "serde_json", diff --git a/native/updater/Cargo.toml b/native/updater/Cargo.toml index 1bf0557..1114136 100644 --- a/native/updater/Cargo.toml +++ b/native/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-updater" -version = "0.0.4" +version = "0.0.5" edition = "2021" description = "Transactional Windows updater for CDriveShiftAI" diff --git a/package-lock.json b/package-lock.json index 4e12d22..f408dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cdriveshiftai", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cdriveshiftai", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "dependencies": { "lucide-react": "^0.536.0", diff --git a/package.json b/package.json index 4d51196..7e27d0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cdriveshiftai", - "version": "0.0.4", + "version": "0.0.5", "private": true, "description": "CDriveShiftAI - AI-assisted Windows disk organizer and safe cross-drive directory migration tool", "main": "dist-electron/main.js", diff --git a/scripts/smoke-indexer.mjs b/scripts/smoke-indexer.mjs index 635189e..50c785a 100644 --- a/scripts/smoke-indexer.mjs +++ b/scripts/smoke-indexer.mjs @@ -16,6 +16,7 @@ const executable = path.join( const testTempRoot = path.join(workspace, ".cdriveshiftai-data", "test-temp"); await mkdir(testTempRoot, { recursive: true }); const fixture = await mkdtemp(path.join(testTempRoot, "indexer-")); +const indexState = await mkdtemp(path.join(testTempRoot, "indexer-state-")); const nested = path.join(fixture, "nested"); const scoped = path.join(fixture, "scope"); const similarlyNamedScope = path.join(fixture, "scope-extra"); @@ -91,8 +92,8 @@ try { await request({ op: "init", root: fixture, - cachePath: path.join(fixture, "names.bin"), - contentCacheDir: path.join(fixture, "content") + cachePath: path.join(indexState, "names.bin"), + contentCacheDir: path.join(indexState, "content") }); await waitForEvent( (message) => message.event === "status" && message.status?.state === "ready" @@ -177,6 +178,34 @@ try { throw new Error("Executable catalog did not include the portable application fixture"); } + const livePath = path.join(fixture, "live-change-probe.txt"); + events.length = 0; + await writeFile(livePath, "live watcher probe", "utf8"); + await waitForEvent((message) => message.event === "indexChanged"); + const liveCreated = await request({ + op: "query", + query: "live-change-probe", + kind: "file", + scope: fixture, + limit: 10 + }); + if (!liveCreated.results?.some((result) => result.name === "live-change-probe.txt")) { + throw new Error("Live watcher did not add the new file to search results"); + } + events.length = 0; + await rm(livePath, { force: true }); + await waitForEvent((message) => message.event === "indexChanged"); + const liveDeleted = await request({ + op: "query", + query: "live-change-probe", + kind: "file", + scope: fixture, + limit: 10 + }); + if (liveDeleted.results?.length !== 0) { + throw new Error("Live watcher did not remove the deleted file from search results"); + } + await request({ op: "contentIndex", scope: fixture }); await waitForEvent( (message) => message.event === "contentStatus" && message.status?.state === "ready" @@ -240,6 +269,8 @@ try { scopedMatches: scopedResult.results.length, regexMatches: regexResult.results.length, executableCatalogMatches: executableCatalog.results.length, + liveCreateMatches: liveCreated.results.length, + liveDeleteMatches: liveDeleted.results.length, contentMatches: contentResult.results.length, contentRegexMatches: contentRegexResult.results.length, invalidRegexRejected, @@ -254,4 +285,5 @@ try { if (!child.killed) child.kill(); await Promise.race([once(child, "exit"), new Promise((resolve) => setTimeout(resolve, 1_000))]); await rm(fixture, { recursive: true, force: true }); + await rm(indexState, { recursive: true, force: true }); } diff --git a/scripts/smoke-tray-idle-performance.mjs b/scripts/smoke-tray-idle-performance.mjs index 357d530..c34fb83 100644 --- a/scripts/smoke-tray-idle-performance.mjs +++ b/scripts/smoke-tray-idle-performance.mjs @@ -34,6 +34,8 @@ await writeFile( JSON.stringify({ settings: { effectMode: "calm", + launchAtLogin: true, + launchMinimized: true, minimizeToTray: true } }), @@ -77,7 +79,7 @@ function processTreeSnapshot(rootPid) { return JSON.parse(output); } -const child = spawn(executable, [], { +const child = spawn(executable, ["--startup-minimized"], { windowsHide: true, stdio: ["ignore", "ignore", "pipe"], env: { diff --git a/src/App.tsx b/src/App.tsx index 0bb511e..cd81835 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -165,11 +165,8 @@ export default function App() { }); const offSettings = api.onSettingsChanged(setSettings); const offUpdate = api.onUpdateStatus(setUpdateInfo); - const updateTimer = window.setTimeout(() => { - void api.checkForUpdates().then(setUpdateInfo).catch(() => undefined); - }, 2_500); + void api.getUpdateState().then(setUpdateInfo).catch(() => undefined); return () => { - window.clearTimeout(updateTimer); offIndexer(); offMigration(); offNavigation(); diff --git a/src/lib/api.ts b/src/lib/api.ts index 0fc9157..3aadf20 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -61,6 +61,7 @@ const mockOverview: SystemOverview = { const mockSettings: AppSettings = { effectMode: "aurora", launchAtLogin: false, + launchMinimized: false, minimizeToTray: true, globalShortcut: "CommandOrControl+Alt+Space", quickSearchShortcut: "CommandOrControl+Alt+F", @@ -202,11 +203,11 @@ const browserFallback: CDriveShiftApi = { status: "available", phase: "downloading", distribution: "portable", - currentVersion: "0.0.2", - latestVersion: "0.0.4", + currentVersion: "0.0.4", + latestVersion: "0.0.5", updateAvailable: true, canAutoUpdate: true, - releaseName: "CDriveShiftAI 0.0.4", + releaseName: "CDriveShiftAI 0.0.5", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", publishedAt: new Date().toISOString(), assets: [], @@ -231,14 +232,14 @@ const browserFallback: CDriveShiftApi = { status: "current", phase: "current", distribution: "development", - currentVersion: "0.0.4", - latestVersion: "0.0.4", + currentVersion: "0.0.5", + latestVersion: "0.0.5", updateAvailable: false, canAutoUpdate: false, - releaseName: "CDriveShiftAI 0.0.4", + releaseName: "CDriveShiftAI 0.0.5", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", assets: [], - message: "当前已是最新版本 0.0.4", + message: "当前已是最新版本 0.0.5", checkedAt: new Date().toISOString() } as const; }, @@ -701,6 +702,7 @@ const browserFallback: CDriveShiftApi = { return before - mockMigrations.length; }, onIndexerStatus: () => () => undefined, + onSearchIndexChanged: () => () => undefined, onContentIndexerStatus: () => () => undefined, onMigrationProgress(listener) { mockMigrationListeners.add(listener); diff --git a/src/styles.css b/src/styles.css index ddce39e..af821f3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4436,6 +4436,11 @@ input:checked + .toggle::after { transform: translateX(13px); } .update-progress-meta { justify-content: flex-end; } +.update-network-route { + display: inline-flex; + align-items: center; + gap: 4px; +} .update-progress-meta > span:first-child { margin-right: auto; } @@ -4593,6 +4598,7 @@ input:checked + .toggle::after { transform: translateX(13px); } .setting-note { display: flex; align-items: flex-start; gap: 7px; margin-top: 10px; color: var(--dim); font-size: 8px; line-height: 1.5; } .setting-note svg { flex: 0 0 auto; color: var(--good); } .setting-row { display: flex; align-items: center; gap: 9px; min-height: 52px; border-bottom: 1px solid var(--border); cursor: pointer; } +.setting-row.is-disabled { cursor: not-allowed; opacity: .52; } .setting-row > div { flex: 1; } .setting-row strong, .setting-row small { display: block; } diff --git a/src/types.ts b/src/types.ts index 0a6aaa1..d5e1a25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,6 +143,11 @@ export interface SearchResult { source: "native-index" | "live-scan"; } +export interface SearchIndexChangedEvent { + changedCount: number; + observedAt: string; +} + export interface SearchContextActionResult { action: "dismissed" | "revealed" | "opened" | "opened-with" | "analyze" | "deleted" | "error"; message?: string; @@ -349,6 +354,7 @@ export interface MigrationRecord { export interface AppSettings { effectMode: EffectMode; launchAtLogin: boolean; + launchMinimized: boolean; minimizeToTray: boolean; globalShortcut: string; quickSearchShortcut: string; @@ -490,7 +496,7 @@ export interface AppUpdateProgress { } export interface AppUpdateInfo { - status: "current" | "available" | "unavailable"; + status: "checking" | "current" | "available" | "unavailable"; phase: UpdatePhase; distribution: "installed" | "portable" | "development"; currentVersion: string; @@ -506,6 +512,11 @@ export interface AppUpdateInfo { message: string; checkedAt: string; errorCode?: string; + network?: { + mode: "system-proxy" | "direct" | "unavailable"; + label: string; + resolvedAt: string; + }; } export interface DiagnosticExportResult { @@ -582,6 +593,7 @@ export interface CDriveShiftApi { reapplyMigration(id: string): Promise; deleteMigrations(ids: string[]): Promise; onIndexerStatus(listener: (status: IndexerStatus) => void): () => void; + onSearchIndexChanged(listener: (event: SearchIndexChangedEvent) => void): () => void; onContentIndexerStatus(listener: (status: ContentIndexerStatus) => void): () => void; onMigrationProgress(listener: (event: MigrationProgressEvent) => void): () => void; onAppNavigation(listener: (event: AppNavigationEvent) => void): () => void; diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index a3cde9c..1a7863c 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -174,6 +174,7 @@ export function SearchView({ initialRename?: boolean; }>(); const [propertyPath, setPropertyPath] = useState(""); + const [liveIndexRevision, setLiveIndexRevision] = useState(0); const requestSequence = useRef(0); const sizeSequence = useRef(0); const skipRestoredSearchRef = useRef(false); @@ -181,6 +182,7 @@ export function SearchView({ const searchInputRef = useRef(null); const bookmarkPanelRef = useRef(null); const workspaceSnapshotRef = useRef(undefined); + const liveRefreshRef = useRef(false); const { feedback: openFeedback, openPath, @@ -222,6 +224,22 @@ export function SearchView({ useEffect(() => api.onContentIndexerStatus(setContentStatus), []); + useEffect(() => { + if (mode !== "name" || !query.trim()) return; + let timer: number | undefined; + const unsubscribe = api.onSearchIndexChanged(() => { + if (timer != null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + liveRefreshRef.current = true; + setLiveIndexRevision((revision) => revision + 1); + }, 180); + }); + return () => { + if (timer != null) window.clearTimeout(timer); + unsubscribe(); + }; + }, [mode, query]); + useEffect(() => { let cancelled = false; void Promise.all([ @@ -467,9 +485,11 @@ export function SearchView({ } const sequence = ++requestSequence.current; + const backgroundRefresh = liveRefreshRef.current; + liveRefreshRef.current = false; const timer = window.setTimeout(() => { const started = performance.now(); - setLoading(true); + if (!backgroundRefresh) setLoading(true); setError(""); const request = mode === "name" @@ -487,7 +507,15 @@ export function SearchView({ .then((items) => { if (sequence !== requestSequence.current) return; if (mode === "name") { - setResults(items as SearchResult[]); + const next = items as SearchResult[]; + setResults((current) => + searchResultsEqual(current, next) ? current : next + ); + setSelectedPath((current) => + current && next.some((item) => item.path === current) + ? current + : next[0]?.path ?? "" + ); setContentResults([]); } else { setContentResults(items as ContentSearchResult[]); @@ -500,7 +528,7 @@ export function SearchView({ setError(reason instanceof Error ? reason.message : String(reason)); }) .finally(() => { - if (sequence === requestSequence.current) setLoading(false); + if (sequence === requestSequence.current && !backgroundRefresh) setLoading(false); }); }, mode === "name" ? 110 : 180); return () => window.clearTimeout(timer); @@ -510,6 +538,7 @@ export function SearchView({ contentReadyRevision, contentScope, filters, + liveIndexRevision, mode, query, regexValidation.message, @@ -1760,6 +1789,11 @@ export function SearchView({ 全盘索引未完成,结果会持续补全 )} + {mode === "name" && indexer.state === "ready" && ( + + 索引变更实时同步 + + )} {mode === "content" && contentStatusMatchesScope && contentStatus.message && ( {contentStatus.message} @@ -2055,3 +2089,19 @@ export function SearchView({ ); } + +function searchResultsEqual(current: SearchResult[], next: SearchResult[]): boolean { + if (current.length !== next.length) return false; + return current.every((item, index) => { + const candidate = next[index]; + return ( + item.path === candidate.path && + item.name === candidate.name && + item.isDirectory === candidate.isDirectory && + item.size === candidate.size && + item.modifiedAt === candidate.modifiedAt && + item.score === candidate.score && + item.source === candidate.source + ); + }); +} diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 2b1e829..d02884e 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -306,6 +306,7 @@ export function SettingsView({ const effectRequest = useRef(0); const behaviorRequests = useRef({ launchAtLogin: 0, + launchMinimized: 0, minimizeToTray: 0 }); const shortcutRequests = useRef({ @@ -327,6 +328,9 @@ export function SettingsView({ if (previous.launchAtLogin !== settings.launchAtLogin) { next.launchAtLogin = settings.launchAtLogin; } + if (previous.launchMinimized !== settings.launchMinimized) { + next.launchMinimized = settings.launchMinimized; + } if (previous.minimizeToTray !== settings.minimizeToTray) { next.minimizeToTray = settings.minimizeToTray; } @@ -391,7 +395,7 @@ export function SettingsView({ }; const updateBehaviorSetting = async ( - field: "launchAtLogin" | "minimizeToTray", + field: "launchAtLogin" | "launchMinimized" | "minimizeToTray", value: boolean ) => { const request = ++behaviorRequests.current[field]; @@ -743,7 +747,7 @@ export function SettingsView({
当前版本 - v{updateInfo?.currentVersion ?? "0.0.4"} + v{updateInfo?.currentVersion ?? "0.0.5"}
最新版本 @@ -766,6 +770,12 @@ export function SettingsView({ · 发布于 {new Date(updateInfo.publishedAt).toLocaleString()} )} + {updateInfo?.network && ( + + + {updateInfo.network.label} + + )}