diff --git a/AeroSpace.xcodeproj/project.pbxproj b/AeroSpace.xcodeproj/project.pbxproj index 2143856f..bf49d3f6 100644 --- a/AeroSpace.xcodeproj/project.pbxproj +++ b/AeroSpace.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 1311398A83B998908773C54D /* FocusCommandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EAADE8D2FB5D05FA5456B0 /* FocusCommandTest.swift */; }; 1C46EBB55D401C0D1AFD50F0 /* CollectionEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CE37C1B8D858C81A396F40 /* CollectionEx.swift */; }; 1FD8762CC7C132D01D4B3090 /* cliUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB6F0170422A7606D89BCF0 /* cliUtil.swift */; }; + 21D0512B48E0E3C28F8CA42A /* parseOnWindowDetected.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9DFF8980BB3F90A3793BE9 /* parseOnWindowDetected.swift */; }; 22175400298B985658E774EE /* ResizeCommandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACD4E9C6C0C08F1B0622C57 /* ResizeCommandTest.swift */; }; 238EF26CAAADD1FE11312D7C /* default-config.toml in Resources */ = {isa = PBXBuildFile; fileRef = 8FE45A887100EB70912B07F0 /* default-config.toml */; }; 2821C1C7AAEF4C290633EA72 /* FullscreenCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECC990B6C2D66D343216A12 /* FullscreenCommand.swift */; }; @@ -115,6 +116,7 @@ 07A342893BEB5525BA1F8B74 /* ResultEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultEx.swift; sourceTree = ""; }; 083785CBAE36EC57F5F51BC8 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 09685297933511208058F7CF /* AeroSpace.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AeroSpace.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0A9DFF8980BB3F90A3793BE9 /* parseOnWindowDetected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = parseOnWindowDetected.swift; sourceTree = ""; }; 0AEE5470AF418906B180A593 /* mouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mouse.swift; sourceTree = ""; }; 0D36D0F8EEB30A7BABC42343 /* LazySequenceProtocolEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazySequenceProtocolEx.swift; sourceTree = ""; }; 0D9301F99737BE4888DBC2A3 /* FocusedWorkspaceSourceOfTruth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedWorkspaceSourceOfTruth.swift; sourceTree = ""; }; @@ -308,6 +310,7 @@ 9752080BBA547C2A0EF076F0 /* Config.swift */, 1C0D40CBD65704BA9595C2FA /* keysMap.swift */, 67DBAF4ECF8A0B931FC34EAD /* parseConfig.swift */, + 0A9DFF8980BB3F90A3793BE9 /* parseOnWindowDetected.swift */, 9164C9401F7DDCACE9278DA4 /* startAtLogin.swift */, ); path = config; @@ -656,6 +659,7 @@ B19980B36D066FD4947D2F92 /* normalizeContainers.swift in Sources */, A5BFF75CF8021A585BC1F9D5 /* parseCommand.swift in Sources */, A0765C31043BCFB0420BF1C9 /* parseConfig.swift in Sources */, + 21D0512B48E0E3C28F8CA42A /* parseOnWindowDetected.swift in Sources */, B3702BB393A9B03CCAE4C60E /* refresh.swift in Sources */, 8086A22EDCDC4C906C337D0B /* resizeWithMouse.swift in Sources */, 43E3628E37D2439B820FFC82 /* server.swift in Sources */, @@ -750,7 +754,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "aerospace-debug"; SDKROOT = macosx; - SWIFT_VERSION = 5.8; + SWIFT_VERSION = 5.9; }; name = Debug; }; @@ -770,7 +774,7 @@ PRODUCT_BUNDLE_IDENTIFIER = bobko.aerospace.cli; PRODUCT_NAME = aerospace; SDKROOT = macosx; - SWIFT_VERSION = 5.8; + SWIFT_VERSION = 5.9; }; name = Release; }; @@ -794,7 +798,7 @@ PRODUCT_NAME = "AeroSpace-Debug"; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = "src/Bridged-Header.h"; - SWIFT_VERSION = 5.8; + SWIFT_VERSION = 5.9; }; name = Debug; }; @@ -865,6 +869,7 @@ "@loader_path/../Frameworks", ); SDKROOT = macosx; + SWIFT_VERSION = 5.9; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AeroSpace-Debug.app/Contents/MacOS/AeroSpace-Debug"; }; name = Release; @@ -881,6 +886,7 @@ "@loader_path/../Frameworks", ); SDKROOT = macosx; + SWIFT_VERSION = 5.9; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AeroSpace-Debug.app/Contents/MacOS/AeroSpace-Debug"; }; name = Debug; @@ -908,7 +914,7 @@ PRODUCT_NAME = AeroSpace; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = "src/Bridged-Header.h"; - SWIFT_VERSION = 5.8; + SWIFT_VERSION = 5.9; }; name = Release; }; diff --git a/config-examples/default-config.toml b/config-examples/default-config.toml index 80c79bea..c57ed9af 100644 --- a/config-examples/default-config.toml +++ b/config-examples/default-config.toml @@ -18,7 +18,7 @@ indent-for-nested-containers-with-the-same-orientation = 30 # Start AeroSpace at login start-at-login = false -# Normalization. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#normalization +# Normalizations. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#normalization enable-normalization-flatten-containers = true enable-normalization-opposite-orientation-for-nested-containers = true @@ -50,7 +50,12 @@ default-root-container-orientation = 'auto' # workspace_name_5 = '^built-in retina display$' # Case insensitive regex match # workspace_name_6 = ['secondary', 'dell'] # You can specify multiple patterns. The first matching pattern will be used -# It's a declaration of 'main' binding mode. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#bindings-modes +# See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#on-window-detected-callback +# [[on-window-detected]] +# appId = 'com.apple.systempreferences' # Application ID match +# run = ['move-node-to-workspace S', 'layout floating'] # The callback itself + +# 'main' binding mode declaration. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#bindings-modes # 'main' binding mode must be always presented [mode.main.binding] @@ -158,20 +163,18 @@ alt-shift-tab = 'move-workspace-to-monitor next' alt-shift-semicolon = 'mode service' alt-shift-slash = 'mode join' -# It's a declaration of binding mode. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#bindings-modes +# 'service' binding mode declaration. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#bindings-modes [mode.service.binding] r = ['flatten-workspace-tree', 'mode main'] # reset layout #s = ['layout sticky tiling', 'mode main'] # sticky is not yet supported https://github.com/nikitabobko/AeroSpace/issues/2 f = ['layout floating tiling', 'mode main'] # Toggle between floating and tiling layout backspace = ['close-all-windows-but-current', 'mode main'] esc = 'mode main' -enter = 'mode main' -# It's a declaration of binding mode. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#bindings-modes +# 'join' binding mode declaration. See: https://github.com/nikitabobko/AeroSpace/blob/main/docs/guide.md#bindings-modes [mode.join.binding] alt-shift-h = ['join-with left', 'mode main'] alt-shift-j = ['join-with down', 'mode main'] alt-shift-k = ['join-with up', 'mode main'] alt-shift-l = ['join-with right', 'mode main'] esc = ['reload-config', 'mode main'] -enter = 'mode main' diff --git a/docs/cli-commands.md b/docs/cli-commands.md index b765da10..b5348673 100644 --- a/docs/cli-commands.md +++ b/docs/cli-commands.md @@ -12,8 +12,6 @@ In addition to [regular commands](./commands.md), the CLI provides commands list list-apps ``` -- Available since: 0.6.0-Beta - Prints the list of ordinary applications that appears in the Dock and may have a user interface. Output format is the table with the following colums: @@ -31,6 +29,9 @@ Output example: The command is useful to inspect list of applications to compose filter for [`on-window-detected`](./guide.md#on-window-detected-callback) +- Available since: 0.6.0-Beta +- The command doesn't have arguments + ## version ``` @@ -39,8 +40,7 @@ version -v ``` -- Available since: 0.4.0-Beta - Prints the version and commit hash to stdout -This command doesn't have any arguments +- Available since: 0.4.0-Beta +- The command doesn't have arguments diff --git a/docs/commands.md b/docs/commands.md index e6ceb141..5de890ce 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -28,7 +28,9 @@ Commands listed in this file can be used in the config and CLI close-all-windows-but-current ``` -On the focused workspace, closes all windows but current. This command doesn't have any arguments. +On the focused workspace, closes all windows but current. + +- The command doesn't have arguments. ## enable @@ -84,7 +86,7 @@ Flattens [the tree](./guide.md#tree) of currently focused workspace. The command is useful when you messed up with your layout, and it's easier to "reset" it and start again. -- This command doesn't have any arguments. +- The command doesn't have arguments. ## focus @@ -115,8 +117,8 @@ Toggles the fullscreen mode for the currently focused window. Switching to a different window within the same workspace while the current focused window is in fullscreen mode results in the fullscreen window exiting fullscreen mode. -- This command doesn't have any arguments. - Available since: 0.3.0-Beta +- The command doesn't have arguments. ## join-with @@ -291,7 +293,7 @@ reload-config Reloads currently active config. -- This command doesn't have any arguments. +- The command doesn't have arguments. ## resize @@ -345,7 +347,7 @@ workspace-back-and-forth Switches between currently active workspace and previously active workspace back and forth. -- This command doesn't have any arguments. +- The command doesn't have arguments. ## workspace diff --git a/docs/guide.md b/docs/guide.md index 97759ebd..c802bf8f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -12,6 +12,7 @@ - [Emulation of virtual workspaces](#emulation-of-virtual-workspaces) - [A note on mission control](#a-note-on-mission-control) - [A note on 'Displays have separate Spaces'](#a-note-on-displays-have-separate-spaces) +- ['on-window-detected' callback](#on-window-detected-callback) - [Multiple monitors](#multiple-monitors) - [Assign workspaces to monitors](#assign-workspaces-to-monitors) @@ -225,6 +226,55 @@ Overview of 'Displays have separate Spaces' | Is it possible for window to span across several monitors? | 😔 No | 😊 Yes | | macOS status bar ... | ... is displayed on both monitors | ... is displayed only on main monitor | +## 'on-window-detected' callback + +- Available since: 0.6.0-Beta + +You can use `on-window-detected` callback to run commands that run on new window detection. + +```toml +[[on-window-detected]] +app-id = 'com.apple.systempreferences' # Application ID match +app-name-regex-substring = 'settings' # Case insensetive regex substring +window-title-regex-substring = 'substring' # Case insensetive regex substring +run = ['move-node-to-workspace S', 'layout floating'] # The callback itself +``` + +> [!IMPORTANT] +> Some windows initialize their title after window appears. `window-title-regex-substring` may not work as expected +> for such windows + +Examples of automations: + +- Assign apps on particular workspaces + ```toml + [[on-window-detected]] + app-id = 'org.alacritty' + run = 'move-node-to-workspace T' # mnemonics 'Terminal' + + [[on-window-detected]] + app-id = 'com.google.Chrome' + run = 'move-node-to-workspace W' # mnemonics 'Web browser' + + [[on-window-detected]] + app-id = 'com.jetbrains.intellij' + run = 'move-node-to-workspace I' # mnemonics 'Ide' + ``` +- Make all windows float by default + ```toml + [[on-window-detected]] + run = 'layout floating' + ``` +- Float 'System Settings' app + ```toml + [[on-window-detected]] + app-id = 'com.apple.systempreferences' + run = 'layout floating' + ``` + +See the [list of popular application IDs](./popular-apps-ids.md), or you can +use [`aerospace list-apps`](./cli-commands.md#list-apps) CLI command to get IDs of running applications + ## Multiple monitors - The pool of workspaces is shared between monitors @@ -252,6 +302,8 @@ monitor often makes sense. ### Assign workspaces to monitors +- Available since: 0.5.0-Beta + You can use `workspace-to-monitor-force-assignment` syntax to assign workspaces to always appear on particular monitors ```toml @@ -273,5 +325,3 @@ Supported monitor patterns: You can specify multiple patterns as an array. The first matching pattern will be used [`move-workspace-to-monitor`](./commands.md#move-workspace-to-monitor) command has no effect for workspaces that have monitor assignment - -- Available since: 0.5.0-Beta diff --git a/docs/popular-apps-ids.md b/docs/popular-apps-ids.md new file mode 100644 index 00000000..c8fbfe0f --- /dev/null +++ b/docs/popular-apps-ids.md @@ -0,0 +1,86 @@ +# List of popular and built-in applications IDs + +The list is useful to compose custom [`on-window-detected` callback](./guide.md#on-window-detected-callback) + +|Application name|Application ID| +|----------------|--------------| +|1Password|`com.1password.1password`| +|Activity Monitor|`com.apple.ActivityMonitor`| +|AirPort Utility|`com.apple.airport.airportutility`| +|Alacritty|`org.alacritty`| +|Android Studio|`com.google.android.studio`| +|App Store|`com.apple.AppStore`| +|AppCode|`com.jetbrains.AppCode`| +|Arc Browser|`company.thebrowser.Browser`| +|Audio MIDI Setup|`com.apple.audio.AudioMIDISetup`| +|Automator|`com.apple.Automator`| +|Battle.net|`net.battle.app`| +|Books|`com.apple.iBooksX`| +|CLion|`com.jetbrains.CLion`| +|Calculator|`com.apple.calculator`| +|Calendar|`com.apple.iCal`| +|Chess|`com.apple.Chess`| +|Clock|`com.apple.clock`| +|ColorSync Utility|`com.apple.ColorSyncUtility`| +|Console|`com.apple.Console`| +|Contacts|`com.apple.AddressBook`| +|Dictionary|`com.apple.Dictionary`| +|Disk Utility|`com.apple.DiskUtility`| +|Docker|`com.docker.docker`| +|FaceTime|`com.apple.FaceTime`| +|Find My|`com.apple.findmy`| +|Finder|`com.apple.finder`| +|Firefox|`org.mozilla.firefox`| +|Freeform|`com.apple.freeform`| +|GIMP|`org.gimp.gimp-2.10`| +|Google Chrome|`com.google.Chrome`| +|Grapher|`com.apple.grapher`| +|Home|`com.apple.Home`| +|Inkscape|`org.inkscape.Inkscape`| +|IntelliJ IDEA Community|`com.jetbrains.intellij.ce`| +|IntelliJ IDEA Ultimate|`com.jetbrains.intellij`| +|Karabiner-Elements|`org.pqrs.Karabiner-Elements.Settings`| +|Keychain Access|`com.apple.keychainaccess`| +|Keynote|`com.apple.iWork.Keynote`| +|Kitty|`net.kovidgoyal.kitty`| +|Mail|`com.apple.mail`| +|Maps|`com.apple.Maps`| +|Marta|`org.yanex.marta`| +|Messages|`com.apple.MobileSMS`| +|Music|`com.apple.Music`| +|Notes|`com.apple.Notes`| +|Pages|`com.apple.iWork.Pages`| +|Photo Booth|`com.apple.PhotoBooth`| +|Photos|`com.apple.Photos`| +|Podcasts|`com.apple.podcasts`| +|Preview|`com.apple.Preview`| +|PyCharm Community|`com.jetbrains.pycharm.ce`| +|PyCharm Professional|`com.jetbrains.pycharm`| +|QuickTime Player|`com.apple.QuickTimePlayerX`| +|Reminders|`com.apple.reminders`| +|Safari|`com.apple.Safari`| +|Shortcuts|`com.apple.shortcuts`| +|Slack|`com.tinyspeck.slackmacgap`| +|Spotify|`com.spotify.client`| +|Steam|`com.valvesoftware.steam`| +|Stocks|`com.apple.stocks`| +|Sublime Merge|`com.sublimemerge`| +|Sublime Text|`com.sublimetext.4`| +|System Settings|`com.apple.systempreferences`| +|TV|`com.apple.TV`| +|Telegram|`com.tdesktop.Telegram`| +|Terminal|`com.apple.Terminal`| +|TextEdit|`com.apple.TextEdit`| +|Thunderbird|`org.mozilla.thunderbird`| +|Time Machine|`com.apple.backup.launcher`| +|Tor Browser|`org.torproject.torbrowser`| +|Transmission|`org.m0k.transmission`| +|VLC|`org.videolan.vlc`| +|Visual Studio Code|`com.microsoft.VSCode`| +|VoiceMemos|`com.apple.VoiceMemos`| +|VoiceOver Utility|`com.apple.VoiceOverUtility`| +|Weather|`com.apple.weather`| +|Xcode|`com.apple.dt.Xcode`| +|iMovie|`com.apple.iMovieApp`| +|iTerm2|`com.googlecode.iterm2`| +|kdenlive|`org.kde.Kdenlive`| diff --git a/project.yml b/project.yml index f1d76f3d..824a33c1 100644 --- a/project.yml +++ b/project.yml @@ -25,7 +25,7 @@ targets: - package: Socket settings: base: - SWIFT_VERSION: 5.8 + SWIFT_VERSION: 5.9 CODE_SIGN_STYLE: Automatic GENERATE_INFOPLIST_FILE: YES MARKETING_VERSION: 0.6.0-Beta # GENERATED BY generate.sh @@ -62,7 +62,7 @@ targets: - target: AeroSpace settings: base: - # SWIFT_VERSION: 5.8 + SWIFT_VERSION: 5.9 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/AeroSpace-Debug.app/Contents/MacOS/AeroSpace-Debug" GENERATE_INFOPLIST_FILE: YES AeroSpace-cli: @@ -74,7 +74,7 @@ targets: - package: Socket settings: base: - SWIFT_VERSION: 5.8 + SWIFT_VERSION: 5.9 CODE_SIGN_STYLE: Automatic configs: release: diff --git a/src/command/Command.swift b/src/command/Command.swift index 281737c6..bed3bf04 100644 --- a/src/command/Command.swift +++ b/src/command/Command.swift @@ -14,16 +14,20 @@ extension Command { check(Thread.current.isMainThread) await [self].run() } + + var isExec: Bool { self is ExecAndWaitCommand || self is ExecAndForgetCommand } } extension [Command] { @MainActor - func run() async { + func run(_ initState: FocusState? = nil) async { check(Thread.current.isMainThread) let commands = TrayMenuModel.shared.isEnabled ? self : (singleOrNil() as? EnableCommand).asList() refresh(layout: false) var state: FocusState - if let window = focusedWindowOrEffectivelyFocused { + if let initState { + state = initState + } else if let window = focusedWindowOrEffectivelyFocused { state = .windowIsFocused(window) } else { state = .emptyWorkspaceIsFocused(focusedWorkspaceName) diff --git a/src/command/cli/ListAppsCommand.swift b/src/command/cli/ListAppsCommand.swift new file mode 100644 index 00000000..b39aa1a9 --- /dev/null +++ b/src/command/cli/ListAppsCommand.swift @@ -0,0 +1,47 @@ +struct ListAppsCommand: QueryCommand { + @MainActor + func run() -> String { + check(Thread.current.isMainThread) + return apps + .map { app in + let pid = String(app.pid) + let appId = app.id ?? "NULL" + let name = app.name ?? "NULL" + return [pid, appId, name] + } + .paddingTable + } +} + +extension [[String]] { + var paddingTable: String { + let pads: [Int] = transposed.map { column in column.map { $0.count }.max()! } + return self + .map { (row: [String]) in + zip(row, pads) + .map { (elem: String, pad: Int) in + elem.padding(toLength: pad, withPad: " ", startingAt: 0) + } + .joined(separator: " ") + } + .joined(separator: "\n") + } +} + +private extension [[String]] { + var transposed: [[String]] { + if isEmpty { + return [] + } + let table: [[String]] = self + var result: [[String]] = [] + for columnIndex in 0... { + if columnIndex < table.first!.count { + result += [table.map { row in row[columnIndex] }] + } else { + break + } + } + return result + } +} diff --git a/src/command/parseCommand.swift b/src/command/parseCommand.swift index 8d624136..2b360fc2 100644 --- a/src/command/parseCommand.swift +++ b/src/command/parseCommand.swift @@ -4,8 +4,8 @@ typealias Parsed = Result extension String: Error {} func parseQueryCommand(_ raw: String) -> Parsed { - if raw.contains("'") { - return .failure("Single quotation mark is reserved for future use") + if raw.contains("'") || raw.contains("\"") { + return .failure("Quotation marks are reserved for future use") } else if raw == "version" || raw == "--version" || raw == "-v" { return .success(VersionCommand()) } else if raw == "list-apps" { @@ -35,8 +35,8 @@ func parseCommand(_ raw: String) -> Parsed { let words: [String] = raw.split(separator: " ").map { String($0) } let args: [String] = Array(words[1...]) let firstWord = String(words.first ?? "") - if raw.contains("'") { - return .failure("Single quotation mark is reserved for future use") + if raw.contains("'") || raw.contains("\"") { + return .failure("Quotation marks are reserved for future use") } else if firstWord == "workspace" { return parseSingleArg(args, firstWord).map { WorkspaceCommand(workspaceName: $0) } } else if firstWord == "move-node-to-workspace" { diff --git a/src/config/Config.swift b/src/config/Config.swift index 3873e7ee..5c600b91 100644 --- a/src/config/Config.swift +++ b/src/config/Config.swift @@ -15,8 +15,10 @@ struct RawConfig: Copyable { var startAtLogin: Bool? var accordionPadding: Int? var enableNormalizationOppositeOrientationForNestedContainers: Bool? + var workspaceToMonitorForceAssignment: [String: [MonitorDescription]]? var modes: [String: Mode]? + var onWindowDetected: [WindowDetectedCallback]? } struct Config { var afterLoginCommand: [Command] @@ -29,12 +31,29 @@ struct Config { var startAtLogin: Bool var accordionPadding: Int var enableNormalizationOppositeOrientationForNestedContainers: Bool - var workspaceToMonitorForceAssignment: [String: [MonitorDescription]] + var workspaceToMonitorForceAssignment: [String: [MonitorDescription]] let modes: [String: Mode] + var onWindowDetected: [WindowDetectedCallback] + var preservedWorkspaceNames: [String] } +struct RawWindowDetectedCallback: Copyable { + var appId: String? + var appNameRegexSubstring: Regex? + var windowTitleRegexSubstring: Regex? + + var run: [any Command]? +} +struct WindowDetectedCallback { + let appId: String? + let appNameRegexSubstring: Regex? + let windowTitleRegexSubstring: Regex? + + let run: [any Command] +} + enum DefaultContainerOrientation: String { case horizontal, vertical, auto } diff --git a/src/config/parseConfig.swift b/src/config/parseConfig.swift index 15cb988a..de262a2c 100644 --- a/src/config/parseConfig.swift +++ b/src/config/parseConfig.swift @@ -29,10 +29,7 @@ private func showConfigParsingErrorsToUser(_ errors: [TomlParseError], configUrl \(errors.map(\.description).joined(separator: "\n")) """ - showMessageToUser( - filename: "config-parse-error.txt", - message: message - ) + showMessageToUser(filename: "config-parse-error.txt", message: message) } enum TomlParseError: Error, CustomStringConvertible { @@ -53,56 +50,58 @@ enum TomlParseError: Error, CustomStringConvertible { } } -private typealias ParsedToml = Result +typealias ParsedToml = Result -private extension ParserProtocol { - func transformRawConfig(_ raw: RawConfig, +extension ParserProtocol { + func transformRawConfig(_ raw: S, _ value: TOMLValueConvertible, _ backtrace: TomlBacktrace, - _ errors: inout [TomlParseError]) -> RawConfig { + _ errors: inout [TomlParseError]) -> S { raw.copy(keyPath, parse(value, backtrace, &errors).getOrNil(appendErrorTo: &errors)) } } -private protocol ParserProtocol { +protocol ParserProtocol { associatedtype T - var keyPath: WritableKeyPath { get } + associatedtype S where S : Copyable + var keyPath: WritableKeyPath { get } var parse: (TOMLValueConvertible, TomlBacktrace, inout [TomlParseError]) -> ParsedToml { get } } -private struct Parser: ParserProtocol { - let keyPath: WritableKeyPath +struct Parser: ParserProtocol { + let keyPath: WritableKeyPath let parse: (TOMLValueConvertible, TomlBacktrace, inout [TomlParseError]) -> ParsedToml - init(_ keyPath: WritableKeyPath, _ parse: @escaping (TOMLValueConvertible, TomlBacktrace, inout [TomlParseError]) -> ParsedToml) { + init(_ keyPath: WritableKeyPath, _ parse: @escaping (TOMLValueConvertible, TomlBacktrace, inout [TomlParseError]) -> T) { self.keyPath = keyPath - self.parse = parse + self.parse = { raw, backtrace, errors -> ParsedToml in .success(parse(raw, backtrace, &errors)) } } - init(_ keyPath: WritableKeyPath, _ parse: @escaping (TOMLValueConvertible, TomlBacktrace) -> ParsedToml) { + init(_ keyPath: WritableKeyPath, _ parse: @escaping (TOMLValueConvertible, TomlBacktrace) -> ParsedToml) { self.keyPath = keyPath self.parse = { raw, backtrace, errors -> ParsedToml in parse(raw, backtrace) } } } -private let parsers: [String: any ParserProtocol] = [ +private let parsers: [String: any ParserProtocol] = [ "after-login-command": Parser(\.afterLoginCommand, { parseCommandOrCommands($0).toParsedToml($1) }), "after-startup-command": Parser(\.afterStartupCommand, { parseCommandOrCommands($0).toParsedToml($1) }), - "enable-normalization-flatten-containers": Parser(\.enableNormalizationFlattenContainers, { parseBool($0, $1) }), - "enable-normalization-opposite-orientation-for-nested-containers": Parser(\.enableNormalizationOppositeOrientationForNestedContainers, { parseBool($0, $1) }), + "enable-normalization-flatten-containers": Parser(\.enableNormalizationFlattenContainers, parseBool), + "enable-normalization-opposite-orientation-for-nested-containers": Parser(\.enableNormalizationOppositeOrientationForNestedContainers, parseBool), - "non-empty-workspaces-root-containers-layout-on-startup": Parser(\.nonEmptyWorkspacesRootContainersLayoutOnStartup, { parseStartupRootContainerLayout($0, $1) }), + "non-empty-workspaces-root-containers-layout-on-startup": Parser(\.nonEmptyWorkspacesRootContainersLayoutOnStartup, parseStartupRootContainerLayout), - "default-root-container-layout": Parser(\.defaultRootContainerLayout, { parseLayout($0, $1) }), - "default-root-container-orientation": Parser(\.defaultRootContainerOrientation, { parseDefaultContainerOrientation($0, $1) }), + "default-root-container-layout": Parser(\.defaultRootContainerLayout, parseLayout), + "default-root-container-orientation": Parser(\.defaultRootContainerOrientation, parseDefaultContainerOrientation), - "indent-for-nested-containers-with-the-same-orientation": Parser(\.indentForNestedContainersWithTheSameOrientation, { parseInt($0, $1) }), - "start-at-login": Parser(\.startAtLogin, { parseBool($0, $1) }), - "accordion-padding": Parser(\.accordionPadding, { parseInt($0, $1) }), + "indent-for-nested-containers-with-the-same-orientation": Parser(\.indentForNestedContainersWithTheSameOrientation, parseInt), + "start-at-login": Parser(\.startAtLogin, parseBool), + "accordion-padding": Parser(\.accordionPadding, parseInt), - "mode": Parser(\.modes, { .success(parseModes($0, $1, &$2)) }), - "workspace-to-monitor-force-assignment": Parser(\.workspaceToMonitorForceAssignment, { .success(parseWorkspaceToMonitorAssignment($0, $1, &$2)) }), + "mode": Parser(\.modes, parseModes), + "workspace-to-monitor-force-assignment": Parser(\.workspaceToMonitorForceAssignment, parseWorkspaceToMonitorAssignment), + "on-window-detected": Parser(\.onWindowDetected, parseOnWindowDetectedArray) ] func parseConfig(_ rawToml: String) -> (config: Config, errors: [TomlParseError]) { @@ -141,9 +140,11 @@ func parseConfig(_ rawToml: String) -> (config: Config, errors: [TomlParseError] startAtLogin: raw.startAtLogin ?? defaultConfig.startAtLogin, accordionPadding: raw.accordionPadding ?? defaultConfig.accordionPadding, enableNormalizationOppositeOrientationForNestedContainers: raw.enableNormalizationOppositeOrientationForNestedContainers ?? defaultConfig.enableNormalizationOppositeOrientationForNestedContainers, - workspaceToMonitorForceAssignment: raw.workspaceToMonitorForceAssignment ?? [:], + workspaceToMonitorForceAssignment: raw.workspaceToMonitorForceAssignment ?? [:], modes: modesOrDefault, + onWindowDetected: raw.onWindowDetected ?? [], + preservedWorkspaceNames: modesOrDefault.values.lazy .flatMap { (mode: Mode) -> [HotkeyBinding] in mode.bindings } .compactMap { (binding: HotkeyBinding) -> String? in (binding.commands.singleOrNil() as? WorkspaceCommand)?.workspaceName ?? nil } @@ -170,7 +171,7 @@ private func parseInt(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) - raw.int.orFailure { expectedActualTypeError(expected: .int, actual: raw.type, backtrace) } } -private func parseString(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml { +func parseString(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml { raw.string.orFailure { expectedActualTypeError(expected: .string, actual: raw.type, backtrace) } } @@ -238,11 +239,13 @@ private func parseMonitorDescription(_ raw: TOMLValueConvertible, _ backtrace: T return .success(.secondary) } - let pattern = (try? Regex(rawString))?.lets { MonitorDescription.pattern($0.ignoresCase()) } - return rawString.isEmpty ? .failure(.semantic(backtrace, "Empty string is an illegal monitor description")) - : pattern.orFailure(.semantic(backtrace, "Can't parse '\(rawString)' regex")) + : parseCaseInsensitiveRegex(rawString).toParsedToml(backtrace).map(MonitorDescription.pattern) +} + +func parseCaseInsensitiveRegex(_ raw: String) -> Parsed> { + (try? Regex(raw)).orFailure("Can't parse '\(raw)' regex").map { $0.ignoresCase() } } private func parseModes(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError]) -> [String: Mode] { @@ -279,7 +282,7 @@ private func parseMode(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, return result } -private extension Parsed where Failure == String { +extension Parsed where Failure == String { func toParsedToml(_ backtrace: TomlBacktrace) -> ParsedToml { mapError { .semantic(backtrace, $0) } } @@ -350,14 +353,14 @@ indirect enum TomlBacktrace: CustomStringConvertible { } } -private func unknownKeyError(_ backtrace: TomlBacktrace) -> TomlParseError { +func unknownKeyError(_ backtrace: TomlBacktrace) -> TomlParseError { .semantic(backtrace, "Unknown key") } -private func expectedActualTypeError(expected: TOMLType, actual: TOMLType, _ backtrace: TomlBacktrace) -> TomlParseError { +func expectedActualTypeError(expected: TOMLType, actual: TOMLType, _ backtrace: TomlBacktrace) -> TomlParseError { .semantic(backtrace, expectedActualTypeError(expected: expected, actual: actual)) } -private func expectedActualTypeError(expected: [TOMLType], actual: TOMLType, _ backtrace: TomlBacktrace) -> TomlParseError { +func expectedActualTypeError(expected: [TOMLType], actual: TOMLType, _ backtrace: TomlBacktrace) -> TomlParseError { .semantic(backtrace, expectedActualTypeError(expected: expected, actual: actual)) } diff --git a/src/config/parseOnWindowDetected.swift b/src/config/parseOnWindowDetected.swift new file mode 100644 index 00000000..eaf380cb --- /dev/null +++ b/src/config/parseOnWindowDetected.swift @@ -0,0 +1,58 @@ +import TOMLKit + +private let windowDetectedParsers: [String: any ParserProtocol] = [ + "app-id": Parser(\.appId, parseString), + "app-name-regex-substring": Parser(\.appNameRegexSubstring, parseCasInsensitiveRegex), + "window-title-regex-substring": Parser(\.windowTitleRegexSubstring, parseCasInsensitiveRegex), + "run": Parser(\.run, { parseCommandOrCommands($0).toParsedToml($1) }), +] + +func parseOnWindowDetectedArray(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError]) -> [WindowDetectedCallback] { + if let array = raw.array { + return array.withIndex.mapToResult(appendErrorsTo: &errors) { (index, raw) in parseWindowDetectedCallback(raw, backtrace + .index(index)) } + } else { + errors += [expectedActualTypeError(expected: .array, actual: raw.type, backtrace)] + return [] + } +} + +private func parseCasInsensitiveRegex(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml> { + parseString(raw, backtrace).flatMap { parseCaseInsensitiveRegex($0).toParsedToml(backtrace) } +} + +private func parseWindowDetectedCallback(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml { + guard let table = raw.table else { + return .failure(expectedActualTypeError(expected: .table, actual: raw.type, backtrace)) + } + + var raw = RawWindowDetectedCallback() + + for (key, value) in table { + let backtrace: TomlBacktrace = backtrace + .key(key) + if let parser = windowDetectedParsers[key] { + var errors: [TomlParseError] = [] + raw = parser.transformRawConfig(raw, value, backtrace, &errors) + if !errors.isEmpty { + return .failure(errors.first!) + } + } else { + return .failure(unknownKeyError(backtrace)) + } + } + + guard let run = raw.run else { + return .failure(.semantic(backtrace, "'run' is mandatory key")) + } + + if run.contains(where: \.isExec) { + return .failure(.semantic(backtrace, "'exec' commands are not yet supported in 'on-window-detected'. " + + "Please report your use-cases to https://github.com/nikitabobko/AeroSpace/issues/20")) + } + + return .success(WindowDetectedCallback( + appId: raw.appId, + appNameRegexSubstring: raw.appNameRegexSubstring, + windowTitleRegexSubstring: raw.windowTitleRegexSubstring, + run: run + )) +} diff --git a/src/server.swift b/src/server.swift index e6252496..c17b4098 100644 --- a/src/server.swift +++ b/src/server.swift @@ -46,7 +46,7 @@ private func newConnection(_ socket: Socket) async { // todo add exit codes _ = try? socket.write(from: error1 + "\n" + error2) continue } - if action is ExecAndForgetCommand || action is ExecAndWaitCommand { + if action?.isExec == true { _ = try? socket.write(from: "exec commands are prohibited in CLI") continue } diff --git a/src/tree/AeroApp.swift b/src/tree/AeroApp.swift index 07b1687f..d39c327e 100644 --- a/src/tree/AeroApp.swift +++ b/src/tree/AeroApp.swift @@ -1,12 +1,14 @@ class AeroApp: Hashable { - let id: Int32 + let pid: Int32 + let id: String? - init(id: Int32) { + init(pid: Int32, id: String?) { + self.pid = pid self.id = id } static func ==(lhs: AeroApp, rhs: AeroApp) -> Bool { - if lhs.id == rhs.id { + if lhs.pid == rhs.pid { check(lhs === rhs) return true } else { @@ -16,7 +18,7 @@ class AeroApp: Hashable { } func hash(into hasher: inout Hasher) { - hasher.combine(id) + hasher.combine(pid) } var name: String? { nil } diff --git a/src/tree/MacApp.swift b/src/tree/MacApp.swift index 77ed02b0..9eb2cb12 100644 --- a/src/tree/MacApp.swift +++ b/src/tree/MacApp.swift @@ -7,7 +7,7 @@ final class MacApp: AeroApp { private init(_ nsApp: NSRunningApplication, _ axApp: AXUIElement) { self.nsApp = nsApp self.axApp = axApp - super.init(id: nsApp.processIdentifier) + super.init(pid: nsApp.processIdentifier, id: nsApp.bundleIdentifier) } private static var allAppsMap: [pid_t: MacApp] = [:] diff --git a/src/tree/MacWindow.swift b/src/tree/MacWindow.swift index 131e1916..93f217fa 100644 --- a/src/tree/MacWindow.swift +++ b/src/tree/MacWindow.swift @@ -31,6 +31,7 @@ final class MacWindow: Window, CustomStringConvertible { window.observe(resizedObs, kAXResizedNotification) { debug("New window detected: \(window)") allWindowsMap[id] = window + onWindowDetected(window) return window } else { window.garbageCollect() @@ -61,7 +62,7 @@ final class MacWindow: Window, CustomStringConvertible { } private func observe(_ handler: AXObserverCallback, _ notifKey: String) -> Bool { - guard let observer = AXObserver.observe(app.id, notifKey, axWindow, handler, data: self) else { return false } + guard let observer = AXObserver.observe(app.pid, notifKey, axWindow, handler, data: self) else { return false } axObservers.append(AxObserverWrapper(obs: observer, ax: axWindow, notif: notifKey as CFString)) return true } @@ -189,3 +190,29 @@ private func destroyedObs(_ obs: AXObserver, ax: AXUIElement, notif: CFString, d data?.window?.garbageCollect() refresh() } + +func onWindowDetected(_ window: Window) { + Task { @MainActor in + check(Thread.current.isMainThread) + for callback in config.onWindowDetected { + if callback.matches(window) { + await callback.run.run(.windowIsFocused(window)) + } + } + } +} + +extension WindowDetectedCallback { + func matches(_ window: Window) -> Bool { + if let windowTitleRegexSubstring, !(window.title ?? "").contains(windowTitleRegexSubstring) { + return false + } + if let appId, appId != window.app.id { + return false + } + if let appNameRegexSubstring, !(window.app.name ?? "").contains(appNameRegexSubstring) { + return false + } + return true + } +} diff --git a/src/util/SequenceEx.swift b/src/util/SequenceEx.swift index 4798c2e4..d1ff76fb 100644 --- a/src/util/SequenceEx.swift +++ b/src/util/SequenceEx.swift @@ -26,6 +26,16 @@ extension Sequence { return .success(result) } + func mapToResult(appendErrorsTo errors: inout [E], _ transform: (Self.Element) -> Result) -> [T] { + var result: [T] = [] + for element in self { + if let good = transform(element).getOrNil(appendErrorTo: &errors) { + result.append(good) + } + } + return result + } + @inlinable public func minByOrThrow(_ selector: (Self.Element) -> S) -> Self.Element { minBy(selector) ?? errorT("Empty sequence") } diff --git a/test/config/ConfigTest.swift b/test/config/ConfigTest.swift index 199166f1..9c7a1af7 100644 --- a/test/config/ConfigTest.swift +++ b/test/config/ConfigTest.swift @@ -176,6 +176,57 @@ final class ConfigTest: XCTestCase { ], errors.descriptions) XCTAssertEqual([:], defaultConfig.workspaceToMonitorForceAssignment) } + + func testParseOnWindowDetected() { + let (parsed, errors) = parseConfig( + """ + [[on-window-detected]] + run = ['layout floating'] + + [[on-window-detected]] + app-id = 'com.apple.systempreferences' + run = [] + + [[on-window-detected]] + """ + ) + XCTAssertEqual(parsed.onWindowDetected, [ + WindowDetectedCallback( + appId: nil, + appNameRegexSubstring: nil, + windowTitleRegexSubstring: nil, + run: [LayoutCommand(toggleBetween: [.floating])!] + ), + WindowDetectedCallback( + appId: "com.apple.systempreferences", + appNameRegexSubstring: nil, + windowTitleRegexSubstring: nil, + run: [] + ) + ]) + + XCTAssertEqual(errors.descriptions, [ + "on-window-detected[2]: \'run\' is mandatory key" + ]) + } + + func testParseOnWindowDetectedRegex() { + let (config, errors) = parseConfig( + """ + [[on-window-detected]] + app-name-regex-substring = '^system settings$' + run = [] + """ + ) + XCTAssertTrue(config.onWindowDetected.singleOrNil()!.appNameRegexSubstring != nil) + XCTAssertEqual(errors.descriptions, []) + } + + func testRegex() { + var devNull: [String] = [] + XCTAssertTrue("System Settings".contains(parseCaseInsensitiveRegex("settings").getOrNil(appendErrorTo: &devNull)!)) + XCTAssertTrue(!"System Settings".contains(parseCaseInsensitiveRegex("^settings^").getOrNil(appendErrorTo: &devNull)!)) + } } extension MonitorDescription: Equatable { @@ -195,18 +246,29 @@ extension MonitorDescription: Equatable { } } +extension WindowDetectedCallback: Equatable { + public static func ==(lhs: WindowDetectedCallback, rhs: WindowDetectedCallback) -> Bool { + check(lhs.appNameRegexSubstring == nil && + lhs.windowTitleRegexSubstring == nil && + rhs.appNameRegexSubstring == nil && + rhs.windowTitleRegexSubstring == nil) + return lhs.appId == rhs.appId && + lhs.run.map(\.describe) == rhs.run.map(\.describe) + } +} + private extension [TomlParseError] { var descriptions: [String] { map(\.description) } } extension Mode: Equatable { - public static func ==(lhs: AeroSpace_Debug.Mode, rhs: AeroSpace_Debug.Mode) -> Bool { + public static func ==(lhs: Mode, rhs: Mode) -> Bool { lhs.name == rhs.name && lhs.bindings == rhs.bindings } } extension HotkeyBinding: Equatable { - public static func ==(lhs: AeroSpace_Debug.HotkeyBinding, rhs: AeroSpace_Debug.HotkeyBinding) -> Bool { + public static func ==(lhs: HotkeyBinding, rhs: HotkeyBinding) -> Bool { lhs.modifiers == rhs.modifiers && lhs.key == rhs.key && lhs.commands.map(\.describe) == rhs.commands.map(\.describe) } } @@ -217,6 +279,8 @@ extension Command { return .focusCommand(focus.direction) } else if let resize = self as? ResizeCommand { return .resizeCommand(dimension: resize.dimension, mode: resize.mode, unit: resize.unit) + } else if let layout = self as? LayoutCommand { + return .layoutCommand(layout.toggleBetween) } error("Unsupported command: \(self)") } @@ -225,4 +289,5 @@ extension Command { enum CommandDescription: Equatable { case focusCommand(CardinalDirection) case resizeCommand(dimension: ResizeCommand.Dimension, mode: ResizeCommand.ResizeMode, unit: UInt) + case layoutCommand([LayoutCommand.LayoutDescription]) } diff --git a/test/testUtil.swift b/test/testUtil.swift index 44e8b486..11678141 100644 --- a/test/testUtil.swift +++ b/test/testUtil.swift @@ -20,6 +20,7 @@ func setUpWorkspacesForTests() { // Don't create any workspaces for tests modes: [mainModeId: Mode(name: nil, bindings: [])], + onWindowDetected: [], preservedWorkspaceNames: [] ) for workspace in Workspace.all { diff --git a/test/tree/TestApp.swift b/test/tree/TestApp.swift index 067c62e5..6073577c 100644 --- a/test/tree/TestApp.swift +++ b/test/tree/TestApp.swift @@ -1,10 +1,10 @@ @testable import AeroSpace_Debug final class TestApp: AeroApp { - static var shared = TestApp(id: 0) + static var shared = TestApp() - private override init(id: Int32) { - super.init(id: id) + private init() { + super.init(pid: 0, id: "bobko.AeroSpace.test-app") } var _windows: [Window] = []