diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 4d101027..dc1b072c 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -130,6 +130,7 @@ BF0C894199BB262BDB2D8D37 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8221101CD179DB1121D5 /* InfoPlist.strings */; }; BF0C894325A54CF0B6B53464 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8B8B31D2F421AC258351 /* Localizable.strings */; }; BF0C896B5D08B7DA8F2024AC /* KeyRepeatTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8D606B53D315F2D9927B /* KeyRepeatTimer.swift */; }; + BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */; }; BF0C898511686611E4D7D81E /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8B2C057099EDABE46779 /* Button.swift */; }; BF0C89B4B17D7843C32A102E /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8353097840D7DB1E0E2E /* Markdown.swift */; }; BF0C89C49CFBAAB4646BB8CB /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82A8848224E76B880A83 /* InfoPlist.strings */; }; @@ -451,6 +452,7 @@ BF0C8C5210ACA9FBFAE9C631 /* preferences-general.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-general.jpg"; sourceTree = ""; }; BF0C8C5B74E5DA2CC31E52F4 /* extract_l10n_strings.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = extract_l10n_strings.sh; sourceTree = ""; }; BF0C8C5C0FF5D567BF190DE5 /* ApplicationServices.HIServices.framework.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationServices.HIServices.framework.swift; sourceTree = ""; }; + BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CliEvents.swift; sourceTree = ""; }; BF0C8C6995992F837A9A83A6 /* ca */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ca; path = InfoPlist.strings; sourceTree = ""; }; BF0C8C77273E00B019725640 /* add_missing_terms_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = add_missing_terms_on_poeditor.ts; sourceTree = ""; }; BF0C8CA36F8BD99BF38A45D0 /* bn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = bn; path = InfoPlist.strings; sourceTree = ""; }; @@ -1777,6 +1779,7 @@ BF0C869E544F968051715452 /* SystemAppearanceEvents.swift */, BF0C8B5D1A9658E64F8ECB56 /* SystemScrollerStyleEvents.swift */, BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */, + BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */, ); path = events; sourceTree = ""; @@ -2325,6 +2328,7 @@ BF0C8E366BC25171C0A3881C /* ApplicationServices.HIServices.framework.swift in Sources */, BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */, BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */, + BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index fa7fca6a..c33227b3 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -314,3 +314,14 @@ extension NSPoint { return NSPoint(x: lhs.x / Double(rhs), y: lhs.y / Double(rhs)) } } + +extension Optional { + enum Error: Swift.Error { + case unexpectedNil + } + + // useful call multiple statements that could fail, and have a unique do-catch block to handle failures + func unwrapOrThrow() throws -> Wrapped { + if let self { return self } else { throw Error.unexpectedNil } + } +} diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index e91e7aea..dfd92e25 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -11,6 +11,7 @@ class BackgroundWork { static var systemPermissionsThread: BackgroundThreadWithRunLoop! static var repeatingKeyThread: BackgroundThreadWithRunLoop! static var missionControlThread: BackgroundThreadWithRunLoop! + static var cliEventsThread: BackgroundThreadWithRunLoop! static let screenshotsDispatchGroup = DispatchGroup() // swift static variables are lazy; we artificially force the threads to init @@ -29,6 +30,8 @@ class BackgroundWork { repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive) // we main Mission Control state on a background thread. We protect reads from main-thread with an NSLock missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive) + // we listen to CLI commands (CFMessagePort events) + cliEventsThread = BackgroundThreadWithRunLoop("cliEventsThread", .userInteractive) } static func startCrashReportsQueue() { diff --git a/src/logic/events/CliEvents.swift b/src/logic/events/CliEvents.swift new file mode 100644 index 00000000..c38a739a --- /dev/null +++ b/src/logic/events/CliEvents.swift @@ -0,0 +1,104 @@ +class CliEvents { + static let portName = "com.lwouis.alt-tab-macos.cli" + + static func observe() { + var context = CFMessagePortContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) + if let messagePort = CFMessagePortCreateLocal(nil, portName as CFString, handleEvent, &context, nil), + let source = CFMessagePortCreateRunLoopSource(nil, messagePort, 0) { + CFRunLoopAddSource(BackgroundWork.cliEventsThread.runLoop, source, .commonModes) + } else { + Logger.error("Can't listen on message port. Is another AltTab already running?") + // TODO: should we quit or restart here? + // It's complex since AltTab can be restarted sometimes, + // and the new instance may coexit with the old for some duration + // There is also the case of multiple instances at login + } + } +} + +fileprivate func handleEvent(_: CFMessagePort?, _: Int32, _ data: CFData?, _: UnsafeMutableRawPointer?) -> Unmanaged? { + Logger.debug() + if let data = data, + let message = String(data: data as Data, encoding: .utf8) { + Logger.info(message) + let output = CliServer.executeCommandAndSendReponse(message) + if let responseData = try? CliServer.jsonEncoder.encode(output) as CFData { + return Unmanaged.passRetained(responseData) + } + } + Logger.error("Failed to decode message") + return nil +} + +class CliServer { + static let jsonEncoder = JSONEncoder() + static let error = "error" + static let noOutput = "noOutput" + + static func executeCommandAndSendReponse(_ rawValue: String) -> Codable { + var output: Codable = "" + DispatchQueue.main.sync { + if rawValue == "--list" { + output = JsonOutput(windows: Windows.list + .filter { !$0.isWindowlessApp } + .map { JsonWindow(id: $0.cgWindowId, title: $0.title) } + ) + return + } + if rawValue.hasPrefix("--focus=") { + if let id = CGWindowID(rawValue.dropFirst("--focus=".count)), + let window = (Windows.list.first { $0.cgWindowId == id }) { + window.focus() + output = noOutput + return + } + } + output = error + } + return output + } +} + +struct JsonOutput: Codable { + var windows: [JsonWindow] +} + +struct JsonWindow: Codable { + var id: CGWindowID? + var title: String +} + +class CliClient { + static func detectCommand() -> String? { + let args = CommandLine.arguments + if args.count == 2 && !args[1].starts(with: "--logs=") { + if args[1] == "--list" || args[1].hasPrefix("--focus=") { + return args[1] + } + } + return nil + } + + static func sendCommandAndProcessResponse(_ command: String) { + do { + let serverPortClient = try CFMessagePortCreateRemote(nil, CliEvents.portName as CFString).unwrapOrThrow() + let data = try command.data(using: .utf8).unwrapOrThrow() + var returnData: Unmanaged? + let _ = CFMessagePortSendRequest(serverPortClient, 0, data as CFData, 2, 2, CFRunLoopMode.defaultMode.rawValue, &returnData) + let responseData = try returnData.unwrapOrThrow().takeRetainedValue() + if let response = String(data: responseData as Data, encoding: .utf8) { + if response != "\"\(CliServer.error)\"" { + if response != "\"\(CliServer.noOutput)\"" { + print(response) + } + exit(0) + } + } + print("Couldn't execute command. Is it correct?") + exit(1) + } catch { + print("AltTab.app needs to be running for CLI commands to work") + exit(1) + } + } +} diff --git a/src/main.swift b/src/main.swift index b5da40b1..c162203b 100644 --- a/src/main.swift +++ b/src/main.swift @@ -1,6 +1,10 @@ import AppKit import Darwin +if let command = CliClient.detectCommand() { + CliClient.sendCommandAndProcessResponse(command) +} + // - if the app is quit/force-quit from Activity Monitor, it will receive SIGTERM and applicationWillTerminate won't be called // - if the app crashes in swift code (e.g. unexpected nil object), SIGTRAP is sent // we intercept these signals, and do an emergency exit diff --git a/src/ui/App.swift b/src/ui/App.swift index 3840b274..d5fe15cd 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -317,7 +317,9 @@ extension App: NSApplicationDelegate { KeyboardEvents.addEventHandlers() MouseEvents.observe() TrackpadEvents.observe() + CliEvents.observe() self.preloadWindows() + Logger.info("AltTab ready") #if DEBUG // self.showPreferencesWindow() #endif