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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在
[GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。

## 0.0.4

### 修复

- 修复更新包完成下载与 SHA-512 校验后,Windows 对隐藏暂存目录中的 `cshift-updater.exe` 返回 `EACCES` 导致无法安装的问题。
- 更新助手增加可见独立运行目录、多次短暂重试和 PowerShell `Start-Process` 回退;所有策略失败时保持旧版本、已下载更新包和回滚数据不变。
- 新版本确认启动后同时清理更新暂存目录与独立助手目录。
- 主进程全局捕获并记录索引通信异常;退出阶段的 `EPIPE` 不再触发 Electron 原生 JavaScript 错误弹窗。

### 诊断与隐私

- 增加按日期滚动、自动清理的 JSONL 本地日志,记录应用、索引器、渲染器和更新器生命周期。
- 设置页增加“打开日志目录”和“一键导出诊断报告”,报告包含系统环境、关键路径权限、更新/索引状态、迁移阶段汇总和崩溃文件元数据。
- 日志字段统一脱敏,不记录 API Key、鉴权令牌、搜索关键词或文件正文。

### 验证

- 增加更新助手主路径 `EACCES` 后切换备用路径,以及全部启动策略失败时保留可诊断错误的单元测试。
- 延续干净退出、安装版/便携版更新、校验拒绝、失败回滚和托盘空闲性能回归。

> 从 `0.0.2` 升级:由于故障发生在旧版更新器启动阶段,`0.0.2` 无法自动安装修复自身的版本,需要手动安装一次 `0.0.4`;从本版开始后续自动更新使用新的回退链路。

## 0.0.3

### 修复
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于:

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

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

## 已实现能力
Expand Down
10 changes: 9 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属分析与跨盘安全迁移工具。名称索引、内容索引和迁移记录都由项目自身维护,不依赖 Everything。

当前版本:`0.0.3`
当前版本:`0.0.4`

## 下载与运行

Expand Down Expand Up @@ -67,6 +67,14 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属
- 支持断点续传、最多三次自动重试和 SHA-512 双重校验。
- 校验失败拒绝安装;替换失败自动恢复旧版本。
- 新版本确认启动成功后才删除安装包、备份和更新临时目录。
- 更新助手会在隐藏暂存目录和可见独立运行目录间自动回退,并在 Windows 短暂拒绝执行时重试;所有启动策略都失败时保留旧版本和下载包。
- `0.0.2` 的更新助手启动失败发生在旧版程序自身,无法通过失败中的更新链路修复;此类用户需手动安装一次 `0.0.4`,之后可继续使用完整自动更新。

## 日志与诊断

- 主进程、索引器退出、更新下载/校验/启动和渲染进程异常会写入安装或便携目录旁的 `.cdriveshiftai-data\logs`。
- 设置页提供“打开日志目录”和“导出诊断报告”。诊断报告包含版本、操作系统、权限探测、索引/更新状态、崩溃文件清单和近期日志。
- 日志与报告不会记录 API Key、搜索关键词或文件正文;故障路径可能会保留,发送前可以自行查看 JSON。

## 性能策略

Expand Down
25 changes: 25 additions & 0 deletions docs/RELEASE-0.0.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# CDriveShiftAI 0.0.4 发布说明

本版集中修复 Windows 自动更新助手启动失败和托盘退出时索引管道 `EPIPE` 两类问题,并加入可由用户直接发送给开发者的脱敏诊断报告。

## 更新行为

- 更新包继续支持断点续传、三次下载重试、尺寸与 SHA-512 校验。
- 更新助手优先从原暂存目录启动;遇到权限或安全软件的短暂拒绝后自动重试。
- 原路径失败时,复制并校验到同盘可见的 `CDriveShiftAI-Update-Runner` 目录再次启动。
- Node 直接启动仍失败时,使用隐藏 PowerShell `Start-Process` 做最后一次启动回退;不调用 .NET `InstallUtil.exe`。
- 任何启动、安装或新版本自检失败都保留旧版本;新版本确认启动后再清理更新包、备份与助手目录。

## 故障诊断

设置页“应用更新”区域现在提供:

- 打开日志目录;
- 导出单个 JSON 诊断报告;
- 更新失败时的明确引导。

诊断报告包含应用版本、Windows/CPU/内存环境、关键文件权限、更新状态、索引状态、崩溃文件元数据与近期日志。API Key、鉴权信息、搜索词和文件正文不会被写入。

## 从旧版升级

`0.0.2` 中的失败位于旧版应用启动更新助手的代码内,因此已经下载完成的新版无法反向修复仍在运行的旧代码。看到 `spawn ... cshift-updater.exe EACCES` 的用户需要从 GitHub Release 手动下载并安装一次 `0.0.4`;安装版选择原安装目录覆盖,便携版替换原文件。此后自动更新由新链路接管。
199 changes: 199 additions & 0 deletions electron/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { app } from "electron";
import { constants as fsConstants } from "node:fs";
import { access, open, readdir, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AppUpdateInfo } from "./update";
import type { AppSettings, IndexerStatus, MigrationRecord } from "./types";

const LOG_TAIL_BYTES = 256 * 1024;
const MAX_LOG_FILES = 4;

function sanitizedEndpoint(value: string): string {
try {
const endpoint = new URL(value);
endpoint.username = "";
endpoint.password = "";
endpoint.search = "";
endpoint.hash = "";
return endpoint.toString().replace(/\/$/, "");
} catch {
return value.split(/[?#]/, 1)[0].slice(0, 2_048);
}
}

async function pathProbe(candidate: string): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = { path: candidate };
for (const [name, mode] of [
["readable", fsConstants.R_OK],
["writable", fsConstants.W_OK],
["executable", fsConstants.X_OK]
] as const) {
try {
await access(candidate, mode);
result[name] = true;
} catch (error) {
const detail = error as NodeJS.ErrnoException;
result[name] = false;
result[`${name}Error`] = detail.code ?? detail.message;
}
}
try {
const details = await stat(candidate);
result.exists = true;
result.size = details.size;
result.modifiedAt = details.mtime.toISOString();
} catch (error) {
result.exists = false;
result.statError = (error as NodeJS.ErrnoException).code ?? String(error);
}
return result;
}

async function tailFile(candidate: string): Promise<string> {
const details = await stat(candidate);
const length = Math.min(details.size, LOG_TAIL_BYTES);
const handle = await open(candidate, "r");
try {
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, Math.max(0, details.size - length));
return buffer.toString("utf8");
} finally {
await handle.close();
}
}

async function recentLogs(logDirectory: string): Promise<Array<Record<string, unknown>>> {
try {
const entries = await readdir(logDirectory, { withFileTypes: true });
const candidates = await Promise.all(
entries
.filter((entry) => entry.isFile() && /\.(?:jsonl|log)(?:\.\d+)?$/i.test(entry.name))
.map(async (entry) => {
const candidate = path.join(logDirectory, entry.name);
return { name: entry.name, candidate, details: await stat(candidate) };
})
);
candidates.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs);
return Promise.all(
candidates.slice(0, MAX_LOG_FILES).map(async ({ name, candidate, details }) => ({
name,
size: details.size,
modifiedAt: details.mtime.toISOString(),
tail: await tailFile(candidate)
}))
);
} catch (error) {
return [{ error: (error as NodeJS.ErrnoException).code ?? String(error) }];
}
}

