From 351b5d2fea6f2e8808744081c54375a977a96e6f Mon Sep 17 00:00:00 2001 From: derekxia1988 Date: Wed, 12 Aug 2026 10:47:10 +0800 Subject: [PATCH] fix(windows): replace native scanner with fs scan to fix crash (#592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ztools_native.node's scanWindowsShortcuts causes an OS-level Access Violation (exit code 3221225477 / 0xC0000005) when: - Windows desktop folder is redirected to a non-ASCII path (e.g. OneDrive/桃面) - uiohook-napi keyboard hook is active in DoubleTapManager The crash bypasses try/catch and kills the subprocess before it can send a result, making the subprocess isolation approach in cbd51e50 ineffective for this case. Replace WindowsShortcutScanner.scan() with fs.readdirSync recursion over .lnk files. No native module is called, so the crash cannot occur. Known regressions vs. the native implementation: - .url shortcuts in Start Menu are not shown - desktop.ini localized names are not resolved (shows file basename) - .lnk target paths are not resolved (deduplication falls back to .lnk path) Fixes #592 --- .../core/commandScanner/windowsScanner.ts | 127 ++++++++++-------- 1 file changed, 69 insertions(+), 58 deletions(-) diff --git a/src/main/core/commandScanner/windowsScanner.ts b/src/main/core/commandScanner/windowsScanner.ts index 1c0ab585..688299e7 100644 --- a/src/main/core/commandScanner/windowsScanner.ts +++ b/src/main/core/commandScanner/windowsScanner.ts @@ -1,7 +1,9 @@ +import fs from 'fs' +import path from 'path' import { extractAcronym } from '../../utils/common' -import { getWindowsRootScanPaths, getWindowsScanPaths } from '../../utils/systemPaths' +import { getWindowsScanPaths } from '../../utils/systemPaths' import { toZToolsIconUrl } from '../../common/iconUtils' -import { WindowsShortcutScanner, type WindowsShortcutInfo } from '../native/index' +import type { WindowsShortcutInfo } from '../native/index' import type { Command } from './types' // ========== 配置 ========== @@ -21,55 +23,33 @@ export const SKIP_FOLDERS = [ ] // 要跳过的快捷方式名称关键词(不区分大小写) -// 仅按名称过滤,不按目标类型/路径/扩展名过滤 -// 因为扫描范围仅限开始菜单和桌面,这些位置的快捷方式都是有意放置的 export const SKIP_NAME_PATTERN = /^uninstall|^卸载|卸载$|website|网站|帮助|help|readme|read me|文档|manual|license|documentation/i // ========== 辅助函数 ========== -/** - * Windows 扫描实现说明: - * - * 原 TS 实现里的这些步骤现在都已迁移到原生模块: - * - 解析 desktop.ini 中的 [LocalizedFileNames] 段。 - * desktop.ini 通常是 UTF-16LE 编码(带 BOM),部分为 UTF-8。 - * 条目值可能是纯文本或 MUI 引用(@dll,-id)。 - * - 批量解析 MUI 资源字符串(如 @%SystemRoot%\system32\shell32.dll,-22067)。 - * 通过 Win32 API 解析 Windows 系统快捷方式的本地化显示名称。 - * - 解析 .url 文件,提取 URL 和 IconFile 字段。 - * 跳过普通网页链接(http/https),保留其他应用协议(如 steam://)。 - * - 处理单个快捷方式 entry(.url / .lnk):解析、过滤、入列。 - * 递归与扁平扫描共用,仅处理文件 entry;目录的下钻 / 跳过由原生模块决定。 - * - 递归扫描目录中的快捷方式(Programs 子树 / 桌面)。 - * 处理子目录时跳过 SDK、示例、文档等开发相关文件夹。 - * - 扁平扫描 Start Menu 根。 - * 仅处理本层文件,不下钻 Programs 子目录,避免重复索引。 - * - * TS 层保留最终的名称过滤、图标协议封装、首字母缩写和去重,避免业务侧行为变化。 - */ - -// 检查是否应该跳过该快捷方式(仅按名称过滤) export function shouldSkipShortcut(name: string): boolean { return SKIP_NAME_PATTERN.test(name) } -/** - * 将 Windows 应用图标源路径转换为动态图标协议 URL。 - * - * @param appPath 用于提取图标的快捷方式、可执行文件或图片路径。 - * @returns 编码后的 ztools-icon URL。 - */ export function getIconUrl(appPath: string): string { return toZToolsIconUrl(appPath) } /** - * 将原生模块扫描结果转换为 Command。 - * - * desktop.ini 本地化名称、MUI 解析、.url 解析、.lnk 目标解析已迁移到原生模块实现; - * TS 层只保留名称过滤、图标协议封装、首字母缩写和去重字段整理。 + * 将 .lnk 文件路径转换为 WindowsShortcutInfo 结构。 + * path 设为 .lnk 文件路径(Launcher 可直接通过 Shell 打开), + * icon 同样指向 .lnk,图标协议层会通过 Windows Shell 提取目标图标。 */ +function lnkToEntry(lnkPath: string): WindowsShortcutInfo { + return { + name: path.basename(lnkPath, '.lnk'), + path: lnkPath, + icon: lnkPath, + sourceType: 'lnk' + } +} + function toCommand(entry: WindowsShortcutInfo): (Command & { _dedupeTarget?: string }) | null { if (!entry.name || !entry.path) { return null @@ -79,10 +59,6 @@ function toCommand(entry: WindowsShortcutInfo): (Command & { _dedupeTarget?: str return null } - // 始终使用原生模块返回的启动路径: - // - .lnk:使用快捷方式路径,Windows Shell API 能正确处理参数、工作目录等 - // - .url 或 .lnk 指向 .url:使用应用协议链接(已在原生模块跳过 http/https) - // 图标使用原生模块返回的 icon 源路径,再封装成 ztools-icon:// 协议 return { name: entry.name, path: entry.path, @@ -92,19 +68,12 @@ function toCommand(entry: WindowsShortcutInfo): (Command & { _dedupeTarget?: str } } -/** - * 去重:按名称+目标路径的组合去重(允许不同名但同目标的应用共存) - * 对于 .lnk 快捷方式,使用 _dedupeTarget(目标路径)而非 .lnk 路径去重 - * 这样同名同目标但位于不同目录(用户/系统开始菜单)的快捷方式只保留一个 - */ export function deduplicateCommands(apps: (Command & { _dedupeTarget?: string })[]): Command[] { const uniqueApps = new Map() apps.forEach((app) => { - // 优先使用 _dedupeTarget(快捷方式的目标路径)去重,降级为 path const dedupeTarget = app._dedupeTarget || app.path const dedupeKey = `${app.name.toLowerCase()}|${dedupeTarget.toLowerCase()}` if (!uniqueApps.has(dedupeKey)) { - // 清除内部去重字段,不泄漏到外部 const { _dedupeTarget, ...cleanApp } = app uniqueApps.set(dedupeKey, cleanApp) } @@ -113,33 +82,75 @@ export function deduplicateCommands(apps: (Command & { _dedupeTarget?: string }) } /** - * 扫描 Windows 快捷方式并转换为去重后的应用命令。 + * 递归收集 dir 下所有 .lnk 文件路径。 + * 跳过 SKIP_FOLDERS 中列出的子目录名称(不区分大小写)。 + */ +function collectLnkFiles(dir: string, out: string[]): void { + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + return + } + for (const name of entries) { + const full = path.join(dir, name) + let stat: fs.Stats + try { + stat = fs.statSync(full) + } catch { + continue + } + if (stat.isDirectory()) { + if (!SKIP_FOLDERS.includes(name.toLowerCase())) { + collectLnkFiles(full, out) + } + } else if (name.toLowerCase().endsWith('.lnk')) { + out.push(full) + } + } +} + +/** + * Windows 应用扫描。 + * + * 使用 fs.readdirSync 递归收集开始菜单和桌面的 .lnk 文件, + * 不调用 ztools_native.node,规避 #592 中原生模块在以下条件下 + * 导致进程崩溃(exit code 3221225477 / 0xC0000005)的问题: + * - Windows 桌面文件夹被重定向到含非 ASCII 字符的路径(如 OneDrive/桌面) + * - uiohook-napi 全局键盘钩子已在 DoubleTapManager 中启动 * - * @returns 扫描完成后的应用命令;native 扫描失败时返回空数组。 + * 由于崩溃属于 OS 级 Access Violation,try/catch 无法捕获, + * 即使在隔离子进程中调用也会导致子进程直接退出、无法返回结果。 + * + * 相比原生实现的已知差异: + * - 不解析 .url 文件(开始菜单中的 URL 快捷方式不显示) + * - 不读取 desktop.ini 本地化名称(显示英文快捷方式文件名) + * - 不解析 .lnk 目标路径(dedupeTarget 为空,依赖文件名去重) */ export async function scanApplications(): Promise { try { - // 获取 Windows 扫描路径(开始菜单 + 桌面) const scanPaths = getWindowsScanPaths() - // 获取 Start Menu 根路径 - const rootScanPaths = getWindowsRootScanPaths() + if (scanPaths.length === 0) return [] + + const lnkPaths: string[] = [] + for (const sp of scanPaths) { + collectLnkFiles(sp, lnkPaths) + } - // 原生模块负责递归扫描 Programs + 桌面,并扁平扫描 Start Menu 根 - // 同时在原生模块中处理 desktop.ini 本地化名称、MUI 资源、.url 和 .lnk - const nativeEntries = await WindowsShortcutScanner.scan(scanPaths, rootScanPaths, SKIP_FOLDERS) - const apps = nativeEntries + const entries = lnkPaths.map(lnkToEntry) + const apps = entries .map((entry) => toCommand(entry)) .filter((entry): entry is Command & { _dedupeTarget?: string } => entry !== null) const deduplicatedApps = deduplicateCommands(apps) console.log( - `[Scanner] native 扫描完成: ${nativeEntries.length} 个条目 -> ${deduplicatedApps.length} 个应用` + `[Scanner] fs scan: ${lnkPaths.length} lnk -> ${deduplicatedApps.length} apps` ) return deduplicatedApps } catch (error) { - console.error('[Scanner] native Windows 应用扫描失败:', error) + console.error('[Scanner] Windows app scan failed:', error) return [] } }