diff --git a/src/VoiceCommandExecutor.swift b/src/VoiceCommandExecutor.swift index 4697fbe..66ca830 100644 --- a/src/VoiceCommandExecutor.swift +++ b/src/VoiceCommandExecutor.swift @@ -1022,10 +1022,17 @@ public final class VoiceCommandExecutor { let ws = NSWorkspace.shared if let running = ws.runningApplications.first(where: { $0.bundleURL == url }) { running.activate(options: [.activateIgnoringOtherApps]) + ensureWindowFullScreen(app: running) } else { let conf = NSWorkspace.OpenConfiguration() conf.activates = true - ws.openApplication(at: url, configuration: conf, completionHandler: nil) + ws.openApplication(at: url, configuration: conf) { app, _ in + if let launched = app { + DispatchQueue.main.async { + self.pollAndMakeFullScreen(app: launched, targetBid: launched.bundleIdentifier, appName: name) + } + } + } } HistoryManager.shared.addEntry("⚡️ [App] Open \(name)") } @@ -1084,10 +1091,10 @@ public final class VoiceCommandExecutor { } } - private func launchOrActivateApp(named appName: String) { + private func launchOrActivateApp(named appName: String, completion: (() -> Void)? = nil) { let ws = NSWorkspace.shared - // 1. Precise bundle identifier map (prevents "Antigravity" substring matching "Antigravity IDE") + // 1. Precise bundle identifier map let bundleIDMap: [String: String] = [ "Antigravity": "com.google.antigravity", "Antigravity IDE": "com.google.antigravity-ide", @@ -1097,40 +1104,135 @@ public final class VoiceCommandExecutor { "Safari": "com.apple.Safari" ] - if let bid = bundleIDMap[appName] { - // Check currently running app by exact bundleIdentifier - if let running = ws.runningApplications.first(where: { $0.bundleIdentifier == bid }) { + let targetBid = bundleIDMap[appName] + + // Check if currently running + let runningApp = ws.runningApplications.first(where: { + if let bid = targetBid, $0.bundleIdentifier == bid { return true } + return $0.localizedName?.caseInsensitiveCompare(appName) == .orderedSame + }) + + if let running = runningApp { + running.activate(options: [.activateIgnoringOtherApps]) + ensureWindowFullScreen(app: running) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { running.activate(options: [.activateIgnoringOtherApps]) - return - } - // If not running, launch via exact URL - if let appURL = ws.urlForApplication(withBundleIdentifier: bid) { - let conf = NSWorkspace.OpenConfiguration() - conf.activates = true - ws.openApplication(at: appURL, configuration: conf, completionHandler: nil) - return + completion?() } + return } - // 2. Strict EXACT localized name match (NOT contains!) - if let running = ws.runningApplications.first(where: { - $0.localizedName?.caseInsensitiveCompare(appName) == .orderedSame - }) { - running.activate(options: [.activateIgnoringOtherApps]) + // Cold launch if app was closed + if let bid = targetBid, let appURL = ws.urlForApplication(withBundleIdentifier: bid) { + let conf = NSWorkspace.OpenConfiguration() + conf.activates = true + ws.openApplication(at: appURL, configuration: conf) { app, _ in + DispatchQueue.main.async { + self.pollAndMakeFullScreen(app: app, targetBid: bid, appName: appName, completion: completion) + } + } return } - // 3. Fallback: launch via /usr/bin/open -a with exact app name + // Fallback: launch via /usr/bin/open -a let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/open") task.arguments = ["-a", appName] try? task.run() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.pollAndMakeFullScreen(app: nil, targetBid: targetBid, appName: appName, completion: completion) + } } private func activateApp(named appName: String, completion: @escaping () -> Void) { - launchOrActivateApp(named: appName) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - completion() + launchOrActivateApp(named: appName, completion: completion) + } + + @discardableResult + private func ensureWindowFullScreen(app: NSRunningApplication) -> Bool { + let appElem = AXUIElementCreateApplication(app.processIdentifier) + + var windowObj: CFTypeRef? + let hasWin = (AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success && windowObj != nil) || + (AXUIElementCopyAttributeValue(appElem, kAXMainWindowAttribute as CFString, &windowObj) == .success && windowObj != nil) + + guard hasWin, let winElem = windowObj else { + var winListObj: CFTypeRef? + if AXUIElementCopyAttributeValue(appElem, kAXWindowsAttribute as CFString, &winListObj) == .success, + let winList = winListObj as? [AXUIElement], let firstWin = winList.first { + return applyFullScreen(to: firstWin, app: app) + } + return false + } + + return applyFullScreen(to: winElem as! AXUIElement, app: app) + } + + private func applyFullScreen(to axWin: AXUIElement, app: NSRunningApplication) -> Bool { + // 1. Check if already full screen + var isFullObj: CFTypeRef? + if AXUIElementCopyAttributeValue(axWin, "AXFullScreen" as CFString, &isFullObj) == .success, + let isFull = isFullObj as? Bool, isFull { + return true + } + + // 2. Set native macOS full-screen space attribute + let setRes = AXUIElementSetAttributeValue(axWin, "AXFullScreen" as CFString, kCFBooleanTrue) + if setRes == .success { + return true + } + + // 3. Fallback: Zoom button (kAXFullScreenButton) + var zoomObj: CFTypeRef? + if AXUIElementCopyAttributeValue(axWin, "AXFullScreenButton" as CFString, &zoomObj) == .success, let btn = zoomObj { + AXUIElementPerformAction(btn as! AXUIElement, kAXPressAction as CFString) + return true + } + + // 4. Fallback: Cmd+Ctrl+F shortcut + app.activate(options: [.activateIgnoringOtherApps]) + pressHotkey(keyCode: 3, modifiers: [.maskCommand, .maskControl]) // 'F' + Cmd + Ctrl + return true + } + + private func pollAndMakeFullScreen( + app: NSRunningApplication?, + targetBid: String?, + appName: String, + attempt: Int = 1, + completion: (() -> Void)? = nil + ) { + let ws = NSWorkspace.shared + let target = app ?? ws.runningApplications.first(where: { + if let bid = targetBid, $0.bundleIdentifier == bid { return true } + return $0.localizedName?.caseInsensitiveCompare(appName) == .orderedSame + }) + + if let target = target { + target.activate(options: [.activateIgnoringOtherApps]) + if ensureWindowFullScreen(app: target) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + target.activate(options: [.activateIgnoringOtherApps]) + completion?() + } + return + } + } + + if attempt < 18 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.pollAndMakeFullScreen( + app: target, + targetBid: targetBid, + appName: appName, + attempt: attempt + 1, + completion: completion + ) + } + } else { + target?.activate(options: [.activateIgnoringOtherApps]) + completion?() } }