async function crashDumpMetadata(crashDirectory: string): Promise<Array<Record<string, unknown>>> {
try {
const entries = await readdir(crashDirectory, { withFileTypes: true });
const files = await Promise.all(
entries
.filter((entry) => entry.isFile())
.slice(-20)
.map(async (entry) => {
const details = await stat(path.join(crashDirectory, entry.name));
return {
name: entry.name,
size: details.size,
modifiedAt: details.mtime.toISOString()
};
})
);
return files.sort((left, right) =>
String(right.modifiedAt).localeCompare(String(left.modifiedAt))
);
} catch {
return [];
}
}

export interface DiagnosticInput {
applicationDataRoot: string;
logDirectory: string;
settings: AppSettings;
update?: AppUpdateInfo;
indexer?: IndexerStatus;
migrations: MigrationRecord[];
}

export async function createDiagnosticReport(input: DiagnosticInput): Promise<string> {
const helperPath = path.join(process.resourcesPath, "bin", "cshift-updater.exe");
const crashDirectory = app.getPath("crashDumps");
const migrationStages = Object.fromEntries(
[...new Set(input.migrations.map((record) => record.stage))].map((stage) => [
stage,
input.migrations.filter((record) => record.stage === stage).length
])
);
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
privacy: {
apiKeysIncluded: false,
searchQueriesIncluded: false,
fileContentsIncluded: false,
note: "日志可能包含发生故障的程序路径;发送前可自行查看。"
},
application: {
name: app.getName(),
version: app.getVersion(),
packaged: app.isPackaged,
pid: process.pid,
execPath: process.execPath,
resourcesPath: process.resourcesPath,
dataRoot: input.applicationDataRoot,
locale: app.getLocale(),
distribution: process.env.PORTABLE_EXECUTABLE_FILE ? "portable" : app.isPackaged ? "installed" : "development"
},
operatingSystem: {
platform: os.platform(),
release: os.release(),
version: os.version(),
architecture: os.arch(),
cpuModel: os.cpus()[0]?.model,
logicalCpuCount: os.cpus().length,
totalMemoryBytes: os.totalmem(),
freeMemoryBytes: os.freemem(),
uptimeSeconds: os.uptime(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
},
settings: {
effectMode: input.settings.effectMode,
launchAtLogin: input.settings.launchAtLogin,
minimizeToTray: input.settings.minimizeToTray,
globalShortcutConfigured: Boolean(input.settings.globalShortcut),
quickSearchShortcutConfigured: Boolean(input.settings.quickSearchShortcut),
mouseQuickSearchButton: input.settings.mouseQuickSearchButton,
mouseQuickSearchHoldMs: input.settings.mouseQuickSearchHoldMs,
ai: {
enabled: input.settings.ai.enabled,
provider: input.settings.ai.provider,
protocol: input.settings.ai.protocol,
baseUrl: sanitizedEndpoint(input.settings.ai.baseUrl),
model: input.settings.ai.model,
hasApiKey: input.settings.ai.hasApiKey,
privacyMode: input.settings.ai.privacyMode
}
},
update: input.update,
indexer: input.indexer,
migrations: {
total: input.migrations.length,
stages: migrationStages
},
pathChecks: await Promise.all([
pathProbe(input.applicationDataRoot),
pathProbe(input.logDirectory),
pathProbe(process.execPath),
pathProbe(helperPath)
]),
crashDumps: await crashDumpMetadata(crashDirectory),
logs: await recentLogs(input.logDirectory)
};
return `${JSON.stringify(report, null, 2)}\n`;
}
Loading
Loading