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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ dist-electron/
release*/
artifacts/
.devtools/
native/indexer/target/
native/*/target/
.cdriveshiftai-data/
*.log
.DS_Store
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于:

当前版本完全使用自研索引管线,不调用 Everything。

当前正式版本:`0.0.1`。安装包与便携包见
当前正式版本:`0.0.2`。安装包与便携包见
[GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。

## 已实现能力
Expand Down Expand Up @@ -108,9 +108,20 @@ AI 是可选的二次判断层,支持三类真实协议:
- 首次运行安装包时可自行选择安装路径;
- 使用相同 `appId` 的后续安装包会读取已安装项的 `InstallLocation`,沿用原路径覆盖升级;
- 每次打开主界面时只检查一次官方 GitHub Release,不在托盘后台循环联网;
- 设置入口右侧绿点表示当前没有发现更新,红点表示存在更高版本;设置页可查看版本、发布时间和安装包/便携包下载入口;
- 设置入口右侧绿点表示当前没有发现更新,红点表示存在更高版本;主题化悬浮提示会显示当前版与最新版;
- 安装版支持静默原路径升级,便携版支持原文件自替换;下载中断后按已有字节断点续传并最多自动重试三次;
- 更新包由主程序和独立更新助手分别执行 SHA-512 校验,文件大小或摘要不符时拒绝安装;
- 替换前在同一磁盘的安装目录外备份旧程序;只有新版本成功启动并回报正确版本后才删除更新包与备份,失败时自动恢复旧版本;
- 设置页使用与方块、科技、晶境主题一致的分段进度界面,显示下载量、速度、重试次数以及下载、校验、备份、替换和清理阶段;
- 当前安装包未使用商业 Authenticode 证书,Windows 可能显示 SmartScreen“未知发布者”提示。

### 全局快捷唤起

- 主窗口与独立极速搜索分别使用 Windows 全局快捷键,录入时会试注册并检查应用内重复、系统或其他程序占用;冲突配置不会保存;
- 独立极速搜索也可通过鼠标后退侧键、前进侧键或中键长按唤起,时长可在 0.5–10 秒之间配置,也可以完全关闭;
- 鼠标快捷操作使用 Windows Raw Input 被动监听,只在达到长按阈值时触发,不拦截原程序的短按前进、后退或中键行为;
- 所有快捷设置在控件失焦或选择完成后自动保存并立即生效,并提供实际唤起测试按钮。

## 安全边界

- 盘符根目录、Windows 目录、系统卷信息、回收站、默认/公共用户目录和关键 Microsoft 系统数据会被硬性拦截;
Expand Down
30 changes: 30 additions & 0 deletions build/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 71 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<void> {
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" }
Expand Down Expand Up @@ -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<
Expand All @@ -907,6 +940,15 @@ function registerIpc(): void {
const settings = await store.updateSettings(
safePatch as Partial<Omit<AppSettings, "ai">>
);
if (
previous.mouseQuickSearchButton !== settings.mouseQuickSearchButton ||
previous.mouseQuickSearchHoldMs !== settings.mouseQuickSearchHoldMs
) {
await searchService?.configureMouseShortcut(
settings.mouseQuickSearchButton,
settings.mouseQuickSearchHoldMs
);
}
emitSettingsChanged(settings);
createTray();
return settings;
Expand Down Expand Up @@ -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("导航请求无效");
Expand Down Expand Up @@ -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]) {
Expand All @@ -1589,6 +1652,11 @@ if (!singleInstance) {
window.webContents.send("content-indexer:status", status);
}
}
},
() => createQuickSearchWindow(),
{
button: currentSettings.mouseQuickSearchButton,
holdMs: currentSettings.mouseQuickSearchHoldMs
}
);
syncSearchBackgroundMode();
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
}
});
74 changes: 71 additions & 3 deletions electron/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
ContentSearchResult,
DirectorySizeResult,
IndexerStatus,
MouseShortcutButton,
MouseShortcutStatus,
NativeResponse,
SearchFilters,
SearchResult
Expand Down Expand Up @@ -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<void> {
const executable = app.isPackaged
Expand Down Expand Up @@ -133,7 +145,9 @@ export class SearchService {
contentCacheDir,
background: this.backgroundMode,
forceRebuild,
rebuildReason
rebuildReason,
mouseButton: this.mouseShortcutStatus.button,
mouseHoldMs: this.mouseShortcutStatus.holdMs
},
90_000
);
Expand All @@ -157,6 +171,41 @@ export class SearchService {
return structuredClone(this.status);
}

getMouseShortcutStatus(): MouseShortcutStatus {
return structuredClone(this.mouseShortcutStatus);
}

async configureMouseShortcut(
button: MouseShortcutButton,
holdMs: number
): Promise<MouseShortcutStatus> {
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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions electron/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -463,6 +465,16 @@ function mergeSettings(input?: Partial<AppSettings>): 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
Expand Down
Loading
Loading