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 8d0d6b9..3122271 100644 --- a/index.js +++ b/index.js @@ -276,6 +276,64 @@ class WindowManager { } return addon.simulateKeyboardTap(key, ...modifiers); } + /** + * 获取所有打开的文件资源管理器/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 或窗口对象 + * @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; + } + } + + 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 e398140..205a063 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 axSubrole == kAXDialogSubrole as String || axSubrole == kAXSystemDialogSubrole as String || axRole == "AXSheet" || 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 { @@ -1608,6 +1740,311 @@ public func simulateMouseRightClick(_ x: Double, _ y: Double) -> Int32 { return 1 } +// MARK: - Finder Windows + +/// 获取所有打开的 Finder 窗口的结构化信息 +@_cdecl("getAllFinderWindows") +public func getAllFinderWindows() -> UnsafeMutablePointer? { + let script = """ + tell application "Finder" + set windowItems to {} + repeat with w in (get every Finder window) + try + set targetPath to POSIX path of (target of w as alias) + 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 windowItems + 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("[]") + } + + var jsonItems: [String] = [] + if output.numberOfItems > 0 { + for i in 1...output.numberOfItems { + 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) + } + } + + return strdup("[\(jsonItems.joined(separator: ","))]") +} + +// MARK: - Set Address Bar + +/// 设置 Finder 或文件选择对话框的地址栏位置 +/// - Parameters: +/// - target: Finder 的 bundleId,或文件选择对话框所属应用的 bundleId/pid 字符串 +/// - address: 要跳转的文件路径或 file:// URL +/// - Returns: 1 成功,0 表示目标不是 Finder/文件选择对话框或系统权限不足 +@_cdecl("setAddressBar") +public func setAddressBar(_ target: UnsafePointer?, _ address: UnsafePointer?) -> Int32 { + guard let target = target, let address = address else { + return 0 + } + + let targetString = String(cString: target) + let addressString = String(cString: address) + + guard !targetString.isEmpty && !addressString.isEmpty else { + return 0 + } + + if targetString.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { + return setAddressBarWithStructuredTarget(targetString, addressString) + } + + if targetString == "com.apple.finder" { + return setFinderLocation(addressString) + } + + 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 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 +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 +} + +/// 判断指定应用当前焦点窗口是否像系统打开/保存文件对话框。 +/// +/// 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 subrole == kAXDialogSubrole as String || subrole == kAXSystemDialogSubrole as String || role == "AXSheet" || subrole == "AXSheet" { + 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, finderId: Int? = nil) -> Int32 { + var targetPath = path + if path.hasPrefix("file://") { + if let url = URL(string: path) { + targetPath = url.path + } + } + + 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 + \(targetLine) + 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 + } + + return (output.booleanValue == true) ? 1 : 0 +} + +/// 设置当前文件选择对话框的位置。 +/// +/// 通过 Cmd+Shift+G 打开系统“前往文件夹”输入框,输入路径后回车。该路径只在 +/// isFocusedFileDialog 通过后发送,且需要辅助功能权限允许本进程模拟键盘事件。 +private func setFileDialogLocation(app: NSRunningApplication, address: String) -> Int32 { + let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] + guard AXIsProcessTrustedWithOptions(options) else { + print("Error: Accessibility permission not granted") + return 0 + } + + var targetPath = address + if address.hasPrefix("file://"), let url = URL(string: address) { + targetPath = url.path + } + + guard !targetPath.isEmpty else { return 0 } + + _ = app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + usleep(120_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 +} + +/// 发送一个键盘按键组合。 +/// - 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 +} + +/// 通过 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 + } + + let chars = Array(text.utf16) + downEvent.keyboardSetUnicodeString(stringLength: chars.count, unicodeString: chars) + downEvent.post(tap: .cghidEventTap) + usleep(10_000) + upEvent.post(tap: .cghidEventTap) + return true +} + +/// 转义 AppleScript 字符串字面量中的特殊字符。 +/// - Parameter string: 原始路径或文本 +/// - Returns: 可安全放入双引号 AppleScript 字符串中的内容 +private func escapeAppleScriptString(_ string: String) -> String { + return string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") +} + // MARK: - Helper Functions (Utilities) /// 辅助函数:转义 JSON 字符串 diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index cf8d3fc..36ceb68 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -31,6 +31,8 @@ typedef void (*ColorPickerCB)(const char *); // 取色器 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; @@ -59,6 +61,8 @@ static napi_threadsafe_function colorPickerTsfn = nullptr; 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 回调 @@ -102,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); } } @@ -294,6 +233,10 @@ bool LoadSwiftLibrary(Napi::Env env) { stopColorPickerFunc = (StopColorPickerFunc)dlsym(swiftLibHandle, "stopColorPicker"); fetchFileIconFunc = (FetchFileIconFunc)dlsym(swiftLibHandle, "fetchFileIcon"); + getAllFinderWindowsFunc = + (GetAllFinderWindowsFunc)dlsym(swiftLibHandle, "getAllFinderWindows"); + setAddressBarFunc = + (SetAddressBarFunc)dlsym(swiftLibHandle, "setAddressBar"); if (!startMonitorFunc || !stopMonitorFunc || !startWindowMonitorFunc || !stopWindowMonitorFunc || !getActiveWindowFunc || !activateWindowFunc || @@ -643,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); } // 激活指定窗口 @@ -1327,6 +1190,79 @@ Napi::Value StopColorPicker(const Napi::CallbackInfo &info) { return env.Undefined(); } +/** + * 获取所有打开的 Finder 窗口的结构化信息。 + */ +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(); + } + + char *jsonResult = getAllFinderWindowsFunc(); + if (jsonResult == nullptr) { + return Napi::Array::New(env, 0); + } + + std::string jsonStr(jsonResult); + free(jsonResult); + return ParseJsonValue(env, jsonStr); +} + +/** + * 设置 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[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); + } + + std::string target; + if (info[0].IsString()) { + target = info[0].As().Utf8Value(); + } 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(); + 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)); @@ -1358,6 +1294,8 @@ 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("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 85bba23..c04af2a 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,440 @@ 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); +} + +/** + * 获取所有打开的文件资源管理器窗口的结构化信息。 + * + * 工作原理: + * 1. 初始化当前线程的 COM STA 环境,枚举所有 Shell 窗口(IShellWindows) + * 2. 读取每个窗口的 HWND 与 LocationURL,并仅保留 file:// URL + * 3. 补充顶级窗口标题和类名,调用方可用 hwnd 精确定位目标窗口 + * + * @returns Array - Explorer 窗口信息数组;COM 初始化失败或无窗口时返回空数组 + */ +Napi::Value GetAllExplorerWindows(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + HRESULT hrInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + bool needUninit = (hrInit == S_OK || hrInit == S_FALSE); + + std::vector results; + + if (FAILED(hrInit)) { + return Napi::Array::New(env, 0); + } + + 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); + + 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* browser = nullptr; + hr = disp->QueryInterface(IID_IWebBrowserApp, (void**)&browser); + disp->Release(); + + if (FAILED(hr) || !browser) continue; + + 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) { + 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); + } + + browser->Release(); + } + + shellWindows->Release(); + } + + if (needUninit) { + CoUninitialize(); + } + + Napi::Array resultArray = Napi::Array::New(env, results.size()); + for (size_t i = 0; i < results.size(); i++) { + resultArray[i] = results[i]; + } + return resultArray; +} + +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; +} + +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)); +} + +/** + * 将目标窗口切到前台并获得键盘输入焦点。 + * + * 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; +} + +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 或文件对话框的地址栏。 + * + * 该快捷键是 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); + } + + 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); + } + + 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) { @@ -5394,7 +5831,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 +5891,12 @@ 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)); + // 设置 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)); 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); +} 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); +});