├── .bundle └── config ├── .editorconfig ├── .gitattributes ├── .github ├── DISCUSSION_TEMPLATE │ └── potential-bugs.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── new-issue.yml └── workflows │ ├── build.yml │ └── close-third-party-issues.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml └── inspectionProfiles │ └── Project_Default.xml ├── .sourcekit-lsp └── config.json ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── AeroSpace.xcodeproj ├── project.pbxproj └── project.xcworkspace │ └── contents.xcworkspacedata ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── Package.resolved ├── Package.swift ├── README.md ├── ShellParserGenerated ├── .gitignore ├── Package.swift └── Sources │ └── ShellParserGenerated │ ├── ShellLexer.swift │ └── ShellParser.swift ├── Sources ├── AeroSpaceApp │ └── AeroSpaceApp.swift ├── AppBundle │ ├── GlobalObserver.swift │ ├── command │ │ ├── CmdEnv.swift │ │ ├── CmdIo.swift │ │ ├── Command.swift │ │ ├── cmdManifest.swift │ │ ├── cmdResolveTargetOrReportError.swift │ │ ├── format.swift │ │ ├── formatToJson.swift │ │ ├── impl │ │ │ ├── BalanceSizesCommand.swift │ │ │ ├── CloseAllWindowsButCurrentCommand.swift │ │ │ ├── CloseCommand.swift │ │ │ ├── ConfigCommand.swift │ │ │ ├── DebugWindowsCommand.swift │ │ │ ├── EnableCommand.swift │ │ │ ├── ExecAndForgetCommand.swift │ │ │ ├── FlattenWorkspaceTreeCommand.swift │ │ │ ├── FocusBackAndForthCommand.swift │ │ │ ├── FocusCommand.swift │ │ │ ├── FocusMonitorCommand.swift │ │ │ ├── FullscreenCommand.swift │ │ │ ├── JoinWithCommand.swift │ │ │ ├── LayoutCommand.swift │ │ │ ├── ListAppsCommand.swift │ │ │ ├── ListExecEnvVarsCommand.swift │ │ │ ├── ListModesCommand.swift │ │ │ ├── ListMonitorsCommand.swift │ │ │ ├── ListWindowsCommand.swift │ │ │ ├── ListWorkspacesCommand.swift │ │ │ ├── MacosNativeFullscreenCommand.swift │ │ │ ├── MacosNativeMinimizeCommand.swift │ │ │ ├── ModeCommand.swift │ │ │ ├── MoveCommand.swift │ │ │ ├── MoveMouseCommand.swift │ │ │ ├── MoveNodeToMonitorCommand.swift │ │ │ ├── MoveNodeToWorkspaceCommand.swift │ │ │ ├── MoveWorkspaceToMonitorCommand.swift │ │ │ ├── ReloadConfigCommand.swift │ │ │ ├── ResizeCommand.swift │ │ │ ├── SplitCommand.swift │ │ │ ├── SummonWorkspaceCommand.swift │ │ │ ├── TriggerBindingCommand.swift │ │ │ ├── VolumeCommand.swift │ │ │ ├── WorkspaceBackAndForthCommand.swift │ │ │ └── WorkspaceCommand.swift │ │ └── parseCommand.swift │ ├── config │ │ ├── Config.swift │ │ ├── ConfigFile.swift │ │ ├── DynamicConfigValue.swift │ │ ├── HotkeyBinding.swift │ │ ├── Mode.swift │ │ ├── keysMap.swift │ │ ├── parseConfig.swift │ │ ├── parseExecEnvVariables.swift │ │ ├── parseGaps.swift │ │ ├── parseKeyMapping.swift │ │ ├── parseOnWindowDetected.swift │ │ ├── parseWorkspaceToMonitorAssignment.swift │ │ └── startAtLogin.swift │ ├── focus.swift │ ├── focusCache.swift │ ├── getNativeFocusedWindow.swift │ ├── initAppBundle.swift │ ├── layout │ │ ├── layoutRecursive.swift │ │ └── refresh.swift │ ├── model │ │ ├── Json.swift │ │ ├── Monitor.swift │ │ ├── MonitorDescriptionEx.swift │ │ ├── MonitorEx.swift │ │ └── Rect.swift │ ├── mouse │ │ ├── mouse.swift │ │ ├── moveWithMouse.swift │ │ └── resizeWithMouse.swift │ ├── normalizeLayoutReason.swift │ ├── runLoop.swift │ ├── server.swift │ ├── shell │ │ └── Shell.swift │ ├── tree │ │ ├── AbstractApp.swift │ │ ├── MacApp.swift │ │ ├── MacWindow.swift │ │ ├── MacosUnconventionalWindowsContainer.swift │ │ ├── TilingContainer.swift │ │ ├── TreeNode.swift │ │ ├── TreeNodeCases.swift │ │ ├── TreeNodeEx.swift │ │ ├── Window.swift │ │ ├── Workspace.swift │ │ ├── WorkspaceEx.swift │ │ ├── frozen │ │ │ ├── FrozenTreeNode.swift │ │ │ ├── FrozenWorld.swift │ │ │ └── closedWindowsCache.swift │ │ └── normalizeContainers.swift │ ├── ui │ │ ├── AppearanceTheme.swift │ │ ├── ExperimentalUISettings.swift │ │ ├── MenuBar.swift │ │ ├── MenuBarLabel.swift │ │ └── TrayMenuModel.swift │ └── util │ │ ├── AeroAny.swift │ │ ├── ArrayEx.swift │ │ ├── AxSubscription.swift │ │ ├── AxUiElementMock.swift │ │ ├── AxUiElementMockEx.swift │ │ ├── LazySequenceProtocolEx.swift │ │ ├── MruStack.swift │ │ ├── NSRunningApplicationEx.swift │ │ ├── NsApplicationEx.swift │ │ ├── SetEx.swift │ │ ├── ThreadGuardedValue.swift │ │ ├── accessibility.swift │ │ ├── appBundleUtil.swift │ │ ├── axTrustedCheckOptionPrompt.swift │ │ └── dumpAx.swift ├── AppBundleTests │ ├── AxWindowKindTest.swift │ ├── assert.swift │ ├── command │ │ ├── BalanceSizesCommandTest.swift │ │ ├── CloseCommandTest.swift │ │ ├── ExecCommandTest.swift │ │ ├── FlattenWorkspaceTreeCommandTest.swift │ │ ├── FocusCommandTest.swift │ │ ├── JoinWithCommandTest.swift │ │ ├── ListAppsTest.swift │ │ ├── ListModesTest.swift │ │ ├── ListMonitorsTest.swift │ │ ├── ListWindowsTest.swift │ │ ├── ListWorkspacesTest.swift │ │ ├── MoveCommandTest.swift │ │ ├── MoveNodeToWorkspaceCommandTest.swift │ │ ├── ResizeCommandTest.swift │ │ ├── SplitCommandTest.swift │ │ ├── SummonWorkspaceCommandTest.swift │ │ └── WorkspaceCommandTest.swift │ ├── config │ │ ├── ConfigTest.swift │ │ ├── ParseEnvVariablesTest.swift │ │ └── SplitArgsTest.swift │ ├── shell │ │ └── ShellTest.swift │ ├── testExtensions.swift │ ├── testUtil.swift │ └── tree │ │ ├── TestApp.swift │ │ ├── TestWindow.swift │ │ ├── TilingContainer.swift │ │ └── TreeNodeTest.swift ├── Cli │ ├── _main.swift │ ├── cliUtil.swift │ └── subcommandDescriptionsGenerated.swift ├── Common │ ├── appMetadata.swift │ ├── cmdArgs │ │ ├── ArgParser.swift │ │ ├── cmdArgsManifest.swift │ │ ├── cmdArgsStringArrayEx.swift │ │ ├── impl │ │ │ ├── BalanceSizesCmdArgs.swift │ │ │ ├── CloseAllWindowsButCurrentCmdArgs.swift │ │ │ ├── CloseCmdArgs.swift │ │ │ ├── ConfigCmdArgs.swift │ │ │ ├── DebugWindowsCmdArgs.swift │ │ │ ├── EnableCmdArgs.swift │ │ │ ├── ExecAndForgetCmdArgs.swift │ │ │ ├── FlattenWorkspaceTreeCmdArgs.swift │ │ │ ├── FocusBackAndForthCmdArgs.swift │ │ │ ├── FocusCmdArgs.swift │ │ │ ├── FocusMonitorCmdArgs.swift │ │ │ ├── FullscreenCmdArgs.swift │ │ │ ├── JoinWithCmdArgs.swift │ │ │ ├── LayoutCmdArgs.swift │ │ │ ├── ListAppsCmdArgs.swift │ │ │ ├── ListExecEnvVarsCmdArgs.swift │ │ │ ├── ListModesCmdArgs.swift │ │ │ ├── ListMonitorsCmdArgs.swift │ │ │ ├── ListWindowsCmdArgs.swift │ │ │ ├── ListWorkspacesCmdArgs.swift │ │ │ ├── MacosNativeFullscreenCmdArgs.swift │ │ │ ├── MacosNativeMinimizeCmdArgs.swift │ │ │ ├── ModeCmdArgs.swift │ │ │ ├── MoveCmdArgs.swift │ │ │ ├── MoveMouseCmdArgs.swift │ │ │ ├── MoveNodeToMonitorCmdArgs.swift │ │ │ ├── MoveNodeToWorkspaceCmdArgs.swift │ │ │ ├── MoveWorkpsaceToMonitorCmdArgs.swift │ │ │ ├── ReloadConfigCmdArgs.swift │ │ │ ├── ResizeCmdArgs.swift │ │ │ ├── SplitCmdArgs.swift │ │ │ ├── SummonWorkspaceCmdArgs.swift │ │ │ ├── TriggerBindingCmdArgs.swift │ │ │ ├── VolumeCmdArgs.swift │ │ │ ├── WorkspaceBackAndForthCmdArgs.swift │ │ │ └── WorkspaceCmdArgs.swift │ │ ├── parseCmdArgs.swift │ │ ├── parseSpecificCmdArgs.swift │ │ ├── splitArgs.swift │ │ └── subcommandParsers.swift │ ├── cmdHelpGenerated.swift │ ├── gitHashGenerated.swift │ ├── macOs13Compatibility.swift │ ├── model │ │ ├── AxAppThreadToken.swift │ │ ├── CardinalDirection.swift │ │ ├── Init.swift │ │ ├── MonitorDescription.swift │ │ ├── Orientation.swift │ │ ├── WorkspaceName.swift │ │ └── clientServer.swift │ ├── util │ │ ├── AeroAny.swift │ │ ├── BoolEx.swift │ │ ├── CollectionEx.swift │ │ ├── ConvenienceCopyable.swift │ │ ├── EquatableNoop.swift │ │ ├── JsonEncoderEx.swift │ │ ├── Lateinit.swift │ │ ├── OptionalEx.swift │ │ ├── ResultEx.swift │ │ ├── SequenceEx.swift │ │ ├── StringEx.swift │ │ ├── StringLogicalSegments.swift │ │ ├── commonUtil.swift │ │ └── showMessageInGui.swift │ └── versionGenerated.swift └── PrivateApi │ └── include │ ├── module.modulemap │ ├── private.h │ └── private.m ├── axDumps ├── about_this_mac.json5 ├── alacritty_decorations_buttonless.json5 ├── calculator.json5 ├── choose_1_5_0.json5 ├── chrome.json5 ├── chrome_extensions_popup.json5 ├── chrome_find_in_page.json5 ├── chrome_pip.json5 ├── finder.json5 ├── firefox.json5 ├── firefox_extensions_popup.json5 ├── firefox_mouse_hover_extensions.json5 ├── firefox_mouse_hover_tab.json5 ├── firefox_non_native_fullscreen.json5 ├── firefox_pinterest_sign_in_with_google.json5 ├── firefox_pip.json5 ├── ghostty.json5 ├── ghostty_window_decoarations_false.json5 ├── intellij.json5 ├── intellij_background_tasks.json5 ├── intellij_context_menu.json5 ├── intellij_native_open_window.json5 ├── intellij_quick_doc_popup.json5 ├── intellij_rebase_dialog.json5 ├── jetbrains_toolbox.json5 ├── marta.json5 ├── mpv_fullscreen.json5 ├── mpv_windowed.json5 ├── qutebrowser.json5 ├── qutebrowser_context_menu.json5 ├── safari.json5 ├── safari_pinterest_sign_in_with_google.json5 ├── spotify.json5 ├── sublime_text_4.json5 ├── system_settings.json5 ├── telegram.json5 ├── terminal_app.json5 ├── vlc_empty.json5 ├── vlc_video_playing.json5 ├── vs_code.json5 ├── xcode.json5 └── xcode_build_succeeded_popup.json5 ├── build-debug.sh ├── build-docs.sh ├── build-release.sh ├── build-shell-completion.sh ├── dev-docs ├── architecture.md └── development.md ├── docs ├── aerospace-balance-sizes.adoc ├── aerospace-close-all-windows-but-current.adoc ├── aerospace-close.adoc ├── aerospace-config.adoc ├── aerospace-debug-windows.adoc ├── aerospace-enable.adoc ├── aerospace-exec-and-forget.adoc ├── aerospace-flatten-workspace-tree.adoc ├── aerospace-focus-back-and-forth.adoc ├── aerospace-focus-monitor.adoc ├── aerospace-focus.adoc ├── aerospace-fullscreen.adoc ├── aerospace-join-with.adoc ├── aerospace-layout.adoc ├── aerospace-list-apps.adoc ├── aerospace-list-exec-env-vars.adoc ├── aerospace-list-modes.adoc ├── aerospace-list-monitors.adoc ├── aerospace-list-windows.adoc ├── aerospace-list-workspaces.adoc ├── aerospace-macos-native-fullscreen.adoc ├── aerospace-macos-native-minimize.adoc ├── aerospace-mode.adoc ├── aerospace-move-mouse.adoc ├── aerospace-move-node-to-monitor.adoc ├── aerospace-move-node-to-workspace.adoc ├── aerospace-move-workspace-to-monitor.adoc ├── aerospace-move.adoc ├── aerospace-reload-config.adoc ├── aerospace-resize.adoc ├── aerospace-split.adoc ├── aerospace-summon-workspace.adoc ├── aerospace-trigger-binding.adoc ├── aerospace-volume.adoc ├── aerospace-workspace-back-and-forth.adoc ├── aerospace-workspace.adoc ├── aerospace.adoc ├── assets │ ├── h_accordion.png │ ├── h_tiles.png │ ├── icon.png │ ├── monitor-arrangement-1-bad.svg │ ├── monitor-arrangement-1-good.svg │ ├── monitor-arrangement-2-bad.svg │ ├── monitor-arrangement-2-good.svg │ ├── tree.png │ └── v_accordion.png ├── commands.adoc ├── config-examples │ ├── default-config.toml │ └── i3-like-config-example.toml ├── goodies.adoc ├── guide.adoc ├── index.html └── util │ ├── all-monitors-option.adoc │ ├── conditional-arguments-header.adoc │ ├── conditional-examples-header.adoc │ ├── conditional-exit-code-header.adoc │ ├── conditional-options-header.adoc │ ├── conditional-output-format-header.adoc │ ├── header.adoc │ ├── man-attributes.adoc │ ├── man-footer.adoc │ ├── monitor-option.adoc │ ├── site-attributes.adoc │ ├── window-id-flag-desc.adoc │ └── workspace-flag-desc.adoc ├── generate-shell-parser.sh ├── generate.sh ├── grammar ├── ShellLexer.g4 ├── ShellParser.g4 └── commands-bnf-grammar.txt ├── install-from-sources.sh ├── legal ├── LICENSE.txt ├── README.md └── third-party-license │ ├── LICENSE-BlueSocket.txt │ ├── LICENSE-HotKey.txt │ ├── LICENSE-ISSoundAdditions.txt │ ├── LICENSE-TOMLKIT.txt │ ├── LICENSE-antlr.txt │ ├── LICENSE-swift-collections.txt │ └── LICENSE-tomlplusplus.txt ├── makefile ├── project.yml ├── resources ├── AeroSpace.entitlements └── Assets.xcassets │ ├── AccentColor.colorset │ └── Contents.json │ ├── AppIcon.appiconset │ ├── Contents.json │ └── icon.png │ └── Contents.json ├── run-cli.sh ├── run-debug.sh ├── run-tests.sh └── script ├── build-brew-cask.sh ├── check-uncommitted-files.sh ├── clean-project.sh ├── clean-xcode.sh ├── generate-cmd-help.sh ├── install-dep.sh ├── publish-release.sh ├── reset-accessibility-permission-for-debug.sh └── setup.sh /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: ".deps/bundler-path" 3 | BUNDLE_DISABLE_SHARED_GEMS: "1" 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # It's better to use "wrap line" in plain text documents 4 | [*.{adoc,md}] 5 | rulers = 1000 6 | max_line_length = 1000 7 | 8 | [*.toml] 9 | rulers = 96 10 | max_line_length = 96 11 | 12 | [*.{swift,sh}] 13 | rulers = 120 14 | max_line_length = 120 15 | indent_style = space 16 | indent_size = 4 17 | charset = utf-8 18 | trim_trailing_whitespace = true 19 | insert_final_newline = true 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # GitHub: linguist-generated marks paths that you would like to be ignored for the repository's language statistics and hidden by default in diffs 2 | AeroSpace.xcodeproj/project.pbxproj linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/potential-bugs.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: textarea 3 | id: body 4 | attributes: 5 | label: Body 6 | value: | 7 | Steps to reproduce: 8 | 1. 9 | 2. 10 | 3. 11 | 12 | Expected result: 13 | Actual result: 14 | 15 | ### Additional info 16 | 17 | ```shell 18 | $ aerospace -v 19 | ``` 20 | validations: 21 | required: true 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [nikitabobko] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-issue.yml: -------------------------------------------------------------------------------- 1 | name: New Issue 2 | description: New Issue 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Unfortunately, AeroSpace project doesn't openly accept Issues. See: https://github.com/nikitabobko/AeroSpace/issues/947 8 | - type: checkboxes 9 | id: checkbox 10 | attributes: 11 | label: '_' 12 | options: 13 | - label: | 14 | I read https://github.com/nikitabobko/AeroSpace/issues/947 15 | required: true 16 | -------------------------------------------------------------------------------- /.github/workflows/close-third-party-issues.yml: -------------------------------------------------------------------------------- 1 | name: close-third-party-issues 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | close-third-party-issues: 9 | name: Close third party issues 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | steps: 14 | - name: Close third party issues 15 | run: | 16 | set -e # Exit if one of commands exit with non-zero exit code 17 | set -u # Treat unset variables and parameters other than the special parameters ‘@’ or ‘*’ as an error 18 | set -o pipefail # Any command failed in the pipe fails the whole pipe 19 | 20 | author="$(gh issue view "$ISSUE" --json author --jq '.author.login')" 21 | 22 | close() { 23 | gh issue edit "$ISSUE" --add-label bin 24 | gh issue close "$ISSUE" --comment "Please don't open issues directly, use GitHub Discussions instead. See: https://github.com/nikitabobko/AeroSpace/issues/947" 25 | gh issue lock "$ISSUE" 26 | } 27 | 28 | test "$author" = nikitabobko || close 29 | env: 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | GH_REPO: ${{ github.repository }} 32 | ISSUE: ${{ github.event.issue.number }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.debug 3 | /.release 4 | /.shell-completion 5 | /.site 6 | /.man 7 | /Gemfile.lock 8 | # IDK, AppCode randomly creates this EMPTY file. I have no idea what this is 9 | /default.profraw 10 | .DS_Store 11 | /.xcode-build 12 | # Swift package manager 13 | /.build 14 | /.swiftpm 15 | /.vscode 16 | # External dependencies 17 | /.deps 18 | 19 | # For whatever local files that developers might want to keep there (I personally keep a separate `generated-html` git worktree here) 20 | /.local 21 | 22 | # XCode User settings 23 | xcuserdata/ 24 | xcshareddata/ 25 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.sourcekit-lsp/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "backgroundIndexing": false, 3 | "backgroundPreparationMode": "build" 4 | } 5 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 6.1.0 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # https://github.com/nicklockwood/SwiftFormat/blob/main/Rules.md 2 | --exclude ./ShellParserGenerated 3 | --indentcase true 4 | --patternlet inline 5 | --indentstrings true 6 | 7 | # https://github.com/nicklockwood/SwiftFormat/issues/483 fix indentation for expressions nested in ternary 8 | --wrapternary before-operators 9 | 10 | --disable andOperator 11 | --disable blankLinesBetweenScopes 12 | --disable consecutiveSpaces 13 | --disable consistentSwitchCaseSpacing 14 | --disable hoistAwait 15 | --disable hoistTry 16 | --disable preferKeyPath 17 | --disable redundantInit 18 | --disable redundantNilInit 19 | --disable redundantParens 20 | --disable redundantRawValues 21 | --disable redundantReturn 22 | --disable redundantSelf 23 | --disable redundantStaticSelf 24 | --disable redundantType 25 | --disable sortImports 26 | --disable spaceAroundComments 27 | --disable spaceInsideComments 28 | --disable void 29 | --disable wrapArguments 30 | --disable wrapMultilineConditionalAssignment 31 | 32 | # This rule is cool but buggy. It feels like heuristics are used instead of real code analysis 33 | --disable unusedArguments 34 | -------------------------------------------------------------------------------- /AeroSpace.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | ruby '~> 3.0' # >= 3.0 and < 4.0 3 | 4 | source "https://rubygems.org" 5 | gem 'asciidoctor', '2.0.23' 6 | gem 'pygments.rb', '3.0' 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nikita Bobko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ce497cf9fcf14272fedb221cc48e1102e7d3c1bb2ab918cedfbfa9120f32fca3", 3 | "pins" : [ 4 | { 5 | "identity" : "antlr4", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/antlr/antlr4", 8 | "state" : { 9 | "revision" : "7ed420ff2c78d62883875c442d75f32e73bc86c8", 10 | "version" : "4.13.1" 11 | } 12 | }, 13 | { 14 | "identity" : "bluesocket", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/Kitura/BlueSocket.git", 17 | "state" : { 18 | "revision" : "7b23a867008e0027bfd6f4d398d44720707bc8ca", 19 | "version" : "2.0.4" 20 | } 21 | }, 22 | { 23 | "identity" : "hotkey", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/soffes/HotKey", 26 | "state" : { 27 | "revision" : "a3cf605d7a96f6ff50e04fcb6dea6e2613cfcbe4", 28 | "version" : "0.2.1" 29 | } 30 | }, 31 | { 32 | "identity" : "issoundadditions", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/InerziaSoft/ISSoundAdditions", 35 | "state" : { 36 | "revision" : "4b555f0354e6c280917bae8a598a258efe87ab98", 37 | "version" : "2.0.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-collections", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-collections", 44 | "state" : { 45 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 46 | "version" : "1.1.4" 47 | } 48 | }, 49 | { 50 | "identity" : "tomlkit", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/LebJe/TOMLKit", 53 | "state" : { 54 | "revision" : "404c4dd011743461bff12d00a5118d0ed59d630c", 55 | "version" : "0.5.5" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /ShellParserGenerated/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /ShellParserGenerated/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ShellParserGenerated", 8 | products: [ 9 | // Products define the executables and libraries a package produces, making them visible to other packages. 10 | .library( 11 | name: "ShellParserGenerated", 12 | targets: ["ShellParserGenerated"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/antlr/antlr4", exact: "4.13.1"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package, defining a module or a test suite. 20 | // Targets can depend on other targets in this package and products from dependencies. 21 | .target( 22 | name: "ShellParserGenerated", 23 | dependencies: [ 24 | .product(name: "Antlr4Static", package: "antlr4"), 25 | ] 26 | ), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Sources/AeroSpaceApp/AeroSpaceApp.swift: -------------------------------------------------------------------------------- 1 | import AppBundle 2 | import SwiftUI 3 | 4 | // This file is shared between SPM and xcode project 5 | 6 | @MainActor // macOS 13 7 | @main 8 | struct AeroSpaceApp: App { 9 | @MainActor // macOS 13 10 | @StateObject var viewModel = TrayMenuModel.shared 11 | 12 | init() { 13 | initAppBundle() 14 | } 15 | 16 | @MainActor // macOS 13 17 | var body: some Scene { 18 | menuBar(viewModel: viewModel) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/CmdEnv.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | struct CmdEnv: ConvenienceCopyable { // todo forward env from cli to server 4 | var windowId: UInt32? 5 | var workspaceName: String? 6 | var pwd: String? 7 | 8 | static var defaultEnv: CmdEnv { CmdEnv(windowId: nil, workspaceName: nil, pwd: nil) } 9 | init( 10 | windowId: UInt32?, 11 | workspaceName: String?, 12 | pwd: String? 13 | ) { 14 | self.windowId = windowId 15 | self.workspaceName = workspaceName 16 | self.pwd = pwd 17 | } 18 | 19 | func withFocus(_ focus: LiveFocus) -> CmdEnv { 20 | switch focus.asLeaf { 21 | case .window(let wd): .defaultEnv.copy(\.windowId, wd.windowId) 22 | case .emptyWorkspace(let ws): .defaultEnv.copy(\.workspaceName, ws.name) 23 | } 24 | } 25 | 26 | @MainActor 27 | var asMap: [String: String] { 28 | var result = config.execConfig.envVariables 29 | if let pwd { 30 | result["PWD"] = pwd 31 | } 32 | if let windowId { 33 | result[AEROSPACE_WINDOW_ID] = windowId.description 34 | } 35 | if let workspaceName { 36 | result[AEROSPACE_WORKSPACE] = workspaceName.description 37 | } 38 | return result 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/CmdIo.swift: -------------------------------------------------------------------------------- 1 | class CmdStdin { 2 | private var input: String = "" 3 | init(_ input: String) { 4 | self.input = input 5 | } 6 | static var emptyStdin: CmdStdin { .init("") } 7 | 8 | func readAll() -> String { 9 | let result = input 10 | input = "" 11 | return result 12 | } 13 | } 14 | 15 | class CmdIo { 16 | private var stdin: CmdStdin 17 | var stdout: [String] = [] 18 | var stderr: [String] = [] 19 | 20 | init(stdin: CmdStdin) { self.stdin = stdin } 21 | 22 | @discardableResult func out(_ msg: String) -> Bool { stdout.append(msg); return true } 23 | @discardableResult func err(_ msg: String) -> Bool { stderr.append(msg); return false } 24 | @discardableResult func out(_ msg: [String]) -> Bool { stdout += msg; return true } 25 | @discardableResult func err(_ msg: [String]) -> Bool { stderr += msg; return false } 26 | 27 | func readStdin() -> String { stdin.readAll() } 28 | } 29 | 30 | struct CmdResult { 31 | let stdout: [String] 32 | let stderr: [String] 33 | let exitCode: Int32 34 | } 35 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/Command.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | protocol Command: AeroAny, Equatable, Sendable { 5 | associatedtype T where T: CmdArgs 6 | var args: T { get } 7 | @MainActor 8 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool 9 | } 10 | 11 | extension Command { 12 | static func == (lhs: Self, rhs: Self) -> Bool { 13 | return lhs.args.equals(rhs.args) 14 | } 15 | 16 | nonisolated func equals(_ other: any Command) -> Bool { 17 | (other as? Self).flatMap { self == $0 } ?? false 18 | } 19 | } 20 | 21 | extension Command { 22 | var info: CmdStaticInfo { T.info } 23 | } 24 | 25 | extension Command { 26 | @MainActor 27 | @discardableResult 28 | func run(_ env: CmdEnv, _ stdin: CmdStdin) async throws -> CmdResult { 29 | return try await [self].runCmdSeq(env, stdin) 30 | } 31 | 32 | var isExec: Bool { self is ExecAndForgetCommand } 33 | } 34 | 35 | // There are 4 entry points for running commands: 36 | // 1. config keybindings 37 | // 2. CLI requests to server 38 | // 3. on-window-detected callback 39 | // 4. Tray icon buttons 40 | extension [Command] { 41 | @MainActor 42 | func runCmdSeq(_ env: CmdEnv, _ io: sending CmdIo) async throws -> Bool { 43 | var isSucc = true 44 | for command in self { 45 | isSucc = try await command.run(env, io) && isSucc 46 | refreshModel() 47 | } 48 | return isSucc 49 | } 50 | 51 | @MainActor 52 | func runCmdSeq(_ env: CmdEnv, _ stdin: CmdStdin) async throws -> CmdResult { 53 | let io: CmdIo = CmdIo(stdin: stdin) 54 | let isSucc = try await runCmdSeq(env, io) 55 | return CmdResult(stdout: io.stdout, stderr: io.stderr, exitCode: isSucc ? 0 : 1) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/cmdResolveTargetOrReportError.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | extension CmdArgs { 4 | @MainActor 5 | var workspace: Workspace? { 6 | if let workspaceName { Workspace.get(byName: workspaceName.raw) } else { nil } 7 | } 8 | 9 | @MainActor 10 | func resolveTargetOrReportError(_ env: CmdEnv, _ io: CmdIo) -> LiveFocus? { 11 | // Flags 12 | if let windowId { 13 | if let wi = Window.get(byId: windowId) { 14 | return wi.toLiveFocusOrReportError(io) 15 | } else { 16 | io.err("Invalid \(windowId) passed to --window-id") 17 | return nil 18 | } 19 | } 20 | if let workspace { 21 | return workspace.toLiveFocus() 22 | } 23 | // Env 24 | if let windowId = env.windowId { 25 | if let wi = Window.get(byId: windowId) { 26 | return wi.toLiveFocusOrReportError(io) 27 | } else { 28 | io.err("Invalid \(windowId) specified in \(AEROSPACE_WINDOW_ID) env variable") 29 | return nil 30 | } 31 | } 32 | if let wsName = env.workspaceName { 33 | return Workspace.get(byName: wsName).toLiveFocus() 34 | } 35 | // Real Focus 36 | return focus 37 | } 38 | } 39 | 40 | extension Window { 41 | @MainActor 42 | func toLiveFocusOrReportError(_ io: CmdIo) -> LiveFocus? { 43 | if let result = toLiveFocusOrNil() { 44 | return result 45 | } else { 46 | io.err("Window \(windowId) doesn't belong to any monitor. And thus can't even define a focused workspace") 47 | return nil 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/formatToJson.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | import Foundation 3 | 4 | extension [AeroObj] { 5 | @MainActor 6 | func formatToJson(_ format: [StringInterToken], ignoreRightPaddingVar: Bool) -> Result { 7 | var list: [[String: Primitive]] = [] 8 | for richObj in self { 9 | var rawObj: [String: Primitive] = [:] 10 | for token in format { 11 | switch token { 12 | case .interVar(PlainInterVar.rightPadding.rawValue) where ignoreRightPaddingVar: 13 | break 14 | case .literal: 15 | break // should be spaces 16 | case .interVar(let varName): 17 | switch varName.expandFormatVar(obj: richObj) { 18 | case .success(let expanded): rawObj[varName] = expanded 19 | case .failure(let error): return .failure(error) 20 | } 21 | } 22 | } 23 | list.append(rawObj) 24 | } 25 | return JSONEncoder.aeroSpaceDefault.encodeToString(list).map(Result.success) 26 | ?? .failure("Can't encode '\(list)' to JSON") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/BalanceSizesCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | import Foundation 4 | 5 | struct BalanceSizesCommand: Command { 6 | let args: BalanceSizesCmdArgs 7 | 8 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 9 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 10 | balance(target.workspace.rootTilingContainer) 11 | return true 12 | } 13 | } 14 | 15 | @MainActor 16 | private func balance(_ parent: TilingContainer) { 17 | for child in parent.children { 18 | switch parent.layout { 19 | case .tiles: child.setWeight(parent.orientation, 1) 20 | case .accordion: break // Do nothing 21 | } 22 | if let child = child as? TilingContainer { 23 | balance(child) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/CloseAllWindowsButCurrentCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct CloseAllWindowsButCurrentCommand: Command { 5 | let args: CloseAllWindowsButCurrentCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool { 8 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 9 | guard let focused = target.windowOrNil else { 10 | return io.err("Empty workspace") 11 | } 12 | guard let workspace = focused.nodeWorkspace else { 13 | return io.err("Focused window '\(focused.windowId)' doesn't belong to workspace") 14 | } 15 | var result = true 16 | for window in workspace.allLeafWindowsRecursive where window != focused { 17 | result = try await CloseCommand(args: args.closeArgs).run(env.copy(\.windowId, window.windowId), io) && result 18 | } 19 | return result 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/CloseCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct CloseCommand: Command { 5 | let args: CloseCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool { 8 | try await allowOnlyCancellationError { 9 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 10 | guard let window = target.windowOrNil else { 11 | return io.err("Empty workspace") 12 | } 13 | // Access ax directly. Not cool :( 14 | if try await args.quitIfLastWindow.andAsync({ @MainActor @Sendable in try await window.macAppUnsafe.getAxWindowsCount() == 1 }) { 15 | let app = window.macAppUnsafe 16 | if app.nsApp.terminate() { 17 | for workspace in Workspace.all { 18 | for window in workspace.allLeafWindowsRecursive where window.app.pid == app.pid { 19 | (window as! MacWindow).garbageCollect(skipClosedWindowsCache: true) 20 | } 21 | } 22 | return true 23 | } else { 24 | return io.err("Failed to quit '\(window.app.name ?? "Unknown app")'") 25 | } 26 | } else { 27 | window.closeAxWindow() 28 | return true 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/EnableCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct EnableCommand: Command { 5 | let args: EnableCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool { 8 | let prevState = TrayMenuModel.shared.isEnabled 9 | let newState: Bool = switch args.targetState.val { 10 | case .on: true 11 | case .off: false 12 | case .toggle: !TrayMenuModel.shared.isEnabled 13 | } 14 | if newState == prevState { 15 | io.out((newState ? "Already enabled" : "Already disabled") + 16 | "Tip: use --fail-if-noop to exit with non-zero code") 17 | return !args.failIfNoop 18 | } 19 | 20 | TrayMenuModel.shared.isEnabled = newState 21 | if newState { 22 | for workspace in Workspace.all { 23 | for window in workspace.allLeafWindowsRecursive where window.isFloating { 24 | window.lastFloatingSize = try await window.getAxSize() ?? window.lastFloatingSize 25 | } 26 | } 27 | activateMode(mainModeId) 28 | } else { 29 | activateMode(nil) 30 | } 31 | return true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ExecAndForgetCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ExecAndForgetCommand: Command { 5 | let args: ExecAndForgetCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | // todo shall exec-and-forget fork exec session? 9 | // It doesn't throw if exit code is non-zero 10 | let process = Process() 11 | process.environment = config.execConfig.envVariables 12 | process.executableURL = URL(filePath: "/bin/bash") 13 | process.arguments = ["-c", args.bashScript] 14 | return Result { try process.run() }.isSuccess 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/FlattenWorkspaceTreeCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct FlattenWorkspaceTreeCommand: Command { 5 | let args: FlattenWorkspaceTreeCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 9 | let workspace = target.workspace 10 | let windows = workspace.rootTilingContainer.allLeafWindowsRecursive 11 | for window in windows { 12 | window.bind(to: workspace.rootTilingContainer, adaptiveWeight: 1, index: INDEX_BIND_LAST) 13 | } 14 | return true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/FocusBackAndForthCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct FocusBackAndForthCommand: Command { 5 | let args: FocusBackAndForthCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | if let prevFocus { 9 | return setFocus(to: prevFocus) 10 | } else { 11 | return io.err("Prev window has been closed") 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/FullscreenCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct FullscreenCommand: Command { 5 | let args: FullscreenCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 9 | guard let window = target.windowOrNil else { 10 | return io.err(noWindowIsFocused) 11 | } 12 | let newState: Bool = switch args.toggle { 13 | case .on: true 14 | case .off: false 15 | case .toggle: !window.isFullscreen 16 | } 17 | if newState == window.isFullscreen { 18 | io.err((newState ? "Already fullscreen. " : "Already not fullscreen. ") + 19 | "Tip: use --fail-if-noop to exit with non-zero code") 20 | return !args.failIfNoop 21 | } 22 | window.isFullscreen = newState 23 | window.noOuterGapsInFullscreen = args.noOuterGaps 24 | 25 | // Focus on its own workspace 26 | window.markAsMostRecentChild() 27 | return true 28 | } 29 | } 30 | 31 | let noWindowIsFocused = "No window is focused" 32 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/JoinWithCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct JoinWithCommand: Command { 5 | let args: JoinWithCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | let direction = args.direction.val 9 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 10 | guard let currentWindow = target.windowOrNil else { 11 | return io.err(noWindowIsFocused) 12 | } 13 | guard let (parent, ownIndex) = currentWindow.closestParent(hasChildrenInDirection: direction, withLayout: nil) else { 14 | return io.err("No windows in specified direction") 15 | } 16 | let joinWithTarget = parent.children[ownIndex + direction.focusOffset] 17 | let prevBinding = joinWithTarget.unbindFromParent() 18 | let newParent = TilingContainer( 19 | parent: parent, 20 | adaptiveWeight: prevBinding.adaptiveWeight, 21 | parent.orientation.opposite, 22 | .tiles, 23 | index: prevBinding.index 24 | ) 25 | currentWindow.unbindFromParent() 26 | 27 | joinWithTarget.bind(to: newParent, adaptiveWeight: WEIGHT_AUTO, index: 0) 28 | currentWindow.bind(to: newParent, adaptiveWeight: WEIGHT_AUTO, index: direction.isPositive ? 0 : INDEX_BIND_LAST) 29 | return true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ListAppsCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ListAppsCommand: Command { 5 | let args: ListAppsCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | var result = Array(MacApp.allAppsMap.values) 9 | if let hidden = args.macosHidden { 10 | result = result.filter { $0.nsApp.isHidden == hidden } 11 | } 12 | 13 | if args.outputOnlyCount { 14 | return io.out("\(result.count)") 15 | } else { 16 | let list = result.map { AeroObj.app($0) } 17 | if args.json { 18 | return switch list.formatToJson(args.format, ignoreRightPaddingVar: args._format.isEmpty) { 19 | case .success(let json): io.out(json) 20 | case .failure(let msg): io.err(msg) 21 | } 22 | } else { 23 | return switch list.format(args.format) { 24 | case .success(let lines): io.out(lines) 25 | case .failure(let msg): io.err(msg) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ListExecEnvVarsCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ListExecEnvVarsCommand: Command { 5 | let args: ListExecEnvVarsCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | for (key, value) in config.execConfig.envVariables { 9 | io.out("\(key)=\(value)") 10 | } 11 | return true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ListModesCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ListModesCommand: Command { 5 | let args: ListModesCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | if args.current { 9 | return io.out(activeMode ?? mainModeId) 10 | } else { 11 | let modeNames: [String] = config.modes.map { $0.key } 12 | return io.out(modeNames) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ListMonitorsCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ListMonitorsCommand: Command { 5 | let args: ListMonitorsCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | let focus = focus 9 | var result = sortedMonitors 10 | if let focused = args.focused { 11 | result = result.filter { (monitor) in (monitor.activeWorkspace == focus.workspace) == focused } 12 | } 13 | if let mouse = args.mouse { 14 | let mouseWorkspace = mouseLocation.monitorApproximation.activeWorkspace 15 | result = result.filter { (monitor) in (monitor.activeWorkspace == mouseWorkspace) == mouse } 16 | } 17 | 18 | if args.outputOnlyCount { 19 | return io.out("\(result.count)") 20 | } else { 21 | let list = result.map { AeroObj.monitor($0) } 22 | if args.json { 23 | return switch list.formatToJson(args.format, ignoreRightPaddingVar: args._format.isEmpty) { 24 | case .success(let json): io.out(json) 25 | case .failure(let msg): io.err(msg) 26 | } 27 | } else { 28 | return switch list.format(args.format) { 29 | case .success(let lines): io.out(lines) 30 | case .failure(let msg): io.err(msg) 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/MacosNativeFullscreenCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | /// Problem ID-B6E178F2: It's not first-class citizen command in AeroSpace model, since it interacts with macOS API directly. 5 | /// Consecutive macos-native-fullscreen commands may not works as expected (because macOS may report correct state with a 6 | /// delay), or may flicker 7 | /// 8 | /// The same applies to macos-native-minimize command 9 | struct MacosNativeFullscreenCommand: Command { 10 | let args: MacosNativeFullscreenCmdArgs 11 | 12 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool { 13 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 14 | guard let window = target.windowOrNil else { 15 | return io.err(noWindowIsFocused) 16 | } 17 | let prevState = try await window.isMacosFullscreen 18 | let newState: Bool = switch args.toggle { 19 | case .on: true 20 | case .off: false 21 | case .toggle: !prevState 22 | } 23 | if newState == prevState { 24 | io.err((newState ? "Already fullscreen. " : "Already not fullscreen. ") + 25 | "Tip: use --fail-if-noop to exit with non-zero exit code") 26 | return !args.failIfNoop 27 | } 28 | window.asMacWindow().setNativeFullscreen(newState) 29 | guard let workspace = window.visualWorkspace else { 30 | return io.err(windowIsntPartOfTree(window)) 31 | } 32 | if newState { // Enter fullscreen 33 | window.bind(to: workspace.macOsNativeFullscreenWindowsContainer, adaptiveWeight: 1, index: INDEX_BIND_LAST) 34 | } else { // Exit fullscreen 35 | switch window.layoutReason { 36 | case .macos(let prevParentKind): 37 | try await exitMacOsNativeUnconventionalState(window: window, prevParentKind: prevParentKind, workspace: workspace) 38 | default: 39 | try await window.relayoutWindow(on: workspace) 40 | } 41 | } 42 | return true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/MacosNativeMinimizeCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | /// See: MacosNativeFullscreenCommand. Problem ID-B6E178F2 5 | struct MacosNativeMinimizeCommand: Command { 6 | let args: MacosNativeMinimizeCmdArgs 7 | 8 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool { 9 | // resolveTargetOrReportError on already minimized windows will alwyas fail 10 | // It would be easier if minimized windows were part of the workspace in tree hierarchy 11 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 12 | guard let window = target.windowOrNil else { 13 | return io.err(noWindowIsFocused) 14 | } 15 | let newState: Bool = try await !window.isMacosMinimized 16 | window.asMacWindow().setNativeMinimized(newState) 17 | if newState { // minimize 18 | window.bind(to: macosMinimizedWindowsContainer, adaptiveWeight: 1, index: INDEX_BIND_LAST) 19 | return true 20 | } else { // unminimize 21 | return io.err("The command is uncapable of unminimizing windows yet. Sorry") // dead code. should never be possible, see the comment above 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ModeCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ModeCommand: Command { 5 | let args: ModeCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | activateMode(args.targetMode.val) 9 | return true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/MoveNodeToMonitorCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct MoveNodeToMonitorCommand: Command { 5 | let args: MoveNodeToMonitorCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 9 | guard let window = target.windowOrNil else { 10 | return io.err(noWindowIsFocused) 11 | } 12 | guard let currentMonitor = window.nodeMonitor else { 13 | return io.err(windowIsntPartOfTree(window)) 14 | } 15 | switch args.target.val.resolve(currentMonitor, wrapAround: args.wrapAround) { 16 | case .success(let targetMonitor): 17 | if let wName = WorkspaceName.parse(targetMonitor.activeWorkspace.name).getOrNil(appendErrorTo: &io.stderr) { 18 | let moveNodeToWorkspace = args.moveNodeToWorkspace.copy(\.target, .initialized(.direct(wName))) 19 | return MoveNodeToWorkspaceCommand(args: moveNodeToWorkspace).run(env, io) 20 | } else { 21 | return false 22 | } 23 | case .failure(let msg): 24 | return io.err(msg) 25 | } 26 | } 27 | } 28 | 29 | func windowIsntPartOfTree(_ window: Window) -> String { 30 | "Window \(window.windowId) is not part of tree (minimized or hidden)" 31 | } 32 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/MoveNodeToWorkspaceCommand.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | struct MoveNodeToWorkspaceCommand: Command { 4 | let args: MoveNodeToWorkspaceCmdArgs 5 | 6 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 7 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 8 | guard let window = target.windowOrNil else { return io.err(noWindowIsFocused) } 9 | let subjectWs = window.nodeWorkspace 10 | let targetWorkspace: Workspace 11 | switch args.target.val { 12 | case .relative(let isNext): 13 | guard let subjectWs else { return io.err("Window \(window.windowId) doesn't belong to any workspace") } 14 | let ws = getNextPrevWorkspace( 15 | current: subjectWs, 16 | isNext: isNext, 17 | wrapAround: args.wrapAround, 18 | stdin: io.readStdin(), 19 | target: target 20 | ) 21 | guard let ws else { return io.err("Can't resolve next or prev workspace") } 22 | targetWorkspace = ws 23 | case .direct(let name): 24 | targetWorkspace = Workspace.get(byName: name.raw) 25 | } 26 | if subjectWs == targetWorkspace { 27 | io.err("Window '\(window.windowId)' already belongs to workspace '\(targetWorkspace.name)'. Tip: use --fail-if-noop to exit with non-zero code") 28 | return !args.failIfNoop 29 | } 30 | let targetContainer: NonLeafTreeNodeObject = window.isFloating ? targetWorkspace : targetWorkspace.rootTilingContainer 31 | 32 | window.bind(to: targetContainer, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST) 33 | return args.focusFollowsWindow ? window.focusWindow() : true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/MoveWorkspaceToMonitorCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct MoveWorkspaceToMonitorCommand: Command { 5 | let args: MoveWorkspaceToMonitorCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | guard let target = args.resolveTargetOrReportError(env, io) else { return false } 9 | let focusedWorkspace = target.workspace 10 | let prevMonitor = focusedWorkspace.workspaceMonitor 11 | 12 | switch args.target.val.resolve(target.workspace.workspaceMonitor, wrapAround: args.wrapAround) { 13 | case .success(let targetMonitor): 14 | if targetMonitor.monitorId == prevMonitor.monitorId { 15 | return true 16 | } 17 | if targetMonitor.setActiveWorkspace(focusedWorkspace) { 18 | let stubWorkspace = getStubWorkspace(for: prevMonitor) 19 | check( 20 | prevMonitor.setActiveWorkspace(stubWorkspace), 21 | "getStubWorkspace generated incompatible stub workspace (\(stubWorkspace)) for the monitor (\(prevMonitor)" 22 | ) 23 | return true 24 | } else { 25 | return io.err( 26 | "Can't move workspace '\(focusedWorkspace.name)' to monitor '\(targetMonitor.name)'. workspace-to-monitor-force-assignment doesn't allow it" 27 | ) 28 | } 29 | case .failure(let msg): 30 | return io.err(msg) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/ReloadConfigCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct ReloadConfigCommand: Command { 5 | let args: ReloadConfigCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | var stdout = "" 9 | let isOk = reloadConfig(args: args, stdout: &stdout) 10 | if !stdout.isEmpty { 11 | io.out(stdout) 12 | } 13 | return isOk 14 | } 15 | } 16 | 17 | @MainActor func reloadConfig(forceConfigUrl: URL? = nil) -> Bool { 18 | var devNull = "" 19 | return reloadConfig(forceConfigUrl: forceConfigUrl, stdout: &devNull) 20 | } 21 | 22 | @MainActor func reloadConfig( 23 | args: ReloadConfigCmdArgs = ReloadConfigCmdArgs(rawArgs: []), 24 | forceConfigUrl: URL? = nil, 25 | stdout: inout String 26 | ) -> Bool { 27 | switch readConfig(forceConfigUrl: forceConfigUrl) { 28 | case .success(let (parsedConfig, url)): 29 | if !args.dryRun { 30 | resetHotKeys() 31 | config = parsedConfig 32 | configUrl = url 33 | activateMode(activeMode) 34 | syncStartAtLogin() 35 | } 36 | return true 37 | case .failure(let msg): 38 | stdout.append(msg) 39 | if !args.noGui { 40 | showMessageInGui( 41 | filenameIfConsoleApp: nil, 42 | title: "AeroSpace Config Error", 43 | message: msg 44 | ) 45 | } 46 | return false 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/SummonWorkspaceCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct SummonWorkspaceCommand: Command { 5 | let args: SummonWorkspaceCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | let workspace = Workspace.get(byName: args.target.val.raw) 9 | let monitor = focus.workspace.workspaceMonitor 10 | if monitor.activeWorkspace == workspace { 11 | io.err("Workspace '\(workspace.name)' is already visible on the focused monitor. Tip: use --fail-if-noop to exit with non-zero code") 12 | return !args.failIfNoop 13 | } 14 | if monitor.setActiveWorkspace(workspace) { 15 | return workspace.focusWorkspace() 16 | } else { 17 | return io.err("Can't move workspace '\(workspace.name)' to monitor '\(monitor.name)'. workspace-to-monitor-force-assignment doesn't allow it") 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/TriggerBindingCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct TriggerBindingCommand: Command { 5 | let args: TriggerBindingCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool { 8 | return if let mode = config.modes[args.mode] { 9 | if let binding = mode.bindings.values.first(where: { $0.descriptionWithKeyNotation == args.binding.val }) { 10 | // refreshSession is not needed since commands are already run in refreshSession 11 | try await binding.commands.runCmdSeq(env, io) 12 | } else { 13 | io.err("Binding '\(args.binding.val)' is not presented in mode '\(args.mode)'") 14 | } 15 | } else { 16 | io.err("Mode '\(args.mode)' doesn't exist. " + 17 | "Available modes: \(config.modes.keys.joined(separator: ","))") 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/VolumeCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | import ISSoundAdditions 4 | 5 | struct VolumeCommand: Command { 6 | let args: VolumeCmdArgs 7 | 8 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 9 | switch args.action.val { 10 | case .up: 11 | Sound.output.increaseVolume(by: 0.0625, autoMuteUnmute: true) 12 | case .down: 13 | Sound.output.decreaseVolume(by: 0.0625, autoMuteUnmute: true) 14 | case .muteToggle: 15 | Sound.output.isMuted.toggle() 16 | case .muteOn: 17 | Sound.output.isMuted = true 18 | case .muteOff: 19 | Sound.output.isMuted = false 20 | case .set(let int): 21 | Sound.output.setVolume(Float(int) / 100, autoMuteUnmute: true) 22 | } 23 | return true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/impl/WorkspaceBackAndForthCommand.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct WorkspaceBackAndForthCommand: Command { 5 | let args: WorkspaceBackAndForthCmdArgs 6 | 7 | func run(_ env: CmdEnv, _ io: CmdIo) -> Bool { 8 | return prevFocusedWorkspace?.focusWorkspace() != nil 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/AppBundle/command/parseCommand.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | import TOMLKit 3 | 4 | func parseCommand(_ raw: String) -> ParsedCmd { 5 | if raw.starts(with: "exec-and-forget") { 6 | return .cmd(ExecAndForgetCommand(args: ExecAndForgetCmdArgs(bashScript: raw.removePrefix("exec-and-forget")))) 7 | } 8 | return switch raw.splitArgs() { 9 | case .success(let args): parseCommand(args) 10 | case .failure(let fail): .failure(fail) 11 | } 12 | } 13 | 14 | func parseCommand(_ args: [String]) -> ParsedCmd { 15 | parseCmdArgs(args).map { $0.toCommand() } 16 | } 17 | 18 | func expectedActualTypeError(expected: TOMLType, actual: TOMLType) -> String { 19 | "Expected type is '\(expected)'. But actual type is '\(actual)'" 20 | } 21 | 22 | func expectedActualTypeError(expected: [TOMLType], actual: TOMLType) -> String { 23 | if let single = expected.singleOrNil() { 24 | return expectedActualTypeError(expected: single, actual: actual) 25 | } else { 26 | return "Expected types are \(expected.map { "'\($0.description)'" }.joined(separator: " or ")). But actual type is '\(actual)'" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/AppBundle/config/ConfigFile.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | import Foundation 3 | 4 | let configDotfileName = isDebug ? ".aerospace-debug.toml" : ".aerospace.toml" 5 | func findCustomConfigUrl() -> ConfigFile { 6 | let fileName = isDebug ? "aerospace-debug.toml" : "aerospace.toml" 7 | let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"]?.lets { URL(filePath: $0) } 8 | ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/") 9 | let candidates: [URL] = if let configLocation = serverArgs.configLocation { 10 | [URL(filePath: configLocation)] 11 | } else { 12 | [ 13 | FileManager.default.homeDirectoryForCurrentUser.appending(path: configDotfileName), 14 | xdgConfigHome.appending(path: "aerospace").appending(path: fileName), 15 | ] 16 | } 17 | let existingCandidates: [URL] = candidates.filter { (candidate: URL) in FileManager.default.fileExists(atPath: candidate.path) } 18 | let count = existingCandidates.count 19 | return switch count { 20 | case 0: .noCustomConfigExists 21 | case 1: .file(existingCandidates.first!) 22 | default: .ambiguousConfigError(existingCandidates) 23 | } 24 | } 25 | 26 | enum ConfigFile { 27 | case file(URL), ambiguousConfigError(_ candidates: [URL]), noCustomConfigExists 28 | 29 | var urlOrNil: URL? { 30 | return switch self { 31 | case .file(let url): url 32 | case .ambiguousConfigError, .noCustomConfigExists: nil 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/AppBundle/config/Mode.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | import HotKey 3 | import TOMLKit 4 | 5 | struct Mode: ConvenienceCopyable, Equatable, Sendable { 6 | /// User visible name. Optional. todo drop it? 7 | var name: String? 8 | var bindings: [String: HotkeyBinding] 9 | 10 | static let zero = Mode(name: nil, bindings: [:]) 11 | } 12 | 13 | func parseModes(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError], _ mapping: [String: Key]) -> [String: Mode] { 14 | guard let rawTable = raw.table else { 15 | errors += [expectedActualTypeError(expected: .table, actual: raw.type, backtrace)] 16 | return [:] 17 | } 18 | var result: [String: Mode] = [:] 19 | for (key, value) in rawTable { 20 | result[key] = parseMode(value, backtrace + .key(key), &errors, mapping) 21 | } 22 | if !result.keys.contains(mainModeId) { 23 | errors += [.semantic(backtrace, "Please specify '\(mainModeId)' mode")] 24 | } 25 | return result 26 | } 27 | 28 | func parseMode(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError], _ mapping: [String: Key]) -> Mode { 29 | guard let rawTable: TOMLTable = raw.table else { 30 | errors += [expectedActualTypeError(expected: .table, actual: raw.type, backtrace)] 31 | return .zero 32 | } 33 | 34 | var result: Mode = .zero 35 | for (key, value) in rawTable { 36 | let backtrace = backtrace + .key(key) 37 | switch key { 38 | case "binding": 39 | result.bindings = parseBindings(value, backtrace, &errors, mapping) 40 | default: 41 | errors += [unknownKeyError(backtrace)] 42 | } 43 | } 44 | return result 45 | } 46 | -------------------------------------------------------------------------------- /Sources/AppBundle/config/parseWorkspaceToMonitorAssignment.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | import TOMLKit 3 | 4 | func parseWorkspaceToMonitorAssignment(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError]) -> [String: [MonitorDescription]] { 5 | guard let rawTable = raw.table else { 6 | errors += [expectedActualTypeError(expected: .table, actual: raw.type, backtrace)] 7 | return [:] 8 | } 9 | var result: [String: [MonitorDescription]] = [:] 10 | for (workspaceName, rawMonitorDescription) in rawTable { 11 | result[workspaceName] = parseMonitorDescriptions(rawMonitorDescription, backtrace + .key(workspaceName), &errors) 12 | } 13 | return result 14 | } 15 | 16 | func parseMonitorDescriptions(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError]) -> [MonitorDescription] { 17 | if let array = raw.array { 18 | return array.enumerated() 19 | .map { (index, rawDesc) in parseMonitorDescription(rawDesc, backtrace + .index(index)).getOrNil(appendErrorTo: &errors) } 20 | .filterNotNil() 21 | } else { 22 | return parseMonitorDescription(raw, backtrace).getOrNil(appendErrorTo: &errors).asList() 23 | } 24 | } 25 | 26 | func parseMonitorDescription(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml { 27 | let rawString: String 28 | if let string = raw.string { 29 | rawString = string 30 | } else if let int = raw.int { 31 | rawString = String(int) 32 | } else { 33 | return .failure(expectedActualTypeError(expected: [.string, .int], actual: raw.type, backtrace)) 34 | } 35 | 36 | return parseMonitorDescription(rawString).toParsedToml(backtrace) 37 | } 38 | -------------------------------------------------------------------------------- /Sources/AppBundle/config/startAtLogin.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | @MainActor 5 | func syncStartAtLogin() { 6 | let launchAgentsDir = FileManager.default.homeDirectoryForCurrentUser.appending(component: "Library/LaunchAgents/") 7 | Result { try FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true) }.getOrDie() 8 | let url: URL = launchAgentsDir.appending(path: "bobko.aerospace.plist") 9 | if config.startAtLogin { 10 | let plist = 11 | """ 12 | 13 | 14 | 15 | 16 | Label 17 | \(aeroSpaceAppId) 18 | ProgramArguments 19 | 20 | \(URL(filePath: CommandLine.arguments.first ?? dieT("Can't get first argument")).absoluteURL.path) 21 | --started-at-login 22 | 23 | RunAtLoad 24 | 25 | 26 | 27 | """ 28 | if plist != (try? String(contentsOf: url)) { 29 | Result { try plist.write(to: url, atomically: false, encoding: .utf8) }.getOrDie("Can't write to \(url) ") 30 | } 31 | } else { 32 | try? FileManager.default.removeItem(at: url) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/AppBundle/focusCache.swift: -------------------------------------------------------------------------------- 1 | @MainActor private var lastKnownNativeFocusedWindowId: UInt32? = nil 2 | 3 | /// The data should flow (from nativeFocused to focused) and 4 | /// (from nativeFocused to lastKnownNativeFocusedWindowId) 5 | /// Alternative names: takeFocusFromMacOs, syncFocusFromMacOs 6 | @MainActor func updateFocusCache(_ nativeFocused: Window?) { 7 | if nativeFocused?.windowId != lastKnownNativeFocusedWindowId { 8 | _ = nativeFocused?.focusWindow() 9 | lastKnownNativeFocusedWindowId = nativeFocused?.windowId 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/AppBundle/getNativeFocusedWindow.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | @MainActor 5 | var appForTests: (any AbstractApp)? = nil 6 | 7 | @MainActor 8 | private var focusedApp: (any AbstractApp)? { 9 | get async throws { 10 | if isUnitTest { 11 | return appForTests 12 | } else { 13 | check(appForTests == nil) 14 | return try await NSWorkspace.shared.frontmostApplication.flatMapAsyncMainActor(MacApp.getOrRegister) 15 | } 16 | } 17 | } 18 | 19 | @MainActor 20 | func getNativeFocusedWindow() async throws -> Window? { 21 | try await focusedApp?.getFocusedWindow() 22 | } 23 | -------------------------------------------------------------------------------- /Sources/AppBundle/model/MonitorDescriptionEx.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | extension MonitorDescription { 4 | func resolveMonitor(sortedMonitors: [Monitor]) -> Monitor? { 5 | return switch self { 6 | case .sequenceNumber(let number): sortedMonitors.getOrNil(atIndex: number - 1) 7 | case .main: mainMonitor 8 | case .pattern(_, let regex): sortedMonitors.first { monitor in monitor.name.contains(regex.val) } 9 | case .secondary: 10 | sortedMonitors.takeIf { $0.count == 2 }? 11 | .first { $0.rect.topLeftCorner != mainMonitor.rect.topLeftCorner } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AppBundle/model/MonitorEx.swift: -------------------------------------------------------------------------------- 1 | extension Monitor { 2 | @MainActor 3 | var visibleRectPaddedByOuterGaps: Rect { 4 | let topLeft = visibleRect.topLeftCorner 5 | let gaps = ResolvedGaps(gaps: config.gaps, monitor: self) 6 | return Rect( 7 | topLeftX: topLeft.x + gaps.outer.left.toDouble(), 8 | topLeftY: topLeft.y + gaps.outer.top.toDouble(), 9 | width: visibleRect.width - gaps.outer.left.toDouble() - gaps.outer.right.toDouble(), 10 | height: visibleRect.height - gaps.outer.top.toDouble() - gaps.outer.bottom.toDouble() 11 | ) 12 | } 13 | 14 | /// todo make 1-based 15 | /// 0-based index 16 | var monitorId: Int? { 17 | let sorted = sortedMonitors 18 | let origin = self.rect.topLeftCorner 19 | return sorted.firstIndex { $0.rect.topLeftCorner == origin } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/AppBundle/model/Rect.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | struct Rect: ConvenienceCopyable { 5 | var topLeftX: CGFloat 6 | var topLeftY: CGFloat 7 | var width: CGFloat 8 | var height: CGFloat 9 | } 10 | 11 | extension CGRect { 12 | func monitorFrameNormalized() -> Rect { 13 | let mainMonitorHeight: CGFloat = mainMonitor.height 14 | let rect = toRect() 15 | return rect.copy(\.topLeftY, mainMonitorHeight - rect.topLeftY) 16 | } 17 | } 18 | 19 | extension CGRect { 20 | func toRect() -> Rect { 21 | Rect(topLeftX: minX, topLeftY: maxY, width: width, height: height) 22 | } 23 | } 24 | 25 | extension Rect { 26 | func contains(_ point: CGPoint) -> Bool { 27 | let x = point.x 28 | let y = point.y 29 | return (minX ..< maxX).contains(x) && (minY ..< maxY).contains(y) 30 | } 31 | 32 | var center: CGPoint { 33 | CGPoint(x: topLeftX + width / 2, y: topLeftY + height / 2) 34 | } 35 | 36 | var topLeftCorner: CGPoint { CGPoint(x: topLeftX, y: topLeftY) } 37 | var topRightCorner: CGPoint { CGPoint(x: maxX, y: minY) } 38 | var bottomRightCorner: CGPoint { CGPoint(x: maxX, y: maxY) } 39 | var bottomLeftCorner: CGPoint { CGPoint(x: minX, y: maxY) } 40 | 41 | var minY: CGFloat { topLeftY } 42 | var maxY: CGFloat { topLeftY + height } 43 | var minX: CGFloat { topLeftX } 44 | var maxX: CGFloat { topLeftX + width } 45 | 46 | var size: CGSize { CGSize(width: width, height: height) } 47 | 48 | func getDimension(_ orientation: Orientation) -> CGFloat { orientation == .h ? width : height } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/AppBundle/mouse/mouse.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | @MainActor var currentlyManipulatedWithMouseWindowId: UInt32? = nil 4 | var isLeftMouseButtonDown: Bool { NSEvent.pressedMouseButtons == 1 } 5 | 6 | @MainActor 7 | func isManipulatedWithMouse(_ window: Window) async throws -> Bool { 8 | try await (!window.isHiddenInCorner && // Don't allow to resize/move windows of hidden workspaces 9 | isLeftMouseButtonDown && 10 | (currentlyManipulatedWithMouseWindowId == nil || window.windowId == currentlyManipulatedWithMouseWindowId)) 11 | .andAsync { @Sendable @MainActor in try await getNativeFocusedWindow() == window } 12 | } 13 | 14 | /// Same motivation as in monitorFrameNormalized 15 | var mouseLocation: CGPoint { 16 | let mainMonitorHeight: CGFloat = mainMonitor.height 17 | let location = NSEvent.mouseLocation 18 | return location.copy(\.y, mainMonitorHeight - location.y) 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AppBundle/tree/AbstractApp.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | protocol AbstractApp: AnyObject, Hashable, AeroAny { 4 | var pid: Int32 { get } 5 | var bundleId: String? { get } 6 | 7 | @MainActor func getFocusedWindow() async throws -> Window? 8 | var name: String? { get } 9 | var execPath: String? { get } 10 | var bundlePath: String? { get } 11 | } 12 | 13 | extension AbstractApp { 14 | static func == (lhs: Self, rhs: Self) -> Bool { 15 | if lhs.pid == rhs.pid { 16 | check(lhs === rhs) 17 | return true 18 | } else { 19 | check(lhs !== rhs) 20 | return false 21 | } 22 | } 23 | 24 | func hash(into hasher: inout Hasher) { 25 | hasher.combine(pid) 26 | } 27 | } 28 | 29 | extension Window { 30 | var macAppUnsafe: MacApp { app as! MacApp } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/AppBundle/tree/MacosUnconventionalWindowsContainer.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | class MacosFullscreenWindowsContainer: TreeNode, NonLeafTreeNodeObject { 4 | @MainActor 5 | init(parent: Workspace) { 6 | super.init(parent: parent, adaptiveWeight: 1, index: INDEX_BIND_LAST) 7 | } 8 | } 9 | 10 | /// The container for macOS windows of hidden apps 11 | class MacosHiddenAppsWindowsContainer: TreeNode, NonLeafTreeNodeObject { 12 | @MainActor 13 | init(parent: Workspace) { 14 | super.init(parent: parent, adaptiveWeight: 1, index: INDEX_BIND_LAST) 15 | } 16 | } 17 | 18 | @MainActor let macosMinimizedWindowsContainer = MacosMinimizedWindowsContainer() 19 | class MacosMinimizedWindowsContainer: TreeNode, NonLeafTreeNodeObject { 20 | @MainActor 21 | fileprivate init() { 22 | super.init(parent: NilTreeNode.instance, adaptiveWeight: 1, index: INDEX_BIND_LAST) 23 | } 24 | } 25 | 26 | @MainActor let macosPopupWindowsContainer = MacosPopupWindowsContainer() 27 | /// The container for macOS objects that are windows from AX perspective but from human perspective they are not even 28 | /// dialogs. E.g. Sonoma (macOS 14) keyboard layout switch 29 | class MacosPopupWindowsContainer: TreeNode, NonLeafTreeNodeObject { 30 | @MainActor 31 | fileprivate init() { 32 | super.init(parent: NilTreeNode.instance, adaptiveWeight: 1, index: INDEX_BIND_LAST) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/AppBundle/tree/frozen/FrozenTreeNode.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | enum FrozenTreeNode: Sendable { 5 | case container(FrozenContainer) 6 | case window(FrozenWindow) 7 | } 8 | 9 | struct FrozenContainer: Sendable { 10 | let children: [FrozenTreeNode] 11 | let layout: Layout 12 | let orientation: Orientation 13 | let weight: CGFloat 14 | 15 | @MainActor init(_ container: TilingContainer) { 16 | children = container.children.map { 17 | switch $0.nodeCases { 18 | case .window(let w): .window(FrozenWindow(w)) 19 | case .tilingContainer(let c): .container(FrozenContainer(c)) 20 | case .workspace, 21 | .macosMinimizedWindowsContainer, 22 | .macosHiddenAppsWindowsContainer, 23 | .macosFullscreenWindowsContainer, 24 | .macosPopupWindowsContainer: 25 | illegalChildParentRelation(child: $0, parent: container) 26 | } 27 | } 28 | layout = container.layout 29 | orientation = container.orientation 30 | weight = getWeightOrNil(container) ?? 1 31 | } 32 | } 33 | 34 | struct FrozenWindow: Sendable { 35 | let id: UInt32 36 | let weight: CGFloat 37 | 38 | @MainActor init(_ window: Window) { 39 | id = window.windowId 40 | weight = getWeightOrNil(window) ?? 1 41 | } 42 | } 43 | 44 | @MainActor private func getWeightOrNil(_ node: TreeNode) -> CGFloat? { 45 | ((node.parent as? TilingContainer)?.orientation).map { node.getWeight($0) } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/AppBundle/tree/frozen/FrozenWorld.swift: -------------------------------------------------------------------------------- 1 | struct FrozenWorld { 2 | let workspaces: [FrozenWorkspace] 3 | let monitors: [FrozenMonitor] 4 | let windowIds: Set 5 | 6 | init(workspaces: [FrozenWorkspace], monitors: [FrozenMonitor]) { 7 | self.workspaces = workspaces 8 | self.monitors = monitors 9 | self.windowIds = workspaces.flatMap { collectAllWindowIds(workspace: $0) }.toSet() 10 | } 11 | } 12 | 13 | private func collectAllWindowIds(workspace: FrozenWorkspace) -> [UInt32] { 14 | workspace.floatingWindows.map { $0.id } + 15 | workspace.macosUnconventionalWindows.map { $0.id } + 16 | collectAllWindowIdsRecursive(node: .container(workspace.rootTilingNode)) 17 | } 18 | 19 | private func collectAllWindowIdsRecursive(node: FrozenTreeNode) -> [UInt32] { 20 | switch node { 21 | case .window(let w): [w.id] 22 | case .container(let c): 23 | c.children.reduce(into: [UInt32]()) { partialResult, elem in 24 | partialResult += collectAllWindowIdsRecursive(node: elem) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/AppBundle/tree/normalizeContainers.swift: -------------------------------------------------------------------------------- 1 | extension Workspace { 2 | @MainActor func normalizeContainers() { 3 | rootTilingContainer.unbindEmptyAndAutoFlatten() // Beware! rootTilingContainer may change after this line of code 4 | if config.enableNormalizationOppositeOrientationForNestedContainers { 5 | rootTilingContainer.normalizeOppositeOrientationForNestedContainers() 6 | } 7 | } 8 | } 9 | 10 | private extension TilingContainer { 11 | @MainActor func unbindEmptyAndAutoFlatten() { 12 | if let child = children.singleOrNil(), config.enableNormalizationFlattenContainers && (child is TilingContainer || !isRootContainer) { 13 | child.unbindFromParent() 14 | let mru = parent?.mostRecentChild 15 | let previousBinding = unbindFromParent() 16 | child.bind(to: previousBinding.parent, adaptiveWeight: previousBinding.adaptiveWeight, index: previousBinding.index) 17 | (child as? TilingContainer)?.unbindEmptyAndAutoFlatten() 18 | if mru != self { 19 | mru?.markAsMostRecentChild() 20 | } else { 21 | child.markAsMostRecentChild() 22 | } 23 | } else { 24 | for child in children { 25 | (child as? TilingContainer)?.unbindEmptyAndAutoFlatten() 26 | } 27 | if children.isEmpty && !isRootContainer { 28 | unbindFromParent() 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/AppBundle/ui/AppearanceTheme.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Common 3 | 4 | enum AppearanceTheme { 5 | case light 6 | case dark 7 | 8 | /// System Settings -> Appearance -> Light/Dark 9 | /// This is the theme representing how the UI should look inside the app (this might be different than the menu bar color) 10 | @MainActor 11 | static var current: AppearanceTheme { 12 | let name = NSApplication.shared.effectiveAppearance.name 13 | let isDarkAppearance = name == .vibrantDark || 14 | name == .darkAqua || 15 | name == .accessibilityHighContrastDarkAqua || 16 | name == .accessibilityHighContrastVibrantDark 17 | return isDarkAppearance ? .dark : .light 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/AeroAny.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | extension TreeNode: AeroAny {} 5 | extension CGFloat: AeroAny {} 6 | extension Rect: AeroAny {} 7 | extension AXUIElement: AeroAny {} 8 | extension CGPoint: AeroAny {} 9 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/ArrayEx.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | extension Array { 4 | func singleOrNil(where predicate: (Self.Element) throws -> Bool) rethrows -> Self.Element? { 5 | var found: Self.Element? = nil 6 | for elem in self where try predicate(elem) { 7 | if found == nil { 8 | found = elem 9 | } else { 10 | return nil 11 | } 12 | } 13 | return found 14 | } 15 | 16 | func firstOrDie(where predicate: (Self.Element) throws -> Bool) rethrows -> Self.Element { 17 | try first(where: predicate) ?? dieT("Can't find the element") 18 | } 19 | } 20 | 21 | extension Array where Self.Element: Equatable { 22 | @discardableResult 23 | mutating func remove(element: Self.Element) -> Int? { 24 | if let index = firstIndex(of: element) { 25 | remove(at: index) 26 | return index 27 | } else { 28 | return nil 29 | } 30 | } 31 | } 32 | 33 | func - (lhs: [T], rhs: [T]) -> [T] where T: Hashable { 34 | let r = rhs.toSet() 35 | return lhs.filter { !r.contains($0) } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/AxUiElementMock.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Common 3 | 4 | /// Alternative name: AttrAddressibleStorage 5 | protocol AxUiElementMock { 6 | func get(_ attr: Attr) -> Attr.T? 7 | func containingWindowId() -> CGWindowID? 8 | } 9 | 10 | extension AxUiElementMock { 11 | var cast: AXUIElement { self as! AXUIElement } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/LazySequenceProtocolEx.swift: -------------------------------------------------------------------------------- 1 | extension LazySequenceProtocol { 2 | func filterNotNil() -> LazyMapSequence.Elements, Unwrapped> where Element == Unwrapped? { 3 | filter { $0 != nil }.map { $0! } 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/MruStack.swift: -------------------------------------------------------------------------------- 1 | /// Stack with most recently element on top 2 | class MruStack: Sequence { 3 | typealias Element = T 4 | 5 | private var mruNode: Node? = nil 6 | 7 | func makeIterator() -> MruStackIterator { 8 | MruStackIterator(mruNode) 9 | } 10 | 11 | var mostRecent: T? { mruNode?.value } 12 | 13 | func pushOrRaise(_ value: T) { 14 | remove(value) 15 | mruNode = Node(value, mruNode) 16 | } 17 | 18 | @discardableResult 19 | func remove(_ value: T) -> Bool { 20 | var prev: Node? = nil 21 | var current = mruNode 22 | while let cur = current { 23 | if cur.value == value { 24 | if let prev { 25 | prev.next = cur.next 26 | } else { 27 | mruNode = current?.next 28 | } 29 | cur.next = nil 30 | return true 31 | } 32 | prev = cur 33 | current = cur.next 34 | } 35 | return false 36 | } 37 | } 38 | 39 | extension MruStack where T: Hashable { 40 | var mruIndexMap: [T: Int] { 41 | var result: [T: Int] = [:] 42 | for (index, value) in enumerated() { 43 | result[value] = index 44 | } 45 | return result 46 | } 47 | } 48 | 49 | struct MruStackIterator: IteratorProtocol { 50 | typealias Element = T 51 | private var current: Node? 52 | 53 | fileprivate init(_ current: Node?) { 54 | self.current = current 55 | } 56 | 57 | mutating func next() -> T? { 58 | let result = current?.value 59 | current = current?.next 60 | return result 61 | } 62 | } 63 | 64 | private class Node { 65 | var next: Node? = nil 66 | let value: T 67 | 68 | init(_ value: T, _ next: Node?) { 69 | self.value = value 70 | self.next = next 71 | } 72 | 73 | init(_ value: T) { 74 | self.value = value 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/NSRunningApplicationEx.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSRunningApplication { 4 | var idForDebug: String { 5 | "PID: \(processIdentifier) ID: \(bundleIdentifier ?? executableURL?.description ?? "")" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/NsApplicationEx.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSApplication.ActivationPolicy { 4 | var prettyDescription: String { 5 | switch self { 6 | case .accessory: "accessory" 7 | case .prohibited: " prohibited" 8 | case .regular: "regular" 9 | @unknown default: "unknown \(self.rawValue)" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/SetEx.swift: -------------------------------------------------------------------------------- 1 | extension Set { 2 | func toArray() -> [Element] { Array(self) } 3 | 4 | @inlinable static func += (lhs: inout Set, rhs: any Sequence) { 5 | lhs.formUnion(rhs) 6 | } 7 | 8 | @inlinable static func -= (lhs: inout Set, rhs: any Sequence) { 9 | lhs.subtract(rhs) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/ThreadGuardedValue.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | 3 | final class ThreadGuardedValue: Sendable { 4 | private nonisolated(unsafe) var _threadGuarded: Value? 5 | private let threadToken: AxAppThreadToken = axTaskLocalAppThreadToken ?? dieT("axTaskLocalAppThreadToken is not initialized") 6 | init(_ value: Value) { self._threadGuarded = value } 7 | var threadGuarded: Value { 8 | get { 9 | threadToken.checkEquals(axTaskLocalAppThreadToken) 10 | return _threadGuarded ?? dieT("Value is already destroyed") 11 | } 12 | set(newValue) { 13 | threadToken.checkEquals(axTaskLocalAppThreadToken) 14 | _threadGuarded = newValue 15 | } 16 | } 17 | func destroy() { 18 | threadToken.checkEquals(axTaskLocalAppThreadToken) 19 | _threadGuarded = nil 20 | } 21 | deinit { 22 | check(_threadGuarded == nil, "The Value must be explicitly destroyed on the appropriate thread before deinit") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/AppBundle/util/axTrustedCheckOptionPrompt.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ApplicationServices 2 | 3 | let axTrustedCheckOptionPrompt: String = kAXTrustedCheckOptionPrompt.takeRetainedValue() as String 4 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/BalanceSizesCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class BalanceSizesCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testBalanceSizesCommand() async throws { 10 | let workspace = Workspace.get(byName: name).apply { wsp in 11 | wsp.rootTilingContainer.apply { 12 | TestWindow.new(id: 1, parent: $0).setWeight(wsp.rootTilingContainer.orientation, 1) 13 | TestWindow.new(id: 2, parent: $0).setWeight(wsp.rootTilingContainer.orientation, 2) 14 | TestWindow.new(id: 3, parent: $0).setWeight(wsp.rootTilingContainer.orientation, 3) 15 | } 16 | } 17 | 18 | try await BalanceSizesCommand(args: BalanceSizesCmdArgs(rawArgs: [])) 19 | .run(.defaultEnv.copy(\.workspaceName, name), .emptyStdin) 20 | 21 | for window in workspace.rootTilingContainer.children { 22 | assertEquals(window.getWeight(workspace.rootTilingContainer.orientation), 1) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/CloseCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class CloseCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testSimple() async throws { 10 | Workspace.get(byName: name).rootTilingContainer.apply { 11 | _ = TestWindow.new(id: 1, parent: $0).focusWindow() 12 | TestWindow.new(id: 2, parent: $0) 13 | } 14 | 15 | assertEquals(focus.windowOrNil?.windowId, 1) 16 | assertEquals(focus.workspace.rootTilingContainer.children.count, 2) 17 | 18 | try await CloseCommand(args: CloseCmdArgs(rawArgs: [])).run(.defaultEnv, .emptyStdin) 19 | 20 | assertEquals(focus.windowOrNil?.windowId, 2) 21 | assertEquals(focus.workspace.rootTilingContainer.children.count, 1) 22 | } 23 | 24 | func testCloseViaWindowIdFlag() async throws { 25 | Workspace.get(byName: name).rootTilingContainer.apply { 26 | _ = TestWindow.new(id: 1, parent: $0).focusWindow() 27 | TestWindow.new(id: 2, parent: $0) 28 | } 29 | 30 | assertEquals(focus.windowOrNil?.windowId, 1) 31 | assertEquals(focus.workspace.rootTilingContainer.children.count, 2) 32 | 33 | try await CloseCommand(args: CloseCmdArgs(rawArgs: []).copy(\.windowId, 2)).run(.defaultEnv, .emptyStdin) 34 | 35 | assertEquals(focus.windowOrNil?.windowId, 1) 36 | assertEquals(focus.workspace.rootTilingContainer.children.count, 1) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/ExecCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class ExecCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testParseExecCommand() { 10 | testParseCommandSucc("exec-and-forget echo 'foo'", ExecAndForgetCmdArgs(bashScript: " echo 'foo'")) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/FlattenWorkspaceTreeCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class FlattenWorkspaceTreeCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testSimple() async throws { 10 | let workspace = Workspace.get(byName: name).apply { 11 | $0.rootTilingContainer.apply { 12 | TestWindow.new(id: 1, parent: $0) 13 | TilingContainer.newHTiles(parent: $0, adaptiveWeight: 1).apply { 14 | TestWindow.new(id: 2, parent: $0) 15 | } 16 | } 17 | TestWindow.new(id: 3, parent: $0) // floating 18 | } 19 | assertEquals(workspace.focusWorkspace(), true) 20 | 21 | try await FlattenWorkspaceTreeCommand(args: FlattenWorkspaceTreeCmdArgs(rawArgs: [])).run(.defaultEnv, .emptyStdin) 22 | workspace.normalizeContainers() 23 | assertEquals(workspace.layoutDescription, .workspace([.h_tiles([.window(1), .window(2)]), .window(3)])) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/JoinWithCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class JoinWithCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testMoveIn() async throws { 10 | let root = Workspace.get(byName: name).rootTilingContainer.apply { 11 | TestWindow.new(id: 0, parent: $0) 12 | assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true) 13 | TestWindow.new(id: 2, parent: $0) 14 | } 15 | 16 | try await JoinWithCommand(args: JoinWithCmdArgs(rawArgs: [], direction: .right)).run(.defaultEnv, .emptyStdin) 17 | assertEquals(root.layoutDescription, .h_tiles([ 18 | .window(0), 19 | .v_tiles([ 20 | .window(1), 21 | .window(2), 22 | ]), 23 | ])) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/ListAppsTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | final class ListAppsTest: XCTestCase { 6 | func testParse() { 7 | assertNotNil(parseCommand("list-apps --macos-native-hidden").cmdOrNil) 8 | assertNotNil(parseCommand("list-apps --macos-native-hidden no").cmdOrNil) 9 | assertNotNil(parseCommand("list-apps --format %{app-bundle-id}").cmdOrNil) 10 | assertNotNil(parseCommand("list-apps --count").cmdOrNil) 11 | assertEquals(parseCommand("list-apps --format %{app-bundle-id} --count").errorOrNil, "ERROR: Conflicting options: --count, --format") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/ListModesTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | final class ListModesTest: XCTestCase { 6 | func testParseListModesCommand() { 7 | testParseCommandSucc("list-modes", ListModesCmdArgs(rawArgs: [])) 8 | testParseCommandSucc("list-modes --current", ListModesCmdArgs(rawArgs: []).copy(\.current, true)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/ListMonitorsTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | final class ListMonitorsTest: XCTestCase { 6 | func testParseListMonitorsCommand() { 7 | testParseCommandSucc("list-monitors", ListMonitorsCmdArgs(rawArgs: [])) 8 | testParseCommandSucc("list-monitors --focused", ListMonitorsCmdArgs(rawArgs: []).copy(\.focused, true)) 9 | testParseCommandSucc("list-monitors --count", ListMonitorsCmdArgs(rawArgs: []).copy(\.outputOnlyCount, true)) 10 | assertEquals(parseCommand("list-monitors --format %{monitor-id} --count").errorOrNil, "ERROR: Conflicting options: --count, --format") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/ListWorkspacesTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | final class ListWorkspacesTest: XCTestCase { 6 | func testParse() { 7 | assertNotNil(parseCommand("list-workspaces --all").cmdOrNil) 8 | assertNil(parseCommand("list-workspaces --all --visible").cmdOrNil) 9 | assertNil(parseCommand("list-workspaces --focused --visible").cmdOrNil) 10 | assertNil(parseCommand("list-workspaces --focused --all").cmdOrNil) 11 | assertNil(parseCommand("list-workspaces --visible").cmdOrNil) 12 | assertNotNil(parseCommand("list-workspaces --visible --monitor 2").cmdOrNil) 13 | assertNotNil(parseCommand("list-workspaces --monitor focused").cmdOrNil) 14 | assertNil(parseCommand("list-workspaces --focused --monitor 2").cmdOrNil) 15 | assertNotNil(parseCommand("list-workspaces --all --format %{workspace}").cmdOrNil) 16 | assertEquals(parseCommand("list-workspaces --all --format %{workspace} --count").errorOrNil, "ERROR: Conflicting options: --count, --format") 17 | assertEquals(parseCommand("list-workspaces --empty").errorOrNil, "Mandatory option is not specified (--all|--focused|--monitor)") 18 | assertEquals(parseCommand("list-workspaces --all --focused --monitor mouse").errorOrNil, "ERROR: Conflicting options: --all, --focused, --monitor") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/ResizeCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | final class ResizeCommandTest: XCTestCase { 6 | func testParseCommand() { 7 | testParseCommandSucc("resize smart +10", ResizeCmdArgs(rawArgs: [], dimension: .smart, units: .add(10))) 8 | testParseCommandSucc("resize smart -10", ResizeCmdArgs(rawArgs: [], dimension: .smart, units: .subtract(10))) 9 | testParseCommandSucc("resize smart 10", ResizeCmdArgs(rawArgs: [], dimension: .smart, units: .set(10))) 10 | 11 | testParseCommandSucc("resize smart-opposite +10", ResizeCmdArgs(rawArgs: [], dimension: .smartOpposite, units: .add(10))) 12 | testParseCommandSucc("resize smart-opposite -10", ResizeCmdArgs(rawArgs: [], dimension: .smartOpposite, units: .subtract(10))) 13 | testParseCommandSucc("resize smart-opposite 10", ResizeCmdArgs(rawArgs: [], dimension: .smartOpposite, units: .set(10))) 14 | 15 | testParseCommandSucc("resize height 10", ResizeCmdArgs(rawArgs: [], dimension: .height, units: .set(10))) 16 | testParseCommandSucc("resize width 10", ResizeCmdArgs(rawArgs: [], dimension: .width, units: .set(10))) 17 | 18 | testParseCommandFail("resize s 10", msg: """ 19 | ERROR: Can't parse 's'. 20 | Possible values: (width|height|smart|smart-opposite) 21 | """) 22 | testParseCommandFail("resize smart foo", msg: "ERROR: argument must be a number") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/SummonWorkspaceCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class SummonWorkspaceCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testParse() { 10 | assertEquals(parseCommand("summon-workspace").errorOrNil, "ERROR: Argument '' is mandatory") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/command/WorkspaceCommandTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | @MainActor 6 | final class WorkspaceCommandTest: XCTestCase { 7 | override func setUp() async throws { setUpWorkspacesForTests() } 8 | 9 | func testParseWorkspaceCommand() { 10 | testParseCommandFail("workspace my mail", msg: "ERROR: Unknown argument 'mail'") 11 | testParseCommandFail("workspace 'my mail'", msg: "ERROR: Whitespace characters are forbidden in workspace names") 12 | assertEquals(parseCommand("workspace").errorOrNil, "ERROR: Argument '(|next|prev)' is mandatory") 13 | testParseCommandSucc("workspace next", WorkspaceCmdArgs(target: .relative(true))) 14 | testParseCommandSucc("workspace --auto-back-and-forth W", WorkspaceCmdArgs(target: .direct(.parse("W").getOrDie()), autoBackAndForth: true)) 15 | assertEquals(parseCommand("workspace --wrap-around W").errorOrNil, "--wrapAround requires using (prev|next) argument") 16 | assertEquals(parseCommand("workspace --auto-back-and-forth next").errorOrNil, "--auto-back-and-forth is incompatible with (next|prev)") 17 | testParseCommandSucc("workspace next --wrap-around", WorkspaceCmdArgs(target: .relative(true), wrapAround: true)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/config/SplitArgsTest.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import XCTest 4 | 5 | final class SplitArgsTest: XCTestCase { 6 | func testSplit() { 7 | testSucSplit("echo foo", expected: ["echo", "foo"]) 8 | testSucSplit("echo 'foo'", expected: ["echo", "foo"]) 9 | testSucSplit("'echo' foo", expected: ["echo", "foo"]) 10 | testSucSplit("echo \"'\"", expected: ["echo", "'"]) 11 | testSucSplit("echo '\"'", expected: ["echo", "\""]) 12 | testSucSplit(" echo ' foo bar'", expected: ["echo", " foo bar"]) 13 | 14 | testFailSplit("echo 'foo") 15 | testFailSplit("echo foo'") 16 | } 17 | } 18 | 19 | private func testSucSplit(_ str: String, expected: [String]) { 20 | let result = str.splitArgs() 21 | switch result { 22 | case .success(let actual): assertEquals(actual, expected) 23 | case .failure: XCTFail("\(str) split is not successful") 24 | } 25 | } 26 | 27 | private func testFailSplit(_ str: String) { 28 | let result = str.splitArgs() 29 | switch result { 30 | case .success: XCTFail("\(str) is expected to fail to split") 31 | case .failure: break 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/testExtensions.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | import Foundation 4 | import TOMLKit 5 | 6 | extension [TomlParseError] { 7 | var descriptions: [String] { map(\.description) } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/tree/TestApp.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import Common 3 | 4 | final class TestApp: AbstractApp { 5 | let pid: Int32 6 | let bundleId: String? 7 | let name: String? 8 | let execPath: String? = nil 9 | let bundlePath: String? = nil 10 | @MainActor 11 | static let shared = TestApp() 12 | 13 | private init() { 14 | self.pid = 0 15 | self.bundleId = "bobko.AeroSpace.test-app" 16 | self.name = bundleId 17 | } 18 | 19 | var _windows: [Window] = [] 20 | var windows: [Window] { 21 | get { _windows } 22 | set { 23 | if let focusedWindow { 24 | check(newValue.contains(focusedWindow)) 25 | } 26 | _windows = newValue 27 | } 28 | } 29 | @MainActor func detectNewWindowsAndGetIds() async throws -> [UInt32] { 30 | return windows.map { $0.windowId } 31 | } 32 | 33 | private var _focusedWindow: Window? = nil 34 | var focusedWindow: Window? { 35 | get { _focusedWindow } 36 | set { 37 | if let window = newValue { 38 | check(windows.contains(window)) 39 | } 40 | _focusedWindow = newValue 41 | } 42 | } 43 | @MainActor func getFocusedWindow() -> Window? { _focusedWindow } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/tree/TestWindow.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import AppKit 3 | 4 | final class TestWindow: Window, CustomStringConvertible { 5 | private var _rect: Rect? 6 | 7 | @MainActor 8 | private init(_ id: UInt32, _ parent: NonLeafTreeNodeObject, _ adaptiveWeight: CGFloat, _ rect: Rect?) { 9 | _rect = rect 10 | super.init(id: id, TestApp.shared, lastFloatingSize: nil, parent: parent, adaptiveWeight: adaptiveWeight, index: INDEX_BIND_LAST) 11 | } 12 | 13 | @discardableResult 14 | @MainActor 15 | static func new(id: UInt32, parent: NonLeafTreeNodeObject, adaptiveWeight: CGFloat = 1, rect: Rect? = nil) -> TestWindow { 16 | let wi = TestWindow(id, parent, adaptiveWeight, rect) 17 | TestApp.shared._windows.append(wi) 18 | return wi 19 | } 20 | 21 | nonisolated var description: String { "TestWindow(\(windowId))" } 22 | 23 | @MainActor 24 | override func nativeFocus() { 25 | appForTests = TestApp.shared 26 | TestApp.shared.focusedWindow = self 27 | } 28 | 29 | override func closeAxWindow() { 30 | unbindFromParent() 31 | } 32 | 33 | override var title: String { description } 34 | 35 | @MainActor override func getAxRect() async throws -> Rect? { // todo change to not Optional 36 | _rect 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/AppBundleTests/tree/TilingContainer.swift: -------------------------------------------------------------------------------- 1 | @testable import AppBundle 2 | import AppKit 3 | 4 | extension TilingContainer { 5 | @MainActor 6 | static func newHTiles(parent: NonLeafTreeNodeObject, adaptiveWeight: CGFloat) -> TilingContainer { 7 | newHTiles(parent: parent, adaptiveWeight: adaptiveWeight, index: INDEX_BIND_LAST) 8 | } 9 | 10 | @MainActor 11 | static func newVTiles(parent: NonLeafTreeNodeObject, adaptiveWeight: CGFloat) -> TilingContainer { 12 | newVTiles(parent: parent, adaptiveWeight: adaptiveWeight, index: INDEX_BIND_LAST) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Cli/cliUtil.swift: -------------------------------------------------------------------------------- 1 | import Common 2 | import Darwin 3 | import Foundation 4 | 5 | let cliClientVersionAndHash: String = "\(aeroSpaceAppVersion) \(gitHash)" 6 | 7 | func hasStdin() -> Bool { 8 | isatty(STDIN_FILENO) != 1 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Common/appMetadata.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | public let aeroSpaceAppId: String = "bobko.aerospace.debug" 3 | public let aeroSpaceAppName: String = "AeroSpace-Debug" 4 | #else 5 | public let aeroSpaceAppId: String = "bobko.aerospace" 6 | public let aeroSpaceAppName: String = "AeroSpace" 7 | #endif 8 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/cmdArgsStringArrayEx.swift: -------------------------------------------------------------------------------- 1 | extension [String] { 2 | mutating func next() -> String { 3 | nextOrNil() ?? dieT("args is empty") 4 | } 5 | 6 | mutating func nextNonFlagOrNil() -> String? { 7 | first?.starts(with: "-") == true ? nil : nextOrNil() 8 | } 9 | 10 | mutating func allNextNonFlagArgs() -> [String] { 11 | var args: [String] = [] 12 | while let nextArg = nextNonFlagOrNil() { 13 | args.append(nextArg) 14 | } 15 | return args 16 | } 17 | 18 | private mutating func nextOrNil() -> String? { 19 | let result = first 20 | self = Array(dropFirst()) 21 | return result 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/BalanceSizesCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct BalanceSizesCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .balanceSizes, 6 | allowInConfig: true, 7 | help: balance_sizes_help_generated, 8 | options: [ 9 | "--workspace": optionalWorkspaceFlag(), 10 | ], 11 | arguments: [] 12 | ) 13 | 14 | public var windowId: UInt32? 15 | public var workspaceName: WorkspaceName? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/CloseAllWindowsButCurrentCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct CloseAllWindowsButCurrentCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .closeAllWindowsButCurrent, 6 | allowInConfig: true, 7 | help: close_all_windows_but_current_help_generated, 8 | options: [ 9 | "--quit-if-last-window": trueBoolFlag(\.closeArgs.quitIfLastWindow), 10 | ], 11 | arguments: [] 12 | ) 13 | 14 | public var closeArgs = CloseCmdArgs(rawArgs: []) 15 | public var windowId: UInt32? 16 | public var workspaceName: WorkspaceName? 17 | } 18 | 19 | public func parseCloseAllWindowsButCurrentCmdArgs(_ args: [String]) -> ParsedCmd { 20 | parseSpecificCmdArgs(CloseAllWindowsButCurrentCmdArgs(rawArgs: .init(args)), args) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/CloseCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct CloseCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .close, 6 | allowInConfig: true, 7 | help: close_help_generated, 8 | options: [ 9 | "--quit-if-last-window": trueBoolFlag(\.quitIfLastWindow), 10 | "--window-id": optionalWindowIdFlag(), 11 | ], 12 | arguments: [] 13 | ) 14 | 15 | public var quitIfLastWindow: Bool = false 16 | public var windowId: UInt32? 17 | public var workspaceName: WorkspaceName? 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/DebugWindowsCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct DebugWindowsCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: EquatableNoop<[String]>) { self.rawArgs = rawArgs } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .debugWindows, 6 | allowInConfig: false, 7 | help: debug_windows_help_generated, 8 | options: [ 9 | "--window-id": ArgParser(\.windowId, upcastArgParserFun(parseArgWithUInt32)), 10 | ], 11 | arguments: [] 12 | ) 13 | 14 | public var windowId: UInt32? 15 | public var workspaceName: WorkspaceName? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/EnableCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct EnableCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | fileprivate init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .enable, 6 | allowInConfig: true, 7 | help: enable_help_generated, 8 | options: [ 9 | "--fail-if-noop": trueBoolFlag(\.failIfNoop), 10 | ], 11 | arguments: [newArgParser(\.targetState, parseState, mandatoryArgPlaceholder: EnableCmdArgs.State.unionLiteral)] 12 | ) 13 | public var windowId: UInt32? 14 | public var workspaceName: WorkspaceName? 15 | public var targetState: Lateinit = .uninitialized 16 | public var failIfNoop: Bool = false 17 | 18 | public init(rawArgs: [String], targetState: State) { 19 | self.rawArgs = .init(rawArgs) 20 | self.targetState = .initialized(targetState) 21 | } 22 | 23 | public enum State: String, CaseIterable, Sendable { 24 | case on, off, toggle 25 | } 26 | } 27 | 28 | public func parseEnableCmdArgs(_ args: [String]) -> ParsedCmd { 29 | return parseSpecificCmdArgs(EnableCmdArgs(rawArgs: args), args) 30 | .filterNot("--fail-if-noop is incompatible with 'toggle' argument") { $0.targetState.val == .toggle && $0.failIfNoop } 31 | } 32 | 33 | private func parseState(arg: String, nextArgs: inout [String]) -> Parsed { 34 | parseEnum(arg, EnableCmdArgs.State.self) 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/ExecAndForgetCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct ExecAndForgetCmdArgs: CmdArgs { 2 | public var rawArgs: EquatableNoop<[String]> { .init([bashScript]) } 3 | public static let parser: CmdParser = cmdParser( 4 | kind: .execAndForget, 5 | allowInConfig: true, 6 | help: exec_and_forget_help_generated, 7 | options: [:], 8 | arguments: [] 9 | ) 10 | 11 | public init(bashScript: String) { 12 | self.bashScript = bashScript 13 | } 14 | 15 | public let bashScript: String 16 | public var windowId: UInt32? 17 | public var workspaceName: WorkspaceName? 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/FlattenWorkspaceTreeCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct FlattenWorkspaceTreeCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .flattenWorkspaceTree, 6 | allowInConfig: true, 7 | help: flatten_workspace_tree_help_generated, 8 | options: [ 9 | "--workspace": optionalWorkspaceFlag(), 10 | ], 11 | arguments: [] 12 | ) 13 | 14 | public var windowId: UInt32? 15 | public var workspaceName: WorkspaceName? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/FocusBackAndForthCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct FocusBackAndForthCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .focusBackAndForth, 6 | allowInConfig: true, 7 | help: focus_back_and_forth_help_generated, 8 | options: [:], 9 | arguments: [] 10 | ) 11 | 12 | public var windowId: UInt32? 13 | public var workspaceName: WorkspaceName? 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/FullscreenCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct FullscreenCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | fileprivate init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .fullscreen, 6 | allowInConfig: true, 7 | help: fullscreen_help_generated, 8 | options: [ 9 | "--no-outer-gaps": trueBoolFlag(\.noOuterGaps), 10 | "--fail-if-noop": trueBoolFlag(\.failIfNoop), 11 | "--window-id": optionalWindowIdFlag(), 12 | ], 13 | arguments: [ArgParser(\.toggle, parseToggleEnum)] 14 | ) 15 | 16 | public var toggle: ToggleEnum = .toggle 17 | public var noOuterGaps: Bool = false 18 | public var failIfNoop: Bool = false 19 | public var windowId: UInt32? 20 | public var workspaceName: WorkspaceName? 21 | } 22 | 23 | public func parseFullscreenCmdArgs(_ args: [String]) -> ParsedCmd { 24 | parseSpecificCmdArgs(FullscreenCmdArgs(rawArgs: args), args) 25 | .filterNot("--no-outer-gaps is incompatible with 'off' argument") { $0.toggle == .off && $0.noOuterGaps } 26 | .filter("--fail-if-noop requires 'on' or 'off' argument") { $0.failIfNoop.implies($0.toggle == .on || $0.toggle == .off) } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/JoinWithCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct JoinWithCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .joinWith, 6 | allowInConfig: true, 7 | help: join_with_help_generated, 8 | options: [ 9 | "--window-id": optionalWindowIdFlag(), 10 | ], 11 | arguments: [newArgParser(\.direction, parseCardinalDirectionArg, mandatoryArgPlaceholder: CardinalDirection.unionLiteral)] 12 | ) 13 | 14 | public var direction: Lateinit = .uninitialized 15 | public var windowId: UInt32? 16 | public var workspaceName: WorkspaceName? 17 | 18 | public init(rawArgs: [String], direction: CardinalDirection) { 19 | self.rawArgs = .init(rawArgs) 20 | self.direction = .initialized(direction) 21 | } 22 | } 23 | 24 | public func parseJoinWithCmdArgs(_ args: [String]) -> ParsedCmd { 25 | parseSpecificCmdArgs(JoinWithCmdArgs(rawArgs: args), args) 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/ListExecEnvVarsCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct ListExecEnvVarsCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .listExecEnvVars, 6 | allowInConfig: true, 7 | help: list_exec_env_vars_help_generated, 8 | options: [:], 9 | arguments: [] 10 | ) 11 | 12 | public var windowId: UInt32? 13 | public var workspaceName: WorkspaceName? 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/ListModesCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct ListModesCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { 4 | self.rawArgs = .init(rawArgs) 5 | } 6 | public static let parser: CmdParser = cmdParser( 7 | kind: .listModes, 8 | allowInConfig: false, 9 | help: list_modes_help_generated, 10 | options: [ 11 | "--current": trueBoolFlag(\.current), 12 | ], 13 | arguments: [] 14 | ) 15 | 16 | public var windowId: UInt32? // unused 17 | public var workspaceName: WorkspaceName? // unused 18 | public var current: Bool = false 19 | } 20 | 21 | public func parseListModesCmdArgs(_ args: [String]) -> ParsedCmd { 22 | parseSpecificCmdArgs(ListModesCmdArgs(rawArgs: args), args) 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/ListMonitorsCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct ListMonitorsCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .listMonitors, 6 | allowInConfig: false, 7 | help: list_monitors_help_generated, 8 | options: [ 9 | "--focused": boolFlag(\.focused), 10 | "--mouse": boolFlag(\.mouse), 11 | 12 | // Formatting flags 13 | "--format": ArgParser(\._format, parseFormat), 14 | "--count": trueBoolFlag(\.outputOnlyCount), 15 | "--json": trueBoolFlag(\.json), 16 | ], 17 | arguments: [], 18 | conflictingOptions: [ 19 | ["--count", "--format"], 20 | ["--count", "--json"], 21 | ] 22 | ) 23 | 24 | public var windowId: UInt32? 25 | public var workspaceName: WorkspaceName? 26 | public var focused: Bool? 27 | public var mouse: Bool? 28 | public var _format: [StringInterToken] = [] 29 | public var outputOnlyCount: Bool = false 30 | public var json: Bool = false 31 | } 32 | 33 | public extension ListMonitorsCmdArgs { 34 | var format: [StringInterToken] { 35 | _format.isEmpty 36 | ? [ 37 | .interVar("monitor-id"), .interVar("right-padding"), .literal(" | "), 38 | .interVar("monitor-name"), 39 | ] 40 | : _format 41 | } 42 | } 43 | 44 | public func parseListMonitorsCmdArgs(_ args: [String]) -> ParsedCmd { 45 | parseSpecificCmdArgs(ListMonitorsCmdArgs(rawArgs: args), args) 46 | .flatMap { if $0.json, let msg = getErrorIfFormatIsIncompatibleWithJson($0._format) { .failure(msg) } else { .cmd($0) } } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MacosNativeFullscreenCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MacosNativeFullscreenCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .macosNativeFullscreen, 6 | allowInConfig: true, 7 | help: macos_native_fullscreen_help_generated, 8 | options: [ 9 | "--fail-if-noop": trueBoolFlag(\.failIfNoop), 10 | "--window-id": optionalWindowIdFlag(), 11 | ], 12 | arguments: [ArgParser(\.toggle, parseToggleEnum)] 13 | ) 14 | 15 | public var toggle: ToggleEnum = .toggle 16 | public var failIfNoop: Bool = false 17 | public var windowId: UInt32? 18 | public var workspaceName: WorkspaceName? 19 | } 20 | 21 | public func parseMacosNativeFullscreenCmdArgs(_ args: [String]) -> ParsedCmd { 22 | parseSpecificCmdArgs(MacosNativeFullscreenCmdArgs(rawArgs: args), args) 23 | .filter("--fail-if-noop requires 'on' or 'off' argument") { $0.failIfNoop.implies($0.toggle == .on || $0.toggle == .off) } 24 | } 25 | 26 | public enum ToggleEnum: Sendable { 27 | case on, off, toggle 28 | } 29 | 30 | func parseToggleEnum(arg: String, nextArgs: inout [String]) -> Parsed { 31 | return switch arg { 32 | case "on": .success(.on) 33 | case "off": .success(.off) 34 | default: .failure("Can't parse '\(arg)'. Possible values: on|off") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MacosNativeMinimizeCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MacosNativeMinimizeCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .macosNativeMinimize, 6 | allowInConfig: true, 7 | help: macos_native_minimize_help_generated, 8 | options: [ 9 | "--window-id": optionalWindowIdFlag(), 10 | ], 11 | arguments: [] 12 | ) 13 | 14 | public var windowId: UInt32? 15 | public var workspaceName: WorkspaceName? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/ModeCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct ModeCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .mode, 6 | allowInConfig: true, 7 | help: mode_help_generated, 8 | options: [:], 9 | arguments: [newArgParser(\.targetMode, parseTargetMode, mandatoryArgPlaceholder: "")] 10 | ) 11 | 12 | public var targetMode: Lateinit = .uninitialized 13 | public var windowId: UInt32? 14 | public var workspaceName: WorkspaceName? 15 | } 16 | 17 | private func parseTargetMode(arg: String, nextArgs: inout [String]) -> Parsed { 18 | .success(arg) 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MoveCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MoveCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | fileprivate init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .move, 6 | allowInConfig: true, 7 | help: move_help_generated, 8 | options: [ 9 | "--window-id": optionalWindowIdFlag(), 10 | ], 11 | arguments: [newArgParser(\.direction, parseCardinalDirectionArg, mandatoryArgPlaceholder: CardinalDirection.unionLiteral)] 12 | ) 13 | 14 | public var direction: Lateinit = .uninitialized 15 | public var windowId: UInt32? 16 | public var workspaceName: WorkspaceName? 17 | 18 | public init(rawArgs: [String], _ direction: CardinalDirection) { 19 | self.rawArgs = .init(rawArgs) 20 | self.direction = .initialized(direction) 21 | } 22 | } 23 | 24 | public func parseMoveCmdArgs(_ args: [String]) -> ParsedCmd { 25 | parseSpecificCmdArgs(MoveCmdArgs(rawArgs: args), args) 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MoveMouseCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MoveMouseCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .moveMouse, 6 | allowInConfig: true, 7 | help: move_mouse_help_generated, 8 | options: [ 9 | "--fail-if-noop": trueBoolFlag(\.failIfNoop), 10 | ], 11 | arguments: [newArgParser(\.mouseTarget, parseMouseTarget, mandatoryArgPlaceholder: "")] 12 | ) 13 | 14 | public var failIfNoop: Bool = false 15 | public var mouseTarget: Lateinit = .uninitialized 16 | public var windowId: UInt32? 17 | public var workspaceName: WorkspaceName? 18 | } 19 | 20 | func parseMouseTarget(arg: String, nextArgs: inout [String]) -> Parsed { 21 | parseEnum(arg, MouseTarget.self) 22 | } 23 | 24 | public func parseMoveMouseCmdArgs(_ args: [String]) -> ParsedCmd { 25 | parseSpecificCmdArgs(MoveMouseCmdArgs(rawArgs: args), args) 26 | .filter("--fail-if-noop is only compatible with window-lazy-center or monitor-lazy-center") { 27 | $0.failIfNoop.implies($0.mouseTarget.val == .windowLazyCenter || $0.mouseTarget.val == .monitorLazyCenter) 28 | } 29 | } 30 | 31 | public enum MouseTarget: String, CaseIterable, Sendable { 32 | case monitorLazyCenter = "monitor-lazy-center" 33 | case monitorForceCenter = "monitor-force-center" 34 | 35 | case windowLazyCenter = "window-lazy-center" 36 | case windowForceCenter = "window-force-center" 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MoveNodeToMonitorCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MoveNodeToMonitorCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | fileprivate init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .moveNodeToMonitor, 6 | allowInConfig: true, 7 | help: move_node_to_monitor_help_generated, 8 | options: [ 9 | // "Own" option 10 | "--wrap-around": trueBoolFlag(\.wrapAround), 11 | 12 | // Forward to moveNodeToWorkspace 13 | "--window-id": optionalWindowIdFlag(), 14 | "--focus-follows-window": trueBoolFlag(\.moveNodeToWorkspace.focusFollowsWindow), 15 | "--fail-if-noop": trueBoolFlag(\.moveNodeToWorkspace.failIfNoop), 16 | ], 17 | arguments: [newArgParser(\.target, parseTarget, mandatoryArgPlaceholder: "(left|down|up|right|next|prev|)")] 18 | ) 19 | 20 | public var workspaceName: WorkspaceName? 21 | public var windowId: UInt32? { // Forward to moveNodeToWorkspace 22 | get { moveNodeToWorkspace.windowId } 23 | set(newValue) { moveNodeToWorkspace.windowId = newValue } 24 | } 25 | 26 | public var moveNodeToWorkspace = MoveNodeToWorkspaceCmdArgs(rawArgs: []) 27 | public var wrapAround: Bool = false 28 | public var target: Lateinit = .uninitialized 29 | } 30 | 31 | public func parseMoveNodeToMonitorCmdArgs(_ args: [String]) -> ParsedCmd { 32 | parseSpecificCmdArgs(MoveNodeToMonitorCmdArgs(rawArgs: args), args) 33 | .filter("--wrap-around is incompatible with argument") { $0.wrapAround.implies(!$0.target.val.isPatterns) } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MoveNodeToWorkspaceCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MoveNodeToWorkspaceCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public static let parser: CmdParser = cmdParser( 4 | kind: .moveNodeToWorkspace, 5 | allowInConfig: true, 6 | help: move_node_to_workspace_help_generated, 7 | options: [ 8 | "--wrap-around": optionalTrueBoolFlag(\._wrapAround), 9 | "--fail-if-noop": trueBoolFlag(\.failIfNoop), 10 | "--window-id": optionalWindowIdFlag(), 11 | "--focus-follows-window": trueBoolFlag(\.focusFollowsWindow), 12 | ], 13 | arguments: [newArgParser(\.target, parseWorkspaceTarget, mandatoryArgPlaceholder: workspaceTargetPlaceholder)] 14 | ) 15 | 16 | public var _wrapAround: Bool? 17 | public var failIfNoop: Bool = false 18 | public var focusFollowsWindow: Bool = false 19 | public var windowId: UInt32? 20 | public var workspaceName: WorkspaceName? 21 | public var target: Lateinit = .uninitialized 22 | 23 | public init(rawArgs: [String]) { 24 | self.rawArgs = .init(rawArgs) 25 | } 26 | } 27 | 28 | public extension MoveNodeToWorkspaceCmdArgs { 29 | var wrapAround: Bool { _wrapAround ?? false } 30 | } 31 | 32 | func implication(ifTrue: Bool, mustHold: @autoclosure () -> Bool) -> Bool { !ifTrue || mustHold() } 33 | 34 | public func parseMoveNodeToWorkspaceCmdArgs(_ args: [String]) -> ParsedCmd { 35 | parseSpecificCmdArgs(MoveNodeToWorkspaceCmdArgs(rawArgs: .init(args)), args) 36 | .filter("--wrapAround requires using (prev|next) argument") { ($0._wrapAround != nil).implies($0.target.val.isRelatve) } 37 | .filterNot("--fail-if-noop is incompatible with (next|prev)") { $0.failIfNoop && $0.target.val.isRelatve } 38 | .filterNot("--window-id is incompatible with (next|prev)") { $0.windowId != nil && $0.target.val.isRelatve } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/MoveWorkpsaceToMonitorCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct MoveWorkspaceToMonitorCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .moveWorkspaceToMonitor, 6 | allowInConfig: true, 7 | help: move_workspace_to_monitor_help_generated, 8 | options: [ 9 | "--wrap-around": trueBoolFlag(\.wrapAround), 10 | "--workspace": optionalWorkspaceFlag(), 11 | ], 12 | arguments: [ 13 | newArgParser( 14 | \.target, 15 | parseTarget, 16 | mandatoryArgPlaceholder: "(left|down|up|right|next|prev|)" 17 | ), 18 | ] 19 | ) 20 | 21 | public var windowId: UInt32? 22 | public var workspaceName: WorkspaceName? 23 | public var wrapAround: Bool = false 24 | public var target: Lateinit = .uninitialized 25 | } 26 | 27 | public func parseWorkspaceToMonitorCmdArgs(_ args: [String]) -> ParsedCmd { 28 | parseSpecificCmdArgs(MoveWorkspaceToMonitorCmdArgs(rawArgs: args), args) 29 | .filter("--wrap-around is incompatible with argument") { 30 | $0.wrapAround.implies(!$0.target.val.isPatterns) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/ReloadConfigCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct ReloadConfigCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .reloadConfig, 6 | allowInConfig: true, 7 | help: reload_config_help_generated, 8 | options: [ 9 | "--no-gui": trueBoolFlag(\.noGui), 10 | "--dry-run": trueBoolFlag(\.dryRun), 11 | ], 12 | arguments: [] 13 | ) 14 | 15 | public var noGui: Bool = false 16 | public var dryRun: Bool = false 17 | public var windowId: UInt32? 18 | public var workspaceName: WorkspaceName? 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/SplitCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct SplitCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | fileprivate init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .split, 6 | allowInConfig: true, 7 | help: split_help_generated, 8 | options: [ 9 | "--window-id": optionalWindowIdFlag(), 10 | ], 11 | arguments: [newArgParser(\.arg, parseSplitArg, mandatoryArgPlaceholder: SplitArg.unionLiteral)] 12 | ) 13 | 14 | public var arg: Lateinit = .uninitialized 15 | public var windowId: UInt32? 16 | public var workspaceName: WorkspaceName? 17 | 18 | public init(rawArgs: [String], _ arg: SplitArg) { 19 | self.rawArgs = .init(rawArgs) 20 | self.arg = .initialized(arg) 21 | } 22 | 23 | public enum SplitArg: String, CaseIterable, Sendable { 24 | case horizontal, vertical, opposite 25 | } 26 | } 27 | 28 | public func parseSplitCmdArgs(_ args: [String]) -> ParsedCmd { 29 | parseSpecificCmdArgs(SplitCmdArgs(rawArgs: args), args) 30 | } 31 | 32 | private func parseSplitArg(arg: String, nextArgs: inout [String]) -> Parsed { 33 | parseEnum(arg, SplitCmdArgs.SplitArg.self) 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/SummonWorkspaceCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct SummonWorkspaceCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .summonWorkspace, 6 | allowInConfig: true, 7 | help: summon_workspace_help_generated, 8 | options: [ 9 | "--fail-if-noop": trueBoolFlag(\.failIfNoop), 10 | ], 11 | arguments: [newArgParser(\.target, parseWorkspaceName, mandatoryArgPlaceholder: "")] 12 | ) 13 | 14 | public var windowId: UInt32? // unused 15 | public var workspaceName: WorkspaceName? // unused 16 | 17 | public var target: Lateinit = .uninitialized 18 | public var failIfNoop: Bool = false 19 | } 20 | 21 | private func parseWorkspaceName(arg: String, nextArgs: inout [String]) -> Parsed { 22 | WorkspaceName.parse(arg) 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/TriggerBindingCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct TriggerBindingCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public static let parser: CmdParser = cmdParser( 4 | kind: .triggerBinding, 5 | allowInConfig: true, 6 | help: trigger_binding_help_generated, 7 | options: [ 8 | "--mode": singleValueOption(\._mode, "") { $0 }, 9 | ], 10 | arguments: [newArgParser(\.binding, { arg, _ in .success(arg) }, mandatoryArgPlaceholder: "")] 11 | ) 12 | 13 | public var _mode: String? = nil 14 | public var binding: Lateinit = .uninitialized 15 | public var windowId: UInt32? 16 | public var workspaceName: WorkspaceName? 17 | } 18 | 19 | public extension TriggerBindingCmdArgs { 20 | var mode: String { _mode! } 21 | } 22 | 23 | public func parseTriggerBindingCmdArgs(_ args: [String]) -> ParsedCmd { 24 | parseSpecificCmdArgs(TriggerBindingCmdArgs(rawArgs: .init(args)), args) 25 | .filter("--mode flag is mandatory") { $0._mode != nil } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/VolumeCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct VolumeCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .volume, 6 | allowInConfig: true, 7 | help: volume_help_generated, 8 | options: [:], 9 | arguments: [newArgParser(\.action, parseVolumeAction, mandatoryArgPlaceholder: VolumeAction.argsUnion)] 10 | ) 11 | 12 | public var windowId: UInt32? 13 | public var workspaceName: WorkspaceName? 14 | 15 | public var action: Lateinit = .uninitialized 16 | } 17 | 18 | public enum VolumeAction: Equatable, Sendable { 19 | case up, down, muteToggle, muteOn, muteOff 20 | case set(Int) 21 | 22 | static let argsUnion: String = "(up|down|mute-toggle|mute-on|mute-off|set)" 23 | } 24 | 25 | func parseVolumeAction(arg: String, nextArgs: inout [String]) -> Parsed { 26 | switch arg { 27 | case "up": return .success(.up) 28 | case "down": return .success(.down) 29 | case "mute-toggle": return .success(.muteToggle) 30 | case "mute-off": return .success(.muteOff) 31 | case "mute-on": return .success(.muteOn) 32 | case "set": 33 | guard let arg = nextArgs.nextNonFlagOrNil() else { return .failure("set argument must be followed by ") } 34 | guard let int = Int(arg) else { return .failure("Can't parse number '\(arg)'") } 35 | if !(0 ... 100).contains(int) { return .failure("\(int) must be in range from 0 to 100") } 36 | return .success(.set(int)) 37 | default: 38 | return .failure("Unknown argument '\(arg)'. Possible values: \(VolumeAction.argsUnion)") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/impl/WorkspaceBackAndForthCmdArgs.swift: -------------------------------------------------------------------------------- 1 | public struct WorkspaceBackAndForthCmdArgs: CmdArgs { 2 | public let rawArgs: EquatableNoop<[String]> 3 | public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) } 4 | public static let parser: CmdParser = cmdParser( 5 | kind: .workspaceBackAndForth, 6 | allowInConfig: true, 7 | help: workspace_back_and_forth_help_generated, 8 | options: [:], 9 | arguments: [] 10 | ) 11 | 12 | public var windowId: UInt32? 13 | public var workspaceName: WorkspaceName? 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Common/cmdArgs/subcommandParsers.swift: -------------------------------------------------------------------------------- 1 | let subcommandParsers: [String: any SubCommandParserProtocol] = initSubcommands() 2 | 3 | protocol SubCommandParserProtocol: Sendable { 4 | associatedtype T where T: CmdArgs 5 | var _parse: @Sendable ([String]) -> ParsedCmd { get } 6 | } 7 | 8 | extension SubCommandParserProtocol { 9 | func parse(args: [String]) -> ParsedCmd { 10 | _parse(args).map { $0 } 11 | } 12 | } 13 | 14 | struct SubCommandParser: SubCommandParserProtocol, Sendable { 15 | let _parse: @Sendable ([String]) -> ParsedCmd 16 | 17 | init(_ parser: @escaping @Sendable ([String]) -> ParsedCmd) { 18 | _parse = parser 19 | } 20 | 21 | init(_ raw: @escaping @Sendable (EquatableNoop<[String]>) -> T) { 22 | _parse = { args in parseSpecificCmdArgs(raw(.init(args)), args) } 23 | } 24 | 25 | init(_ raw: @escaping @Sendable ([String]) -> T) { 26 | _parse = { args in parseSpecificCmdArgs(raw(args), args) } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Common/gitHashGenerated.swift: -------------------------------------------------------------------------------- 1 | // FILE IS GENERATED BY generate.sh AND AUTO-UPDATED BY build-release.sh 2 | public let gitHash = "SNAPSHOT" 3 | public let gitShortHash = "SNAPSHOT" 4 | -------------------------------------------------------------------------------- /Sources/Common/macOs13Compatibility.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import os 3 | 4 | // This file allows to compile against macOS 13 SDK & Xcode 15 5 | extension NSRunningApplication: @unchecked @retroactive Sendable {} 6 | extension OSSignposter: @unchecked @retroactive Sendable {} 7 | -------------------------------------------------------------------------------- /Sources/Common/model/AxAppThreadToken.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @TaskLocal 4 | public var axTaskLocalAppThreadToken: AxAppThreadToken? = nil 5 | 6 | public struct AxAppThreadToken: Sendable, Equatable, CustomStringConvertible { 7 | public let pid: pid_t 8 | public let idForDebug: String 9 | 10 | public init(pid: pid_t, idForDebug: String) { 11 | self.pid = pid 12 | self.idForDebug = idForDebug 13 | } 14 | 15 | public static func == (lhs: Self, rhs: Self) -> Bool { lhs.pid == rhs.pid } 16 | 17 | public func checkEquals(_ other: AxAppThreadToken?) { 18 | check(self == other, "\(self) != \(other.prettyDescription)") 19 | } 20 | 21 | public var description: String { idForDebug } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Common/model/CardinalDirection.swift: -------------------------------------------------------------------------------- 1 | public enum CardinalDirection: String, CaseIterable, Equatable, Sendable { 2 | case left, down, up, right 3 | } 4 | 5 | public extension CardinalDirection { 6 | var orientation: Orientation { self == .up || self == .down ? .v : .h } 7 | var isPositive: Bool { self == .down || self == .right } 8 | var opposite: CardinalDirection { 9 | return switch self { 10 | case .left: .right 11 | case .down: .up 12 | case .up: .down 13 | case .right: .left 14 | } 15 | } 16 | var focusOffset: Int { isPositive ? 1 : -1 } 17 | var insertionOffset: Int { isPositive ? 1 : 0 } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Common/model/Init.swift: -------------------------------------------------------------------------------- 1 | public nonisolated(unsafe) var isCli = true 2 | public var isServer: Bool { !isCli } 3 | 4 | public nonisolated(unsafe) var terminationHandler: TerminationHandler = EmptyTerminationHandler() 5 | 6 | struct EmptyTerminationHandler: TerminationHandler { 7 | func beforeTermination() {} 8 | } 9 | 10 | @MainActor 11 | public protocol TerminationHandler: Sendable { 12 | func beforeTermination() async throws 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Common/model/Orientation.swift: -------------------------------------------------------------------------------- 1 | public enum Orientation: Sendable { 2 | /// Windows are planced along the **horizontal** line 3 | /// x-axis 4 | case h 5 | /// Windows are planced along the **vertical** line 6 | /// y-axis 7 | case v 8 | } 9 | 10 | public extension Orientation { 11 | var opposite: Orientation { self == .h ? .v : .h } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Common/model/WorkspaceName.swift: -------------------------------------------------------------------------------- 1 | public struct WorkspaceName: Equatable, Sendable { 2 | public let raw: String 3 | 4 | private init(_ raw: String) { 5 | self.raw = raw 6 | } 7 | 8 | public static func parse(_ raw: String) -> Parsed { 9 | // reserved names 10 | if raw == "focused" || raw == "non-focused" || 11 | raw == "visible" || raw == "invisible" || raw == "non-visible" || 12 | raw == "active" || raw == "non-active" || raw == "inactive" || 13 | raw == "back-and-forth" || raw == "back_and_forth" || raw == "previous" || 14 | raw == "prev" || raw == "next" || 15 | raw == "monitor" || raw == "workspace" || 16 | raw == "monitors" || raw == "workspaces" || 17 | raw == "all" || raw == "none" || 18 | raw == "mouse" || raw == "target" 19 | { 20 | return .failure("'\(raw)' is a reserved workspace name") 21 | } 22 | if raw.contains(",") { 23 | return .failure("Workspace names are not allowed to contain comma") 24 | } 25 | if raw.starts(with: "_") { 26 | return .failure("Workspace names starting with underscore are reserved for future use") 27 | } 28 | if raw.starts(with: "-") { 29 | // The syntax conflicts with CLI options. E.g. list-windows --workspace -foo 30 | return .failure("Workspace names starting with dash are disallowed") 31 | } 32 | if raw.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { 33 | return .failure("Whitespace characters are forbidden in workspace names") 34 | } 35 | return .success(WorkspaceName(raw)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Common/model/clientServer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ServerAnswer: Codable, Sendable { 4 | public let exitCode: Int32 5 | public let stdout: String 6 | public let stderr: String 7 | public let serverVersionAndHash: String 8 | 9 | public init( 10 | exitCode: Int32, 11 | stdout: String = "", 12 | stderr: String = "", 13 | serverVersionAndHash: String 14 | ) { 15 | self.exitCode = exitCode 16 | self.stdout = stdout 17 | self.stderr = stderr 18 | self.serverVersionAndHash = serverVersionAndHash 19 | } 20 | } 21 | 22 | public struct ClientRequest: Codable, Sendable { 23 | public let command: String // Unused. keep it for API compatibility with old servers for a couple of version 24 | public let args: [String] 25 | public let stdin: String 26 | 27 | public init( 28 | args: [String], 29 | stdin: String 30 | ) { 31 | if args.contains(where: { $0.rangeOfCharacter(from: .whitespacesAndNewlines) != nil || $0.contains("\"") || $0.contains("\'") }) { 32 | self.command = "" // Old server won't understand it anyway 33 | } else { 34 | self.command = args.joined(separator: " ") 35 | } 36 | self.args = args 37 | self.stdin = stdin 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Common/util/AeroAny.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol AeroAny {} 4 | 5 | public extension AeroAny { 6 | @discardableResult 7 | @inlinable 8 | func apply(_ block: (Self) -> Void) -> Self { 9 | block(self) 10 | return self 11 | } 12 | 13 | @discardableResult 14 | @inlinable 15 | func also(_ block: (Self) -> Void) -> Self { 16 | block(self) 17 | return self 18 | } 19 | 20 | @inlinable func takeIf(_ predicate: (Self) -> Bool) -> Self? { predicate(self) ? self : nil } 21 | @inlinable func lets(_ body: (Self) -> R) -> R { body(self) } 22 | } 23 | 24 | extension Int: AeroAny {} 25 | extension String: AeroAny {} 26 | extension Character: AeroAny {} 27 | extension Regex: AeroAny {} 28 | extension Array: AeroAny {} 29 | extension URL: AeroAny {} 30 | -------------------------------------------------------------------------------- /Sources/Common/util/BoolEx.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // https://forums.swift.org/t/using-async-call-in-boolean-expression/52943 4 | // https://github.com/swiftlang/swift/issues/56869 5 | // https://forums.swift.org/t/potential-false-positive-sending-risks-causing-data-races/78859 6 | public extension Bool { 7 | @inlinable 8 | func andAsync(_ rhs: () async throws -> Bool) async rethrows -> Bool { 9 | if self { 10 | return try await rhs() 11 | } 12 | return false 13 | } 14 | 15 | @inlinable 16 | func orAsync(_ rhs: () async throws -> Bool) async rethrows -> Bool { 17 | if self { 18 | return true 19 | } 20 | return try await rhs() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Common/util/CollectionEx.swift: -------------------------------------------------------------------------------- 1 | public extension Collection { 2 | func singleOrNil() -> Element? { 3 | count == 1 ? first : nil 4 | } 5 | 6 | func getOrNil(atIndex index: Index) -> Element? { 7 | indices.contains(index) ? self[index] : nil 8 | } 9 | } 10 | 11 | public extension Collection where Index == Int { 12 | func get(wrappingIndex: Int) -> Element? { isEmpty ? nil : self[(count + wrappingIndex) % count] } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Common/util/ConvenienceCopyable.swift: -------------------------------------------------------------------------------- 1 | public protocol ConvenienceCopyable {} 2 | 3 | public extension ConvenienceCopyable { 4 | func copy(_ key: WritableKeyPath, _ value: T) -> Self { 5 | var copy = self 6 | copy[keyPath: key] = value 7 | return copy 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Common/util/EquatableNoop.swift: -------------------------------------------------------------------------------- 1 | public struct EquatableNoop: Equatable { 2 | public var value: Value 3 | public init(_ value: Value) { self.value = value } 4 | public static func == (lhs: EquatableNoop, rhs: EquatableNoop) -> Bool { true } 5 | } 6 | 7 | extension EquatableNoop: Sendable where Value: Sendable {} 8 | -------------------------------------------------------------------------------- /Sources/Common/util/JsonEncoderEx.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension JSONEncoder { 4 | static var aeroSpaceDefault: JSONEncoder { 5 | let encoder = JSONEncoder() 6 | encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] 7 | return encoder 8 | } 9 | 10 | func encodeToString(_ value: Encodable) -> String? { 11 | guard let data = Result(catching: { try encode(value) }).getOrNil() else { return nil } 12 | return String(data: data, encoding: .utf8) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Common/util/Lateinit.swift: -------------------------------------------------------------------------------- 1 | // "Happy path" Optional 2 | public enum Lateinit { 3 | case initialized(T) 4 | case uninitialized 5 | 6 | public var val: T { 7 | switch self { 8 | case .initialized(let value): return value 9 | case .uninitialized: die("Property is not initialized") 10 | } 11 | } 12 | 13 | public var isInitialized: Bool { 14 | return switch self { 15 | case .initialized: true 16 | case .uninitialized: false 17 | } 18 | } 19 | } 20 | 21 | extension Lateinit: Equatable where T: Equatable { 22 | public static func == (lhs: Self, rhs: Self) -> Bool { 23 | lhs.isInitialized && rhs.isInitialized && lhs.val == rhs.val || 24 | lhs.isInitialized == rhs.isInitialized 25 | } 26 | } 27 | 28 | extension Lateinit: Sendable where T: Sendable {} 29 | -------------------------------------------------------------------------------- /Sources/Common/util/OptionalEx.swift: -------------------------------------------------------------------------------- 1 | public extension Optional { 2 | func orElse(_ other: () -> Wrapped) -> Wrapped { self ?? other() } 3 | 4 | func orFailure(_ or: @autoclosure () -> F) -> Result { 5 | if let ok = self { 6 | return .success(ok) 7 | } else { 8 | return .failure(or()) 9 | } 10 | } 11 | 12 | func mapAsync(_ transform: (Wrapped) async throws(E) -> U) async throws(E) -> U? where E: Error, U: ~Copyable { 13 | if let ok = self { 14 | return try await transform(ok) 15 | } else { 16 | return nil 17 | } 18 | } 19 | 20 | // todo cleanup in future Swift versions 21 | @MainActor 22 | func mapAsyncMainActor(_ transform: @MainActor (Wrapped) async throws(E) -> U) async throws(E) -> U? where E: Error, U: ~Copyable { 23 | if let ok = self { 24 | return try await transform(ok) 25 | } else { 26 | return nil 27 | } 28 | } 29 | 30 | func flatMapAsync(_ transform: (Wrapped) async throws(E) -> U?) async throws(E) -> U? where E: Error, U: ~Copyable { 31 | if let ok = self { 32 | return try await transform(ok) 33 | } else { 34 | return nil 35 | } 36 | } 37 | 38 | // todo cleanup in future Swift versions 39 | @MainActor 40 | func flatMapAsyncMainActor(_ transform: @MainActor (Wrapped) async throws(E) -> U?) async throws(E) -> U? where E: Error, U: ~Copyable { 41 | if let ok = self { 42 | return try await transform(ok) 43 | } else { 44 | return nil 45 | } 46 | } 47 | 48 | func asList() -> [Wrapped] { 49 | if let ok = self { 50 | return [ok] 51 | } else { 52 | return [] 53 | } 54 | } 55 | 56 | var prettyDescription: String { 57 | if let unwrapped = self { 58 | return String(describing: unwrapped) 59 | } 60 | return "nil" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Common/util/ResultEx.swift: -------------------------------------------------------------------------------- 1 | public extension Result { 2 | func getOrNil(appendErrorTo errors: inout [Failure]) -> Success? { 3 | switch self { 4 | case .success(let success): 5 | return success 6 | case .failure(let error): 7 | errors += [error] 8 | return nil 9 | } 10 | } 11 | 12 | func filter(_ failure: @autoclosure () -> Failure, _ predicate: (Success) -> Bool) -> Self { 13 | flatMap { succ in predicate(succ) ? .success(succ) : .failure(failure()) } 14 | } 15 | 16 | func getOrNil() -> Success? { 17 | return switch self { 18 | case .success(let success): success 19 | case .failure: nil 20 | } 21 | } 22 | 23 | func getOrNils() -> (Success?, Failure?) { 24 | return switch self { 25 | case .success(let success): (success, nil) 26 | case .failure(let failure): (nil, failure) 27 | } 28 | } 29 | 30 | var errorOrNil: Failure? { 31 | return switch self { 32 | case .success: nil 33 | case .failure(let f): f 34 | } 35 | } 36 | 37 | var isSuccess: Bool { 38 | switch self { 39 | case .success: true 40 | case .failure: false 41 | } 42 | } 43 | } 44 | 45 | public extension Result { 46 | @discardableResult 47 | func getOrDie( 48 | _ msgPrefix: String = "", 49 | file: String = #fileID, 50 | line: Int = #line, 51 | column: Int = #column, 52 | function: String = #function 53 | ) -> Success { 54 | switch self { 55 | case .success(let suc): 56 | return suc 57 | case .failure(let e): 58 | die(msgPrefix + e.localizedDescription, file: file, line: line, column: column, function: function) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/Common/util/StringLogicalSegments.swift: -------------------------------------------------------------------------------- 1 | public typealias StringLogicalSegments = [StringLogicalSegment] 2 | public extension StringLogicalSegments { 3 | static func < (lhs: Self, rhs: Self) -> Bool { 4 | for (a, b) in zip(lhs, rhs) { 5 | if a < b { 6 | return true 7 | } 8 | if a > b { 9 | return false 10 | } 11 | } 12 | if lhs.count != rhs.count { 13 | return lhs.count < rhs.count 14 | } 15 | return false 16 | } 17 | } 18 | 19 | public enum StringLogicalSegment: Comparable, Equatable, Sendable { 20 | case string(String) 21 | case number(Int) 22 | 23 | public static func < (lhs: Self, rhs: Self) -> Bool { 24 | switch (lhs, rhs) { 25 | case (.string(let a), .string(let b)): a < b 26 | case (.number(let a), .number(let b)): a < b 27 | case (.number, _): true 28 | case (.string, _): false 29 | } 30 | } 31 | } 32 | 33 | public extension String { 34 | func toLogicalSegments() -> StringLogicalSegments { 35 | var currentSegment: String = "" 36 | var isPrevNumber: Bool = false // Initial value doesn't matter 37 | var result: [String] = [] 38 | for char in self { 39 | let isCurNumber = Int(char.description) != nil 40 | if isCurNumber != isPrevNumber && !currentSegment.isEmpty { 41 | result.append(currentSegment) 42 | currentSegment = "" 43 | } 44 | currentSegment.append(char) 45 | isPrevNumber = isCurNumber 46 | } 47 | if !currentSegment.isEmpty { 48 | result.append(currentSegment) 49 | } 50 | return result.map { Int($0).flatMap(StringLogicalSegment.number) ?? .string($0) } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Common/util/showMessageInGui.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // todo refactor. showMessageInGui in common code looks weird 4 | public func showMessageInGui(filenameIfConsoleApp: String?, title: String, message: String) { 5 | let titleAndMessage = "##### \(title) #####\n\n" + message 6 | if isCli { 7 | print(titleAndMessage) 8 | } else if let filenameIfConsoleApp { 9 | let cachesDir = URL(filePath: "/tmp/bobko.aerospace/") 10 | try! FileManager.default.createDirectory(at: cachesDir, withIntermediateDirectories: true) 11 | let file = cachesDir.appending(component: filenameIfConsoleApp) 12 | try! (titleAndMessage + "\n").write(to: file, atomically: true, encoding: .utf8) 13 | 14 | file.absoluteURL.open(with: URL(filePath: "/System/Applications/Utilities/Console.app")) 15 | } else { 16 | try! Process.run(URL(filePath: "/usr/bin/osascript"), 17 | arguments: [ 18 | "-e", 19 | """ 20 | display dialog "\(message.replacing("\"", with: "\\\""))" with title "\(title)" 21 | """, 22 | ] 23 | ) 24 | // === Alternatives === 25 | // let myPopup = NSAlert() 26 | // myPopup.messageText = message 27 | // myPopup.alertStyle = NSAlert.Style.informational 28 | // myPopup.addButton(withTitle: "OK") 29 | // myPopup.runModal() 30 | 31 | // let alert = UIAlertController(title: "Alert", message: message, preferredStyle: UIAlertControllerStyle.alert) 32 | // alert.addAction(UIAlertAction(title: "Click", style: UIAlertActionStyle.default, handler: nil)) 33 | // self.present(alert, animated: true, completion: nil) 34 | 35 | // file.absoluteURL.open(with: URL(filePath: "/System/Applications/Utilities/Console.app")) 36 | // file.absoluteURL.open(with: URL(filePath: "/System/Applications/TextEdit.app")) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Common/versionGenerated.swift: -------------------------------------------------------------------------------- 1 | // FILE IS GENERATED BY generate.sh 2 | public let aeroSpaceAppVersion = "0.0.0-SNAPSHOT" 3 | -------------------------------------------------------------------------------- /Sources/PrivateApi/include/module.modulemap: -------------------------------------------------------------------------------- 1 | module PrivateApi { 2 | header "private.h" 3 | export * 4 | } 5 | -------------------------------------------------------------------------------- /Sources/PrivateApi/include/private.h: -------------------------------------------------------------------------------- 1 | #ifndef private_header_h 2 | #define private_header_h 3 | 4 | #import 5 | 6 | // Potential alternative 1? 7 | // func allWindowsOnCurrentMacOsSpace() { 8 | // let options = CGWindowListOption(arrayLiteral: .excludeDesktopElements, .optionOnScreenOnly) 9 | // let windowsListInfo = CGWindowListCopyWindowInfo(options, CGWindowID(0)) 10 | // let infoList = windowsListInfo as! [[String:Any]] 11 | // let windows = infoList.filter { $0["kCGWindowLayer"] as! Int == 0 } 12 | // print(windows.count) 13 | // for window in windows { 14 | // print(window) 15 | // print("Name: \(window["kCGWindowOwnerName"].unsafelyUnwrapped)") 16 | // print("PID: \(window["kCGWindowOwnerPID"].unsafelyUnwrapped)") 17 | // print("window ID: \(window["kCGWindowNumber"])") 18 | // print("---") 19 | // } 20 | // } 21 | // 22 | // Alternative 2: 23 | // @_silgen_name("_AXUIElementGetWindow") 24 | // @discardableResult 25 | // func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ id: inout CGWindowID) -> AXError 26 | AXError _AXUIElementGetWindow(AXUIElementRef element, uint32_t *identifier); 27 | 28 | #endif 29 | -------------------------------------------------------------------------------- /Sources/PrivateApi/include/private.m: -------------------------------------------------------------------------------- 1 | // This file exists purely because xcode doesn't like header only targets, SPM is fine with them 2 | #import "private.h" 3 | -------------------------------------------------------------------------------- /build-debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./generate.sh --ignore-xcodeproj 6 | swift build 7 | swift build --target AppBundleTests # swift build doesn't build test targets by default :( 8 | 9 | rm -rf .debug && mkdir .debug 10 | cp -r .build/debug/aerospace .debug 11 | cp -r .build/debug/AeroSpaceApp .debug 12 | -------------------------------------------------------------------------------- /build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./script/install-dep.sh --bundler 6 | 7 | rm -rf .site && mkdir .site 8 | rm -rf .man && mkdir .man 9 | 10 | cp-docs() { 11 | cp -r ./docs/*.adoc "$1" 12 | cp -r ./docs/assets "$1" 13 | cp -r ./docs/util "$1" 14 | cp -r ./docs/config-examples "$1" 15 | } 16 | 17 | build-site() { 18 | cp-docs ./.site 19 | cp ./docs/index.html ./.site 20 | 21 | cd .site 22 | # Delete "aerospace " prefifx in synopsis 23 | sed -E -i '' '/tag::synopsis/, /end::synopsis/ s/^(aerospace | {10})//' aerospace* 24 | bundler exec asciidoctor ./guide.adoc ./commands.adoc ./goodies.adoc 25 | cp goodies.html goodness.html # backwards compatibility 26 | rm -rf ./*.adoc 27 | cd - > /dev/null 28 | 29 | git rev-parse HEAD > .site/version.html 30 | if ! test -z "$(git status --porcelain)"; then 31 | echo "git working directory is dirty" >> .site/version.html 32 | fi 33 | } 34 | 35 | build-man() { 36 | cp-docs .man 37 | cd .man 38 | bundler exec asciidoctor -b manpage aerospace*.adoc 39 | rm -rf -- *.adoc 40 | cd - > /dev/null 41 | } 42 | 43 | build-site 44 | build-man 45 | -------------------------------------------------------------------------------- /build-shell-completion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./script/install-dep.sh --complgen 6 | 7 | rm -rf .shell-completion && mkdir -p \ 8 | .shell-completion/zsh \ 9 | .shell-completion/fish \ 10 | .shell-completion/bash 11 | 12 | ./.deps/cargo-root/bin/complgen aot ./grammar/commands-bnf-grammar.txt \ 13 | --zsh-script .shell-completion/zsh/_aerospace \ 14 | --fish-script .shell-completion/fish/aerospace.fish \ 15 | --bash-script .shell-completion/bash/aerospace 16 | 17 | if ! (not-outdated-bash --version | grep -q 'version 5'); then 18 | echo "bash version is too old. At least version 5 is required" > /dev/stderr 19 | exit 1 20 | fi 21 | 22 | # Check basic syntax 23 | zsh -c 'autoload -Uz compinit; compinit; source ./.shell-completion/zsh/_aerospace' 24 | fish -c 'source ./.shell-completion/fish/aerospace.fish' 25 | not-outdated-bash -c 'source ./.shell-completion/bash/aerospace' 26 | -------------------------------------------------------------------------------- /docs/aerospace-balance-sizes.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-balance-sizes(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-balance-sizes 4 | // tag::purpose[] 5 | :manpurpose: Balance sizes of all windows in the current workspace 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace balance-sizes [-h|--help] [--workspace ] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | // =========================================================== Options 23 | include::./util/conditional-options-header.adoc[] 24 | 25 | -h, --help:: Print help 26 | 27 | --workspace :: 28 | include::./util/workspace-flag-desc.adoc[] 29 | 30 | // end::body[] 31 | 32 | // =========================================================== Footer 33 | include::util/man-footer.adoc[] 34 | -------------------------------------------------------------------------------- /docs/aerospace-close-all-windows-but-current.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-close-all-windows-but-current(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-close-all-windows-but-current 4 | // tag::purpose[] 5 | :manpurpose: On the focused workspace, close all windows but current 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace close-all-windows-but-current [-h|--help] [--quit-if-last-window] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | // =========================================================== Options 23 | include::util/conditional-options-header.adoc[] 24 | 25 | -h, --help:: Print help 26 | --quit-if-last-window:: Quit the apps instead of closing them if it's their last window 27 | 28 | // end::body[] 29 | 30 | // =========================================================== Footer 31 | include::util/man-footer.adoc[] 32 | -------------------------------------------------------------------------------- /docs/aerospace-close.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-close(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-close 4 | // tag::purpose[] 5 | :manpurpose: Close the focused window 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace close [-h|--help] [--quit-if-last-window] [--window-id ] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | Normally, you don’t need to use this command, because macOS offers its own `cmd+w` binding. 23 | You might want to use the command from CLI for scripting purposes 24 | 25 | // =========================================================== Options 26 | include::./util/conditional-options-header.adoc[] 27 | 28 | -h, --help:: Print help 29 | --quit-if-last-window:: Quit the app instead of closing if it's the last window of the app 30 | 31 | --window-id :: 32 | include::./util/window-id-flag-desc.adoc[] 33 | 34 | // end::body[] 35 | 36 | // =========================================================== Footer 37 | include::util/man-footer.adoc[] 38 | -------------------------------------------------------------------------------- /docs/aerospace-debug-windows.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-debug-windows(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-debug-windows 4 | // tag::purpose[] 5 | :manpurpose: Interactive command to record Accessibility API debug information to create bug reports 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace debug-windows [-h|--help] [--window-id ] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | Use this command output to report bug reports about incorrect windows handling 23 | (e.g. some windows are floated when they shouldn't). 24 | 25 | The intended usage is the following: 26 | 27 | . Run the command to start the debug session recording 28 | . Focus problematic window or make the window appear. 29 | . Run the command one more time to stop the debug session recording and print the results 30 | 31 | `debug-windows` command is *not stable API*. 32 | Please *don't rely on* the command existence and output format. 33 | The only intended use case is to report bugs about incorrect windows handling. 34 | 35 | // =========================================================== Options 36 | include::util/conditional-options-header.adoc[] 37 | 38 | -h, --help:: Print help 39 | 40 | --window-id :: 41 | Print debug information of the specified window right away. 42 | Usage of this flag disables interactive mode. 43 | 44 | // end::body[] 45 | 46 | // =========================================================== Footer 47 | include::util/man-footer.adoc[] 48 | -------------------------------------------------------------------------------- /docs/aerospace-enable.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-enable(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-enable 4 | // tag::purpose[] 5 | :manpurpose: Temporarily disable window management 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace enable [-h|--help] toggle 13 | aerospace enable [-h|--help] on [--fail-if-noop] 14 | aerospace enable [-h|--help] off [--fail-if-noop] 15 | 16 | // end::synopsis[] 17 | 18 | // =========================================================== Description 19 | == Description 20 | 21 | // tag::body[] 22 | {manpurpose} 23 | 24 | When you disable AeroSpace, windows from currently invisible workspaces will be placed to the visible area of the screen 25 | 26 | Key events are not intercepted when AeroSpace is disabled 27 | 28 | // =========================================================== Options 29 | include::util/conditional-options-header.adoc[] 30 | 31 | -h, --help:: Print help 32 | --fail-if-noop:: Exit with non-zero exit code if already in the requested mode 33 | 34 | // end::body[] 35 | 36 | // =========================================================== Footer 37 | include::util/man-footer.adoc[] 38 | -------------------------------------------------------------------------------- /docs/aerospace-exec-and-forget.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-exec-and-forget(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-exec-and-forget 4 | // tag::purpose[] 5 | :manpurpose: Run /bin/bash -c '' 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace exec-and-forget 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | Run `/bin/bash -c ''`, and don't wait for the command termination. 21 | Stdout, stderr and exit code are ignored. 22 | 23 | For example, you can use this command to launch applications: 24 | 25 | [source,toml] 26 | ---- 27 | alt-enter = 'exec-and-forget open -n /System/Applications/Utilities/Terminal.app' 28 | ---- 29 | 30 | `` is passed "as is" to bash without any transformations and escaping. `` is treated as suffix of the TOML string, it's not even an argument in classic CLI sense 31 | 32 | * The command is available in config 33 | * The command is *NOT* available in CLI 34 | 35 | // end::body[] 36 | 37 | // =========================================================== Footer 38 | include::util/man-footer.adoc[] 39 | -------------------------------------------------------------------------------- /docs/aerospace-flatten-workspace-tree.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-flatten-workspace-tree(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-flatten-workspace-tree 4 | // tag::purpose[] 5 | :manpurpose: Flatten the tree of the focused workspace 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace flatten-workspace-tree [-h|--help] [--workspace ] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | The command is useful when you messed up with your layout, and it's easier to "reset" it and start again. 23 | 24 | // =========================================================== Options 25 | include::./util/conditional-options-header.adoc[] 26 | 27 | -h, --help:: Print help 28 | 29 | --workspace :: 30 | include::./util/workspace-flag-desc.adoc[] 31 | 32 | // end::body[] 33 | 34 | // =========================================================== Footer 35 | include::util/man-footer.adoc[] 36 | -------------------------------------------------------------------------------- /docs/aerospace-focus-back-and-forth.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-focus-back-and-forth(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Switch between the current and previously focused elements back and forth 5 | // end::purpose[] 6 | :manname: aerospace-focus-back-and-forth 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace focus-back-and-forth [-h|--help] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose}. 21 | The element is either a window or an empty workspace. 22 | 23 | AeroSpace stores only one previously focused window in history, 24 | which means that if you close the previous window, 25 | `focus-back-and-forth` has no window to switch focus to. 26 | In that case, the command will exit with non-zero exit code. 27 | 28 | That's why it may be preferred to combine `focus-back-and-forth` with `workspace-back-and-forth`: + 29 | ---- 30 | aerospace focus-back-and-forth || aerospace workspace-back-and-forth 31 | ---- 32 | 33 | Also see: <> 34 | // end::body[] 35 | 36 | // =========================================================== Options 37 | include::util/conditional-options-header.adoc[] 38 | 39 | -h, --help:: Print help 40 | 41 | // =========================================================== Footer 42 | include::util/man-footer.adoc[] 43 | -------------------------------------------------------------------------------- /docs/aerospace-focus-monitor.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-focus-monitor(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-focus-monitor 4 | // tag::purpose[] 5 | :manpurpose: Focus monitor by relative direction, by order, or by pattern 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace focus-monitor [-h|--help] [--wrap-around] (left|down|up|right) 13 | aerospace focus-monitor [-h|--help] [--wrap-around] (next|prev) 14 | aerospace focus-monitor [-h|--help] ... 15 | 16 | // end::synopsis[] 17 | 18 | // =========================================================== Description 19 | == Description 20 | 21 | // tag::body[] 22 | {manpurpose} 23 | 24 | // =========================================================== Options 25 | include::./util/conditional-options-header.adoc[] 26 | 27 | -h, --help:: Print help 28 | --wrap-around:: Make it possible to wrap around focus 29 | 30 | // =========================================================== Arguments 31 | include::./util/conditional-arguments-header.adoc[] 32 | 33 | (left|down|up|right):: 34 | Focus monitor in direction relative to the focused monitor 35 | 36 | (next|prev):: 37 | Focus next|prev monitor in order they appear in tray icon 38 | 39 | ...:: 40 | Find the first monitor pattern in the list that doesn't describe the current monitor and focus it. 41 | Monitor pattern is the same as in `workspace-to-monitor-force-assignment` config option 42 | 43 | // end::body[] 44 | 45 | // =========================================================== Footer 46 | include::util/man-footer.adoc[] 47 | -------------------------------------------------------------------------------- /docs/aerospace-fullscreen.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-fullscreen(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-fullscreen 4 | // tag::purpose[] 5 | :manpurpose: Toggle the fullscreen mode for the focused window 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace fullscreen [-h|--help] [--window-id ] [--no-outer-gaps] 13 | aerospace fullscreen [-h|--help] on [--window-id ] [--no-outer-gaps] [--fail-if-noop] 14 | aerospace fullscreen [-h|--help] off [--window-id ] [--fail-if-noop] 15 | 16 | // end::synopsis[] 17 | 18 | // =========================================================== Description 19 | == Description 20 | 21 | // tag::body[] 22 | {manpurpose} 23 | 24 | Switching to a different tiling window within the same workspace while the current focused window is in fullscreen mode results in the fullscreen window exiting fullscreen mode. 25 | 26 | // =========================================================== Options 27 | include::./util/conditional-options-header.adoc[] 28 | 29 | -h, --help:: Print help 30 | --no-outer-gaps:: Remove the outer gaps when in fullscreen mode 31 | --fail-if-noop:: Exit with non-zero exit code if already fullscreen or already not fullscreen 32 | 33 | --window-id :: 34 | include::./util/window-id-flag-desc.adoc[] 35 | 36 | // =========================================================== Arguments 37 | include::./util/conditional-arguments-header.adoc[] 38 | 39 | on, off:: 40 | `on` means enter fullscreen mode. `off` means exit fullscreen mode. 41 | Toggle between the two if not specified 42 | 43 | // end::body[] 44 | 45 | // =========================================================== Footer 46 | include::util/man-footer.adoc[] 47 | -------------------------------------------------------------------------------- /docs/aerospace-join-with.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-join-with(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Put the focused window and the nearest node in the specified direction under a common parent container 5 | // end::purpose[] 6 | :manname: aerospace-join-with 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace join-with [-h|--help] [--window-id ] (left|down|up|right) 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | // =========================================================== Examples 23 | include::util/conditional-examples-header.adoc[] 24 | 25 | Given this layout 26 | 27 | ---- 28 | h_tiles 29 | ├── window 1 30 | ├── window 2 (focused) 31 | └── window 3 32 | ---- 33 | 34 | `join-with right` will result in the following layout 35 | 36 | ---- 37 | h_tiles 38 | ├── window 1 39 | └── v_tiles 40 | ├── window 2 (focused) 41 | └── window 3 42 | ---- 43 | 44 | NOTE: `join-with` is a high-level replacement for i3's https://i3wm.org/docs/userguide.html#_splitting_containers[split command]. 45 | There is an observation that the only reason why you might want to split a node is to put several windows under a common "umbrella" parent. Unlike `split`, `join-with` can be used with xref:guide.adoc#normalization[`enable-normalization-flatten-containers`] 46 | 47 | // =========================================================== Options 48 | include::util/conditional-options-header.adoc[] 49 | 50 | -h, --help:: Print help 51 | 52 | --window-id :: 53 | include::./util/window-id-flag-desc.adoc[] 54 | 55 | // end::body[] 56 | 57 | // =========================================================== Footer 58 | include::util/man-footer.adoc[] 59 | -------------------------------------------------------------------------------- /docs/aerospace-list-exec-env-vars.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-list-exec-env-vars(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-list-exec-env-vars 4 | // tag::purpose[] 5 | :manpurpose: List environment variables that exec-* commands and callbacks are run with 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace list-exec-env-vars [-h|--help] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | Examples of commands and callbacks: 23 | 24 | * `aerospace exec-and-forget` command 25 | * `exec-on-workspace-change-callback` 26 | 27 | // end::body[] 28 | 29 | // =========================================================== Options 30 | include::util/conditional-options-header.adoc[] 31 | 32 | -h, --help:: Print help 33 | 34 | // =========================================================== Footer 35 | include::util/man-footer.adoc[] 36 | -------------------------------------------------------------------------------- /docs/aerospace-list-modes.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-list-modes(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Print a list of modes currently specified in the configuration 5 | // end::purpose[] 6 | :manname: aerospace-list-modes 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace list-modes [-h|--help] [--current] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | See xref:guide.adoc#binding-modes[the guide] for documentation about binding modes 23 | 24 | // =========================================================== Options 25 | include::util/conditional-options-header.adoc[] 26 | 27 | -h, --help:: Print help 28 | 29 | --current:: 30 | Only print the currently active mode 31 | 32 | // end::body[] 33 | 34 | // =========================================================== Footer 35 | include::util/man-footer.adoc[] 36 | -------------------------------------------------------------------------------- /docs/aerospace-macos-native-fullscreen.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-macos-native-fullscreen(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-macos-native-fullscreen 4 | // tag::purpose[] 5 | :manpurpose: Toggle macOS fullscreen for the focused window 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace macos-native-fullscreen [-h|--help] [--window-id ] 13 | aerospace macos-native-fullscreen [-h|--help] [--window-id ] [--fail-if-noop] on 14 | aerospace macos-native-fullscreen [-h|--help] [--window-id ] [--fail-if-noop] off 15 | 16 | // end::synopsis[] 17 | 18 | // =========================================================== Description 19 | == Description 20 | 21 | // tag::body[] 22 | {manpurpose} 23 | 24 | // =========================================================== Options 25 | include::./util/conditional-options-header.adoc[] 26 | 27 | -h, --help:: Print help 28 | --fail-if-noop:: Exit with non-zero exit code if already fullscreen or already not fullscreen 29 | 30 | --window-id :: 31 | include::./util/window-id-flag-desc.adoc[] 32 | 33 | // =========================================================== Arguments 34 | include::./util/conditional-arguments-header.adoc[] 35 | 36 | on, off:: 37 | `on` means enter fullscreen mode. 38 | `off` means exit fullscreen mode. 39 | Toggle between the two if not specified 40 | 41 | // end::body[] 42 | 43 | // =========================================================== Footer 44 | include::util/man-footer.adoc[] 45 | -------------------------------------------------------------------------------- /docs/aerospace-macos-native-minimize.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-macos-native-minimize(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-macos-native-minimize 4 | // tag::purpose[] 5 | :manpurpose: Minimize focused window 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace macos-native-minimize [-h|--help] [--window-id ] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | // =========================================================== Options 23 | include::./util/conditional-options-header.adoc[] 24 | 25 | -h, --help:: Print help 26 | 27 | --window-id :: 28 | include::./util/window-id-flag-desc.adoc[] 29 | 30 | // end::body[] 31 | 32 | // =========================================================== Footer 33 | include::util/man-footer.adoc[] 34 | -------------------------------------------------------------------------------- /docs/aerospace-mode.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-mode(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Activate the specified binding mode 5 | // end::purpose[] 6 | :manname: aerospace-mode 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace mode [-h|--help] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | See xref:guide.adoc#binding-modes[the guide] for documentation about binding modes 23 | // end::body[] 24 | 25 | // =========================================================== Options 26 | include::util/conditional-options-header.adoc[] 27 | 28 | -h, --help:: Print help 29 | 30 | // =========================================================== Footer 31 | include::util/man-footer.adoc[] 32 | -------------------------------------------------------------------------------- /docs/aerospace-move-node-to-workspace.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-move-node-to-workspace(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-move-node-to-workspace 4 | // tag::purpose[] 5 | :manpurpose: Move the focused window to the specified workspace 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace move-node-to-workspace [-h|--help] [--focus-follows-window] [--wrap-around] 13 | (next|prev) 14 | aerospace move-node-to-workspace [-h|--help] [--focus-follows-window] [--fail-if-noop] 15 | [--window-id ] 16 | 17 | // end::synopsis[] 18 | 19 | // =========================================================== Description 20 | == Description 21 | 22 | // tag::body[] 23 | {manpurpose} 24 | 25 | `(next|prev)` is identical to `workspace (next|prev)` 26 | 27 | // =========================================================== Options 28 | include::./util/conditional-options-header.adoc[] 29 | 30 | -h, --help:: Print help 31 | --wrap-around:: Make it possible to jump between first and last workspaces using (next|prev) 32 | --fail-if-noop:: Exit with non-zero code if move window to workspace it already belongs to 33 | 34 | --focus-follows-window:: 35 | Make sure that the window in question receives focus after moving. 36 | This flag is a shortcut for manually running `aerospace-workspace`/`aerospace-focus` after `move-node-to-workspace` successful execution. 37 | 38 | --window-id :: 39 | include::./util/window-id-flag-desc.adoc[] 40 | 41 | // =========================================================== Arguments 42 | include::./util/conditional-arguments-header.adoc[] 43 | 44 | (next|prev):: Move window to next or prev workspace 45 | :: Specifies workspace name where to move window to 46 | 47 | // end::body[] 48 | 49 | // =========================================================== Footer 50 | include::util/man-footer.adoc[] 51 | -------------------------------------------------------------------------------- /docs/aerospace-reload-config.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-reload-config(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-reload-config 4 | // tag::purpose[] 5 | :manpurpose: Reload currently active config 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace reload-config [-h|--help] [--no-gui] [--dry-run] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | If the config contains errors they will be printed to stdout, and GUI will open to show the errors. 23 | 24 | // =========================================================== Options 25 | include::util/conditional-options-header.adoc[] 26 | 27 | -h, --help:: Print help 28 | --no-gui:: Don't open GUI to show error. Only use stdout to report errors 29 | --dry-run:: Validate the config and show errors (if any) but don't reload the config 30 | 31 | include::util/conditional-exit-code-header.adoc[] 32 | 33 | 0:: Success. The config is reloaded successfully. 34 | non-zero exit code:: Failure. The config contains errors. 35 | 36 | // end::body[] 37 | 38 | // =========================================================== Footer 39 | include::util/man-footer.adoc[] 40 | -------------------------------------------------------------------------------- /docs/aerospace-resize.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-resize(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-resize 4 | // tag::purpose[] 5 | :manpurpose: Resize the focused window 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace resize [-h|--help] [--window-id ] (smart|smart-opposite|width|height) [+|-] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | The dimension to resize is chosen by the first argument 23 | 24 | * `width` changes width 25 | * `height` changes height 26 | * `smart` changes width if the parent has horizontal orientation, and 27 | it changes height if the parent has vertical orientation 28 | * `smart-opposite` does resizes the opposite axis of smart 29 | 30 | Second argument controls how much the size changes 31 | 32 | * If the `` is prefixed with `+` then the dimension is increased 33 | * If the `` is prefixed with `-` then the dimension is decreased 34 | * If the `` is prefixed with neither `+` nor `-` then the command changes the absolute value of the dimension 35 | 36 | // =========================================================== Options 37 | include::./util/conditional-options-header.adoc[] 38 | 39 | -h, --help:: Print help 40 | 41 | --window-id :: 42 | include::./util/window-id-flag-desc.adoc[] 43 | 44 | // end::body[] 45 | 46 | // =========================================================== Footer 47 | include::util/man-footer.adoc[] 48 | -------------------------------------------------------------------------------- /docs/aerospace-split.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-split(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-split 4 | // tag::purpose[] 5 | :manpurpose: Split focused window 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace split [-h|--help] [--window-id ] (horizontal|vertical|opposite) 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | `split` command exist solely for compatibility with i3. 21 | Unless you're hardcore i3 user who knows what they are doing, it's recommended to use `join-with` 22 | 23 | *If the parent of focused window contains more than one child*, then the command 24 | 25 | . Creates a new tiling container 26 | . Replaces the focused window with the container 27 | . Puts the focused window into the container as its the only child 28 | 29 | The argument configures orientation of the newly created container. 30 | `opposite` means opposite orientation compared to the parent container. 31 | 32 | *If the parent of the focused window contains only a single child* (the window itself), then `split` command changes the orientation of the parent container 33 | 34 | IMPORTANT: `split` command has no effect if `enable-normalization-flatten-containers` is turned on. 35 | Consider using `join-with` if you want to keep `enable-normalization-flatten-containers` enabled 36 | 37 | // =========================================================== Options 38 | include::util/conditional-options-header.adoc[] 39 | 40 | -h, --help:: Print help 41 | 42 | --window-id :: 43 | include::./util/window-id-flag-desc.adoc[] 44 | 45 | // end::body[] 46 | 47 | // =========================================================== Footer 48 | include::util/man-footer.adoc[] 49 | -------------------------------------------------------------------------------- /docs/aerospace-summon-workspace.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-summon-workspace(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Move the requested workspace to the focused monitor. 5 | // end::purpose[] 6 | :manname: aerospace-summon-workspace 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace summon-workspace [-h|--help] [--fail-if-noop] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | The moved workspace becomes focused. 22 | The behavior is identical to Xmonad. 23 | 24 | The command makes sense only in multi-monitor setup. 25 | In single monitor setup the command is identical to `workspace` command. 26 | 27 | // =========================================================== Options 28 | include::./util/conditional-options-header.adoc[] 29 | 30 | -h, --help:: Print help 31 | --fail-if-noop:: Exit with non-zero exit code if the workspace already visible on the focused monitor. 32 | 33 | // =========================================================== Arguments 34 | include::./util/conditional-arguments-header.adoc[] 35 | 36 | :: The workspace to operate on. 37 | 38 | // end::body[] 39 | 40 | // =========================================================== Footer 41 | include::util/man-footer.adoc[] 42 | -------------------------------------------------------------------------------- /docs/aerospace-trigger-binding.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-trigger-binding(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-trigger-binding 4 | // tag::purpose[] 5 | :manpurpose: Trigger AeroSpace binding as if it was pressed by user 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace trigger-binding [-h|--help] --mode 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | You can use aerospace-config command to inspect available bindings: + 23 | `aerospace config --get mode.main.binding --keys` 24 | 25 | // =========================================================== Options 26 | include::util/conditional-options-header.adoc[] 27 | 28 | -h, --help:: Print help 29 | --mode :: Mode to search `` in 30 | 31 | // =========================================================== Arguments 32 | include::util/conditional-arguments-header.adoc[] 33 | 34 | :: Binding to trigger 35 | 36 | // =========================================================== Examples 37 | include::util/conditional-examples-header.adoc[] 38 | 39 | * Run alphabetically first binding from config (useless and synthetic example): + 40 | `aerospace trigger-binding --mode main "$(aerospace config --get mode.main.binding --keys | head -1)"` 41 | * Trigger `alt-tab` binding: + 42 | `aerospace trigger-binding --mode main alt-tab` 43 | 44 | // end::body[] 45 | 46 | // =========================================================== Footer 47 | include::util/man-footer.adoc[] 48 | -------------------------------------------------------------------------------- /docs/aerospace-volume.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-volume(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Manipulate volume 5 | // end::purpose[] 6 | :manname: aerospace-volume 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace volume [-h|--help] (up|down) 13 | aerospace volume [-h|--help] (mute-toggle|mute-off|mute-on) 14 | aerospace volume [-h|--help] set 15 | 16 | // end::synopsis[] 17 | 18 | // =========================================================== Description 19 | == Description 20 | 21 | // tag::body[] 22 | {manpurpose} 23 | 24 | // =========================================================== Options 25 | include::./util/conditional-options-header.adoc[] 26 | 27 | -h, --help:: Print help 28 | 29 | // =========================================================== Arguments 30 | include::./util/conditional-arguments-header.adoc[] 31 | 32 | (up|down):: Increase or decrease the volume 33 | (mute-toggle|mute-on|mute-off):: Toggle/On/Off mute 34 | set :: Set volume to the exact value on scale from 0 to 100 35 | 36 | // end::body[] 37 | 38 | // =========================================================== Footer 39 | include::util/man-footer.adoc[] 40 | -------------------------------------------------------------------------------- /docs/aerospace-workspace-back-and-forth.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-workspace-back-and-forth(1) 2 | include::util/man-attributes.adoc[] 3 | // tag::purpose[] 4 | :manpurpose: Switch between the focused workspace and previously focused workspace back and forth 5 | // end::purpose[] 6 | :manname: aerospace-workspace-back-and-forth 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace workspace-back-and-forth [-h|--help] 13 | 14 | // end::synopsis[] 15 | 16 | // =========================================================== Description 17 | == Description 18 | 19 | // tag::body[] 20 | {manpurpose} 21 | 22 | Unlike `focus-back-and-forth`, `workspace-back-and-forth` always succeeds. 23 | Because unlike windows, workspaces can not be "closed". 24 | Workspaces are name-addressable objects. 25 | They are created and destroyed on the fly. 26 | 27 | Also see: <> 28 | // end::body[] 29 | 30 | // =========================================================== Options 31 | include::util/conditional-options-header.adoc[] 32 | 33 | -h, --help:: Print help 34 | 35 | // =========================================================== Footer 36 | include::util/man-footer.adoc[] 37 | -------------------------------------------------------------------------------- /docs/aerospace-workspace.adoc: -------------------------------------------------------------------------------- 1 | = aerospace-workspace(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace-workspace 4 | // tag::purpose[] 5 | :manpurpose: Focus the specified workspace 6 | // end::purpose[] 7 | 8 | // =========================================================== Synopsis 9 | == Synopsis 10 | [verse] 11 | // tag::synopsis[] 12 | aerospace workspace [-h|--help] [--auto-back-and-forth] [--fail-if-noop] 13 | aerospace workspace [-h|--help] [--wrap-around] (next|prev) 14 | 15 | // end::synopsis[] 16 | 17 | // =========================================================== Description 18 | == Description 19 | 20 | // tag::body[] 21 | *1. syntax* 22 | 23 | {manpurpose} 24 | 25 | *2. (next|prev) syntax* 26 | 27 | Focuses next or previous workspace in *the list*. 28 | 29 | * If stdin is not TTY and stdin contains non whitespace characters then *the list* is taken from stdin 30 | * Otherwise, *the list* is defined as all workspaces on focused monitor in alphabetical order 31 | 32 | // =========================================================== Options 33 | include::util/conditional-options-header.adoc[] 34 | 35 | -h, --help:: Print help 36 | --wrap-around:: Make it possible to jump between first and last workspaces using `(next|prev)` 37 | 38 | --auto-back-and-forth:: 39 | Automatic `back-and-forth` when switching to already focused workspace. 40 | Incompatible with `--fail-if-noop` 41 | 42 | --fail-if-noop:: 43 | Exit with non-zero exit code if switch to the already focused workspace 44 | Incompatible with `--auto-back-and-forth` 45 | 46 | // =========================================================== Examples 47 | include::util/conditional-examples-header.adoc[] 48 | 49 | * Go to the next non empty workspace on the focused monitor: + 50 | `aerospace list-workspaces --monitor focused --empty no | aerospace workspace next` 51 | 52 | // end::body[] 53 | 54 | // =========================================================== Footer 55 | include::util/man-footer.adoc[] 56 | -------------------------------------------------------------------------------- /docs/aerospace.adoc: -------------------------------------------------------------------------------- 1 | = aerospace(1) 2 | include::util/man-attributes.adoc[] 3 | :manname: aerospace 4 | :manpurpose: i3-like tiling window manager for macOS 5 | 6 | == Synopsis 7 | [verse] 8 | aerospace [-h|--help] [-v|--version] [...] [...] 9 | 10 | == Description 11 | 12 | AeroSpace is an i3-like tiling window manager for macOS 13 | 14 | *aerospace* command line program is used to manipulate AeroSpace and query its state. 15 | 16 | See https://nikitabobko.github.io/AeroSpace/commands for available options 17 | 18 | See each individual man page for and 19 | 20 | == Options 21 | 22 | -h, --help:: Print help 23 | 24 | include::util/man-footer.adoc[] 25 | -------------------------------------------------------------------------------- /docs/assets/h_accordion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitabobko/AeroSpace/74bb715eb1a1818a98ecb3120e244b91d01f50b4/docs/assets/h_accordion.png -------------------------------------------------------------------------------- /docs/assets/h_tiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitabobko/AeroSpace/74bb715eb1a1818a98ecb3120e244b91d01f50b4/docs/assets/h_tiles.png -------------------------------------------------------------------------------- /docs/assets/icon.png: -------------------------------------------------------------------------------- 1 | ../../resources/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /docs/assets/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitabobko/AeroSpace/74bb715eb1a1818a98ecb3120e244b91d01f50b4/docs/assets/tree.png -------------------------------------------------------------------------------- /docs/assets/v_accordion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitabobko/AeroSpace/74bb715eb1a1818a98ecb3120e244b91d01f50b4/docs/assets/v_accordion.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redirect 7 | 8 | 9 | Redirect 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/util/all-monitors-option.adoc: -------------------------------------------------------------------------------- 1 | --all:: 2 | Alias for `--monitor all`. 3 | Please use this option *with cautious*. 4 | Use it when you really need to get workspaces/windows from *all monitors*. 5 | + 6 | For multi-monitor setup `--monitor focused` is almost always a preferred option. 7 | If you're automating something then you don't want to mess up with workspaces/windows on a different monitor. 8 | + 9 | With great power comes great responsibility. 10 | -------------------------------------------------------------------------------- /docs/util/conditional-arguments-header.adoc: -------------------------------------------------------------------------------- 1 | ifndef::env-site[] 2 | == Arguments 3 | endif::[] 4 | 5 | ifdef::env-site[] 6 | [.lead] 7 | **ARGUMENTS** 8 | endif::[] 9 | -------------------------------------------------------------------------------- /docs/util/conditional-examples-header.adoc: -------------------------------------------------------------------------------- 1 | ifndef::env-site[] 2 | == Examples 3 | endif::[] 4 | 5 | ifdef::env-site[] 6 | [.lead] 7 | **EXAMPLES** 8 | endif::[] 9 | -------------------------------------------------------------------------------- /docs/util/conditional-exit-code-header.adoc: -------------------------------------------------------------------------------- 1 | ifndef::env-site[] 2 | == Exit code 3 | endif::[] 4 | 5 | ifdef::env-site[] 6 | [.lead] 7 | **EXIT CODE** 8 | endif::[] 9 | -------------------------------------------------------------------------------- /docs/util/conditional-options-header.adoc: -------------------------------------------------------------------------------- 1 | ifndef::env-site[] 2 | == Options 3 | endif::[] 4 | 5 | ifdef::env-site[] 6 | [.lead] 7 | **OPTIONS** 8 | endif::[] 9 | -------------------------------------------------------------------------------- /docs/util/conditional-output-format-header.adoc: -------------------------------------------------------------------------------- 1 | ifndef::env-site[] 2 | == Output Format 3 | endif::[] 4 | 5 | ifdef::env-site[] 6 | [.lead] 7 | **OUTPUT FORMAT** 8 | endif::[] 9 | -------------------------------------------------------------------------------- /docs/util/header.adoc: -------------------------------------------------------------------------------- 1 | ==== 2 | AeroSpace is an i3-like tiling window manager for macOS 3 | 4 | *Project homepage*: https://github.com/nikitabobko/AeroSpace 5 | 6 | image:assets/icon.png[300,300,float="right"] 7 | 8 | * xref:guide.adoc[AeroSpace Guide] 9 | * xref:commands.adoc[AeroSpace Commands] 10 | * xref:goodies.adoc[AeroSpace Goodies] 11 | ==== 12 | -------------------------------------------------------------------------------- /docs/util/man-attributes.adoc: -------------------------------------------------------------------------------- 1 | ifndef::env-site[] 2 | :doctype: manpage 3 | :manmanual: AeroSpace Manual 4 | :mansource: AeroSpace 5 | endif::[] 6 | -------------------------------------------------------------------------------- /docs/util/man-footer.adoc: -------------------------------------------------------------------------------- 1 | == Resources 2 | 3 | *Project homepage:* https://github.com/nikitabobko/AeroSpace + 4 | *Guide:* https://nikitabobko.github.io/AeroSpace/guide + 5 | 6 | == BUGS 7 | 8 | Bugs can be reported to https://github.com/nikitabobko/AeroSpace/discussions/categories/potential-bugs 9 | 10 | Maintainers will move verified bugs to https://github.com/nikitabobko/AeroSpace/issues 11 | 12 | == License 13 | 14 | Copyright (C) 2023 Nikita Bobko + 15 | Free use of this software is granted under the terms of the MIT License + 16 | You can find the full text of AeroSpace license and its dependencies in the 'legal' directory of the distributed zip archieve. 17 | 18 | == AUTHOR 19 | 20 | Nikita Bobko and contributors 21 | -------------------------------------------------------------------------------- /docs/util/monitor-option.adoc: -------------------------------------------------------------------------------- 1 | --monitor :: 2 | Filter results to only print workspaces/windows that are attached to specified monitors. 3 | `` is a space separated list of monitor IDs. + 4 | + 5 | Possible monitors IDs: + 6 | + 7 | . 1-based index of a monitor as if monitors were ordered horizontally from left to right 8 | . `all` is a special monitor ID that represents all monitors 9 | . `mouse` is a special monitor ID that represents monitor with the mouse 10 | . `focused` is a special monitor ID that represents the focused monitor 11 | -------------------------------------------------------------------------------- /docs/util/site-attributes.adoc: -------------------------------------------------------------------------------- 1 | :idprefix: 2 | :idseparator: - 3 | :prewrap!: 4 | :relfilesuffix: 5 | :sectanchors: 6 | :sectlinks: 7 | :sectnums: 8 | :source-highlighter: pygments 9 | :toc: left 10 | :env-site: 11 | :favicon: ./assets/icon.png 12 | -------------------------------------------------------------------------------- /docs/util/window-id-flag-desc.adoc: -------------------------------------------------------------------------------- 1 | Act on the specified window instead of the focused window 2 | -------------------------------------------------------------------------------- /docs/util/workspace-flag-desc.adoc: -------------------------------------------------------------------------------- 1 | Act on the specified workspace instead of the focused workspace 2 | -------------------------------------------------------------------------------- /generate-shell-parser.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./script/install-dep.sh --antlr 6 | ./.deps/python-venv/bin/antlr4 -v 4.13.1 -no-listener -Dlanguage=Swift \ 7 | -o ./ShellParserGenerated/Sources/ShellParserGenerated \ 8 | ./grammar/ShellLexer.g4 \ 9 | ./grammar/ShellParser.g4 10 | 11 | 12 | mv ./ShellParserGenerated/Sources/ShellParserGenerated/grammar/*.swift ./ShellParserGenerated/Sources/ShellParserGenerated/ 13 | rm -rf ./ShellParserGenerated/Sources/ShellParserGenerated/grammar # Antlr generates weird *.interp and *.tokens files 14 | 15 | # Sources/ShellParserGenerated/ShellParser.swift:557:7: warning: variable '_prevctx' was written to, but never read 16 | # var _prevctx: CmdContext = _localctx 17 | sed -i '' '/_prevctx/d' ./ShellParserGenerated/Sources/ShellParserGenerated/ShellParser.swift 18 | -------------------------------------------------------------------------------- /grammar/ShellLexer.g4: -------------------------------------------------------------------------------- 1 | // shell lexer grammar. Powered by https://github.com/antlr/antlr4 2 | // Use ./generate-shell-parser.sh to regenerate grammar code 3 | lexer grammar ShellLexer; 4 | 5 | TRIPLE_QUOTE : '"""' | '\'\'\'' ; // Reserved 6 | 7 | SINGLE_QUOTED_STRING : '\'' .*? '\'' ; 8 | 9 | LDQUOTE : '"' -> pushMode(IN_DSTRING) ; 10 | LPAR : '(' -> pushMode(DEFAULT_MODE) ; 11 | INTERPOLATION_START : '$(' -> pushMode(DEFAULT_MODE) ; 12 | RPAR : ')' { 13 | _ = try? popMode() 14 | } ; 15 | 16 | // Keywords (some of them are unused, just reserved) 17 | ELIF : 'elif' NL* ; 18 | IF : 'if' NL* ; 19 | SWITCH : 'switch' NL* ; 20 | CASE : 'case' NL* ; 21 | DO : 'do' NL* ; 22 | THEN : 'then' NL* ; 23 | ELSE : 'else' NL* ; 24 | FOR : 'for' NL* ; 25 | WHILE : 'while' NL* ; 26 | CATCH : 'catch' NL* ; 27 | IN : 'in' NL* ; 28 | END : 'end' NL* ; 29 | DEFER : 'defer' NL* ; 30 | 31 | AND : '&&' ; 32 | PIPE : '|' ; 33 | OR : '||' ; 34 | SEMICOLON : ';' ; 35 | NL : SPACES? ('\r')? '\n' ; 36 | WORD : ([a-zA-Z] | '.' | '_' | '-' | '/')+ ; 37 | ARG : ([a-zA-Z0-9] | '.' | '_' | '-' | '/' | '+' | ',' | '=' | '!' | '%' | '{' | '}' | '^')+ ; 38 | 39 | ESCAPE_NEWLINE : '\\' SPACES? COMMENT? '\n' -> skip ; 40 | COMMENT : '#' ~('\n')* -> skip ; 41 | SPACES : [ \t]+ -> skip ; 42 | 43 | ANY : . ; // Catch all other 44 | 45 | mode IN_DSTRING; 46 | TEXT : ~('\\' | '"' | '$')+ ; 47 | INTERPOLATION_START_IN_DSTRING : '$(' -> pushMode(DEFAULT_MODE) ; 48 | ESCAPE_SEQUENCE : '\\' . ; 49 | RDQUOTE : '"' { 50 | _ = try? popMode() 51 | } ; 52 | -------------------------------------------------------------------------------- /install-from-sources.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | rebuild=1 6 | while test $# -gt 0; do 7 | case $1 in 8 | --dont-rebuild) rebuild=0; shift ;; 9 | *) echo "Unknown option $1"; exit 1 ;; 10 | esac 11 | done 12 | 13 | if test $rebuild == 1; then 14 | ./build-release.sh 15 | fi 16 | 17 | brew list aerospace-dev > /dev/null 2>&1 && brew uninstall aerospace-dev 18 | brew list aerospace > /dev/null 2>&1 && brew uninstall aerospace 19 | 20 | # Override HOMEBREW_CACHE. Otherwise, homebrew refuses to "redownload" the snapshot file 21 | # Maybe there is a better way, I don't know 22 | rm -rf /tmp/aerospace-from-sources-brew-cache 23 | env HOMEBREW_CACHE=/tmp/aerospace-from-sources-brew-cache brew install --cask ./.release/aerospace-dev.rb 24 | -------------------------------------------------------------------------------- /legal/LICENSE.txt: -------------------------------------------------------------------------------- 1 | ../LICENSE.txt -------------------------------------------------------------------------------- /legal/README.md: -------------------------------------------------------------------------------- 1 | # LICENSE 2 | 3 | The AeroSpace itself is licensed under MIT. See [LICENSE](./LICENSE.txt) for the full license text. 4 | 5 | ## Bundled dependencies and materials 6 | 7 | AeroSpace bundles the following dependencies and uses the following materials: 8 | 9 | **BlueSocket**. 10 | [BlueSocket GitHub link](https://github.com/Kitura/BlueSocket). 11 | [BlueSocket Apache 2.0 license](./third-party-license/LICENSE-BlueSocket.txt). 12 | BlueSocket is used as a more convenient Swift wrapper around UNIX C socket API. 13 | 14 | **HotKey**. 15 | [HotKey GitHub link](https://github.com/soffes/HotKey). 16 | [HotKey MIT license](./third-party-license/LICENSE-HotKey.txt). 17 | HotKey is used as a more convenient wrapper around macOS Carbon API to listen for global shortcuts. 18 | 19 | **TOMLKIT**. 20 | [TOMLKIT GitHub link](https://github.com/LebJe/TOMLKit). 21 | [TOMLKIT MIT license](./third-party-license/LICENSE-TOMLKIT.txt). 22 | TOMLKIT is used as a more convenient Swift wrapper around tomlplusplus C++ API. 23 | 24 | **tomlplusplus**. 25 | [tomlplusplus GitHub link](https://github.com/marzer/tomlplusplus). 26 | [tomlplusplus MIT license](./third-party-license/LICENSE-tomlplusplus.txt). 27 | tomlplusplus is used as TOML parser. tomlplusplus is used indirectly through TOMLKIT Swift API. 28 | 29 | **ANTLR v4**. 30 | [ANTLR v4 GitHub link](https://github.com/antlr/antlr4). 31 | [ANTLR BSD-3 license](./third-party-license/LICENSE-antlr.txt). 32 | ANTLR is used to parse AeroSpace built-in shell like language. 33 | 34 | **swift-collections**. 35 | [swift-collections GitHub link](https://github.com/apple/swift-collections). 36 | [swift-collections Apache 2.0 license](./third-party-license/LICENSE-swift-collections.txt). 37 | swift-collections is used for more advanced Swift collections. 38 | 39 | **ISSoundAdditions** 40 | [ISSoundAdditions GitHub link](https://github.com/InerziaSoft/ISSoundAdditions). 41 | [ISSoundAdditions MIT license](./third-party-license/LICENSE-ISSoundAdditions.txt). 42 | ISSoundAdditions is used as a convenient API to change system volume. 43 | -------------------------------------------------------------------------------- /legal/third-party-license/LICENSE-HotKey.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017–2019 Sam Soffes, http://soff.es 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /legal/third-party-license/LICENSE-ISSoundAdditions.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 InerziaSoft - Massimo and Alessio Moiso. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /legal/third-party-license/LICENSE-TOMLKIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jeff Lebrun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /legal/third-party-license/LICENSE-antlr.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2022 The ANTLR Project. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither name of copyright holders nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR 22 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 23 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 24 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 25 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 26 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 27 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /legal/third-party-license/LICENSE-tomlplusplus.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Mark Gillard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | # makefile is used to make :make command in vim work out of the box 2 | .PHONY: build test 3 | 4 | build: 5 | ./build-debug.sh 6 | 7 | test: 8 | ./run-tests.sh 9 | -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | # Xcode project configuration. Managed by https://github.com/yonaskolb/XcodeGen 2 | # Xcode is only used to build the release App Bundle. Debug builds only use Swift Package Manager 3 | 4 | name: AeroSpace 5 | 6 | packages: 7 | AeroSpacePackage: 8 | path: . 9 | 10 | configs: 11 | Debug: debug 12 | Release: release 13 | 14 | targets: 15 | AeroSpace: 16 | type: application 17 | platform: macOS 18 | deploymentTarget: "13.0" 19 | sources: 20 | - "Sources/AeroSpaceApp" 21 | - "resources" 22 | - "docs/config-examples/default-config.toml" 23 | dependencies: 24 | - package: AeroSpacePackage 25 | product: AppBundle 26 | # https://developer.apple.com/documentation/xcode/build-settings-reference 27 | settings: 28 | base: 29 | SWIFT_VERSION: 6.0 30 | GENERATE_INFOPLIST_FILE: YES 31 | MARKETING_VERSION: ${XCODEGEN_AEROSPACE_VERSION} 32 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/20001431-108256 33 | # Specifies whether the app runs as an agent app. If this key is set to YES, Launch Services runs the app as an agent app. 34 | # Agent apps do not appear in the Dock or in the Force Quit window 35 | INFOPLIST_KEY_LSUIElement: YES 36 | CODE_SIGN_IDENTITY: ${XCODEGEN_AEROSPACE_CODE_SIGN_IDENTITY} 37 | configs: 38 | Debug: 39 | PRODUCT_NAME: AeroSpace-Debug 40 | PRODUCT_BUNDLE_IDENTIFIER: bobko.aerospace.debug 41 | Release: 42 | PRODUCT_NAME: AeroSpace 43 | PRODUCT_BUNDLE_IDENTIFIER: bobko.aerospace 44 | entitlements: 45 | path: resources/AeroSpace.entitlements 46 | properties: 47 | # Accessibility API doesn't work in sandboxed app 48 | com.apple.security.app-sandbox: false 49 | -------------------------------------------------------------------------------- /resources/AeroSpace.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "filename" : "icon.png", 50 | "idiom" : "mac", 51 | "scale" : "2x", 52 | "size" : "512x512" 53 | } 54 | ], 55 | "info" : { 56 | "author" : "xcode", 57 | "version" : 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /resources/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitabobko/AeroSpace/74bb715eb1a1818a98ecb3120e244b91d01f50b4/resources/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /run-cli.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./build-debug.sh > /dev/null || ./build-debug.sh 6 | ./.debug/aerospace "$@" 7 | -------------------------------------------------------------------------------- /run-debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./build-debug.sh 6 | ./.debug/AeroSpaceApp "$@" 7 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | source ./script/setup.sh 4 | 5 | ./build-debug.sh 6 | swift test 7 | 8 | ./.debug/aerospace -h > /dev/null 9 | ./.debug/aerospace --help > /dev/null 10 | ./.debug/aerospace -v | grep -q "0.0.0-SNAPSHOT SNAPSHOT" 11 | ./.debug/aerospace --version | grep -q "0.0.0-SNAPSHOT SNAPSHOT" 12 | 13 | ./script/install-dep.sh --swiftformat 14 | ./.deps/swiftformat/swiftformat . 15 | 16 | ./script/install-dep.sh --swiftlint 17 | ./.deps/swiftlint/swiftlint lint --quiet 18 | 19 | ./generate.sh --all 20 | ./script/check-uncommitted-files.sh 21 | -------------------------------------------------------------------------------- /script/check-uncommitted-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")/.." 3 | source ./script/setup.sh 4 | 5 | if ! test -z "$(git status --porcelain)"; then 6 | echo !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 7 | echo !!! Uncommitted files detected !!! 8 | echo !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 9 | git diff | sed 's/^/ /' 10 | echo !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 11 | echo !!! Uncommitted files detected !!! 12 | echo !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /script/clean-project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")/.." 3 | source ./script/setup.sh 4 | 5 | ./script/clean-xcode.sh 6 | git clean -ffxd 7 | -------------------------------------------------------------------------------- /script/clean-xcode.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")/.." 3 | source ./script/setup.sh 4 | 5 | ./script/check-uncommitted-files.sh 6 | 7 | git clean -ffxd 8 | rm -rf ~/Library/Developer/Xcode/DerivedData/AeroSpace-* 9 | rm -rf ./.xcode-build 10 | 11 | rm -rf AeroSpace.xcodeproj 12 | ./generate.sh 13 | -------------------------------------------------------------------------------- /script/generate-cmd-help.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")/.." 3 | source ./script/setup.sh 4 | 5 | out_file='./Sources/Common/cmdHelpGenerated.swift' 6 | 7 | aerospace_prefix="aerospace" 8 | ____usage_prefix=" USAGE:" 9 | _______or_prefix=" OR:" 10 | ____strip_prefix=" " 11 | 12 | triple_quote='"""' 13 | 14 | cat << EOF > $out_file 15 | // FILE IS GENERATED FROM docs/aerospace-*.adoc files 16 | // TO REGENERATE THE FILE RUN generate.sh --all 17 | 18 | EOF 19 | 20 | for file in docs/aerospace-*.adoc; do 21 | subcommand=$(basename "$file" | sed 's/^aerospace-//' | sed 's/\.adoc$//' | sed 's/-/_/g') 22 | sed -n -E '/tag::synopsis/, /end::synopsis/ p' "$file" | \ 23 | sed '1d' | \ 24 | sed '$d' | \ 25 | sed '/^$/ d' | \ 26 | sed "1 s/^$aerospace_prefix/$____usage_prefix/" | \ 27 | sed "2,$ s/^$aerospace_prefix/$_______or_prefix/" | \ 28 | sed "s/^$____strip_prefix//" | \ 29 | sed "1 s/^/let ${subcommand}_help_generated = $triple_quote\n/" | \ 30 | sed "\$ s/$/\n${triple_quote}/" | \ 31 | sed '2,$ s/^/ /' >> $out_file 32 | done 33 | -------------------------------------------------------------------------------- /script/publish-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")/.." 3 | source ./script/setup.sh 4 | 5 | build_version="" 6 | cask_git_repo_path="" 7 | site_git_repo_path="" 8 | while test $# -gt 0; do 9 | case $1 in 10 | --build-version) build_version="$2"; shift 2;; 11 | --cask-git-repo-path) cask_git_repo_path="$2"; shift 2;; 12 | --site-git-repo-path) site_git_repo_path="$2"; shift 2;; 13 | *) echo "Unknown option $1"; exit 1;; 14 | esac 15 | done 16 | 17 | if test -z "$build_version"; then 18 | echo "--build-version flag is mandatory" > /dev/stderr 19 | exit 1 20 | fi 21 | 22 | if ! test -d "$cask_git_repo_path"; then 23 | echo "--cask-git-repo-path is a mandatory flag that must point to existing directory" > /dev/stderr 24 | exit 1 25 | fi 26 | 27 | if ! test -d "$site_git_repo_path"; then 28 | echo "--site-git-repo-path is a mandatory flag that must point to existing directory" > /dev/stderr 29 | exit 1 30 | fi 31 | 32 | ./run-tests.sh 33 | ./build-release.sh --build-version "$build_version" 34 | 35 | git tag -a "v$build_version" -m "v$build_version" && git push git@github.com:nikitabobko/AeroSpace.git "v$build_version" 36 | link="https://github.com/nikitabobko/AeroSpace/releases/new?tag=v$build_version" 37 | open "$link" || { echo "$link"; exit 1; } 38 | sleep 1 39 | open -R "./.release/AeroSpace-v$build_version.zip" 40 | 41 | echo "Please upload .zip to GitHub release and hit Enter" 42 | read -r 43 | 44 | ./script/build-brew-cask.sh \ 45 | --cask-name aerospace \ 46 | --zip-uri "https://github.com/nikitabobko/AeroSpace/releases/download/v$build_version/AeroSpace-v$build_version.zip" \ 47 | --build-version "$build_version" 48 | 49 | eval "$cask_git_repo_path/pin.sh" 50 | cp -r .release/aerospace.rb "$cask_git_repo_path/Casks/aerospace.rb" 51 | 52 | rm -rf "${site_git_repo_path:?}/*" # https://www.shellcheck.net/wiki/SC2115 53 | cp -r .site/* "$site_git_repo_path" 54 | -------------------------------------------------------------------------------- /script/reset-accessibility-permission-for-debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # Exit if one of commands exit with non-zero exit code 3 | set -u # Treat unset variables and parameters other than the special parameters ‘@’ or ‘*’ as an error 4 | set -o pipefail # Any command failed in the pipe fails the whole pipe 5 | # set -x # Print shell commands as they are executed (or you can try -v which is less verbose) 6 | 7 | tccutil reset Accessibility bobko.AeroSpace.debug 8 | --------------------------------------------------------------------------------