From e5db72171d764ab3d6f8b672be9efe7aae0f3590 Mon Sep 17 00:00:00 2001 From: pantao <980141374@qq.com> Date: Fri, 5 Jun 2026 17:20:48 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E5=99=A8=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.js | 15 ++ src/ZToolsNative.swift | 282 ++++++++++++++++++++++++++++++++++ src/binding_mac.cpp | 106 +++++++++++++ src/binding_windows.cpp | 107 ++++++++++++- test/test-explorer-windows.js | 23 +++ 5 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 test/test-explorer-windows.js diff --git a/index.js b/index.js index 8d0d6b9..f569efe 100644 --- a/index.js +++ b/index.js @@ -276,6 +276,21 @@ class WindowManager { } return addon.simulateKeyboardTap(key, ...modifiers); } + /** + * 获取所有打开的文件资源管理器窗口的 URL 列表 + * @returns {Array} file:/// 格式的路径字符串数组 + * @example + * // Windows + * const urls = WindowManager.getAllExplorerWindows(); + * // ['file:///C:/Users/username/Documents', 'file:///D:/Projects'] + * + * // macOS + * const urls = WindowManager.getAllExplorerWindows(); + * // ['file:///Users/username/Documents', 'file:///Volumes/Data/Projects'] + */ + static getAllExplorerWindows() { + return addon.getAllExplorerWindows(); + } } class MouseMonitor { diff --git a/src/ZToolsNative.swift b/src/ZToolsNative.swift index e398140..9e8b9e7 100644 --- a/src/ZToolsNative.swift +++ b/src/ZToolsNative.swift @@ -1608,6 +1608,288 @@ public func simulateMouseRightClick(_ x: Double, _ y: Double) -> Int32 { return 1 } +// MARK: - Finder Windows + +/// 获取所有打开的 Finder 窗口的 file URL 列表 +/// +/// 通过 AppleScript 读取 Finder 窗口目标路径,再使用 URL(fileURLWithPath:) +/// 统一转换为标准 file:/// URL,确保空格、中文和特殊字符被正确编码。 +/// - Returns: JSON 字符串数组,格式为 ["file:///path1", "file:///path2"];失败或无窗口时返回 [] +@_cdecl("getAllFinderWindows") +public func getAllFinderWindows() -> UnsafeMutablePointer? { + let script = """ + tell application "Finder" + set windowPaths to {} + repeat with w in (get every Finder window) + try + set targetPath to POSIX path of (target of w as alias) + set end of windowPaths to targetPath + end try + end repeat + return windowPaths + end tell + """ + + var error: NSDictionary? + guard let scriptObject = NSAppleScript(source: script) else { + return strdup("[]") + } + + let output = scriptObject.executeAndReturnError(&error) + + if let error = error { + print("AppleScript error: \(error)") + return strdup("[]") + } + + // 解析 AppleScript 返回的列表 + var paths: [String] = [] + if output.numberOfItems > 0 { + for i in 1...output.numberOfItems { + if let item = output.atIndex(i), let path = item.stringValue { + paths.append(path) + } + } + } + + // 将 POSIX 路径数组转换为标准 file URL JSON 字符串数组 + let jsonPaths = paths.map { path -> String in + let fileURL = URL(fileURLWithPath: path).absoluteString + return "\"\(escapeJSON(fileURL))\"" + }.joined(separator: ",") + let jsonString = "[\(jsonPaths)]" + + return strdup(jsonString) +} + +// MARK: - Set Address Bar + +/// 设置指定窗口的地址栏内容(支持浏览器和 Finder) +/// - Parameters: +/// - bundleId: 应用的 bundle identifier +/// - address: 要设置的地址(URL 或文件路径) +/// - Returns: 是否设置成功 (1: 成功, 0: 失败) +@_cdecl("setAddressBar") +public func setAddressBar(_ bundleId: UnsafePointer?, _ address: UnsafePointer?) -> Int32 { + guard let bundleId = bundleId, let address = address else { + return 0 + } + + let bundleIdString = String(cString: bundleId) + let addressString = String(cString: address) + + guard !bundleIdString.isEmpty && !addressString.isEmpty else { + return 0 + } + + // 根据 bundleId 判断应用类型 + if bundleIdString == "com.apple.finder" { + return setFinderLocation(addressString) + } else if isBrowserApp(bundleIdString) { + return setBrowserURL(bundleIdString, addressString) + } + + return 0 +} + +/// 判断是否为浏览器应用 +private func isBrowserApp(_ bundleId: String) -> Bool { + let browserBundleIds = [ + "com.apple.Safari", + "com.google.Chrome", + "com.microsoft.edgemac", + "org.mozilla.firefox", + "com.brave.Browser", + "com.vivaldi.Vivaldi", + "company.thebrowser.Browser", // Arc + "com.operasoftware.Opera" + ] + return browserBundleIds.contains(bundleId) +} + +/// 设置 Finder 窗口位置 +private func setFinderLocation(_ path: String) -> Int32 { + // 转换为 POSIX 路径(如果是 file:// URL) + var targetPath = path + if path.hasPrefix("file://") { + if let url = URL(string: path) { + targetPath = url.path + } + } + + let escapedPath = targetPath.replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "Finder" + if (count of Finder windows) > 0 then + set target of front Finder window to (POSIX file "\(escapedPath)") + return true + else + return false + end if + end tell + """ + + var error: NSDictionary? + guard let scriptObject = NSAppleScript(source: script) else { + return 0 + } + + let output = scriptObject.executeAndReturnError(&error) + + if let error = error { + print("AppleScript error: \(error)") + return 0 + } + + // 检查返回值 + if let result = output.booleanValue, result { + return 1 + } + + return 0 +} + +/// 设置浏览器地址栏 URL +private func setBrowserURL(_ bundleId: String, _ urlString: String) -> Int32 { + let appName = getAppNameFromBundleId(bundleId) + let escapedURL = urlString.replacingOccurrences(of: "\"", with: "\\\"") + + var script = "" + + switch bundleId { + case "com.apple.Safari": + script = """ + tell application "\(appName)" + if (count of windows) > 0 then + set URL of front document to "\(escapedURL)" + return true + else + return false + end if + end tell + """ + + case "com.google.Chrome", "com.microsoft.edgemac", "com.brave.Browser", "com.vivaldi.Vivaldi": + script = """ + tell application "\(appName)" + if (count of windows) > 0 then + set URL of active tab of front window to "\(escapedURL)" + return true + else + return false + end if + end tell + """ + + case "org.mozilla.firefox": + // Firefox 不支持直接通过 AppleScript 设置 URL + // 使用模拟按键的方式:Cmd+L 聚焦地址栏,输入 URL,回车 + return setFirefoxURL(escapedURL) + + default: + return 0 + } + + var error: NSDictionary? + guard let scriptObject = NSAppleScript(source: script) else { + return 0 + } + + let output = scriptObject.executeAndReturnError(&error) + + if let error = error { + print("AppleScript error: \(error)") + return 0 + } + + if let result = output.booleanValue, result { + return 1 + } + + return 0 +} + +/// 设置 Firefox URL(使用键盘模拟) +private func setFirefoxURL(_ urlString: String) -> Int32 { + // 检查辅助功能权限 + let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] + let accessEnabled = AXIsProcessTrustedWithOptions(options) + + if !accessEnabled { + print("Error: Accessibility permission not granted") + return 0 + } + + guard let eventSource = CGEventSource(stateID: .hidSystemState) else { + return 0 + } + + // Cmd+L 聚焦地址栏 + let lKeyCode: CGKeyCode = 37 + guard let cmdLDown = CGEvent(keyboardEventSource: eventSource, virtualKey: lKeyCode, keyDown: true) else { + return 0 + } + cmdLDown.flags = .maskCommand + + guard let cmdLUp = CGEvent(keyboardEventSource: eventSource, virtualKey: lKeyCode, keyDown: false) else { + return 0 + } + cmdLUp.flags = .maskCommand + + cmdLDown.post(tap: .cghidEventTap) + usleep(50_000) + cmdLUp.post(tap: .cghidEventTap) + usleep(100_000) + + // 输入 URL + let chars = Array(urlString.utf16) + guard let typeEvent = CGEvent(keyboardEventSource: eventSource, virtualKey: 0, keyDown: true) else { + return 0 + } + typeEvent.keyboardSetUnicodeString(stringLength: chars.count, unicodeString: chars) + typeEvent.post(tap: .cghidEventTap) + usleep(50_000) + + // 回车 + let returnKeyCode: CGKeyCode = 36 + guard let returnDown = CGEvent(keyboardEventSource: eventSource, virtualKey: returnKeyCode, keyDown: true) else { + return 0 + } + guard let returnUp = CGEvent(keyboardEventSource: eventSource, virtualKey: returnKeyCode, keyDown: false) else { + return 0 + } + + returnDown.post(tap: .cghidEventTap) + usleep(10_000) + returnUp.post(tap: .cghidEventTap) + + return 1 +} + +/// 根据 bundleId 获取应用名称 +private func getAppNameFromBundleId(_ bundleId: String) -> String { + switch bundleId { + case "com.apple.Safari": + return "Safari" + case "com.google.Chrome": + return "Google Chrome" + case "com.microsoft.edgemac": + return "Microsoft Edge" + case "org.mozilla.firefox": + return "Firefox" + case "com.brave.Browser": + return "Brave Browser" + case "com.vivaldi.Vivaldi": + return "Vivaldi" + case "company.thebrowser.Browser": + return "Arc" + case "com.operasoftware.Opera": + return "Opera" + default: + return "" + } +} + // MARK: - Helper Functions (Utilities) /// 辅助函数:转义 JSON 字符串 diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index cf8d3fc..42ed480 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -31,6 +31,7 @@ typedef void (*ColorPickerCB)(const char *); // 取色器 typedef void (*StartColorPickerFunc)(ColorPickerCB); // 启动取色器 typedef void (*StopColorPickerFunc)(); // 停止取色器 typedef void *(*FetchFileIconFunc)(const char *, size_t *); // 获取文件图标 PNG +typedef char *(*GetAllFinderWindowsFunc)(); // 获取所有 Finder 窗口 // 全局变量 static void *swiftLibHandle = nullptr; @@ -59,6 +60,7 @@ static napi_threadsafe_function colorPickerTsfn = nullptr; static StartColorPickerFunc startColorPickerFunc = nullptr; static StopColorPickerFunc stopColorPickerFunc = nullptr; static FetchFileIconFunc fetchFileIconFunc = nullptr; +static GetAllFinderWindowsFunc getAllFinderWindowsFunc = nullptr; static bool g_isPaused = false; // 剪贴板监控暂停状态 // 在主线程调用 JS 回调 @@ -294,6 +296,8 @@ bool LoadSwiftLibrary(Napi::Env env) { stopColorPickerFunc = (StopColorPickerFunc)dlsym(swiftLibHandle, "stopColorPicker"); fetchFileIconFunc = (FetchFileIconFunc)dlsym(swiftLibHandle, "fetchFileIcon"); + getAllFinderWindowsFunc = + (GetAllFinderWindowsFunc)dlsym(swiftLibHandle, "getAllFinderWindows"); if (!startMonitorFunc || !stopMonitorFunc || !startWindowMonitorFunc || !stopWindowMonitorFunc || !getActiveWindowFunc || !activateWindowFunc || @@ -1327,6 +1331,107 @@ Napi::Value StopColorPicker(const Napi::CallbackInfo &info) { return env.Undefined(); } +/** + * 获取所有打开的 Finder 窗口的 file URL 列表。 + * + * Swift 动态库返回 JSON 字符串数组;这里解析成 JS Array。getAllFinderWindows + * 是可选符号,避免旧版 dylib 缺少该符号时阻断其它 macOS 原生能力加载。 + * + * @returns Array - file:/// URL 列表;无窗口或调用失败时返回空数组,符号缺失时抛出错误 + */ +Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + + if (!LoadSwiftLibrary(env)) { + return env.Undefined(); + } + + if (getAllFinderWindowsFunc == nullptr) { + Napi::Error::New(env, "getAllFinderWindows is not available") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + // 调用 Swift 函数获取 JSON 字符串 + char *jsonResult = getAllFinderWindowsFunc(); + if (jsonResult == nullptr) { + // 返回空数组 + return Napi::Array::New(env, 0); + } + + std::string jsonStr(jsonResult); + free(jsonResult); + + // 解析 JSON 字符串数组 ["path1", "path2", ...] + Napi::Array result = Napi::Array::New(env); + + // 简单的 JSON 数组解析(假设格式良好) + if (jsonStr.length() >= 2 && jsonStr[0] == '[' && jsonStr[jsonStr.length() - 1] == ']') { + std::string content = jsonStr.substr(1, jsonStr.length() - 2); + + if (!content.empty()) { + std::vector paths; + size_t start = 0; + bool inQuote = false; + bool escape = false; + + for (size_t i = 0; i < content.length(); i++) { + char ch = content[i]; + + if (escape) { + escape = false; + continue; + } + + if (ch == '\\') { + escape = true; + continue; + } + + if (ch == '"') { + inQuote = !inQuote; + if (!inQuote) { + // 结束引号 + std::string path = content.substr(start + 1, i - start - 1); + // 反转义 + std::string unescaped; + for (size_t j = 0; j < path.length(); j++) { + if (path[j] == '\\' && j + 1 < path.length()) { + char next = path[j + 1]; + if (next == '"' || next == '\\' || next == 'n' || next == 'r' || next == 't') { + if (next == 'n') unescaped += '\n'; + else if (next == 'r') unescaped += '\r'; + else if (next == 't') unescaped += '\t'; + else unescaped += next; + j++; + continue; + } + } + unescaped += path[j]; + } + paths.push_back(unescaped); + } else { + // 开始引号 + start = i; + } + } else if (ch == ',' && !inQuote) { + // 逗号分隔符,跳过空白 + while (i + 1 < content.length() && (content[i + 1] == ' ' || content[i + 1] == '\t')) { + i++; + } + } + } + + // 转换为 Napi::Array + for (size_t i = 0; i < paths.size(); i++) { + result[i] = Napi::String::New(env, paths[i]); + } + } + } + + return result; +} + // 模块初始化 Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("startMonitor", Napi::Function::New(env, StartMonitor)); @@ -1358,6 +1463,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("unicodeType", Napi::Function::New(env, UnicodeType)); exports.Set("setClipboardFiles", Napi::Function::New(env, SetClipboardFiles)); exports.Set("getFileIcon", Napi::Function::New(env, GetFileIcon)); + exports.Set("getAllExplorerWindows", Napi::Function::New(env, GetAllExplorerWindows)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); return exports; } diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index 85bba23..07d8a70 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -4978,8 +4978,11 @@ Napi::Value GetExplorerFolderPath(const Napi::CallbackInfo& info) { // 初始化 COM(STA 模式,与 Electron 主线程兼容) HRESULT hrInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - // S_OK 或 S_FALSE(已初始化)都是可接受的 - bool needUninit = (hrInit == S_OK); + // S_OK 和 S_FALSE 都表示本次 CoInitializeEx 成功,需要配对 CoUninitialize + bool needUninit = (hrInit == S_OK || hrInit == S_FALSE); + if (FAILED(hrInit)) { + return env.Null(); + } std::string result; @@ -5054,6 +5057,98 @@ Napi::Value GetExplorerFolderPath(const Napi::CallbackInfo& info) { return Napi::String::New(env, result); } +/** + * 获取所有打开的文件资源管理器窗口的 file URL 列表 + * + * 工作原理: + * 1. 初始化当前线程的 COM STA 环境,枚举所有 Shell 窗口(IShellWindows) + * 2. 读取每个窗口的 LocationURL,并仅保留 file:/// URL,避免混入浏览器等非 Explorer URL + * 3. 对本函数成功初始化的 COM 调用执行配对 CoUninitialize,避免初始化计数泄露 + * + * @returns Array - file:/// 格式的路径字符串数组;COM 初始化失败或无窗口时返回空数组 + */ +Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + // 初始化 COM(STA 模式,与 Electron 主线程兼容) + HRESULT hrInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + // S_OK 和 S_FALSE 都表示本次 CoInitializeEx 成功,需要配对 CoUninitialize + bool needUninit = (hrInit == S_OK || hrInit == S_FALSE); + + std::vector results; + + if (FAILED(hrInit)) { + return Napi::Array::New(env, 0); + } + + // 创建 ShellWindows COM 对象,枚举所有打开的 Explorer 窗口 + IShellWindows* shellWindows = nullptr; + HRESULT hr = CoCreateInstance( + CLSID_ShellWindows, nullptr, CLSCTX_ALL, + IID_IShellWindows, (void**)&shellWindows + ); + + if (SUCCEEDED(hr) && shellWindows) { + long count = 0; + shellWindows->get_Count(&count); + + // 遍历所有 Shell 窗口 + for (long i = 0; i < count; i++) { + VARIANT idx; + idx.vt = VT_I4; + idx.lVal = i; + + IDispatch* disp = nullptr; + hr = shellWindows->Item(idx, &disp); + if (FAILED(hr) || !disp) continue; + + // 查询 IWebBrowserApp 接口(Explorer 窗口实现该接口) + IWebBrowserApp* browser = nullptr; + hr = disp->QueryInterface(IID_IWebBrowserApp, (void**)&browser); + disp->Release(); + + if (FAILED(hr) || !browser) continue; + + // 获取当前目录的 URL + BSTR url = nullptr; + hr = browser->get_LocationURL(&url); + if (SUCCEEDED(hr) && url) { + // 将 BSTR (UTF-16) 转换为 UTF-8 字符串 + int len = SysStringLen(url); + if (len > 0) { + int size = WideCharToMultiByte(CP_UTF8, 0, url, len, NULL, 0, NULL, NULL); + if (size > 0) { + std::string urlStr; + urlStr.resize(size); + WideCharToMultiByte(CP_UTF8, 0, url, len, &urlStr[0], size, NULL, NULL); + // IShellWindows 可能包含非文件窗口,仅保留 Explorer/Finder 语义一致的 file URL + if (urlStr.rfind("file:///", 0) == 0) { + results.push_back(urlStr); + } + } + } + SysFreeString(url); + } + + browser->Release(); + } + + shellWindows->Release(); + } + + // 仅在本次调用初始化 COM 时才反初始化 + if (needUninit) { + CoUninitialize(); + } + + // 返回字符串数组 + Napi::Array resultArray = Napi::Array::New(env, results.size()); + for (size_t i = 0; i < results.size(); i++) { + resultArray[i] = Napi::String::New(env, results[i]); + } + return resultArray; +} + // ==================== 浏览器 URL 查询 ==================== std::wstring Utf8ToWideString(const std::string& input) { @@ -5394,7 +5489,11 @@ Napi::Value ReadBrowserWindowUrl(const Napi::CallbackInfo& info) { Napi::Function callback = info[2].As(); HRESULT hrInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - const bool needUninit = (hrInit == S_OK); + const bool needUninit = (hrInit == S_OK || hrInit == S_FALSE); + if (FAILED(hrInit)) { + callback.Call({ env.Null() }); + return env.Undefined(); + } std::string result; @@ -5450,6 +5549,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("unicodeType", Napi::Function::New(env, UnicodeType)); // 通过 COM IShellWindows 查询 Explorer 窗口的当前文件夹路径 exports.Set("getExplorerFolderPath", Napi::Function::New(env, GetExplorerFolderPath)); + // 获取所有打开的文件资源管理器窗口的 URL 列表 + exports.Set("getAllExplorerWindows", Napi::Function::New(env, GetAllExplorerWindows)); // 读取指定浏览器窗口的当前 URL exports.Set("readBrowserWindowUrl", Napi::Function::New(env, ReadBrowserWindowUrl)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); diff --git a/test/test-explorer-windows.js b/test/test-explorer-windows.js new file mode 100644 index 0000000..5e280d9 --- /dev/null +++ b/test/test-explorer-windows.js @@ -0,0 +1,23 @@ +const { WindowManager } = require('../index.js'); + +console.log('测试获取所有文件资源管理器窗口的 URL...\n'); + +try { + const urls = WindowManager.getAllExplorerWindows(); + + console.log(`找到 ${urls.length} 个打开的文件资源管理器窗口:\n`); + + if (urls.length === 0) { + console.log('没有打开的文件资源管理器窗口'); + console.log('\n提示:请打开一个或多个文件资源管理器窗口后再运行此测试'); + } else { + urls.forEach((url, index) => { + console.log(`[${index + 1}] ${url}`); + }); + + console.log('\n✅ 测试成功!'); + } +} catch (error) { + console.error('❌ 错误:', error.message); + console.error(error.stack); +} From 5f8dcb6c7a7a5d3081b9a4023d943860f6f92fc5 Mon Sep 17 00:00:00 2001 From: pantao <980141374@qq.com> Date: Fri, 5 Jun 2026 17:59:17 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AA=97=E5=8F=A3=E5=9C=B0=E5=9D=80=E6=A0=8F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 25 ++++ index.js | 36 +++++ src/ZToolsNative.swift | 282 ++++++++++++++++++---------------------- src/binding_mac.cpp | 48 +++++++ src/binding_windows.cpp | 241 ++++++++++++++++++++++++++++++++++ 5 files changed, 475 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index a04a171..61dcf28 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ macOS 和 Windows 原生 API 的 Node.js 封装,使用 Swift + Win32 API + Nod 9. **鼠标监控** - 实时监听鼠标移动、点击事件 10. **鼠标模拟** - 模拟鼠标移动、点击操作 11. **取色器** - 全屏取色工具 +12. **设置文件窗口地址栏** - 跳转 Finder/Explorer 或文件选择对话框到指定路径 ## 🔧 系统要求 @@ -224,6 +225,30 @@ WindowManager.activateWindow(12345); 获取当前平台 - **返回**: `'darwin' | 'win32'` +#### `WindowManager.setAddressBar(target, address)` +设置文件资源管理器/Finder 或文件选择对话框的地址栏位置 +- **参数**: + - `target` - 目标窗口 + - **Windows**: `hwnd` 数字,或 `WindowManager.getActiveWindow()` 返回的窗口对象 + - **macOS**: `bundleId` / `pid`,或 `WindowManager.getActiveWindow()` 返回的窗口对象 + - `address` (string) - 要跳转的文件路径或 `file:///` 地址 +- **返回**: `boolean` - 是否设置成功 +- **平台**: ✅ Windows 和 macOS + +**限制**: +- Windows 仅允许 Explorer 顶级窗口和包含 Shell 文件视图的文件选择对话框 +- macOS 仅允许 Finder 和当前焦点为系统文件选择对话框的应用窗口 +- 需要目标窗口可被激活;macOS 文件选择对话框场景需要辅助功能权限 + +**示例**: +```javascript +const current = WindowManager.getActiveWindow(); +WindowManager.setAddressBar(current, process.platform === 'win32' + ? 'C:\\Users\\username\\Documents' + : '/Users/username/Documents' +); +``` + #### `WindowManager.simulateKeyboardTap(key, ...modifiers)` 模拟键盘按键 - **参数**: diff --git a/index.js b/index.js index f569efe..268eacf 100644 --- a/index.js +++ b/index.js @@ -291,6 +291,42 @@ class WindowManager { static getAllExplorerWindows() { return addon.getAllExplorerWindows(); } + + /** + * 设置指定文件资源管理器/Finder 或文件选择对话框的地址栏位置 + * @param {Object|string|number} target - 目标窗口;Windows 支持 hwnd 数字或包含 hwnd 的窗口对象,macOS 支持 bundleId/pid 或窗口对象 + * @param {string} address - 要跳转的文件路径或 file:/// 地址 + * @returns {boolean} 是否设置成功 + * @example + * const win = WindowManager.getActiveWindow(); + * WindowManager.setAddressBar(win, 'C:\\Users\\username\\Documents'); + */ + static setAddressBar(target, address) { + if (typeof address !== 'string' || !address) { + throw new TypeError('address must be a non-empty string'); + } + + let identifier = target; + if (target && typeof target === 'object') { + if (platform === 'win32') { + identifier = target.hwnd; + } else if (platform === 'darwin') { + identifier = target.bundleId || target.pid; + } + } + + if (platform === 'win32') { + if (typeof identifier !== 'number') { + throw new TypeError('On Windows, target must be a hwnd number or a window object with hwnd'); + } + } else if (platform === 'darwin') { + if (typeof identifier !== 'string' && typeof identifier !== 'number') { + throw new TypeError('On macOS, target must be a bundleId, pid, or window object with bundleId/pid'); + } + } + + return addon.setAddressBar(identifier, address); + } } class MouseMonitor { diff --git a/src/ZToolsNative.swift b/src/ZToolsNative.swift index 9e8b9e7..b50bfe6 100644 --- a/src/ZToolsNative.swift +++ b/src/ZToolsNative.swift @@ -1664,50 +1664,88 @@ public func getAllFinderWindows() -> UnsafeMutablePointer? { // MARK: - Set Address Bar -/// 设置指定窗口的地址栏内容(支持浏览器和 Finder) +/// 设置 Finder 或文件选择对话框的地址栏位置 /// - Parameters: -/// - bundleId: 应用的 bundle identifier -/// - address: 要设置的地址(URL 或文件路径) -/// - Returns: 是否设置成功 (1: 成功, 0: 失败) +/// - target: Finder 的 bundleId,或文件选择对话框所属应用的 bundleId/pid 字符串 +/// - address: 要跳转的文件路径或 file:// URL +/// - Returns: 1 成功,0 表示目标不是 Finder/文件选择对话框或系统权限不足 @_cdecl("setAddressBar") -public func setAddressBar(_ bundleId: UnsafePointer?, _ address: UnsafePointer?) -> Int32 { - guard let bundleId = bundleId, let address = address else { +public func setAddressBar(_ target: UnsafePointer?, _ address: UnsafePointer?) -> Int32 { + guard let target = target, let address = address else { return 0 } - let bundleIdString = String(cString: bundleId) + let targetString = String(cString: target) let addressString = String(cString: address) - guard !bundleIdString.isEmpty && !addressString.isEmpty else { + guard !targetString.isEmpty && !addressString.isEmpty else { return 0 } - // 根据 bundleId 判断应用类型 - if bundleIdString == "com.apple.finder" { + if targetString == "com.apple.finder" { return setFinderLocation(addressString) - } else if isBrowserApp(bundleIdString) { - return setBrowserURL(bundleIdString, addressString) } - return 0 + guard let app = runningApplication(for: targetString) else { + return 0 + } + + if app.bundleIdentifier == "com.apple.finder" { + return setFinderLocation(addressString) + } + + guard isFocusedFileDialog(of: app.processIdentifier) else { + return 0 + } + + return setFileDialogLocation(app: app, address: addressString) } -/// 判断是否为浏览器应用 -private func isBrowserApp(_ bundleId: String) -> Bool { - let browserBundleIds = [ - "com.apple.Safari", - "com.google.Chrome", - "com.microsoft.edgemac", - "org.mozilla.firefox", - "com.brave.Browser", - "com.vivaldi.Vivaldi", - "company.thebrowser.Browser", // Arc - "com.operasoftware.Opera" - ] - return browserBundleIds.contains(bundleId) +/// 根据 bundleId 或 pid 字符串查找正在运行的应用。 +/// - Parameter target: bundleId(如 com.apple.TextEdit)或十进制 pid 字符串 +/// - Returns: 匹配到的 NSRunningApplication;未找到返回 nil +private func runningApplication(for target: String) -> NSRunningApplication? { + if let pidValue = Int32(target), pidValue > 0 { + return NSRunningApplication(processIdentifier: pid_t(pidValue)) + } + + return NSRunningApplication.runningApplications(withBundleIdentifier: target).first } -/// 设置 Finder 窗口位置 +/// 判断指定应用当前焦点窗口是否像系统打开/保存文件对话框。 +/// +/// macOS 的文件选择器通常以 AXDialog 或 AXSystemDialog 暴露,并包含 browser/table/list +/// 等文件列表控件;该检查用于把快捷键注入限制在文件定位场景,避免误改普通输入框。 +private func isFocusedFileDialog(of pid: pid_t) -> Bool { + let appElement = AXUIElementCreateApplication(pid) + var windowValue: AnyObject? + let result = AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &windowValue) + + guard result == .success, let window = windowValue else { + return false + } + + var roleValue: AnyObject? + var subroleValue: AnyObject? + AXUIElementCopyAttributeValue(window as! AXUIElement, kAXRoleAttribute as CFString, &roleValue) + AXUIElementCopyAttributeValue(window as! AXUIElement, kAXSubroleAttribute as CFString, &subroleValue) + + let role = roleValue as? String ?? "" + let subrole = subroleValue as? String ?? "" + if role == kAXDialogRole as String || subrole == kAXSystemDialogSubrole as String { + return true + } + + var titleValue: AnyObject? + AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleValue) + let title = (titleValue as? String ?? "").lowercased() + let dialogTitleKeywords = ["open", "save", "choose", "export", "import", "打开", "存储", "保存", "选择", "导出", "导入"] + return dialogTitleKeywords.contains { title.contains($0) } +} + +/// 设置 Finder 前台窗口位置。 +/// - Parameter path: POSIX 路径或 file:// URL +/// - Returns: 1 成功,0 失败 private func setFinderLocation(_ path: String) -> Int32 { // 转换为 POSIX 路径(如果是 file:// URL) var targetPath = path @@ -1717,7 +1755,7 @@ private func setFinderLocation(_ path: String) -> Int32 { } } - let escapedPath = targetPath.replacingOccurrences(of: "\"", with: "\\\"") + let escapedPath = escapeAppleScriptString(targetPath) let script = """ tell application "Finder" if (count of Finder windows) > 0 then @@ -1741,153 +1779,83 @@ private func setFinderLocation(_ path: String) -> Int32 { return 0 } - // 检查返回值 - if let result = output.booleanValue, result { - return 1 - } - - return 0 -} - -/// 设置浏览器地址栏 URL -private func setBrowserURL(_ bundleId: String, _ urlString: String) -> Int32 { - let appName = getAppNameFromBundleId(bundleId) - let escapedURL = urlString.replacingOccurrences(of: "\"", with: "\\\"") - - var script = "" - - switch bundleId { - case "com.apple.Safari": - script = """ - tell application "\(appName)" - if (count of windows) > 0 then - set URL of front document to "\(escapedURL)" - return true - else - return false - end if - end tell - """ - - case "com.google.Chrome", "com.microsoft.edgemac", "com.brave.Browser", "com.vivaldi.Vivaldi": - script = """ - tell application "\(appName)" - if (count of windows) > 0 then - set URL of active tab of front window to "\(escapedURL)" - return true - else - return false - end if - end tell - """ - - case "org.mozilla.firefox": - // Firefox 不支持直接通过 AppleScript 设置 URL - // 使用模拟按键的方式:Cmd+L 聚焦地址栏,输入 URL,回车 - return setFirefoxURL(escapedURL) - - default: - return 0 - } - - var error: NSDictionary? - guard let scriptObject = NSAppleScript(source: script) else { - return 0 - } - - let output = scriptObject.executeAndReturnError(&error) - - if let error = error { - print("AppleScript error: \(error)") - return 0 - } - - if let result = output.booleanValue, result { - return 1 - } - - return 0 + return (output.booleanValue == true) ? 1 : 0 } -/// 设置 Firefox URL(使用键盘模拟) -private func setFirefoxURL(_ urlString: String) -> Int32 { - // 检查辅助功能权限 +/// 设置当前文件选择对话框的位置。 +/// +/// 通过 Cmd+Shift+G 打开系统“前往文件夹”输入框,输入路径后回车。该路径只在 +/// isFocusedFileDialog 通过后发送,且需要辅助功能权限允许本进程模拟键盘事件。 +private func setFileDialogLocation(app: NSRunningApplication, address: String) -> Int32 { let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] - let accessEnabled = AXIsProcessTrustedWithOptions(options) - - if !accessEnabled { + guard AXIsProcessTrustedWithOptions(options) else { print("Error: Accessibility permission not granted") return 0 } - guard let eventSource = CGEventSource(stateID: .hidSystemState) else { - return 0 + var targetPath = address + if address.hasPrefix("file://"), let url = URL(string: address) { + targetPath = url.path } - // Cmd+L 聚焦地址栏 - let lKeyCode: CGKeyCode = 37 - guard let cmdLDown = CGEvent(keyboardEventSource: eventSource, virtualKey: lKeyCode, keyDown: true) else { - return 0 - } - cmdLDown.flags = .maskCommand + guard !targetPath.isEmpty else { return 0 } - guard let cmdLUp = CGEvent(keyboardEventSource: eventSource, virtualKey: lKeyCode, keyDown: false) else { - return 0 - } - cmdLUp.flags = .maskCommand + _ = app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + usleep(120_000) - cmdLDown.post(tap: .cghidEventTap) - usleep(50_000) - cmdLUp.post(tap: .cghidEventTap) - usleep(100_000) + guard sendKeyTap(keyCode: 5, flags: [.maskCommand, .maskShift]) else { return 0 } // Cmd+Shift+G + usleep(160_000) + guard sendUnicodeString(targetPath) else { return 0 } + usleep(40_000) + return sendKeyTap(keyCode: 36, flags: []) ? 1 : 0 +} - // 输入 URL - let chars = Array(urlString.utf16) - guard let typeEvent = CGEvent(keyboardEventSource: eventSource, virtualKey: 0, keyDown: true) else { - return 0 - } - typeEvent.keyboardSetUnicodeString(stringLength: chars.count, unicodeString: chars) - typeEvent.post(tap: .cghidEventTap) - usleep(50_000) +/// 发送一个键盘按键组合。 +/// - Parameters: +/// - keyCode: macOS 虚拟键码 +/// - flags: 需要同时按下的修饰键 +/// - Returns: 事件创建并发送成功返回 true +private func sendKeyTap(keyCode: CGKeyCode, flags: CGEventFlags) -> Bool { + guard let eventSource = CGEventSource(stateID: .hidSystemState), + let downEvent = CGEvent(keyboardEventSource: eventSource, virtualKey: keyCode, keyDown: true), + let upEvent = CGEvent(keyboardEventSource: eventSource, virtualKey: keyCode, keyDown: false) else { + return false + } + + downEvent.flags = flags + upEvent.flags = flags + downEvent.post(tap: .cghidEventTap) + usleep(10_000) + upEvent.post(tap: .cghidEventTap) + return true +} - // 回车 - let returnKeyCode: CGKeyCode = 36 - guard let returnDown = CGEvent(keyboardEventSource: eventSource, virtualKey: returnKeyCode, keyDown: true) else { - return 0 - } - guard let returnUp = CGEvent(keyboardEventSource: eventSource, virtualKey: returnKeyCode, keyDown: false) else { - return 0 +/// 通过 Unicode 键盘事件输入字符串。 +/// - Parameter text: 要输入的文本,支持中文、空格和其它非 ASCII 字符 +/// - Returns: 事件创建并发送成功返回 true +private func sendUnicodeString(_ text: String) -> Bool { + guard !text.isEmpty, + let eventSource = CGEventSource(stateID: .hidSystemState), + let downEvent = CGEvent(keyboardEventSource: eventSource, virtualKey: 0, keyDown: true), + let upEvent = CGEvent(keyboardEventSource: eventSource, virtualKey: 0, keyDown: false) else { + return false } - returnDown.post(tap: .cghidEventTap) + let chars = Array(text.utf16) + downEvent.keyboardSetUnicodeString(stringLength: chars.count, unicodeString: chars) + downEvent.post(tap: .cghidEventTap) usleep(10_000) - returnUp.post(tap: .cghidEventTap) - - return 1 + upEvent.post(tap: .cghidEventTap) + return true } -/// 根据 bundleId 获取应用名称 -private func getAppNameFromBundleId(_ bundleId: String) -> String { - switch bundleId { - case "com.apple.Safari": - return "Safari" - case "com.google.Chrome": - return "Google Chrome" - case "com.microsoft.edgemac": - return "Microsoft Edge" - case "org.mozilla.firefox": - return "Firefox" - case "com.brave.Browser": - return "Brave Browser" - case "com.vivaldi.Vivaldi": - return "Vivaldi" - case "company.thebrowser.Browser": - return "Arc" - case "com.operasoftware.Opera": - return "Opera" - default: - return "" - } +/// 转义 AppleScript 字符串字面量中的特殊字符。 +/// - Parameter string: 原始路径或文本 +/// - Returns: 可安全放入双引号 AppleScript 字符串中的内容 +private func escapeAppleScriptString(_ string: String) -> String { + return string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") } // MARK: - Helper Functions (Utilities) diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index 42ed480..9c8abf4 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -32,6 +32,7 @@ typedef void (*StartColorPickerFunc)(ColorPickerCB); // 启动取 typedef void (*StopColorPickerFunc)(); // 停止取色器 typedef void *(*FetchFileIconFunc)(const char *, size_t *); // 获取文件图标 PNG typedef char *(*GetAllFinderWindowsFunc)(); // 获取所有 Finder 窗口 +typedef int (*SetAddressBarFunc)(const char *, const char *); // 设置 Finder/文件对话框地址 // 全局变量 static void *swiftLibHandle = nullptr; @@ -61,6 +62,7 @@ static StartColorPickerFunc startColorPickerFunc = nullptr; static StopColorPickerFunc stopColorPickerFunc = nullptr; static FetchFileIconFunc fetchFileIconFunc = nullptr; static GetAllFinderWindowsFunc getAllFinderWindowsFunc = nullptr; +static SetAddressBarFunc setAddressBarFunc = nullptr; static bool g_isPaused = false; // 剪贴板监控暂停状态 // 在主线程调用 JS 回调 @@ -298,6 +300,8 @@ bool LoadSwiftLibrary(Napi::Env env) { fetchFileIconFunc = (FetchFileIconFunc)dlsym(swiftLibHandle, "fetchFileIcon"); getAllFinderWindowsFunc = (GetAllFinderWindowsFunc)dlsym(swiftLibHandle, "getAllFinderWindows"); + setAddressBarFunc = + (SetAddressBarFunc)dlsym(swiftLibHandle, "setAddressBar"); if (!startMonitorFunc || !stopMonitorFunc || !startWindowMonitorFunc || !stopWindowMonitorFunc || !getActiveWindowFunc || !activateWindowFunc || @@ -1432,6 +1436,49 @@ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo &info) { return result; } +/** + * 设置 Finder 或文件选择对话框等文件定位窗口的地址。 + * + * 第一个参数接受 bundleId 字符串或 pid 数字,C++ 层统一转为字符串传给 Swift; + * Swift 会限制目标为 Finder 或常见文件选择对话框所属应用,避免修改普通浏览器地址栏。 + * + * @returns boolean - 地址设置成功返回 true,目标不支持或系统权限不足返回 false + */ +Napi::Value SetAddressBar(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + + if (!LoadSwiftLibrary(env)) { + return Napi::Boolean::New(env, false); + } + + if (setAddressBarFunc == nullptr) { + Napi::Error::New(env, "setAddressBar is not available") + .ThrowAsJavaScriptException(); + return Napi::Boolean::New(env, false); + } + + if (info.Length() < 2 || (!info[0].IsString() && !info[0].IsNumber()) || !info[1].IsString()) { + Napi::TypeError::New(env, "target (bundleId or pid) and address (string) are required") + .ThrowAsJavaScriptException(); + return Napi::Boolean::New(env, false); + } + + std::string target; + if (info[0].IsString()) { + target = info[0].As().Utf8Value(); + } else { + target = std::to_string(info[0].As().Int64Value()); + } + + std::string address = info[1].As().Utf8Value(); + if (target.empty() || address.empty()) { + return Napi::Boolean::New(env, false); + } + + int success = setAddressBarFunc(target.c_str(), address.c_str()); + return Napi::Boolean::New(env, success == 1); +} + // 模块初始化 Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("startMonitor", Napi::Function::New(env, StartMonitor)); @@ -1464,6 +1511,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("setClipboardFiles", Napi::Function::New(env, SetClipboardFiles)); exports.Set("getFileIcon", Napi::Function::New(env, GetFileIcon)); exports.Set("getAllExplorerWindows", Napi::Function::New(env, GetAllExplorerWindows)); + exports.Set("setAddressBar", Napi::Function::New(env, SetAddressBar)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); return exports; } diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index 07d8a70..1610966 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -5149,6 +5149,245 @@ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo& info) { return resultArray; } +std::wstring Utf8ToWideString(const std::string& input); + +struct ChildClassSearchContext { + const wchar_t** classNames; + size_t classCount; + bool found; +}; + +/** + * 枚举子窗口并查找指定窗口类名。 + * + * 用于确认 #32770 对话框是否真的是 Shell 文件选择对话框:普通系统对话框也会使用 + * #32770 顶级类名,只有包含地址栏/文件列表相关子控件时才允许写入地址。 + */ +static BOOL CALLBACK FindChildClassProc(HWND child, LPARAM lParam) { + ChildClassSearchContext* context = reinterpret_cast(lParam); + if (!context || context->found) { + return FALSE; + } + + WCHAR className[256] = {0}; + int classLen = GetClassNameW(child, className, 256); + if (classLen > 0) { + for (size_t i = 0; i < context->classCount; i++) { + if (wcscmp(className, context->classNames[i]) == 0) { + context->found = true; + return FALSE; + } + } + } + + EnumChildWindows(child, FindChildClassProc, lParam); + return !context->found; +} + +/** + * 判断窗口是否包含 Shell 文件对话框的典型子控件。 + * + * 现代和旧版 Windows 文件选择对话框的内部类名略有差异,因此同时匹配面包屑地址栏、 + * Shell 文件视图和 DirectUI 文件视图,尽量覆盖打开/保存/选择文件夹等场景。 + */ +static bool HasFileDialogChildControls(HWND hwnd) { + const wchar_t* fileDialogClasses[] = { + L"Breadcrumb Parent", + L"Address Band Root", + L"SHELLDLL_DefView", + L"DUIViewWndClassName" + }; + ChildClassSearchContext context = { fileDialogClasses, 4, false }; + EnumChildWindows(hwnd, FindChildClassProc, reinterpret_cast(&context)); + return context.found; +} + +/** + * 判断目标窗口是否是允许修改地址栏的文件定位窗口。 + * + * 仅放行 Explorer 顶级窗口和常见文件选择对话框,避免把传入地址写入浏览器、编辑器 + * 或其它普通应用的输入框。文件对话框可能属于任意宿主进程,因此额外允许 #32770 + * 对话框类名;Explorer 则通过 CabinetWClass/ExploreWClass 识别。 + */ +static bool IsFileLocationWindow(HWND hwnd) { + if (!hwnd || !IsWindow(hwnd)) { + return false; + } + + WCHAR className[256] = {0}; + int classLen = GetClassNameW(hwnd, className, 256); + if (classLen <= 0) { + return false; + } + + if (wcscmp(className, L"CabinetWClass") == 0 || + wcscmp(className, L"ExploreWClass") == 0) { + return true; + } + + if (wcscmp(className, L"#32770") == 0) { + return HasFileDialogChildControls(hwnd); + } + + return false; +} + +/** + * 将目标窗口切到前台并获得键盘输入焦点。 + * + * Windows 对跨进程 SetForegroundWindow 有限制,这里临时附加当前线程、前台线程和目标 + * 窗口线程的输入队列,确保后续 Ctrl+L、文本输入和 Enter 被发送到指定窗口。 + */ +static bool FocusTargetWindow(HWND hwnd) { + if (!hwnd || !IsWindow(hwnd)) { + return false; + } + + if (IsIconic(hwnd)) { + ShowWindow(hwnd, SW_RESTORE); + } + + HWND foregroundWnd = GetForegroundWindow(); + DWORD foregroundThreadId = GetWindowThreadProcessId(foregroundWnd, NULL); + DWORD targetThreadId = GetWindowThreadProcessId(hwnd, NULL); + DWORD currentThreadId = GetCurrentThreadId(); + + BOOL attachedForeground = FALSE; + BOOL attachedCurrent = FALSE; + + if (foregroundThreadId != targetThreadId) { + attachedForeground = AttachThreadInput(foregroundThreadId, targetThreadId, TRUE); + } + if (currentThreadId != targetThreadId && currentThreadId != foregroundThreadId) { + attachedCurrent = AttachThreadInput(currentThreadId, targetThreadId, TRUE); + } + + BringWindowToTop(hwnd); + SetForegroundWindow(hwnd); + SetActiveWindow(hwnd); + SetFocus(hwnd); + + if (attachedForeground) { + AttachThreadInput(foregroundThreadId, targetThreadId, FALSE); + } + if (attachedCurrent) { + AttachThreadInput(currentThreadId, targetThreadId, FALSE); + } + + return GetForegroundWindow() == hwnd; +} + +/** + * 发送 Ctrl+L 快捷键,用于聚焦 Explorer 或文件对话框的地址栏。 + * + * 该快捷键是 Windows Shell 文件定位界面的通用入口;如果窗口类型已被 + * IsFileLocationWindow 限制为文件定位窗口,使用它比遍历不同版本 Shell UIA 树更稳定。 + */ +static bool SendFocusAddressBarShortcut() { + INPUT inputs[4] = {}; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].ki.wVk = VK_CONTROL; + + inputs[1].type = INPUT_KEYBOARD; + inputs[1].ki.wVk = 'L'; + + inputs[2].type = INPUT_KEYBOARD; + inputs[2].ki.wVk = 'L'; + inputs[2].ki.dwFlags = KEYEVENTF_KEYUP; + + inputs[3].type = INPUT_KEYBOARD; + inputs[3].ki.wVk = VK_CONTROL; + inputs[3].ki.dwFlags = KEYEVENTF_KEYUP; + + return SendInput(4, inputs, sizeof(INPUT)) == 4; +} + +/** + * 使用 KEYEVENTF_UNICODE 输入任意 UTF-16 文本。 + * + * 直接发送 Unicode 字符可以覆盖中文、空格和非 ASCII 路径,避免键盘布局影响。 + */ +static bool SendUnicodeText(const std::wstring& text) { + if (text.empty()) { + return false; + } + + std::vector inputs; + inputs.reserve(text.size() * 2); + for (wchar_t ch : text) { + INPUT down = {}; + down.type = INPUT_KEYBOARD; + down.ki.wScan = ch; + down.ki.dwFlags = KEYEVENTF_UNICODE; + inputs.push_back(down); + + INPUT up = {}; + up.type = INPUT_KEYBOARD; + up.ki.wScan = ch; + up.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP; + inputs.push_back(up); + } + + UINT sent = SendInput(static_cast(inputs.size()), inputs.data(), sizeof(INPUT)); + return sent == inputs.size(); +} + +/** + * 发送一个虚拟键的按下和释放事件。 + * + * 主要用于在地址栏文本写入后发送 Enter,让 Explorer 或文件对话框跳转到目标地址。 + */ +static bool SendVirtualKeyTap(WORD vk) { + INPUT inputs[2] = {}; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].ki.wVk = vk; + inputs[1].type = INPUT_KEYBOARD; + inputs[1].ki.wVk = vk; + inputs[1].ki.dwFlags = KEYEVENTF_KEYUP; + return SendInput(2, inputs, sizeof(INPUT)) == 2; +} + +/** + * 将指定 Explorer 或文件选择对话框的地址栏设置为传入地址。 + * + * 参数: + * 1. hwnd: number - 目标文件资源管理器或文件选择对话框顶级窗口句柄 + * 2. address: string - 目标文件夹路径或 file:/// URL + * + * 返回 true 表示快捷键、文本输入和跳转键都发送成功;目标窗口不是受支持类型、 + * 不存在或无法获得焦点时返回 false。调用方可用 getActiveWindow() 的 hwnd 字段作为目标。 + */ +Napi::Value SetAddressBar(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) { + Napi::TypeError::New(env, "hwnd (number) and address (string) are required").ThrowAsJavaScriptException(); + return Napi::Boolean::New(env, false); + } + + uint64_t hwndValue = static_cast(info[0].As().Int64Value()); + HWND targetHwnd = reinterpret_cast(hwndValue); + std::string addressUtf8 = info[1].As().Utf8Value(); + std::wstring address = Utf8ToWideString(addressUtf8); + + if (address.empty() || !IsFileLocationWindow(targetHwnd)) { + return Napi::Boolean::New(env, false); + } + + if (!FocusTargetWindow(targetHwnd)) { + return Napi::Boolean::New(env, false); + } + + Sleep(60); + bool success = SendFocusAddressBarShortcut(); + Sleep(80); + success = SendUnicodeText(address) && success; + Sleep(30); + success = SendVirtualKeyTap(VK_RETURN) && success; + + return Napi::Boolean::New(env, success); +} + // ==================== 浏览器 URL 查询 ==================== std::wstring Utf8ToWideString(const std::string& input) { @@ -5551,6 +5790,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("getExplorerFolderPath", Napi::Function::New(env, GetExplorerFolderPath)); // 获取所有打开的文件资源管理器窗口的 URL 列表 exports.Set("getAllExplorerWindows", Napi::Function::New(env, GetAllExplorerWindows)); + // 设置 Explorer 或文件选择对话框地址栏 + exports.Set("setAddressBar", Napi::Function::New(env, SetAddressBar)); // 读取指定浏览器窗口的当前 URL exports.Set("readBrowserWindowUrl", Napi::Function::New(env, ReadBrowserWindowUrl)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); From e3ee298cfdcf6ec362bd7e1c1ecc7c22bb3eb371 Mon Sep 17 00:00:00 2001 From: patrick <980141374@qq.com> Date: Fri, 5 Jun 2026 21:49:18 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E5=9C=B0=E5=9D=80=E6=A0=8F=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/binding_windows.cpp | 72 +++++++++++++++++++++++++++++ test/test-set-address-bar-active.js | 63 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 test/test-set-address-bar-active.js diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index 1610966..d69b61f 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -5277,6 +5277,71 @@ static bool FocusTargetWindow(HWND hwnd) { return GetForegroundWindow() == hwnd; } +static bool NavigateExplorerWindow(HWND targetHwnd, const std::wstring& address) { + HRESULT hrInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + bool needUninit = (hrInit == S_OK || hrInit == S_FALSE); + if (FAILED(hrInit)) { + return false; + } + + bool success = false; + IShellWindows* shellWindows = nullptr; + HRESULT hr = CoCreateInstance( + CLSID_ShellWindows, nullptr, CLSCTX_ALL, + IID_IShellWindows, reinterpret_cast(&shellWindows) + ); + + if (SUCCEEDED(hr) && shellWindows) { + long count = 0; + shellWindows->get_Count(&count); + + for (long i = 0; i < count; i++) { + VARIANT idx; + VariantInit(&idx); + idx.vt = VT_I4; + idx.lVal = i; + + IDispatch* disp = nullptr; + hr = shellWindows->Item(idx, &disp); + if (FAILED(hr) || !disp) continue; + + IWebBrowser2* browser = nullptr; + hr = disp->QueryInterface(IID_IWebBrowser2, reinterpret_cast(&browser)); + disp->Release(); + + if (FAILED(hr) || !browser) continue; + + SHANDLE_PTR browserHwndPtr = 0; + hr = browser->get_HWND(&browserHwndPtr); + if (SUCCEEDED(hr) && reinterpret_cast(browserHwndPtr) == targetHwnd) { + VARIANT url; + VariantInit(&url); + url.vt = VT_BSTR; + url.bstrVal = SysAllocString(address.c_str()); + + VARIANT empty; + VariantInit(&empty); + hr = browser->Navigate2(&url, &empty, &empty, &empty, &empty); + success = SUCCEEDED(hr); + + VariantClear(&url); + browser->Release(); + break; + } + + browser->Release(); + } + + shellWindows->Release(); + } + + if (needUninit) { + CoUninitialize(); + } + + return success; +} + /** * 发送 Ctrl+L 快捷键,用于聚焦 Explorer 或文件对话框的地址栏。 * @@ -5374,6 +5439,13 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo& info) { return Napi::Boolean::New(env, false); } + WCHAR className[256] = {0}; + GetClassNameW(targetHwnd, className, 256); + if (wcscmp(className, L"CabinetWClass") == 0 || + wcscmp(className, L"ExploreWClass") == 0) { + return Napi::Boolean::New(env, NavigateExplorerWindow(targetHwnd, address)); + } + if (!FocusTargetWindow(targetHwnd)) { return Napi::Boolean::New(env, false); } diff --git a/test/test-set-address-bar-active.js b/test/test-set-address-bar-active.js new file mode 100644 index 0000000..1fe101c --- /dev/null +++ b/test/test-set-address-bar-active.js @@ -0,0 +1,63 @@ +const { WindowManager } = require('../index.js'); +const os = require('os'); +const path = require('path'); + +const targetAddress = os.homedir(); + +console.log('测试 3 秒后获取当前 active 窗口并设置地址为用户目录...\n'); +console.log(`目标地址: ${targetAddress}`); +console.log('提示: 请在 3 秒内激活一个文件资源管理器/Finder 窗口或文件选择对话框。\n'); + +let remaining = 3; +const countdown = setInterval(() => { + process.stdout.write(`\r剩余时间: ${remaining} 秒...`); + remaining -= 1; + + if (remaining < 0) { + clearInterval(countdown); + } +}, 1000); + +setTimeout(() => { + clearInterval(countdown); + process.stdout.write('\r'); + + try { + const activeWindow = WindowManager.getActiveWindow(); + + if (!activeWindow) { + console.log('❌ 获取当前 active 窗口失败'); + process.exit(1); + } + + console.log('当前 active 窗口:'); + console.log(` 应用: ${activeWindow.appName || activeWindow.app || '未知'}`); + console.log(` 标题: ${activeWindow.title || '无'}`); + + if (process.platform === 'win32') { + console.log(` HWND: ${activeWindow.hwnd}`); + } else if (process.platform === 'darwin') { + console.log(` Bundle ID: ${activeWindow.bundleId || '无'}`); + } + + const success = WindowManager.setAddressBar(activeWindow, targetAddress); + + if (success) { + console.log(`\n✅ 设置地址成功: ${path.normalize(targetAddress)}`); + process.exit(0); + } + + console.log('\n❌ 设置地址失败,请确认当前 active 窗口是文件资源管理器/Finder 或文件选择对话框'); + process.exit(1); + } catch (error) { + console.error('\n❌ 错误:', error.message); + console.error(error.stack); + process.exit(1); + } +}, 3000); + +process.on('SIGINT', () => { + clearInterval(countdown); + console.log('\n\n用户中断测试'); + process.exit(130); +}); From ef75dbe43b7026240963cf27bc6c2327d791a62d Mon Sep 17 00:00:00 2001 From: patrick <980141374@qq.com> Date: Sat, 6 Jun 2026 19:00:40 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AA=97=E5=8F=A3=E7=B2=BE=E7=A1=AE=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.js | 29 ++-- src/ZToolsNative.swift | 347 +++++++++++++++++++++++++++++++--------- src/binding_mac.cpp | 252 +++-------------------------- src/binding_windows.cpp | 89 +++++++---- 4 files changed, 364 insertions(+), 353 deletions(-) diff --git a/index.js b/index.js index 268eacf..3122271 100644 --- a/index.js +++ b/index.js @@ -277,21 +277,28 @@ class WindowManager { return addon.simulateKeyboardTap(key, ...modifiers); } /** - * 获取所有打开的文件资源管理器窗口的 URL 列表 - * @returns {Array} file:/// 格式的路径字符串数组 - * @example - * // Windows - * const urls = WindowManager.getAllExplorerWindows(); - * // ['file:///C:/Users/username/Documents', 'file:///D:/Projects'] - * - * // macOS - * const urls = WindowManager.getAllExplorerWindows(); - * // ['file:///Users/username/Documents', 'file:///Volumes/Data/Projects'] + * 获取所有打开的文件资源管理器/Finder 窗口信息 + * @returns {Array<{platform?: string, kind?: string, preciseTarget?: boolean, hwnd?: number, windowId?: number, finderId?: number, pid?: number, bundleId?: string, app?: string, title?: string, className?: string, axRole?: string, axSubrole?: string, path?: string, url?: string}>} */ static getAllExplorerWindows() { return addon.getAllExplorerWindows(); } + /** + * 判断指定窗口是否是可安全修改地址栏的文件定位窗口 + * @param {number} hwnd - Windows 窗口句柄 + * @returns {boolean} + */ + static isFileLocationWindow(hwnd) { + if (platform !== 'win32') { + throw new Error('isFileLocationWindow is only available on Windows'); + } + if (typeof hwnd !== 'number' || !Number.isFinite(hwnd) || hwnd <= 0) { + throw new TypeError('hwnd must be a positive number'); + } + return addon.isFileLocationWindow(hwnd); + } + /** * 设置指定文件资源管理器/Finder 或文件选择对话框的地址栏位置 * @param {Object|string|number} target - 目标窗口;Windows 支持 hwnd 数字或包含 hwnd 的窗口对象,macOS 支持 bundleId/pid 或窗口对象 @@ -311,7 +318,7 @@ class WindowManager { if (platform === 'win32') { identifier = target.hwnd; } else if (platform === 'darwin') { - identifier = target.bundleId || target.pid; + identifier = target; } } diff --git a/src/ZToolsNative.swift b/src/ZToolsNative.swift index b50bfe6..5bb2b8a 100644 --- a/src/ZToolsNative.swift +++ b/src/ZToolsNative.swift @@ -18,6 +18,7 @@ private var windowMonitorQueue: DispatchQueue? private var isWindowMonitoring = false private var lastBundleId: String = "" private var lastProcessId: pid_t = 0 +private var lastWindowId: Int = 0 private let fileIconSize = NSSize(width: 80, height: 80) // MARK: - File Icon @@ -148,6 +149,124 @@ public func stopClipboardMonitor() { // MARK: - Window Management +private struct WindowMetadata { + var pid: pid_t + var bundleId: String + var appName: String + var title: String + var app: String + var appPath: String + var bounds: CGRect + var windowId: Int + var finderId: Int? + var path: String? + var url: String? + var axRole: String + var axSubrole: String + var kind: String? + var preciseTarget: Bool +} + +private func focusedAXWindow(for pid: pid_t) -> AXUIElement? { + let app = AXUIElementCreateApplication(pid) + var windowValue: AnyObject? + let result = AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowValue) + if result == .success, let window = windowValue { + return (window as! AXUIElement) + } + return nil +} + +private func axStringAttribute(_ window: AXUIElement, _ attribute: CFString) -> String { + var value: AnyObject? + AXUIElementCopyAttributeValue(window, attribute, &value) + if let stringValue = value as? String { + return stringValue + } + if let urlValue = value as? URL { + return urlValue.absoluteString + } + return "" +} + +private func axBounds(_ window: AXUIElement) -> CGRect { + var positionValue: AnyObject? + var sizeValue: AnyObject? + AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &positionValue) + AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) + + var position = CGPoint.zero + var size = CGSize.zero + if let posValue = positionValue { + AXValueGetValue(posValue as! AXValue, .cgPoint, &position) + } + if let szValue = sizeValue { + AXValueGetValue(szValue as! AXValue, .cgSize, &size) + } + return CGRect(origin: position, size: size) +} + +private func normalizedFileLocation(from rawValue: String) -> (path: String?, url: String?) { + guard !rawValue.isEmpty else { return (nil, nil) } + if rawValue.hasPrefix("file://"), let fileURL = URL(string: rawValue) { + return (fileURL.path, fileURL.absoluteString) + } + if rawValue.hasPrefix("/") { + let fileURL = URL(fileURLWithPath: rawValue) + return (rawValue, fileURL.absoluteString) + } + return (nil, rawValue) +} + +private func frontFinderWindowInfo() -> (finderId: Int?, path: String?, url: String?) { + let script = """ + tell application "Finder" + if (count of Finder windows) is 0 then return "" + set w to front Finder window + set targetPath to POSIX path of (target of w as alias) + return ((id of w) as text) & tab & targetPath + end tell + """ + + var error: NSDictionary? + guard let scriptObject = NSAppleScript(source: script) else { + return (nil, nil, nil) + } + let output = scriptObject.executeAndReturnError(&error) + if error != nil, output.stringValue == nil { + return (nil, nil, nil) + } + let parts = (output.stringValue ?? "").components(separatedBy: "\t") + guard parts.count >= 2, let finderId = Int(parts[0]) else { + return (nil, nil, nil) + } + let path = parts[1] + return (finderId, path, URL(fileURLWithPath: path).absoluteString) +} + +private func jsonForWindowMetadata(_ info: WindowMetadata) -> String { + var fields: [String] = [] + fields.append("\"appName\":\"\(escapeJSON(info.appName))\"") + fields.append("\"bundleId\":\"\(escapeJSON(info.bundleId))\"") + fields.append("\"title\":\"\(escapeJSON(info.title))\"") + fields.append("\"app\":\"\(escapeJSON(info.app))\"") + fields.append("\"x\":\(Int(info.bounds.origin.x))") + fields.append("\"y\":\(Int(info.bounds.origin.y))") + fields.append("\"width\":\(Int(info.bounds.size.width))") + fields.append("\"height\":\(Int(info.bounds.size.height))") + fields.append("\"appPath\":\"\(escapeJSON(info.appPath))\"") + fields.append("\"pid\":\(info.pid)") + fields.append("\"windowId\":\(info.windowId)") + fields.append("\"axRole\":\"\(escapeJSON(info.axRole))\"") + fields.append("\"axSubrole\":\"\(escapeJSON(info.axSubrole))\"") + fields.append("\"preciseTarget\":\(info.preciseTarget ? "true" : "false")") + if let finderId = info.finderId { fields.append("\"finderId\":\(finderId)") } + if let path = info.path { fields.append("\"path\":\"\(escapeJSON(path))\"") } + if let url = info.url { fields.append("\"url\":\"\(escapeJSON(url))\"") } + if let kind = info.kind { fields.append("\"kind\":\"\(escapeJSON(kind))\"") } + return "{\(fields.joined(separator: ","))}" +} + /// 获取窗口标题(使用 Accessibility API) private func getWindowTitle(for pid: pid_t) -> String { let app = AXUIElementCreateApplication(pid) @@ -223,28 +342,14 @@ private func getAppName(from app: NSRunningApplication) -> String { } /// 获取当前激活窗口的信息(JSON 格式) -/// - Returns: JSON 字符串包含 appName、bundleId、title、app、x、y、width、height、appPath 和 pid,需要调用者 free +/// - Returns: JSON 字符串包含窗口级元数据,需要调用者 free @_cdecl("getActiveWindow") public func getActiveWindow() -> UnsafeMutablePointer? { - // 获取当前激活的应用 - guard let frontmostApp = NSWorkspace.shared.frontmostApplication else { + guard let metadata = getFrontmostAppUsingCG() else { return strdup("{\"error\":\"No frontmost application\"}") } - let appName = frontmostApp.localizedName ?? "Unknown" - let bundleId = frontmostApp.bundleIdentifier ?? "unknown.bundle.id" - let pid = frontmostApp.processIdentifier - let windowTitle = getWindowTitle(for: pid) - let app = getAppName(from: frontmostApp) - let appPath = frontmostApp.bundleURL?.path ?? "" - let bounds = getWindowBounds(for: pid) - - // 构建 JSON 字符串 - let jsonString = """ - {"appName":"\(escapeJSON(appName))","bundleId":"\(escapeJSON(bundleId))","title":"\(escapeJSON(windowTitle))","app":"\(escapeJSON(app))","x":\(Int(bounds.origin.x)),"y":\(Int(bounds.origin.y)),"width":\(Int(bounds.size.width)),"height":\(Int(bounds.size.height)),"appPath":"\(escapeJSON(appPath))","pid":\(pid)} - """ - - return strdup(jsonString) + return strdup(jsonForWindowMetadata(metadata)) } /// 根据 bundleId 激活应用窗口 @@ -270,38 +375,76 @@ public func activateWindow(_ bundleId: UnsafePointer?) -> Int32 { // MARK: - Window Monitor -/// 使用 Core Graphics API 获取当前激活的应用(最可靠) -private func getFrontmostAppUsingCG() -> (pid: pid_t, bundleId: String, appName: String, windowTitle: String, app: String, appPath: String, bounds: CGRect)? { - // 获取所有窗口列表,按层级排序 +/// 使用 Core Graphics API 获取当前激活的窗口 +private func getFrontmostAppUsingCG() -> WindowMetadata? { let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { return nil } - // 找到最前面的窗口(layer 最小) for window in windowList { - // 跳过没有 owner PID 的窗口 guard let pid = window[kCGWindowOwnerPID as String] as? pid_t, pid > 0 else { continue } - // 跳过窗口层级为 0 的(通常是系统 UI) - if let layer = window[kCGWindowLayer as String] as? Int, layer == 0 { - // 获取该窗口所属的应用信息 - if let runningApp = NSRunningApplication(processIdentifier: pid) { - // 只返回有UI的普通应用 - if runningApp.activationPolicy == .regular { - let bundleId = runningApp.bundleIdentifier ?? "unknown.bundle.id" - let appName = runningApp.localizedName ?? "Unknown" - let windowTitle = getWindowTitle(for: pid) - let app = getAppName(from: runningApp) - let appPath = runningApp.bundleURL?.path ?? "" - let bounds = getWindowBounds(for: pid) - return (pid: pid, bundleId: bundleId, appName: appName, windowTitle: windowTitle, app: app, appPath: appPath, bounds: bounds) - } + guard let layer = window[kCGWindowLayer as String] as? Int, layer == 0 else { + continue + } + + guard let runningApp = NSRunningApplication(processIdentifier: pid), + runningApp.activationPolicy == .regular else { + continue + } + + let bundleId = runningApp.bundleIdentifier ?? "unknown.bundle.id" + let appName = runningApp.localizedName ?? "Unknown" + let app = getAppName(from: runningApp) + let appPath = runningApp.bundleURL?.path ?? "" + let windowId = window[kCGWindowNumber as String] as? Int ?? 0 + let focusedWindow = focusedAXWindow(for: pid) + let title = focusedWindow.map { axStringAttribute($0, kAXTitleAttribute as CFString) } ?? (window[kCGWindowName as String] as? String ?? "") + let bounds = focusedWindow.map { axBounds($0) } ?? getWindowBounds(for: pid) + let axRole = focusedWindow.map { axStringAttribute($0, kAXRoleAttribute as CFString) } ?? "" + let axSubrole = focusedWindow.map { axStringAttribute($0, kAXSubroleAttribute as CFString) } ?? "" + let axUrl = focusedWindow.map { axStringAttribute($0, kAXURLAttribute as CFString) } ?? "" + let axDocument = focusedWindow.map { axStringAttribute($0, kAXDocumentAttribute as CFString) } ?? "" + var location = normalizedFileLocation(from: !axUrl.isEmpty ? axUrl : axDocument) + var finderId: Int? + var kind: String? + var preciseTarget = false + + if bundleId == "com.apple.finder" { + let finderInfo = frontFinderWindowInfo() + finderId = finderInfo.finderId + if let finderPath = finderInfo.path { + location.path = finderPath + location.url = finderInfo.url } + kind = "mac-finder" + preciseTarget = finderId != nil + } else if axRole == kAXDialogRole as String || axSubrole == kAXSystemDialogSubrole as String || axSubrole == "AXSheet" { + kind = "mac-file-dialog" + preciseTarget = windowId > 0 } + + return WindowMetadata( + pid: pid, + bundleId: bundleId, + appName: appName, + title: title, + app: app, + appPath: appPath, + bounds: bounds, + windowId: windowId, + finderId: finderId, + path: location.path, + url: location.url, + axRole: axRole, + axSubrole: axSubrole, + kind: kind, + preciseTarget: preciseTarget + ) } return nil @@ -328,11 +471,9 @@ public func startWindowMonitor(_ callback: WindowCallback?) { if let appInfo = getFrontmostAppUsingCG() { lastProcessId = appInfo.pid lastBundleId = appInfo.bundleId + lastWindowId = appInfo.windowId - // 立即回调初始窗口状态 - let jsonString = """ - {"appName":"\(escapeJSON(appInfo.appName))","bundleId":"\(escapeJSON(appInfo.bundleId))","title":"\(escapeJSON(appInfo.windowTitle))","app":"\(escapeJSON(appInfo.app))","x":\(Int(appInfo.bounds.origin.x)),"y":\(Int(appInfo.bounds.origin.y)),"width":\(Int(appInfo.bounds.size.width)),"height":\(Int(appInfo.bounds.size.height)),"appPath":"\(escapeJSON(appInfo.appPath))","pid":\(appInfo.pid)} - """ + let jsonString = jsonForWindowMetadata(appInfo) jsonString.withCString { cString in callback(cString) } @@ -353,24 +494,14 @@ public func startWindowMonitor(_ callback: WindowCallback?) { } let currentPid = appInfo.pid - let currentBundleId = appInfo.bundleId - let appName = appInfo.appName - let windowTitle = appInfo.windowTitle - let app = appInfo.app - let appPath = appInfo.appPath - let bounds = appInfo.bounds - - // 检测到窗口切换(使用 PID 比较更可靠) - if currentPid != lastProcessId { - lastProcessId = currentPid - lastBundleId = currentBundleId + let currentWindowId = appInfo.windowId - // 构建JSON字符串 - let jsonString = """ - {"appName":"\(escapeJSON(appName))","bundleId":"\(escapeJSON(currentBundleId))","title":"\(escapeJSON(windowTitle))","app":"\(escapeJSON(app))","x":\(Int(bounds.origin.x)),"y":\(Int(bounds.origin.y)),"width":\(Int(bounds.size.width)),"height":\(Int(bounds.size.height)),"appPath":"\(escapeJSON(appPath))","pid":\(currentPid)} - """ + if currentPid != lastProcessId || currentWindowId != lastWindowId { + lastProcessId = currentPid + lastBundleId = appInfo.bundleId + lastWindowId = currentWindowId - // 调用回调 + let jsonString = jsonForWindowMetadata(appInfo) jsonString.withCString { cString in callback(cString) } @@ -390,6 +521,7 @@ public func stopWindowMonitor() { windowMonitorQueue = nil lastBundleId = "" lastProcessId = 0 + lastWindowId = 0 // 清理观察者(如果有的话) if let observer = windowMonitorObserver { @@ -1610,23 +1742,21 @@ public func simulateMouseRightClick(_ x: Double, _ y: Double) -> Int32 { // MARK: - Finder Windows -/// 获取所有打开的 Finder 窗口的 file URL 列表 -/// -/// 通过 AppleScript 读取 Finder 窗口目标路径,再使用 URL(fileURLWithPath:) -/// 统一转换为标准 file:/// URL,确保空格、中文和特殊字符被正确编码。 -/// - Returns: JSON 字符串数组,格式为 ["file:///path1", "file:///path2"];失败或无窗口时返回 [] +/// 获取所有打开的 Finder 窗口的结构化信息 @_cdecl("getAllFinderWindows") public func getAllFinderWindows() -> UnsafeMutablePointer? { let script = """ tell application "Finder" - set windowPaths to {} + set windowItems to {} repeat with w in (get every Finder window) try set targetPath to POSIX path of (target of w as alias) - set end of windowPaths to targetPath + set windowTitle to name of w + set windowId to id of w + set end of windowItems to ((windowId as text) & tab & windowTitle & tab & targetPath) end try end repeat - return windowPaths + return windowItems end tell """ @@ -1642,24 +1772,31 @@ public func getAllFinderWindows() -> UnsafeMutablePointer? { return strdup("[]") } - // 解析 AppleScript 返回的列表 - var paths: [String] = [] + var jsonItems: [String] = [] if output.numberOfItems > 0 { for i in 1...output.numberOfItems { - if let item = output.atIndex(i), let path = item.stringValue { - paths.append(path) - } + guard let item = output.atIndex(i), let raw = item.stringValue else { continue } + let parts = raw.components(separatedBy: "\t") + guard parts.count >= 3, let finderId = Int(parts[0]) else { continue } + let title = parts[1] + let path = parts[2] + let fileURL = URL(fileURLWithPath: path).absoluteString + let object = "{" + [ + "\"platform\":\"darwin\"", + "\"kind\":\"mac-finder\"", + "\"preciseTarget\":true", + "\"bundleId\":\"com.apple.finder\"", + "\"app\":\"Finder.app\"", + "\"finderId\":\(finderId)", + "\"title\":\"\(escapeJSON(title))\"", + "\"path\":\"\(escapeJSON(path))\"", + "\"url\":\"\(escapeJSON(fileURL))\"" + ].joined(separator: ",") + "}" + jsonItems.append(object) } } - // 将 POSIX 路径数组转换为标准 file URL JSON 字符串数组 - let jsonPaths = paths.map { path -> String in - let fileURL = URL(fileURLWithPath: path).absoluteString - return "\"\(escapeJSON(fileURL))\"" - }.joined(separator: ",") - let jsonString = "[\(jsonPaths)]" - - return strdup(jsonString) + return strdup("[\(jsonItems.joined(separator: ","))]") } // MARK: - Set Address Bar @@ -1682,6 +1819,10 @@ public func setAddressBar(_ target: UnsafePointer?, _ address: UnsafePoin return 0 } + if targetString.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { + return setAddressBarWithStructuredTarget(targetString, addressString) + } + if targetString == "com.apple.finder" { return setFinderLocation(addressString) } @@ -1701,6 +1842,47 @@ public func setAddressBar(_ target: UnsafePointer?, _ address: UnsafePoin return setFileDialogLocation(app: app, address: addressString) } +private func intTargetField(_ value: Any?) -> Int? { + if let number = value as? NSNumber { + return number.intValue + } + if let string = value as? String { + return Int(string) + } + return nil +} + +private func stringTargetField(_ value: Any?) -> String? { + if let string = value as? String, !string.isEmpty { + return string + } + return nil +} + +private func setAddressBarWithStructuredTarget(_ targetJson: String, _ address: String) -> Int32 { + guard let data = targetJson.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return 0 + } + + let kind = stringTargetField(object["kind"]) + if kind == "mac-finder" || intTargetField(object["finderId"]) != nil { + guard let finderId = intTargetField(object["finderId"]) else { return 0 } + return setFinderLocation(address, finderId: finderId) + } + + if kind == "mac-file-dialog" { + guard let pidValue = intTargetField(object["pid"]), + let app = NSRunningApplication(processIdentifier: pid_t(pidValue)), + isFocusedFileDialog(of: app.processIdentifier) else { + return 0 + } + return setFileDialogLocation(app: app, address: address) + } + + return 0 +} + /// 根据 bundleId 或 pid 字符串查找正在运行的应用。 /// - Parameter target: bundleId(如 com.apple.TextEdit)或十进制 pid 字符串 /// - Returns: 匹配到的 NSRunningApplication;未找到返回 nil @@ -1746,8 +1928,7 @@ private func isFocusedFileDialog(of pid: pid_t) -> Bool { /// 设置 Finder 前台窗口位置。 /// - Parameter path: POSIX 路径或 file:// URL /// - Returns: 1 成功,0 失败 -private func setFinderLocation(_ path: String) -> Int32 { - // 转换为 POSIX 路径(如果是 file:// URL) +private func setFinderLocation(_ path: String, finderId: Int? = nil) -> Int32 { var targetPath = path if path.hasPrefix("file://") { if let url = URL(string: path) { @@ -1756,10 +1937,16 @@ private func setFinderLocation(_ path: String) -> Int32 { } let escapedPath = escapeAppleScriptString(targetPath) + let targetLine: String + if let finderId = finderId { + targetLine = "set target of Finder window id \(finderId) to (POSIX file \"\(escapedPath)\")" + } else { + targetLine = "set target of front Finder window to (POSIX file \"\(escapedPath)\")" + } let script = """ tell application "Finder" if (count of Finder windows) > 0 then - set target of front Finder window to (POSIX file "\(escapedPath)") + \(targetLine) return true else return false diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index 9c8abf4..36ceb68 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -106,90 +106,25 @@ int parseJsonNumber(const std::string &jsonString, const std::string &key) { return 0; } +Napi::Value ParseJsonValue(Napi::Env env, const std::string &jsonString) { + Napi::Object json = env.Global().Get("JSON").As(); + Napi::Function parse = json.Get("parse").As(); + return parse.Call(json, {Napi::String::New(env, jsonString)}); +} + // 在主线程调用 JS 回调(窗口监控,带JSON参数) void CallWindowJs(napi_env env, napi_value js_callback, void *context, void *data) { if (env != nullptr && js_callback != nullptr && data != nullptr) { char *jsonStr = static_cast(data); - - // 解析JSON字符串为对象 Napi::Env napiEnv(env); - Napi::Object result = Napi::Object::New(napiEnv); - std::string jsonString(jsonStr); free(jsonStr); - // 查找 "appName":"xxx" - size_t appNamePos = jsonString.find("\"appName\":\""); - if (appNamePos != std::string::npos) { - size_t start = appNamePos + 11; - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string appName = jsonString.substr(start, end - start); - result.Set("appName", Napi::String::New(napiEnv, appName)); - } - } - - // 查找 "bundleId":"xxx" - size_t bundleIdPos = jsonString.find("\"bundleId\":\""); - if (bundleIdPos != std::string::npos) { - size_t start = bundleIdPos + 12; - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string bundleId = jsonString.substr(start, end - start); - result.Set("bundleId", Napi::String::New(napiEnv, bundleId)); - } - } - - // 查找 "title":"xxx" - size_t titlePos = jsonString.find("\"title\":\""); - if (titlePos != std::string::npos) { - size_t start = titlePos + 9; - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string title = jsonString.substr(start, end - start); - result.Set("title", Napi::String::New(napiEnv, title)); - } - } - - // 查找 "app":"xxx" - size_t appPos = jsonString.find("\"app\":\""); - if (appPos != std::string::npos) { - size_t start = appPos + 7; - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string app = jsonString.substr(start, end - start); - result.Set("app", Napi::String::New(napiEnv, app)); - } - } - - // 解析数字字段 - result.Set("x", - Napi::Number::New(napiEnv, parseJsonNumber(jsonString, "x"))); - result.Set("y", - Napi::Number::New(napiEnv, parseJsonNumber(jsonString, "y"))); - result.Set("width", Napi::Number::New( - napiEnv, parseJsonNumber(jsonString, "width"))); - result.Set("height", Napi::Number::New( - napiEnv, parseJsonNumber(jsonString, "height"))); - result.Set("pid", - Napi::Number::New(napiEnv, parseJsonNumber(jsonString, "pid"))); - - // 查找 "appPath":"xxx" - size_t appPathPos = jsonString.find("\"appPath\":\""); - if (appPathPos != std::string::npos) { - size_t start = appPathPos + 11; - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string appPath = jsonString.substr(start, end - start); - result.Set("appPath", Napi::String::New(napiEnv, appPath)); - } - } - - // 调用回调 napi_value global; napi_get_global(env, &global); - napi_value resultValue = result; + Napi::Value parsed = ParseJsonValue(napiEnv, jsonString); + napi_value resultValue = parsed; napi_call_function(env, global, js_callback, 1, &resultValue, nullptr); } } @@ -651,89 +586,9 @@ Napi::Value GetActiveWindow(const Napi::CallbackInfo &info) { return env.Null(); } - // 解析 JSON 字符串 std::string jsonString(jsonStr); free(jsonStr); - - // 手动解析简单的 JSON(避免引入额外依赖) - Napi::Object result = Napi::Object::New(env); - - // 查找 "appName":"xxx" - size_t appNamePos = jsonString.find("\"appName\":\""); - if (appNamePos != std::string::npos) { - size_t start = appNamePos + 11; // 跳过 "appName":" - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string appName = jsonString.substr(start, end - start); - result.Set("appName", Napi::String::New(env, appName)); - } - } - - // 查找 "bundleId":"xxx" - size_t bundleIdPos = jsonString.find("\"bundleId\":\""); - if (bundleIdPos != std::string::npos) { - size_t start = bundleIdPos + 12; // 跳过 "bundleId":" - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string bundleId = jsonString.substr(start, end - start); - result.Set("bundleId", Napi::String::New(env, bundleId)); - } - } - - // 查找 "title":"xxx" - size_t titlePos = jsonString.find("\"title\":\""); - if (titlePos != std::string::npos) { - size_t start = titlePos + 9; // 跳过 "title":" - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string title = jsonString.substr(start, end - start); - result.Set("title", Napi::String::New(env, title)); - } - } - - // 查找 "app":"xxx" - size_t appPos = jsonString.find("\"app\":\""); - if (appPos != std::string::npos) { - size_t start = appPos + 7; // 跳过 "app":" - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string app = jsonString.substr(start, end - start); - result.Set("app", Napi::String::New(env, app)); - } - } - - // 解析数字字段 - result.Set("x", Napi::Number::New(env, parseJsonNumber(jsonString, "x"))); - result.Set("y", Napi::Number::New(env, parseJsonNumber(jsonString, "y"))); - result.Set("width", - Napi::Number::New(env, parseJsonNumber(jsonString, "width"))); - result.Set("height", - Napi::Number::New(env, parseJsonNumber(jsonString, "height"))); - result.Set("pid", Napi::Number::New(env, parseJsonNumber(jsonString, "pid"))); - - // 查找 "appPath":"xxx" - size_t appPathPos = jsonString.find("\"appPath\":\""); - if (appPathPos != std::string::npos) { - size_t start = appPathPos + 11; // 跳过 "appPath":" - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string appPath = jsonString.substr(start, end - start); - result.Set("appPath", Napi::String::New(env, appPath)); - } - } - - // 检查是否有错误 - size_t errorPos = jsonString.find("\"error\":\""); - if (errorPos != std::string::npos) { - size_t start = errorPos + 9; - size_t end = jsonString.find("\"", start); - if (end != std::string::npos) { - std::string error = jsonString.substr(start, end - start); - result.Set("error", Napi::String::New(env, error)); - } - } - - return result; + return ParseJsonValue(env, jsonString); } // 激活指定窗口 @@ -1336,12 +1191,7 @@ Napi::Value StopColorPicker(const Napi::CallbackInfo &info) { } /** - * 获取所有打开的 Finder 窗口的 file URL 列表。 - * - * Swift 动态库返回 JSON 字符串数组;这里解析成 JS Array。getAllFinderWindows - * 是可选符号,避免旧版 dylib 缺少该符号时阻断其它 macOS 原生能力加载。 - * - * @returns Array - file:/// URL 列表;无窗口或调用失败时返回空数组,符号缺失时抛出错误 + * 获取所有打开的 Finder 窗口的结构化信息。 */ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -1356,84 +1206,14 @@ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo &info) { return env.Undefined(); } - // 调用 Swift 函数获取 JSON 字符串 char *jsonResult = getAllFinderWindowsFunc(); if (jsonResult == nullptr) { - // 返回空数组 return Napi::Array::New(env, 0); } std::string jsonStr(jsonResult); free(jsonResult); - - // 解析 JSON 字符串数组 ["path1", "path2", ...] - Napi::Array result = Napi::Array::New(env); - - // 简单的 JSON 数组解析(假设格式良好) - if (jsonStr.length() >= 2 && jsonStr[0] == '[' && jsonStr[jsonStr.length() - 1] == ']') { - std::string content = jsonStr.substr(1, jsonStr.length() - 2); - - if (!content.empty()) { - std::vector paths; - size_t start = 0; - bool inQuote = false; - bool escape = false; - - for (size_t i = 0; i < content.length(); i++) { - char ch = content[i]; - - if (escape) { - escape = false; - continue; - } - - if (ch == '\\') { - escape = true; - continue; - } - - if (ch == '"') { - inQuote = !inQuote; - if (!inQuote) { - // 结束引号 - std::string path = content.substr(start + 1, i - start - 1); - // 反转义 - std::string unescaped; - for (size_t j = 0; j < path.length(); j++) { - if (path[j] == '\\' && j + 1 < path.length()) { - char next = path[j + 1]; - if (next == '"' || next == '\\' || next == 'n' || next == 'r' || next == 't') { - if (next == 'n') unescaped += '\n'; - else if (next == 'r') unescaped += '\r'; - else if (next == 't') unescaped += '\t'; - else unescaped += next; - j++; - continue; - } - } - unescaped += path[j]; - } - paths.push_back(unescaped); - } else { - // 开始引号 - start = i; - } - } else if (ch == ',' && !inQuote) { - // 逗号分隔符,跳过空白 - while (i + 1 < content.length() && (content[i + 1] == ' ' || content[i + 1] == '\t')) { - i++; - } - } - } - - // 转换为 Napi::Array - for (size_t i = 0; i < paths.size(); i++) { - result[i] = Napi::String::New(env, paths[i]); - } - } - } - - return result; + return ParseJsonValue(env, jsonStr); } /** @@ -1457,8 +1237,8 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo &info) { return Napi::Boolean::New(env, false); } - if (info.Length() < 2 || (!info[0].IsString() && !info[0].IsNumber()) || !info[1].IsString()) { - Napi::TypeError::New(env, "target (bundleId or pid) and address (string) are required") + if (info.Length() < 2 || (!info[0].IsString() && !info[0].IsNumber() && !info[0].IsObject()) || !info[1].IsString()) { + Napi::TypeError::New(env, "target (object, bundleId or pid) and address (string) are required") .ThrowAsJavaScriptException(); return Napi::Boolean::New(env, false); } @@ -1466,8 +1246,12 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo &info) { std::string target; if (info[0].IsString()) { target = info[0].As().Utf8Value(); - } else { + } else if (info[0].IsNumber()) { target = std::to_string(info[0].As().Int64Value()); + } else { + Napi::Object json = env.Global().Get("JSON").As(); + Napi::Function stringify = json.Get("stringify").As(); + target = stringify.Call(json, {info[0]}).As().Utf8Value(); } std::string address = info[1].As().Utf8Value(); diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index d69b61f..c04af2a 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -5057,31 +5057,42 @@ Napi::Value GetExplorerFolderPath(const Napi::CallbackInfo& info) { return Napi::String::New(env, result); } +std::wstring Utf8ToWideString(const std::string& input); +std::string WideToUtf8String(const std::wstring& input); + +static std::string FileUrlToPath(const std::wstring& fileUrl) { + DWORD pathLength = 32768; + std::wstring path(pathLength, L'\0'); + HRESULT hr = PathCreateFromUrlW(fileUrl.c_str(), &path[0], &pathLength, 0); + if (FAILED(hr) || pathLength == 0) { + return std::string(); + } + path.resize(pathLength); + return WideToUtf8String(path); +} + /** - * 获取所有打开的文件资源管理器窗口的 file URL 列表 + * 获取所有打开的文件资源管理器窗口的结构化信息。 * * 工作原理: * 1. 初始化当前线程的 COM STA 环境,枚举所有 Shell 窗口(IShellWindows) - * 2. 读取每个窗口的 LocationURL,并仅保留 file:/// URL,避免混入浏览器等非 Explorer URL - * 3. 对本函数成功初始化的 COM 调用执行配对 CoUninitialize,避免初始化计数泄露 + * 2. 读取每个窗口的 HWND 与 LocationURL,并仅保留 file:// URL + * 3. 补充顶级窗口标题和类名,调用方可用 hwnd 精确定位目标窗口 * - * @returns Array - file:/// 格式的路径字符串数组;COM 初始化失败或无窗口时返回空数组 + * @returns Array - Explorer 窗口信息数组;COM 初始化失败或无窗口时返回空数组 */ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); - // 初始化 COM(STA 模式,与 Electron 主线程兼容) HRESULT hrInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - // S_OK 和 S_FALSE 都表示本次 CoInitializeEx 成功,需要配对 CoUninitialize bool needUninit = (hrInit == S_OK || hrInit == S_FALSE); - std::vector results; + std::vector results; if (FAILED(hrInit)) { return Napi::Array::New(env, 0); } - // 创建 ShellWindows COM 对象,枚举所有打开的 Explorer 窗口 IShellWindows* shellWindows = nullptr; HRESULT hr = CoCreateInstance( CLSID_ShellWindows, nullptr, CLSCTX_ALL, @@ -5092,40 +5103,52 @@ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo& info) { long count = 0; shellWindows->get_Count(&count); - // 遍历所有 Shell 窗口 for (long i = 0; i < count; i++) { VARIANT idx; + VariantInit(&idx); idx.vt = VT_I4; idx.lVal = i; IDispatch* disp = nullptr; hr = shellWindows->Item(idx, &disp); + VariantClear(&idx); if (FAILED(hr) || !disp) continue; - // 查询 IWebBrowserApp 接口(Explorer 窗口实现该接口) IWebBrowserApp* browser = nullptr; hr = disp->QueryInterface(IID_IWebBrowserApp, (void**)&browser); disp->Release(); if (FAILED(hr) || !browser) continue; - // 获取当前目录的 URL + SHANDLE_PTR browserHwndPtr = 0; + browser->get_HWND(&browserHwndPtr); + HWND browserHwnd = reinterpret_cast(browserHwndPtr); + BSTR url = nullptr; hr = browser->get_LocationURL(&url); if (SUCCEEDED(hr) && url) { - // 将 BSTR (UTF-16) 转换为 UTF-8 字符串 - int len = SysStringLen(url); - if (len > 0) { - int size = WideCharToMultiByte(CP_UTF8, 0, url, len, NULL, 0, NULL, NULL); - if (size > 0) { - std::string urlStr; - urlStr.resize(size); - WideCharToMultiByte(CP_UTF8, 0, url, len, &urlStr[0], size, NULL, NULL); - // IShellWindows 可能包含非文件窗口,仅保留 Explorer/Finder 语义一致的 file URL - if (urlStr.rfind("file:///", 0) == 0) { - results.push_back(urlStr); - } + std::wstring urlWide(url, SysStringLen(url)); + std::string urlStr = WideToUtf8String(urlWide); + if (urlStr.rfind("file://", 0) == 0 && browserHwnd && IsWindow(browserHwnd)) { + WCHAR title[512] = {0}; + WCHAR className[256] = {0}; + GetWindowTextW(browserHwnd, title, 512); + GetClassNameW(browserHwnd, className, 256); + + Napi::Object item = Napi::Object::New(env); + item.Set("platform", Napi::String::New(env, "win32")); + item.Set("kind", Napi::String::New(env, "windows-explorer")); + item.Set("preciseTarget", Napi::Boolean::New(env, true)); + item.Set("hwnd", Napi::Number::New(env, reinterpret_cast(browserHwnd))); + item.Set("url", Napi::String::New(env, urlStr)); + const std::string pathStr = FileUrlToPath(urlWide); + if (!pathStr.empty()) { + item.Set("path", Napi::String::New(env, pathStr)); } + item.Set("title", Napi::String::New(env, WideToUtf8String(title))); + item.Set("className", Napi::String::New(env, WideToUtf8String(className))); + item.Set("app", Napi::String::New(env, "explorer.exe")); + results.push_back(item); } SysFreeString(url); } @@ -5136,21 +5159,17 @@ Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo& info) { shellWindows->Release(); } - // 仅在本次调用初始化 COM 时才反初始化 if (needUninit) { CoUninitialize(); } - // 返回字符串数组 Napi::Array resultArray = Napi::Array::New(env, results.size()); for (size_t i = 0; i < results.size(); i++) { - resultArray[i] = Napi::String::New(env, results[i]); + resultArray[i] = results[i]; } return resultArray; } -std::wstring Utf8ToWideString(const std::string& input); - struct ChildClassSearchContext { const wchar_t** classNames; size_t classCount; @@ -5232,6 +5251,18 @@ static bool IsFileLocationWindow(HWND hwnd) { return false; } +Napi::Value IsFileLocationWindowBinding(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) { + Napi::TypeError::New(env, "hwnd (number) is required").ThrowAsJavaScriptException(); + return Napi::Boolean::New(env, false); + } + + uint64_t hwndValue = static_cast(info[0].As().Int64Value()); + HWND hwnd = reinterpret_cast(hwndValue); + return Napi::Boolean::New(env, IsFileLocationWindow(hwnd)); +} + /** * 将目标窗口切到前台并获得键盘输入焦点。 * @@ -5864,6 +5895,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("getAllExplorerWindows", Napi::Function::New(env, GetAllExplorerWindows)); // 设置 Explorer 或文件选择对话框地址栏 exports.Set("setAddressBar", Napi::Function::New(env, SetAddressBar)); + // 判断窗口是否是可安全修改地址栏的文件定位窗口 + exports.Set("isFileLocationWindow", Napi::Function::New(env, IsFileLocationWindowBinding)); // 读取指定浏览器窗口的当前 URL exports.Set("readBrowserWindowUrl", Napi::Function::New(env, ReadBrowserWindowUrl)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); From fc515aa22c9be11e7ff5dd24d24a61318e3c2560 Mon Sep 17 00:00:00 2001 From: patrick <980141374@qq.com> Date: Sat, 6 Jun 2026 21:08:41 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20mac=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=AF=B9=E8=AF=9D=E6=A1=86=E7=BC=96=E8=AF=91=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ZToolsNative.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ZToolsNative.swift b/src/ZToolsNative.swift index 5bb2b8a..205a063 100644 --- a/src/ZToolsNative.swift +++ b/src/ZToolsNative.swift @@ -423,7 +423,7 @@ private func getFrontmostAppUsingCG() -> WindowMetadata? { } kind = "mac-finder" preciseTarget = finderId != nil - } else if axRole == kAXDialogRole as String || axSubrole == kAXSystemDialogSubrole as String || axSubrole == "AXSheet" { + } else if axSubrole == kAXDialogSubrole as String || axSubrole == kAXSystemDialogSubrole as String || axRole == "AXSheet" || axSubrole == "AXSheet" { kind = "mac-file-dialog" preciseTarget = windowId > 0 } @@ -1914,7 +1914,7 @@ private func isFocusedFileDialog(of pid: pid_t) -> Bool { let role = roleValue as? String ?? "" let subrole = subroleValue as? String ?? "" - if role == kAXDialogRole as String || subrole == kAXSystemDialogSubrole as String { + if subrole == kAXDialogSubrole as String || subrole == kAXSystemDialogSubrole as String || role == "AXSheet" || subrole == "AXSheet" { return true }