From 1c1aae77bf77e977c88c2969eda357cae3157b65 Mon Sep 17 00:00:00 2001 From: showen Date: Sat, 18 Apr 2026 17:09:03 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Windows=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E6=94=AF=E6=8C=81=E3=80=90=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E8=B7=AF=E5=BE=84=E3=80=91=E5=92=8C=E3=80=90=E5=9C=A8?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E6=89=93=E5=BC=80=E3=80=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 plugin.json 中添加 explorer.exe 到 window match 配置 - 实现 handleCopyPath Windows 平台支持 - 使用 WindowManager.getExplorerFolderPath(hwnd) 获取路径 - 将 file:/// URL 转换为普通路径格式 - 支持桌面窗口(Progman/WorkerW)回退到桌面路径 - 实现 handleOpenTerminal Windows 平台支持 - 终端启动优先级:Windows Terminal -> PowerShell -> CMD - 自动切换到资源管理器当前目录 - 支持桌面窗口(Progman/WorkerW)回退到桌面路径 - 添加 hwnd/className 类型定义到 windowManager - 抽取通用函数减少重复代码 - getWindowsExplorerPath(): 统一获取 Explorer 路径 - tryLaunchWindowsTerminal(): 统一启动终端逻辑 - 使用轻量级方式获取桌面路径(os.homedir() + Desktop) - 所有 import 放在文件开头 - 修复 Linux 代码的 lint 错误 Closes #需求: Windows 文件管理器支持 --- .github/workflows/windows-test.yml | 36 ++++ docs/development/windows-explorer-support.md | 189 +++++++++++++++++++ internal-plugins/system/public/plugin.json | 4 +- src/main/api/renderer/systemCommands.ts | 107 ++++++++++- src/main/managers/windowManager.ts | 2 + tests/main/windowsExplorerCommands.test.ts | 99 ++++++++++ 6 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/windows-test.yml create mode 100644 docs/development/windows-explorer-support.md create mode 100644 tests/main/windowsExplorerCommands.test.ts diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml new file mode 100644 index 00000000..24d539fe --- /dev/null +++ b/.github/workflows/windows-test.yml @@ -0,0 +1,36 @@ +name: Windows E2E Test + +on: + push: + branches: [ main, feat/windows-* ] + pull_request: + branches: [ main ] + +jobs: + test-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Install dependencies + run: pnpm install + + - name: Build application + run: pnpm build:win + + - name: Run unit tests + run: pnpm test + + # E2E 测试需要实际 GUI 环境,此处仅作示例 + # - name: E2E Test (requires display) + # run: npm run test:e2e:win diff --git a/docs/development/windows-explorer-support.md b/docs/development/windows-explorer-support.md new file mode 100644 index 00000000..9c42cdd4 --- /dev/null +++ b/docs/development/windows-explorer-support.md @@ -0,0 +1,189 @@ +# Windows 文件管理器支持开发文档 + +## 需求背景 +当前【复制路径】和【在终端打开】两个系统指令仅支持 macOS 的 Finder,需要在 Windows 系统的文件管理器(explorer.exe)中也支持这两个功能。 + +## 技术方案 + +### 核心原理 +利用已有的原生模块 API `getExplorerFolderPath(hwnd)` 获取 Windows Explorer 当前窗口的文件夹路径。该 API 通过 COM IShellWindows 接口查询指定窗口句柄对应的 Explorer 文件夹路径。 + +### 修改范围 + +#### 1. 指令配置 (`internal-plugins/system/public/plugin.json`) +- 修改 `copy-path` 和 `open-terminal` 两个 feature 的 window match 配置 +- 在 `match.app` 数组中添加 `"explorer.exe"` + +#### 2. 系统命令实现 (`src/main/api/renderer/systemCommands.ts`) +- 修改 `handleCopyPath` 函数,添加 Windows 平台支持 +- 修改 `handleOpenTerminal` 函数,添加 Windows 平台支持 +- 通过 `WindowManager.getExplorerFolderPath(hwnd)` 获取 Explorer 路径 + +## 详细设计 + +### 1. plugin.json 修改 + +```json +{ + "code": "copy-path", + "cmds": [{ + "type": "window", + "match": { "app": ["Finder.app", "explorer.exe"] } + }] +} +``` + +```json +{ + "code": "open-terminal", + "cmds": [{ + "type": "window", + "match": { "app": ["Finder.app", "explorer.exe"] } + }] +} +``` + +### 2. handleCopyPath Windows 实现 + +```typescript +async function handleCopyPathWin(ctx: SystemCommandContext): Promise { + const previousWindow = windowManager.getPreviousActiveWindow() + if (!previousWindow?.hwnd) { + return { success: false, error: '无法获取当前窗口信息' } + } + + const { WindowManager } = await import('../../core/native/index.js') + const folderPath = WindowManager.getExplorerFolderPath(previousWindow.hwnd) + + if (!folderPath) { + return { success: false, error: '无法获取资源管理器路径' } + } + + // 将 file:/// 路径转换为普通路径 + const normalPath = folderPath.replace(/^file:\/\/\//i, '').replace(/\//g, '\\') + clipboard.writeText(normalPath) + ctx.mainWindow?.hide() + return { success: true, path: normalPath } +} +``` + +### 3. handleOpenTerminal Windows 实现 + +```typescript +async function handleOpenTerminalWin(ctx: SystemCommandContext): Promise { + const previousWindow = windowManager.getPreviousActiveWindow() + if (!previousWindow?.hwnd) { + return { success: false, error: '无法获取当前窗口信息' } + } + + const { WindowManager } = await import('../../core/native/index.js') + const folderPath = WindowManager.getExplorerFolderPath(previousWindow.hwnd) + + if (!folderPath) { + return { success: false, error: '无法获取资源管理器路径' } + } + + const normalPath = folderPath.replace(/^file:\/\/\//i, '').replace(/\//g, '\\') + + // 尝试打开 Windows Terminal,回退到 PowerShell,再回退到 CMD + const tryLaunchTerminal = async (): Promise => { + const { spawn } = await import('child_process') + return new Promise((resolve) => { + const child = spawn('wt.exe', ['-d', normalPath], { + detached: true, + stdio: 'ignore', + shell: true + }) + child.on('error', () => resolve(false)) + if (child.pid) { + child.unref() + resolve(true) + } + }) + } + + const tryLaunchPowerShell = async (): Promise => { + const { spawn } = await import('child_process') + return new Promise((resolve) => { + const child = spawn('powershell.exe', ['-NoExit', '-Command', `Set-Location -Path "${normalPath}"`], { + detached: true, + stdio: 'ignore' + }) + child.on('error', () => resolve(false)) + if (child.pid) { + child.unref() + resolve(true) + } + }) + } + + const tryLaunchCMD = async (): Promise => { + const { spawn } = await import('child_process') + return new Promise((resolve) => { + const child = spawn('cmd.exe', ['/K', `cd /d "${normalPath}"`], { + detached: true, + stdio: 'ignore' + }) + child.on('error', () => resolve(false)) + if (child.pid) { + child.unref() + resolve(true) + } + }) + } + + const launched = await tryLaunchTerminal() || await tryLaunchPowerShell() || await tryLaunchCMD() + + if (!launched) { + return { success: false, error: '无法启动终端' } + } + + ctx.mainWindow?.hide() + return { success: true } +} +``` + +### 4. 路由修改 + +在 `executeSystemCommand` 函数的 switch 语句中,根据平台调用不同实现: + +```typescript +case 'copy-path': + if (process.platform === 'win32') { + return handleCopyPathWin(ctx) + } + return handleCopyPath(ctx, execAsync) + +case 'open-terminal': + if (process.platform === 'win32') { + return handleOpenTerminalWin(ctx) + } + return handleOpenTerminal(ctx, execAsync) +``` + +## 测试计划 + +### 单元测试 +1. 测试 `handleCopyPathWin` 正常获取路径并复制到剪贴板 +2. 测试 `handleOpenTerminalWin` 正常打开终端并切换到对应目录 +3. 测试无 hwnd 时的错误处理 +4. 测试无法获取路径时的错误处理 +5. 测试终端启动失败时的回退逻辑 + +### 集成测试 +1. 在 Windows 文件管理器中唤起超级面板,验证【复制路径】指令显示 +2. 点击【复制路径】,验证路径正确复制到剪贴板 +3. 在 Windows 文件管理器中唤起超级面板,验证【在终端打开】指令显示 +4. 点击【在终端打开】,验证终端正确打开并切换到对应目录 +5. 验证在非 explorer 窗口唤起时不会显示这两个指令 + +### 回归测试 +1. 验证 macOS 的【复制路径】和【在终端打开】功能正常工作 +2. 验证 Linux 的【在终端打开】功能正常工作 + +## 风险与注意事项 + +1. **原生模块依赖**:`getExplorerFolderPath` 需要 Windows 原生模块支持,确保在 Windows 环境测试 +2. **路径格式转换**:Windows 路径需要将 `file:///` URL 格式转换为普通路径格式 +3. **终端兼容性**:优先使用 Windows Terminal (wt.exe),但需要兼容旧系统(PowerShell/CMD) +4. **权限问题**:某些目录可能需要管理员权限才能访问 diff --git a/internal-plugins/system/public/plugin.json b/internal-plugins/system/public/plugin.json index 4c720b89..02d39460 100644 --- a/internal-plugins/system/public/plugin.json +++ b/internal-plugins/system/public/plugin.json @@ -77,7 +77,7 @@ "type": "window", "label": "复制路径", "match": { - "app": ["Finder.app"] + "app": ["Finder.app", "explorer.exe"] } } ] @@ -91,7 +91,7 @@ "type": "window", "label": "在终端打开", "match": { - "app": ["Finder.app"] + "app": ["Finder.app", "explorer.exe"] } } ] diff --git a/src/main/api/renderer/systemCommands.ts b/src/main/api/renderer/systemCommands.ts index 8c036485..3f40aa10 100644 --- a/src/main/api/renderer/systemCommands.ts +++ b/src/main/api/renderer/systemCommands.ts @@ -1,4 +1,6 @@ import { exec, spawn } from 'child_process' +import os from 'os' +import path from 'path' import type { PluginManager } from '../../managers/pluginManager' import { BrowserWindow, clipboard, nativeImage, Notification, shell } from 'electron' import { promisify } from 'util' @@ -7,13 +9,75 @@ import { screenCapture } from '../../core/screenCapture' import windowManager from '../../managers/windowManager' import webSearchAPI from './webSearch' import databaseAPI from '../shared/database' -import { ColorPicker } from '../../core/native/index.js' +import { ColorPicker, WindowManager } from '../../core/native/index.js' interface SystemCommandContext { mainWindow: Electron.BrowserWindow | null pluginManager: PluginManager | null } +/** + * Windows 窗口信息类型 + */ +interface WindowsWindowInfo { + hwnd?: number + className?: string +} + +/** + * 获取 Windows 资源管理器当前文件夹路径 + * 支持标准 Explorer 窗口(通过 COM)和桌面窗口(回退到桌面路径) + */ +function getWindowsExplorerPath(windowInfo: WindowsWindowInfo): string | null { + // 桌面窗口特殊处理(Progman: 桌面主窗口;WorkerW: 桌面壁纸层窗口) + if (windowInfo.className === 'Progman' || windowInfo.className === 'WorkerW') { + // 使用轻量级方式获取桌面路径:用户主目录 + Desktop + return path.join(os.homedir(), 'Desktop') + } + + // 普通 Explorer 窗口,通过 COM 查询路径 + if (!windowInfo.hwnd) { + return null + } + + const folderUrl = WindowManager.getExplorerFolderPath(windowInfo.hwnd) + if (!folderUrl) { + return null + } + + // 将 file:/// URL 转换为本地路径 + return folderUrl.startsWith('file:///') + ? decodeURIComponent(folderUrl.replace(/^file:\/\/\//i, '')).replace(/\//g, '\\') + : folderUrl +} + +/** + * 尝试启动终端(Windows 平台) + * 回退优先级:Windows Terminal -> PowerShell -> CMD + */ +async function tryLaunchWindowsTerminal(folderPath: string): Promise { + const tryLaunch = (cmd: string, args: string[]): Promise => { + return new Promise((resolve) => { + const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) + child.on('error', () => resolve(false)) + if (child.pid) { + child.unref() + resolve(true) + } + }) + } + + return ( + (await tryLaunch('wt.exe', ['-d', folderPath])) || + (await tryLaunch('powershell.exe', [ + '-NoExit', + '-Command', + `Set-Location -Path "${folderPath}"` + ])) || + (await tryLaunch('cmd.exe', ['/K', `cd /d "${folderPath}"`])) + ) +} + /** * 执行系统内置指令 */ @@ -405,6 +469,22 @@ async function handleCopyPath( console.error('[SystemCmd] 获取 Finder 路径失败:', error) return { success: false, error: String(error) } } + } else if (process.platform === 'win32') { + try { + const folderPath = getWindowsExplorerPath(previousWindow as WindowsWindowInfo) + + if (!folderPath) { + return { success: false, error: '无法获取资源管理器路径' } + } + + clipboard.writeText(folderPath) + console.log('[SystemCmd] 已复制路径:', folderPath) + ctx.mainWindow?.hide() + return { success: true, path: folderPath } + } catch (error) { + console.error('[SystemCmd] 获取资源管理器路径失败:', error) + return { success: false, error: String(error) } + } } return { success: false, error: `不支持的平台: ${process.platform}` } } @@ -447,11 +527,11 @@ async function handleOpenTerminal( } else if (process.platform === 'linux') { try { // 获取当前用户主目录作为默认路径 - const folderPath = require('os').homedir() + const folderPath = os.homedir() // 依次尝试常用的终端启动方式,由于 spawn 不会像 exec 那样容易受到注入攻击 // 我们通过尝试启动不同的进程来实现兼容性 - const tryLaunch = (cmd: string, args: string[]) => { + const tryLaunch = (cmd: string, args: string[]): Promise => { return new Promise((resolve) => { const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) child.on('error', () => resolve(false)) @@ -484,6 +564,27 @@ async function handleOpenTerminal( console.error('[SystemCmd] 在终端打开失败:', error) return { success: false, error: String(error) } } + } else if (process.platform === 'win32') { + try { + const folderPath = getWindowsExplorerPath(previousWindow as WindowsWindowInfo) + + if (!folderPath) { + return { success: false, error: '无法获取资源管理器路径' } + } + + const launched = await tryLaunchWindowsTerminal(folderPath) + + if (!launched) { + return { success: false, error: '无法启动终端' } + } + + console.log('[SystemCmd] 已在终端打开:', folderPath) + ctx.mainWindow?.hide() + return { success: true } + } catch (error) { + console.error('[SystemCmd] 在终端打开失败:', error) + return { success: false, error: String(error) } + } } return { success: false, error: `不支持的平台: ${process.platform}` } } diff --git a/src/main/managers/windowManager.ts b/src/main/managers/windowManager.ts index 7f5c1c5b..62c4f7ae 100644 --- a/src/main/managers/windowManager.ts +++ b/src/main/managers/windowManager.ts @@ -70,6 +70,7 @@ class WindowManager { width?: number height?: number appPath?: string + hwnd?: number } | null = null // 打开应用前激活的窗口 // private _shouldRestoreFocus = true // TODO: 是否在隐藏窗口时恢复焦点(待实现) private windowPositionsByDisplay: Record = {} @@ -787,6 +788,7 @@ class WindowManager { width?: number height?: number appPath?: string + hwnd?: number } | null { return this.previousActiveWindow } diff --git a/tests/main/windowsExplorerCommands.test.ts b/tests/main/windowsExplorerCommands.test.ts new file mode 100644 index 00000000..e7e3149e --- /dev/null +++ b/tests/main/windowsExplorerCommands.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// 模拟 Windows 窗口信息接口 +interface WindowsWindowInfo { + hwnd?: number + className?: string +} + +// 模拟 getWindowsExplorerPath 函数逻辑 +function getWindowsExplorerPath( + windowInfo: WindowsWindowInfo, + mockExplorerPath: string | null +): string | null { + // 桌面窗口特殊处理 + if (windowInfo.className === 'Progman' || windowInfo.className === 'WorkerW') { + return 'C:\\Users\\TestUser\\Desktop' + } + + // 普通 Explorer 窗口 + if (!windowInfo.hwnd) { + return null + } + + const folderUrl = mockExplorerPath + if (!folderUrl) { + return null + } + + // 将 file:/// URL 转换为本地路径 + return folderUrl.startsWith('file:///') + ? decodeURIComponent(folderUrl.replace(/^file:\/\/\//i, '')).replace(/\//g, '\\') + : folderUrl +} + +describe('Windows Explorer Commands', () => { + describe('getWindowsExplorerPath', () => { + it('should return desktop path for Progman window', () => { + const result = getWindowsExplorerPath({ className: 'Progman' }, null) + expect(result).toBe('C:\\Users\\TestUser\\Desktop') + }) + + it('should return desktop path for WorkerW window', () => { + const result = getWindowsExplorerPath({ className: 'WorkerW' }, null) + expect(result).toBe('C:\\Users\\TestUser\\Desktop') + }) + + it('should return null when hwnd is missing', () => { + const result = getWindowsExplorerPath({ className: 'CabinetWClass' }, null) + expect(result).toBeNull() + }) + + it('should convert file URL to normal path', () => { + const mockPath = 'file:///C:/Users/TestUser/Documents' + const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) + expect(result).toBe('C:\\Users\\TestUser\\Documents') + }) + + it('should handle URL encoded characters', () => { + const mockPath = 'file:///C:/Users/TestUser/My%20Documents' + const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) + expect(result).toBe('C:\\Users\\TestUser\\My Documents') + }) + + it('should handle paths with hash symbol', () => { + const mockPath = 'file:///C:/Users/TestUser/Docs%23Work' + const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) + expect(result).toBe('C:\\Users\\TestUser\\Docs#Work') + }) + + it('should return null when COM query returns null', () => { + const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, null) + expect(result).toBeNull() + }) + }) + + describe('tryLaunchWindowsTerminal', () => { + it('should generate correct command arguments', () => { + const folderPath = 'C:\\Users\\Test\\Documents' + + // 验证命令参数构建逻辑 + const wtArgs = ['-d', folderPath] + const psArgs = ['-NoExit', '-Command', `Set-Location -Path "${folderPath}"`] + const cmdArgs = ['/K', `cd /d "${folderPath}"`] + + expect(wtArgs).toEqual(['-d', 'C:\\Users\\Test\\Documents']) + expect(psArgs).toContain('-NoExit') + expect(psArgs).toContain(`Set-Location -Path "${folderPath}"`) + expect(cmdArgs).toContain(`/K`) + expect(cmdArgs).toContain(`cd /d "${folderPath}"`) + }) + + it('should handle paths with spaces', () => { + const folderPath = 'C:\\Program Files\\My Folder' + const psArgs = ['-NoExit', '-Command', `Set-Location -Path "${folderPath}"`] + + expect(psArgs[2]).toBe('Set-Location -Path "C:\\Program Files\\My Folder"') + }) + }) +}) From 11922212bed16df9151b00f07bd6e10298bfac0e Mon Sep 17 00:00:00 2001 From: showen Date: Sun, 19 Apr 2026 21:00:57 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E8=B6=85=E7=BA=A7=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=94=AF=E6=8C=81=E5=89=AA=E8=B4=B4=E6=9D=BF=E6=96=87?= =?UTF-8?q?=E4=BB=B6/=E6=96=87=E4=BB=B6=E5=A4=B9=E7=9A=84=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 copy_path 的 files 类型匹配(支持单文件或单文件夹) - 添加 open-terminal 的 files 类型匹配(仅支持单文件夹) - 更新 handleCopyPath 支持从剪贴板获取文件路径 - 重构 handleOpenTerminal 减少代码重复 - 新增 openTerminalOnMac 统一处理 macOS 终端打开 - 新增 openTerminalOnLinux 统一处理 Linux 终端启动 - 新增 getMacFinderPath 获取访达当前路径 实现后,当选中文件或文件夹时,超级面板会显示: - 复制路径:适用于单文件或单文件夹 - 在终端打开:仅适用于文件夹 --- .github/workflows/windows-test.yml | 36 --- docs/development/windows-explorer-support.md | 42 ++-- internal-plugins/system/public/plugin.json | 13 ++ src/main/api/renderer/systemCommands.ts | 223 +++++++++++-------- 4 files changed, 178 insertions(+), 136 deletions(-) delete mode 100644 .github/workflows/windows-test.yml diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml deleted file mode 100644 index 24d539fe..00000000 --- a/.github/workflows/windows-test.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Windows E2E Test - -on: - push: - branches: [ main, feat/windows-* ] - pull_request: - branches: [ main ] - -jobs: - test-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Setup pnpm - uses: pnpm/action-setup@v2 - with: - version: 9 - - - name: Install dependencies - run: pnpm install - - - name: Build application - run: pnpm build:win - - - name: Run unit tests - run: pnpm test - - # E2E 测试需要实际 GUI 环境,此处仅作示例 - # - name: E2E Test (requires display) - # run: npm run test:e2e:win diff --git a/docs/development/windows-explorer-support.md b/docs/development/windows-explorer-support.md index 9c42cdd4..e25323a7 100644 --- a/docs/development/windows-explorer-support.md +++ b/docs/development/windows-explorer-support.md @@ -1,20 +1,24 @@ # Windows 文件管理器支持开发文档 ## 需求背景 + 当前【复制路径】和【在终端打开】两个系统指令仅支持 macOS 的 Finder,需要在 Windows 系统的文件管理器(explorer.exe)中也支持这两个功能。 ## 技术方案 ### 核心原理 + 利用已有的原生模块 API `getExplorerFolderPath(hwnd)` 获取 Windows Explorer 当前窗口的文件夹路径。该 API 通过 COM IShellWindows 接口查询指定窗口句柄对应的 Explorer 文件夹路径。 ### 修改范围 #### 1. 指令配置 (`internal-plugins/system/public/plugin.json`) + - 修改 `copy-path` 和 `open-terminal` 两个 feature 的 window match 配置 - 在 `match.app` 数组中添加 `"explorer.exe"` #### 2. 系统命令实现 (`src/main/api/renderer/systemCommands.ts`) + - 修改 `handleCopyPath` 函数,添加 Windows 平台支持 - 修改 `handleOpenTerminal` 函数,添加 Windows 平台支持 - 通过 `WindowManager.getExplorerFolderPath(hwnd)` 获取 Explorer 路径 @@ -26,20 +30,24 @@ ```json { "code": "copy-path", - "cmds": [{ - "type": "window", - "match": { "app": ["Finder.app", "explorer.exe"] } - }] + "cmds": [ + { + "type": "window", + "match": { "app": ["Finder.app", "explorer.exe"] } + } + ] } ``` ```json { "code": "open-terminal", - "cmds": [{ - "type": "window", - "match": { "app": ["Finder.app", "explorer.exe"] } - }] + "cmds": [ + { + "type": "window", + "match": { "app": ["Finder.app", "explorer.exe"] } + } + ] } ``` @@ -105,10 +113,14 @@ async function handleOpenTerminalWin(ctx: SystemCommandContext): Promise { const tryLaunchPowerShell = async (): Promise => { const { spawn } = await import('child_process') return new Promise((resolve) => { - const child = spawn('powershell.exe', ['-NoExit', '-Command', `Set-Location -Path "${normalPath}"`], { - detached: true, - stdio: 'ignore' - }) + const child = spawn( + 'powershell.exe', + ['-NoExit', '-Command', `Set-Location -Path "${normalPath}"`], + { + detached: true, + stdio: 'ignore' + } + ) child.on('error', () => resolve(false)) if (child.pid) { child.unref() @@ -132,7 +144,8 @@ async function handleOpenTerminalWin(ctx: SystemCommandContext): Promise { }) } - const launched = await tryLaunchTerminal() || await tryLaunchPowerShell() || await tryLaunchCMD() + const launched = + (await tryLaunchTerminal()) || (await tryLaunchPowerShell()) || (await tryLaunchCMD()) if (!launched) { return { success: false, error: '无法启动终端' } @@ -164,6 +177,7 @@ case 'open-terminal': ## 测试计划 ### 单元测试 + 1. 测试 `handleCopyPathWin` 正常获取路径并复制到剪贴板 2. 测试 `handleOpenTerminalWin` 正常打开终端并切换到对应目录 3. 测试无 hwnd 时的错误处理 @@ -171,6 +185,7 @@ case 'open-terminal': 5. 测试终端启动失败时的回退逻辑 ### 集成测试 + 1. 在 Windows 文件管理器中唤起超级面板,验证【复制路径】指令显示 2. 点击【复制路径】,验证路径正确复制到剪贴板 3. 在 Windows 文件管理器中唤起超级面板,验证【在终端打开】指令显示 @@ -178,6 +193,7 @@ case 'open-terminal': 5. 验证在非 explorer 窗口唤起时不会显示这两个指令 ### 回归测试 + 1. 验证 macOS 的【复制路径】和【在终端打开】功能正常工作 2. 验证 Linux 的【在终端打开】功能正常工作 diff --git a/internal-plugins/system/public/plugin.json b/internal-plugins/system/public/plugin.json index 02d39460..6311703f 100644 --- a/internal-plugins/system/public/plugin.json +++ b/internal-plugins/system/public/plugin.json @@ -79,6 +79,12 @@ "match": { "app": ["Finder.app", "explorer.exe"] } + }, + { + "type": "files", + "label": "复制路径", + "minLength": 1, + "maxLength": 1 } ] }, @@ -93,6 +99,13 @@ "match": { "app": ["Finder.app", "explorer.exe"] } + }, + { + "type": "files", + "label": "在终端打开", + "fileType": "directory", + "minLength": 1, + "maxLength": 1 } ] }, diff --git a/src/main/api/renderer/systemCommands.ts b/src/main/api/renderer/systemCommands.ts index 3f40aa10..050081ce 100644 --- a/src/main/api/renderer/systemCommands.ts +++ b/src/main/api/renderer/systemCommands.ts @@ -167,11 +167,23 @@ export async function executeSystemCommand( case 'window-info': return handleWindowInfo(ctx) - case 'copy-path': - return handleCopyPath(ctx, execAsync) + case 'copy-path': { + // 从参数中提取文件路径(剪贴板文件) + let filePath: string | undefined + if (param?.type === 'files' && Array.isArray(param.payload) && param.payload.length === 1) { + filePath = param.payload[0].path + } + return handleCopyPath(ctx, execAsync, filePath) + } - case 'open-terminal': - return handleOpenTerminal(ctx, execAsync) + case 'open-terminal': { + // 从参数中提取文件夹路径(剪贴板文件夹) + let folderPath: string | undefined + if (param?.type === 'files' && Array.isArray(param.payload) && param.payload.length === 1) { + folderPath = param.payload[0].path + } + return handleOpenTerminal(ctx, execAsync, folderPath) + } case 'color-picker': return handleColorPicker(ctx) @@ -439,9 +451,20 @@ function handleWindowInfo(ctx: SystemCommandContext): any { async function handleCopyPath( ctx: SystemCommandContext, - execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }> + execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }>, + filePath?: string ): Promise { - console.log('[SystemCmd] 执行复制路径') + console.log('[SystemCmd] 执行复制路径', filePath ? `(剪贴板文件: ${filePath})` : '(从窗口获取)') + + // 如果提供了文件路径(来自剪贴板),直接复制该路径 + if (filePath) { + clipboard.writeText(filePath) + console.log('[SystemCmd] 已复制路径:', filePath) + ctx.mainWindow?.hide() + return { success: true, path: filePath } + } + + // 否则从窗口获取路径(原有逻辑) const previousWindow = windowManager.getPreviousActiveWindow() if (!previousWindow) { @@ -489,104 +512,130 @@ async function handleCopyPath( return { success: false, error: `不支持的平台: ${process.platform}` } } -async function handleOpenTerminal( - ctx: SystemCommandContext, +/** + * 在 macOS 上打开终端并切换到指定目录 + */ +async function openTerminalOnMac( + folderPath: string, execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }> -): Promise { - console.log('[SystemCmd] 执行在终端打开') - const previousWindow = windowManager.getPreviousActiveWindow() +): Promise { + const script = ` + tell application "Terminal" + activate + do script "cd " & quoted form of "${folderPath}" + end tell + ` + await execAsync(`osascript -e '${script}'`) +} - if (!previousWindow) { - return { success: false, error: '无法获取当前窗口信息' } +/** + * 在 Linux 上尝试启动终端并切换到指定目录 + * 回退优先级:exo-open -> gnome-terminal -> xterm + */ +async function openTerminalOnLinux(folderPath: string): Promise { + const tryLaunch = (cmd: string, args: string[]): Promise => { + return new Promise((resolve) => { + const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) + child.on('error', () => resolve(false)) + if (child.pid) { + child.unref() + resolve(true) + } + }) } - if (process.platform === 'darwin') { - try { - const script = ` - tell application "Finder" - if (count of Finder windows) is 0 then - set folderPath to POSIX path of (desktop as alias) - else - set folderPath to POSIX path of (target of front window as alias) - end if - end tell + return ( + (await tryLaunch('exo-open', [ + '--launch', + 'TerminalEmulator', + '--working-directory', + folderPath + ])) || + (await tryLaunch('gnome-terminal', [`--working-directory=${folderPath}`])) || + (await tryLaunch('xterm', ['-cd', folderPath])) + ) +} - tell application "Terminal" - activate - do script "cd " & quoted form of folderPath - end tell - ` - await execAsync(`osascript -e '${script}'`) - console.log('[SystemCmd] 已在终端打开') - ctx.mainWindow?.hide() - return { success: true } - } catch (error) { - console.error('[SystemCmd] 在终端打开失败:', error) - return { success: false, error: String(error) } - } - } else if (process.platform === 'linux') { - try { - // 获取当前用户主目录作为默认路径 - const folderPath = os.homedir() - - // 依次尝试常用的终端启动方式,由于 spawn 不会像 exec 那样容易受到注入攻击 - // 我们通过尝试启动不同的进程来实现兼容性 - const tryLaunch = (cmd: string, args: string[]): Promise => { - return new Promise((resolve) => { - const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) - child.on('error', () => resolve(false)) - // 只要进程成功启动(没有立即触发 error 且 pid 存在),就认为成功 - if (child.pid) { - child.unref() - resolve(true) - } - }) - } +/** + * 从窗口信息获取 macOS 访达当前目录路径 + */ +async function getMacFinderPath( + execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }> +): Promise { + const script = ` + tell application "Finder" + if (count of Finder windows) is 0 then + return POSIX path of (desktop as alias) + else + return POSIX path of (target of front window as alias) + end if + end tell + ` + const { stdout } = await execAsync(`osascript -e '${script}'`) + return stdout.trim() +} - const launched = - (await tryLaunch('exo-open', [ - '--launch', - 'TerminalEmulator', - '--working-directory', - folderPath - ])) || - (await tryLaunch('gnome-terminal', [`--working-directory=${folderPath}`])) || - (await tryLaunch('xterm', ['-cd', folderPath])) +async function handleOpenTerminal( + ctx: SystemCommandContext, + execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }>, + folderPath?: string +): Promise { + console.log( + '[SystemCmd] 执行在终端打开', + folderPath ? `(剪贴板文件夹: ${folderPath})` : '(从窗口获取)' + ) - if (!launched) { - throw new Error('Could not find a supported terminal emulator') - } + try { + let targetPath: string | null = folderPath ?? null - console.log('[SystemCmd] 已在终端打开') - ctx.mainWindow?.hide() - return { success: true } - } catch (error) { - console.error('[SystemCmd] 在终端打开失败:', error) - return { success: false, error: String(error) } - } - } else if (process.platform === 'win32') { - try { - const folderPath = getWindowsExplorerPath(previousWindow as WindowsWindowInfo) + // 如果没有提供路径,从窗口获取 + if (!targetPath) { + const previousWindow = windowManager.getPreviousActiveWindow() + if (!previousWindow) { + return { success: false, error: '无法获取当前窗口信息' } + } - if (!folderPath) { - return { success: false, error: '无法获取资源管理器路径' } + if (process.platform === 'darwin') { + targetPath = await getMacFinderPath(execAsync) + } else if (process.platform === 'win32') { + targetPath = getWindowsExplorerPath(previousWindow as WindowsWindowInfo) + if (!targetPath) { + return { success: false, error: '无法获取资源管理器路径' } + } + } else if (process.platform === 'linux') { + // linux获取当前目录路径的方式待定,先写home目录 + targetPath = os.homedir() } + } - const launched = await tryLaunchWindowsTerminal(folderPath) + if (!targetPath) { + return { success: false, error: '无法确定目标路径' } + } + // 根据平台打开终端 + if (process.platform === 'darwin') { + await openTerminalOnMac(targetPath, execAsync) + } else if (process.platform === 'linux') { + const launched = await openTerminalOnLinux(targetPath) + if (!launched) { + throw new Error('Could not find a supported terminal emulator') + } + } else if (process.platform === 'win32') { + const launched = await tryLaunchWindowsTerminal(targetPath) if (!launched) { return { success: false, error: '无法启动终端' } } - - console.log('[SystemCmd] 已在终端打开:', folderPath) - ctx.mainWindow?.hide() - return { success: true } - } catch (error) { - console.error('[SystemCmd] 在终端打开失败:', error) - return { success: false, error: String(error) } + } else { + return { success: false, error: `不支持的平台: ${process.platform}` } } + + console.log('[SystemCmd] 已在终端打开:', targetPath) + ctx.mainWindow?.hide() + return { success: true } + } catch (error) { + console.error('[SystemCmd] 在终端打开失败:', error) + return { success: false, error: String(error) } } - return { success: false, error: `不支持的平台: ${process.platform}` } } function handleColorPicker(ctx: SystemCommandContext): Promise { From 2b4860aacda67721a47b3782637fbc6a6ef0d406 Mon Sep 17 00:00:00 2001 From: showen Date: Mon, 20 Apr 2026 00:01:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DPR=20review?= =?UTF-8?q?=E6=8F=90=E5=87=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 使用 fileURLToPath 替代正则表达式转换 file:// URL 2. 使用 app.getPath('desktop') 替代硬编码桌面路径 3. 添加路径转义函数防止 PowerShell/CMD 命令注入 - escapePowerShellPath: 使用单引号包裹,单引号转义为两个单引号 - escapeCmdPath: 使用双引号包裹,双引号用 ^ 转义 4. 重构测试文件,添加对转义函数的测试 Fixes review comments on PR #426 --- src/main/api/renderer/systemCommands.ts | 44 +++++++--- tests/main/windowsExplorerCommands.test.ts | 98 ++++++++++++++++------ 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/src/main/api/renderer/systemCommands.ts b/src/main/api/renderer/systemCommands.ts index 050081ce..ddc7c887 100644 --- a/src/main/api/renderer/systemCommands.ts +++ b/src/main/api/renderer/systemCommands.ts @@ -1,8 +1,8 @@ import { exec, spawn } from 'child_process' import os from 'os' -import path from 'path' +import { fileURLToPath } from 'url' import type { PluginManager } from '../../managers/pluginManager' -import { BrowserWindow, clipboard, nativeImage, Notification, shell } from 'electron' +import { app, BrowserWindow, clipboard, nativeImage, Notification, shell } from 'electron' import { promisify } from 'util' import { GLOBAL_SCROLLBAR_CSS } from '../../core/globalStyles' import { screenCapture } from '../../core/screenCapture' @@ -28,11 +28,11 @@ interface WindowsWindowInfo { * 获取 Windows 资源管理器当前文件夹路径 * 支持标准 Explorer 窗口(通过 COM)和桌面窗口(回退到桌面路径) */ -function getWindowsExplorerPath(windowInfo: WindowsWindowInfo): string | null { +export function getWindowsExplorerPath(windowInfo: WindowsWindowInfo): string | null { // 桌面窗口特殊处理(Progman: 桌面主窗口;WorkerW: 桌面壁纸层窗口) if (windowInfo.className === 'Progman' || windowInfo.className === 'WorkerW') { // 使用轻量级方式获取桌面路径:用户主目录 + Desktop - return path.join(os.homedir(), 'Desktop') + return app.getPath('desktop') } // 普通 Explorer 窗口,通过 COM 查询路径 @@ -46,16 +46,38 @@ function getWindowsExplorerPath(windowInfo: WindowsWindowInfo): string | null { } // 将 file:/// URL 转换为本地路径 - return folderUrl.startsWith('file:///') - ? decodeURIComponent(folderUrl.replace(/^file:\/\/\//i, '')).replace(/\//g, '\\') - : folderUrl + try { + return fileURLToPath(folderUrl) + } catch { + // 如果不是有效的 file URL,直接返回原值 + return folderUrl + } } /** * 尝试启动终端(Windows 平台) * 回退优先级:Windows Terminal -> PowerShell -> CMD */ -async function tryLaunchWindowsTerminal(folderPath: string): Promise { +/** + * 安全转义 PowerShell 路径参数 + * 使用单引号包裹,将路径中的单引号替换为两个单引号 + */ +function escapePowerShellPath(folderPath: string): string { + const escaped = folderPath.replace(/'/g, "''") + return `'${escaped}'` +} + +/** + * 安全转义 CMD 路径参数 + * 使用双引号包裹,将路径中的双引号转义 + */ +function escapeCmdPath(folderPath: string): string { + // CMD 中双引号内的双引号需要用 ^ 转义 + const escaped = folderPath.replace(/"/g, '^"') + return `"${escaped}"` +} + +export async function tryLaunchWindowsTerminal(folderPath: string): Promise { const tryLaunch = (cmd: string, args: string[]): Promise => { return new Promise((resolve) => { const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) @@ -67,14 +89,16 @@ async function tryLaunchWindowsTerminal(folderPath: string): Promise { }) } + // Windows Terminal 使用 spawn 参数数组,天然安全 + // PowerShell 和 CMD 需要转义路径以防止命令注入 return ( (await tryLaunch('wt.exe', ['-d', folderPath])) || (await tryLaunch('powershell.exe', [ '-NoExit', '-Command', - `Set-Location -Path "${folderPath}"` + `Set-Location -Path ${escapePowerShellPath(folderPath)}` ])) || - (await tryLaunch('cmd.exe', ['/K', `cd /d "${folderPath}"`])) + (await tryLaunch('cmd.exe', ['/K', `cd /d ${escapeCmdPath(folderPath)}`])) ) } diff --git a/tests/main/windowsExplorerCommands.test.ts b/tests/main/windowsExplorerCommands.test.ts index e7e3149e..8dc86473 100644 --- a/tests/main/windowsExplorerCommands.test.ts +++ b/tests/main/windowsExplorerCommands.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' +import { fileURLToPath } from 'url' // 模拟 Windows 窗口信息接口 interface WindowsWindowInfo { @@ -6,14 +7,18 @@ interface WindowsWindowInfo { className?: string } -// 模拟 getWindowsExplorerPath 函数逻辑 +/** + * 获取 Windows 资源管理器当前文件夹路径 + * 从 systemCommands.ts 复制以便测试 + */ function getWindowsExplorerPath( windowInfo: WindowsWindowInfo, - mockExplorerPath: string | null + mockExplorerPath: string | null, + mockDesktopPath: string = 'C:\\Users\\TestUser\\Desktop' ): string | null { // 桌面窗口特殊处理 if (windowInfo.className === 'Progman' || windowInfo.className === 'WorkerW') { - return 'C:\\Users\\TestUser\\Desktop' + return mockDesktopPath } // 普通 Explorer 窗口 @@ -26,10 +31,28 @@ function getWindowsExplorerPath( return null } - // 将 file:/// URL 转换为本地路径 - return folderUrl.startsWith('file:///') - ? decodeURIComponent(folderUrl.replace(/^file:\/\/\//i, '')).replace(/\//g, '\\') - : folderUrl + // 使用 fileURLToPath 转换 URL + try { + return fileURLToPath(folderUrl) + } catch { + return folderUrl + } +} + +/** + * 安全转义 PowerShell 路径参数 + */ +function escapePowerShellPath(folderPath: string): string { + const escaped = folderPath.replace(/'/g, "''") + return `'${escaped}'` +} + +/** + * 安全转义 CMD 路径参数 + */ +function escapeCmdPath(folderPath: string): string { + const escaped = folderPath.replace(/"/g, '^"') + return `"${escaped}"` } describe('Windows Explorer Commands', () => { @@ -52,48 +75,69 @@ describe('Windows Explorer Commands', () => { it('should convert file URL to normal path', () => { const mockPath = 'file:///C:/Users/TestUser/Documents' const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) - expect(result).toBe('C:\\Users\\TestUser\\Documents') + // fileURLToPath 返回格式取决于平台,在 Linux 上是 /C:/Users/... + expect(result).toMatch(/C:.*Users.*TestUser.*Documents/) }) it('should handle URL encoded characters', () => { const mockPath = 'file:///C:/Users/TestUser/My%20Documents' const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) - expect(result).toBe('C:\\Users\\TestUser\\My Documents') + expect(result).toContain('Users') + expect(result).toContain('TestUser') + expect(result).toContain('My Documents') }) it('should handle paths with hash symbol', () => { const mockPath = 'file:///C:/Users/TestUser/Docs%23Work' const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) - expect(result).toBe('C:\\Users\\TestUser\\Docs#Work') + expect(result).toContain('Users') + expect(result).toContain('TestUser') + expect(result).toContain('Docs#Work') }) it('should return null when COM query returns null', () => { const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, null) expect(result).toBeNull() }) + + it('should return raw value for non-file URLs', () => { + const mockPath = 'C:\\Users\\TestUser\\Documents' + const result = getWindowsExplorerPath({ hwnd: 123456, className: 'CabinetWClass' }, mockPath) + expect(result).toBe('C:\\Users\\TestUser\\Documents') + }) }) - describe('tryLaunchWindowsTerminal', () => { - it('should generate correct command arguments', () => { - const folderPath = 'C:\\Users\\Test\\Documents' + describe('escapePowerShellPath', () => { + it('should escape single quotes by doubling them', () => { + const result = escapePowerShellPath("C:\\Users\\Test\\Folder's Name") + expect(result).toBe("'C:\\Users\\Test\\Folder''s Name'") + }) + + it('should handle normal paths without special chars', () => { + const result = escapePowerShellPath('C:\\Users\\Test\\Documents') + expect(result).toBe("'C:\\Users\\Test\\Documents'") + }) - // 验证命令参数构建逻辑 - const wtArgs = ['-d', folderPath] - const psArgs = ['-NoExit', '-Command', `Set-Location -Path "${folderPath}"`] - const cmdArgs = ['/K', `cd /d "${folderPath}"`] + it('should handle paths with double quotes', () => { + const result = escapePowerShellPath('C:\\Users\\Test\\"Quoted" Folder') + expect(result).toBe('\'C:\\Users\\Test\\"Quoted" Folder\'') + }) + }) - expect(wtArgs).toEqual(['-d', 'C:\\Users\\Test\\Documents']) - expect(psArgs).toContain('-NoExit') - expect(psArgs).toContain(`Set-Location -Path "${folderPath}"`) - expect(cmdArgs).toContain(`/K`) - expect(cmdArgs).toContain(`cd /d "${folderPath}"`) + describe('escapeCmdPath', () => { + it('should escape double quotes with caret', () => { + const result = escapeCmdPath('C:\\Users\\Test\\"Quoted" Folder') + expect(result).toBe('"C:\\Users\\Test\\^"Quoted^" Folder"') }) - it('should handle paths with spaces', () => { - const folderPath = 'C:\\Program Files\\My Folder' - const psArgs = ['-NoExit', '-Command', `Set-Location -Path "${folderPath}"`] + it('should handle normal paths without special chars', () => { + const result = escapeCmdPath('C:\\Users\\Test\\Documents') + expect(result).toBe('"C:\\Users\\Test\\Documents"') + }) - expect(psArgs[2]).toBe('Set-Location -Path "C:\\Program Files\\My Folder"') + it('should handle paths with single quotes', () => { + const result = escapeCmdPath("C:\\Users\\Test\\Folder's Name") + expect(result).toBe('"C:\\Users\\Test\\Folder\'s Name"') }) }) })