diff --git a/.gitignore b/.gitignore index 38fe947c..cb0edd4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ /.idea/ xcuserdata/ -xcshareddata/ +WorkspaceSettings.xcsettings +IDEWorkspaceChecks.plist /node_modules/ +/DerivedData/ +/Pods/ /build/ .DS_Store - diff --git a/.travis.yml b/.travis.yml index 1884b699..3811b65e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,25 +5,33 @@ node_js: - 10 cache: npm env: - - IS_RELEASE=$(if [ "$TRAVIS_BRANCH" = "master" -a "$TRAVIS_PULL_REQUEST" = "false" ]; then echo true; fi) -install: - - npm ci + global: + - IS_RELEASE=$(if [ "$TRAVIS_BRANCH" = "master" -a "$TRAVIS_PULL_REQUEST" = "false" ]; then echo true; fi) + - XCODE_BUILD_PATH="DerivedData/alt-tab-macos/Build/Products/Release" + - VERSION_FILE="VERSION.txt" + - CHANGELOG_DELTA_FILE="CHANGELOG_DELTA.txt" + - APP_NAME="$(awk -F ' = ' '/PRODUCT_NAME/ { print $2; }' < config/base.xcconfig)" +install: true # disable default install script: + - env | sort + - npm ci - npx commitlint-travis - - if [ $IS_RELEASE ]; then ci/determine_version.sh; fi - - if [ $IS_RELEASE ]; then ci/set_version_in_app.sh; fi - - xcodebuild - - if [ $IS_RELEASE ]; then ci/package_release.sh; fi + - if [ $IS_RELEASE ]; then scripts/determine_version_and_changelog.sh; fi + - if [ $IS_RELEASE ]; then scripts/replace_environment_variables_in_app.sh; fi + - pod install + - scripts/import_codesign_certificate_into_keychain.sh + - xcodebuild -workspace alt-tab-macos.xcworkspace -scheme Release + - if [ $IS_RELEASE ]; then scripts/package_and_notarize_release.sh; fi + - if [ $IS_RELEASE ]; then scripts/update_appcast.sh; fi + - if [ $IS_RELEASE ]; then scripts/update_homebrew_cask.sh; fi - if [ $IS_RELEASE ]; then npx semantic-release; fi deploy: provider: releases - api_key: + token: secure: wcAMT2rbXhhdk6lsc6EG9VZZkSW0BjOwjYSyXzHjKrgiXsQcZAzAsOkuisj8EfeYN9/uhpa8XX7f5KsdkE5reMqVGGWqdmJU4NnmK5Aew0LmBbzFHfffliQecVCzMmGYPmhPcDxAjidYxgCMrMbvQFaElc0WXdIsQNcaNieSkEgZw6TwGsTxgHXABD2e575K3SN/PPcq7Nx5mRZkURI7YLmo2KfsD2OPR4IcEXFpJRFbPcAZIpKaHUIC2VzpayJA93pBe+siwU150p04PzfSs/nTH/nUO9m24SUi6kAIzVN77cExvX+JDnQoVLz0V45L0iJjkKDTVb07rVOsfm6Pcuaaj2ZVXlpujSb7B5LDUIspUn/NnqMLOtzbSTuaGcx2u+zCcC0b17KooznilQ/sFq3YpeNdmjL4VL5fsw1hdpjcP6S9DqZ90TJZqy6xBQP5NubuJukH//BRilhWGfnq5rOFUlqrN8aApJ7gYry0K2rB8rVWWvA9cU+zX351iy0EERICEU6q3rQwq8gFUbdNATPJJgNg7n0PaNXSQTTF9VluZTRVUtMrpA0hWe7dn+Tvrvhrnhmqvccw3Qfw/i3SECIDHHtsjwLqHCx5gkz/xUdf7+t3k6x+r6Veua0Z3jvyVhdHprU47VTSUhM/n87EiKyNRuKa9IpA18eSlZ59exU= file_glob: true - file: - - build/Release/*.zip - - build/Release/*.tar.gz - skip_cleanup: true + file: "$XCODE_BUILD_PATH/*.zip" + cleanup: false on: repo: lwouis/alt-tab-macos branches: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d1b7490f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to the project + +This project is open-source under the [GPL v3 license](https://github.com/lwouis/alt-tab-macos/blob/master/LICENCE.md). Contributions are welcomed! + +In this document you will find some pointers to get started + +## Mac development ecosystem + +Mac development ecosystem is pretty terrible in general. They keep piling on the tech stacks on top of each other, so you have C APIs, ObjC APIs, Swift APIs, Interface builder, Playgrounds, Swift UI. All these are bridging each other with a bunch of macros, SDKs glue, compiler flags, compatibility mode, XCode legacy build system, etc. So keep that in mind. For alt-tab, we are on Swift 4.2. Note that swift just recently started being stable, but overall any change of version breaks a lot of stuff. Swift itself is the worst governed language project I’ve seen in modern times. + +Regarding SDKs, it’s very different from other (better) ecosystems like Java. Here the SDK is bundled with XCode, and XCode is bundled with the OS. This means that from a machine running let’s say macOS 10.10, you have access to only a specific range of XCode versions (you can’t run the latest for instance), and these give you access to a specific range of SDKs (i.e. Swift + objc + c + bridges + compiler + toolchain + etc) + +Documentation is abysmal. Very simple things are not documented at all, and good information is hard to find. Compared to other ecosystem I’ve worked on in the past like Android, nodejs, Java, rust, this is really a bad spot. You can truly tell Apple doesn’t care about supporting third-parties. They are in such a good position that people will struggle and just push through to deliver on their ecosystem because it is so valuable, and because they don’t have to care, they don’t. They could pay an intern to update the docs over the summer for instance, just to give you context of the lack of care we are talking about here. + +Dependencies were historically never handled by Apple. The community came up with [Cocoapods](https://cocoapods.org/) which is the de-facto dependency manager for Apple ecosystem projects these days, even though Apple is now trying to push their own. + +OS APIs are quite limited for the kind of low-level, system-wide app alt-tab is. This means often we just don’t have an API to do something. For instance, there is no API to ask the OS “how many Spaces does the user have?” or “Can you focus the window on Space 2?”. There are however, retro-engineered private APIs which you can call. These are not documented at all, not guaranteed to be there in future macOS releases, and prevent us from releasing alt-tab on the Mac AppStore. We have tried my best to [document](src/api-wrappers/PrivateApis.swift) the ones we are using, as well as ones we investigated in the past. + +## This project specifically + +To mitigate the issues listed above, we took some measures. + +We minimize reliance on XCode, InterfaceBuilder, Playground, and other GUI tools. You can’t cut the dependency completely though as only XCode can build macos apps. Currently the project has these files: + +* 1 xib (InterfaceBuilder UI file, describing the menubar items like “Edit” or “Format”) +* `alt-tab-macos.xcodeproj` file describing alt-tab itself. It contains some settings for the app +* `alt-tab-macos.xcworkspace` file describing an xcode workspace containing alt-tab + cocoapods dependencies. You open that file to open the project in XCode or AppCode +* `Alt-tab-macos.entitlements` and Info.plist which are static files describing some app config for XCode +* `PodFile` and `PodFile.lock` describe dependencies on open-source libraries (e.g. [Sparkle](https://github.com/sparkle-project/Sparkle)) +* Some `.xcconfig` files in `config/` which contain XCode settings that people typically change using XCode UI, but that I want to be version controlled + +We use the command line to build the project, not XCode GUI. See how to build in the [README.md](README.md). + +The project directory is organized in the following way: + +| Path | Role | +|------|-------| +| `config/` | XCode build settings | +| `docs/` | supporting material to document the project | +| `resources/` | files that are shipped inside the final `.app` (e.g. icons) | +| `scripts/` | bash scripts useful for CI and local workflows | +| `src/` | Swift source code | +| `src/api-wrappers` | Wrapping some unfriendly APIs (usually C-APIs) | +| `src/logic` | Business logic (i.e. "models") | +| `src/ui` | UI code (e.g. sublasses of NSView or NSCollectionView) | + +Other folders/files are either tooling or auto-generated (e.g. `Pods/` and `Frameworks/` are generated by `pod install`) \ No newline at end of file diff --git a/alt-tab-macos/Info.plist b/Info.plist similarity index 66% rename from alt-tab-macos/Info.plist rename to Info.plist index dbdf52ee..53e31b29 100644 --- a/alt-tab-macos/Info.plist +++ b/Info.plist @@ -2,12 +2,12 @@ + ATSApplicationFontsPath + CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + en CFBundleExecutable $(EXECUTABLE_NAME) - CFBundleIconFile - CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -20,15 +20,25 @@ #VERSION# CFBundleVersion #VERSION# + FeedbackToken + #FEEDBACK_TOKEN# LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + true NSHumanReadableCopyright - Copyright © 2019 Pontoise, Louis. All rights reserved. + GPL-3.0 licence NSPrincipalClass NSApplication - LSUIElement - 1 - ATSApplicationFontsPath - + NSSupportsSuddenTermination + true + SUEnableAutomaticChecks + true + SUFeedURL + https://raw.githubusercontent.com/lwouis/alt-tab-macos/master/appcast.xml + SUPublicEDKey + 2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4= + CFBundleIconFile + app-icon diff --git a/Podfile b/Podfile new file mode 100644 index 00000000..de4594bd --- /dev/null +++ b/Podfile @@ -0,0 +1,8 @@ +platform :osx, '10.12' + +target 'alt-tab-macos' do + use_frameworks! + pod 'LetsMove', '1.24' + pod 'Sparkle', '1.23.0' +end + diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 00000000..1e903bb5 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,20 @@ +PODS: + - LetsMove (1.24) + - Sparkle (1.23.0) + +DEPENDENCIES: + - LetsMove (= 1.24) + - Sparkle (= 1.23.0) + +SPEC REPOS: + trunk: + - LetsMove + - Sparkle + +SPEC CHECKSUMS: + LetsMove: fefe56bc7bc7fb7d37049e28a14f297961229fc5 + Sparkle: 55b1a87ba69d56913375a281546b7c82dec95bb0 + +PODFILE CHECKSUM: 465451026269525f0f1d2dc7053cf0b789a35421 + +COCOAPODS: 1.8.4 diff --git a/README.md b/README.md index 61341a47..1716176d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ choose **Open** then click **Open** in the prompt that appears. From macOS 10.12 to 10.15 +## Localization + +The app is currently available in: English, Français, Deutsch, Español, 日本語 + +[Contribute your own language easily!](https://poeditor.com/join/project/8AOEZ0eAZE) + ## Usage * Press `⌥ option` + `⇥ tab` to shows the thumbnails. @@ -33,9 +39,11 @@ From macOS 10.12 to 10.15 * Change the shortcut key, switch to a Windows theme and more, using the Preferences window. * If you like scriptable/portable configuration, you can edit `~/Library/Preferences/alt-tab-macos.json`. -## Community +## Contributing -Come discuss with us on the [Discord server](https://discord.gg/mHvmcqT). +* [CONTRIBUTING.md](CONTRIBUTING.md) gives an overview of the project for newcomers who want to contribute. +* Come discuss with us on the [Discord server](https://discord.gg/mHvmcqT). +* [Suggest an enhancement or discuss an issue on github](https://github.com/lwouis/alt-tab-macos/issues). ## Alternatives @@ -51,6 +59,17 @@ Before building my own app, I looked around at similar apps. However, none was c | `⌘ command` + `⇥ tab` | Only shows apps, not windows (note: can press down to see window of selected app) | | `⌘ command` + `` ` `` | Cycles through tabs and windows, but only of the same app. Only cycling, no direct access | +## Building the project locally + +This project has minimal dependency on Xcode-only features (e.g. InterfaceBuilder, Playgrounds). You can build it using 2 commands: + +* `pod install` to fetch the dependencies with [CocoaPods](https://cocoapods.org/) +* `xcodebuild -workspace alt-tab-macos.xcworkspace -scheme Release` to build the .app + +Note that on debug builds, to avoid having to re-check the `System Preferences > Security & Privacy` permissions on every build, we use a code-signing certificate. You can generate one on your local machine in one step by running `scripts/generate_selfsigned_codesign_certificate.sh`. + +If you want to contribute a PR, please run `npm install` once. It will add the pre-commit hook to ensure that your commits follow the convention and will pass the PR. + ## Screenshots ### 1 row diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index fab848a4..a64ca210 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -8,92 +8,167 @@ /* Begin PBXBuildFile section */ 4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; }; - D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */; }; - D04BA0496ACF1427B6E9D369 /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA78E3B4E73B40DB77174 /* CGWindow.swift */; }; - D04BA20D4A240843293B3B52 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA56355579F78776E6D51 /* Cell.swift */; }; - D04BA278D9EFA568C8D18A4C /* TrackedWindows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */; }; - D04BA2CBF0EFA04CC80EC1BC /* TrackedWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE80772D25834E440975 /* TrackedWindow.swift */; }; - D04BA308162F8043F8561D03 /* AXUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA40A4291E4F310527DBF /* AXUIElement.swift */; }; - D04BA3261C7DA5F48310E654 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA90C6C36DB1D65BC2B66 /* Application.swift */; }; - D04BA4D356055A39B97712DE /* PrivateApis.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */; }; - D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA35456DA0DDA74F9687E /* Keyboard.swift */; }; - D04BA57FB9EF1373D59A1AA7 /* CGWindowID.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */; }; - D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA02F476DE30C4647886C /* PreferencesPanel.swift */; }; - D04BA6B6B703DCEFE892D5A4 /* Spaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA5EB5ED248C8C22CC672 /* Spaces.swift */; }; - D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */; }; - D04BA8373D4DE452C0C081ED /* SF-Pro-Text-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = D04BABC654F40BE74DA25BC7 /* SF-Pro-Text-Regular.otf */; }; - D04BA8EBC0365A019A27C7EA /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */; }; - D04BA9119E2329DB5A35B3C7 /* ThumbnailsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */; }; - D04BA960DDD1D32A3019C835 /* CollectionViewCenterFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */; }; - D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */; }; - D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD32E130E4A061DC8332 /* Labels.swift */; }; - D04BAE2E8E9B9898A4DF9B3B /* FontIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAED53465957807CBF8B2 /* FontIcon.swift */; }; - D04BAE369A14C3126A1606FE /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */; }; - D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.swift */; }; - F029861A378EC1417106FEC3 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298E42A818112B290FF6C7 /* TextField.swift */; }; - F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */; }; + 76D02BB22BFE7C9E0056008D /* Pods_alt_tab_macos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */; }; + D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; }; + D04BA084CD1236EC78D90A01 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BACCBE5F97BE9B6CA645B /* Localizable.strings */; }; + D04BA100BD0F47828EB649FF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAAEC2847830A3991F8D1 /* InfoPlist.strings */; }; + D04BA14D93726795A6937832 /* LabelAndControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */; }; + D04BA15A1B0C4871EA7CB899 /* GeneralTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACE22DC907F03D193075 /* GeneralTab.swift */; }; + D04BA1B133D53572D7B312C2 /* CollectionViewItemFontIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */; }; + D04BA1CEC6B9C8945FEC8740 /* CollectionViewItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA258B56193958D60978A /* CollectionViewItemView.swift */; }; + D04BA26A691D56031FCCF00C /* Sysctl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8DB8AA7E5570DAC568A /* Sysctl.swift */; }; + D04BA276B3241D440F65B149 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5C2BB394F1624DD5B45 /* InfoPlist.strings */; }; + D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA282BB16C1554595A968 /* Applications.swift */; }; + D04BA2E64C59D96F6EB27D9D /* FeedbackWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA26C75F76C277653C932 /* FeedbackWindow.swift */; }; + D04BA34AC850A273AB288B1E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA3B51D05213404938366 /* Localizable.strings */; }; + D04BA3BFB0CDF4ED343914B2 /* PreferencesWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA47FF1B7838CF4814538 /* PreferencesWindow.swift */; }; + D04BA3C24F4F644EA91DE38C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA717693DA18CB74BAED1 /* Localizable.strings */; }; + D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */; }; + D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACABD048E62EBE4576CC /* DebugProfile.swift */; }; + D04BA570E7806F28741B1472 /* SF-Pro-Text-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = D04BA0CDCFF1F0B1A77E3E86 /* SF-Pro-Text-Regular.otf */; }; + D04BA5F99B45DC13B9E9DD91 /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8276B3D3905E80B1739 /* Keyboard.swift */; }; + D04BA6187A91A847844B6ABB /* Window.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA015A45DE7AFDC9794FE /* Window.swift */; }; + D04BA691CB6082A3C39CBC89 /* TabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE757BB2B605234FBF58 /* TabViewController.swift */; }; + D04BA69D47B5E60A6AD9CBD9 /* CollectionViewItemTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */; }; + D04BA6C953494839648107D1 /* CollectionViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */; }; + D04BA737008AA2CD4E230A21 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA10777505D8A67ABD186 /* Application.swift */; }; + D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */; }; + D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; }; + D04BA76DDB00FC50D203D62C /* CollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */; }; + D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA68C2561D9EE4FD851B8 /* Screen.swift */; }; + D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */; }; + D04BA7F86F1926FBE31F44BF /* BaseLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA53992F116E5E704CAB3 /* BaseLabel.swift */; }; + D04BA8092885B40CE3527370 /* UpdatesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD60C97E609A759E721E /* UpdatesTab.swift */; }; + D04BA8480A8FF466CA89DA5B /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA70746DEEC3D30B43F81 /* main.swift */; }; + D04BA8D6055F6F2E42C976EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5DE6536C4A6558FD80C /* InfoPlist.strings */; }; + D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */; }; + D04BAADED6FE28D42924AEBF /* PrivateApis.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA5C401AFA55CC67C7188 /* PrivateApis.swift */; }; + D04BAB048DE698E013577C51 /* ThumbnailsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */; }; + D04BAB4EB890853B5B9B2C61 /* BoldLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */; }; + D04BAB68B7B8D1B548BC3AD5 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAAB92261FC04854FDDE9 /* App.swift */; }; + D04BABED81800E18732912CC /* CGWindowID.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA03200F5A8FC0CD03607 /* CGWindowID.swift */; }; + D04BABEECBC6D922298BC93A /* Spaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */; }; + D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA9B93823398A542FF7A0 /* Preferences.swift */; }; + D04BAC3169D54014CBA9ACE5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAE7F55A4A3EC5F6D311A /* Localizable.strings */; }; + D04BAC61DCCB79B5D436F022 /* app-icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = D04BAE5D665680CB4B13CA26 /* app-icon.icns */; }; + D04BACE5702CEB1819866875 /* menubar-icon@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = D04BA399F1DF2C61FC2C9599 /* menubar-icon@2x.png */; }; + D04BAD1BE9DC22C48C53D195 /* AboutTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA4A26987F67DD94C827F /* AboutTab.swift */; }; + D04BAD2A7F2E8BF64EE982E9 /* TextArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA7C836A8CE8C0B8D128B /* TextArea.swift */; }; + D04BAD451966B43720120D2E /* Menubar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD905546AA93E5117B0A /* Menubar.swift */; }; + D04BADBCF20CD72057E7CF09 /* TabViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA27C87B86C4484A5B15B /* TabViewItem.swift */; }; + D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA7C6F2519091717F4B4E /* SystemPermissions.swift */; }; + D04BAEE31B6FFCDC779E6C17 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */; }; + D04BAF12DF5D15B9D7D316A4 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA61693F710CD7BD054D7 /* InfoPlist.strings */; }; + D04BAF25E67A5B31CF7676DB /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA44F7B5E58A08416706B /* TextField.swift */; }; + D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACD976030676FD0761D5 /* Windows.swift */; }; + D04BAFBC862BA5FE0294EA7A /* AXUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6F823BC0EDA9AA4B80A /* AXUIElement.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 38C426DAE17708EDD4FDADBF /* Pods-alt-tab-macos.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-alt-tab-macos.release.xcconfig"; path = "Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.release.xcconfig"; sourceTree = ""; }; 4807A6C523A9CD190052A53E /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = ""; }; - D04BA02F476DE30C4647886C /* PreferencesPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPanel.swift; sourceTree = ""; }; - D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; + BF12DEA89785CA78B0FE2706 /* Pods-alt-tab-macos.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-alt-tab-macos.debug.xcconfig"; path = "Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.debug.xcconfig"; sourceTree = ""; }; + C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_alt_tab_macos.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D04BA015A45DE7AFDC9794FE /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = ""; }; + D04BA03200F5A8FC0CD03607 /* CGWindowID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindowID.swift; sourceTree = ""; }; + D04BA0AAAE82C72855DBBA26 /* update_appcast.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = update_appcast.sh; sourceTree = ""; }; + D04BA0CDCFF1F0B1A77E3E86 /* SF-Pro-Text-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file.otf; path = "SF-Pro-Text-Regular.otf"; sourceTree = ""; }; D04BA0CE87BE264C52987ED1 /* 7 windows - 2 lines - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - wide window.jpg"; sourceTree = ""; }; + D04BA0E071D2EDFDB9A20523 /* Podfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Podfile; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + D04BA0E11E83F177B07EE19C /* fr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; D04BA0E1C5DBC07108AC2F54 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; D04BA0F5EB832B8E142B654B /* 4 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "4 windows - 1 line.jpg"; sourceTree = ""; }; + D04BA10777505D8A67ABD186 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; D04BA107C8B8FE7FF8536606 /* too many windows - 4 lines - paginated.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "too many windows - 4 lines - paginated.jpg"; sourceTree = ""; }; + D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = debug.xcconfig; sourceTree = ""; }; + D04BA123744B0C27E9F54B05 /* codesign_sparkle_embedded_apps.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = codesign_sparkle_embedded_apps.sh; sourceTree = ""; }; + D04BA1C3E42AC44CA2C5D3D8 /* app-icon.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "app-icon.svg"; sourceTree = ""; }; D04BA1D80F4EEF2A91BAD29C /* release.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = release.config.js; sourceTree = ""; }; + D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemFontIcon.swift; sourceTree = ""; }; D04BA1FC9022590D7AA02486 /* 1 window - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1 window - 1 line.jpg"; sourceTree = ""; }; + D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelAndControl.swift; sourceTree = ""; }; + D04BA258B56193958D60978A /* CollectionViewItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemView.swift; sourceTree = ""; }; D04BA26154AB2A2897E08CAF /* windows-theme.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "windows-theme.jpg"; sourceTree = ""; }; + D04BA26C75F76C277653C932 /* FeedbackWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbackWindow.swift; sourceTree = ""; }; + D04BA27C87B86C4484A5B15B /* TabViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabViewItem.swift; sourceTree = ""; }; + D04BA282BB16C1554595A968 /* Applications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Applications.swift; sourceTree = ""; }; + D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItem.swift; sourceTree = ""; }; + D04BA2A4F257F4DCE1421758 /* Podfile.lock */ = {isa = PBXFileReference; lastKnownFileType = file.lock; path = Podfile.lock; sourceTree = ""; }; D04BA2C7B51F68651B3C60E2 /* 6 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "6 windows - 1 line.jpg"; sourceTree = ""; }; - D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemPermissions.swift; sourceTree = ""; }; - D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewCenterFlowLayout.swift; sourceTree = ""; }; D04BA32F25860B686DFE818A /* 3 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line.jpg"; sourceTree = ""; }; - D04BA35456DA0DDA74F9687E /* Keyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keyboard.swift; sourceTree = ""; }; - D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; - D04BA40A4291E4F310527DBF /* AXUIElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXUIElement.swift; sourceTree = ""; }; + D04BA399F1DF2C61FC2C9599 /* menubar-icon@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-icon@2x.png"; sourceTree = ""; }; D04BA4336B6004A0A99849AD /* package.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = package.json; sourceTree = ""; }; + D04BA44F7B5E58A08416706B /* TextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; D04BA459034C1885CA43A807 /* LICENCE.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = LICENCE.md; sourceTree = ""; }; - D04BA4B5292629AA6B560216 /* package_release.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = package_release.sh; sourceTree = ""; }; + D04BA47FF1B7838CF4814538 /* PreferencesWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = ""; }; + D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HyperlinkLabel.swift; sourceTree = ""; }; + D04BA4A26987F67DD94C827F /* AboutTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutTab.swift; sourceTree = ""; }; + D04BA4B5292629AA6B560216 /* package_and_notarize_release.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = package_and_notarize_release.sh; sourceTree = ""; }; D04BA4F23325560BC0BCDDB7 /* 7 windows - 2 lines - tall window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - tall window.jpg"; sourceTree = ""; }; D04BA51D43775E57CE91154A /* 3 windows - 1 line - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line - wide window.jpg"; sourceTree = ""; }; - D04BA56355579F78776E6D51 /* Cell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = ""; }; + D04BA53992F116E5E704CAB3 /* BaseLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseLabel.swift; sourceTree = ""; }; D04BA5ABFA5457A86536E2E4 /* 5 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 1 line.jpg"; sourceTree = ""; }; - D04BA5EB5ED248C8C22CC672 /* Spaces.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spaces.swift; sourceTree = ""; }; - D04BA78E3B4E73B40DB77174 /* CGWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindow.swift; sourceTree = ""; }; - D04BA7B6AAB0812631BBC7A2 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.info; path = Info.plist; sourceTree = ""; }; - D04BA7ECCE728582D9ECA613 /* determine_version.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = determine_version.sh; sourceTree = ""; }; - D04BA82F792DF53958D92572 /* alt-tab-macos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "alt-tab-macos.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperExtensions.swift; sourceTree = ""; }; - D04BA90C6C36DB1D65BC2B66 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; - D04BA92541D46EA4F6943A72 /* package-lock.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = ""; }; + D04BA5C401AFA55CC67C7188 /* PrivateApis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivateApis.swift; sourceTree = ""; }; + D04BA5D2FD6E26D82721C574 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = InfoPlist.strings; sourceTree = ""; }; + D04BA5E819181CB83C5602C7 /* generate_selfsigned_codesign_certificate.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = generate_selfsigned_codesign_certificate.sh; sourceTree = ""; }; + D04BA62D5EACAAAD7BCD3E79 /* release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = release.xcconfig; sourceTree = ""; }; + D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppearanceTab.swift; sourceTree = ""; }; + D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = ""; }; + D04BA662DF59DF9A97FDA598 /* de */ = {isa = PBXFileReference; fileEncoding = 2483028224; lastKnownFileType = text.plist.strings; name = de; path = Localizable.strings; sourceTree = ""; }; + D04BA68C2561D9EE4FD851B8 /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; + D04BA6D57A1456C07318B8EA /* GridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = ""; }; + D04BA6EF1954DCE80E539142 /* base.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = base.xcconfig; sourceTree = ""; }; + D04BA6F823BC0EDA9AA4B80A /* AXUIElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXUIElement.swift; sourceTree = ""; }; + D04BA70746DEEC3D30B43F81 /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + D04BA7957E5E02FB09BCE10C /* de */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = de; path = InfoPlist.strings; sourceTree = ""; }; + D04BA7C25BD7B68BCEEE38B5 /* en */ = {isa = PBXFileReference; fileEncoding = 2483028224; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; + D04BA7C6F2519091717F4B4E /* SystemPermissions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemPermissions.swift; sourceTree = ""; }; + D04BA7C836A8CE8C0B8D128B /* TextArea.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextArea.swift; sourceTree = ""; }; + D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spaces.swift; sourceTree = ""; }; + D04BA7ECCE728582D9ECA613 /* determine_version_and_changelog.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = determine_version_and_changelog.sh; sourceTree = ""; }; + D04BA8276B3D3905E80B1739 /* Keyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keyboard.swift; sourceTree = ""; }; + D04BA82F792DF53958D92572 /* AltTab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AltTab.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D04BA8DB8AA7E5570DAC568A /* Sysctl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Sysctl.swift; sourceTree = ""; }; + D04BA926AF41D226EA1ACBA1 /* CONTRIBUTING.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; + D04BA9B93823398A542FF7A0 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D04BA9EF65B2E7AF9E3ADCA3 /* 2 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2 windows - 1 line.jpg"; sourceTree = ""; }; D04BAA34E0CB00DED7C04B4F /* 2-rows.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2-rows.jpg"; sourceTree = ""; }; - D04BAA44C837F3A67403B9DB /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + D04BAA9E0539EE620D08F63F /* fr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = fr; path = InfoPlist.strings; sourceTree = ""; }; + D04BAAB92261FC04854FDDE9 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + D04BAAF760E3A8A22BDA84D6 /* appcast.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = appcast.xml; sourceTree = ""; }; D04BAB6652494D7575057E86 /* 14 windows - 3 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "14 windows - 3 lines.jpg"; sourceTree = ""; }; - D04BABC654F40BE74DA25BC7 /* SF-Pro-Text-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file.otf; path = "SF-Pro-Text-Regular.otf"; sourceTree = ""; }; + D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = ""; }; + D04BABFEC8F9DF41BB7A449E /* import_codesign_certificate_into_keychain.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = import_codesign_certificate_into_keychain.sh; sourceTree = ""; }; D04BAC02D60EF22D9CC7D969 /* commitlint.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = commitlint.config.js; sourceTree = ""; }; D04BAC159731F80FDAF4EA6C /* 1-row.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1-row.jpg"; sourceTree = ""; }; - D04BAC6AFC7F06D1A567F27A /* set_version_in_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = set_version_in_app.sh; sourceTree = ""; }; - D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindows.swift; sourceTree = ""; }; - D04BAD1C9F215BCCD3B620AC /* alt_tab_macos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = alt_tab_macos.entitlements; sourceTree = ""; }; - D04BAD32E130E4A061DC8332 /* Labels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Labels.swift; sourceTree = ""; }; + D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewFlowLayout.swift; sourceTree = ""; }; + D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; + D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindow.swift; sourceTree = ""; }; + D04BAC6AFC7F06D1A567F27A /* replace_environment_variables_in_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = replace_environment_variables_in_app.sh; sourceTree = ""; }; + D04BACABD048E62EBE4576CC /* DebugProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugProfile.swift; sourceTree = ""; }; + D04BACB97A5895839BCB14BD /* es */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = es; path = InfoPlist.strings; sourceTree = ""; }; + D04BACD976030676FD0761D5 /* Windows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = ""; }; + D04BACE22DC907F03D193075 /* GeneralTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralTab.swift; sourceTree = ""; }; + D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoldLabel.swift; sourceTree = ""; }; + D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemTitle.swift; sourceTree = ""; }; + D04BAD241A6928F45355B315 /* es */ = {isa = PBXFileReference; fileEncoding = 2483028224; lastKnownFileType = text.plist.strings; name = es; path = Localizable.strings; sourceTree = ""; }; D04BAD40CE2D3A8AAC3819D0 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = file.gitignore; path = .gitignore; sourceTree = ""; }; + D04BAD60C97E609A759E721E /* UpdatesTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdatesTab.swift; sourceTree = ""; }; D04BAD7D8F3CC2C36C8769C6 /* .travis.yml */ = {isa = PBXFileReference; lastKnownFileType = file.yml; path = .travis.yml; sourceTree = ""; }; D04BAD82F652C4ED5D8BF7AE /* 3 windows - 1 line - tall window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line - tall window.jpg"; sourceTree = ""; }; + D04BAD905546AA93E5117B0A /* Menubar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Menubar.swift; sourceTree = ""; }; + D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperExtensions.swift; sourceTree = ""; }; D04BADB20AB31BF83593E0BE /* greetings.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = greetings.yml; sourceTree = ""; }; - D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D04BAE1243C9B4BE3ED1B524 /* 7 windows - 2 lines - extra wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - extra wide window.jpg"; sourceTree = ""; }; - D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = ""; }; - D04BAE80772D25834E440975 /* TrackedWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindow.swift; sourceTree = ""; }; - D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindowID.swift; sourceTree = ""; }; - D04BAED53465957807CBF8B2 /* FontIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontIcon.swift; sourceTree = ""; }; + D04BAE2DC036FD84446E1AE6 /* menubar-icon.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "menubar-icon.svg"; sourceTree = ""; }; + D04BAE5D665680CB4B13CA26 /* app-icon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = "app-icon.icns"; sourceTree = ""; }; + D04BAE757BB2B605234FBF58 /* TabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabViewController.swift; sourceTree = ""; }; + D04BAE93A5854C501639C640 /* update_homebrew_cask.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = update_homebrew_cask.sh; sourceTree = ""; }; D04BAF076A30A1BAFEDBEA66 /* 5 windows - 2 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 2 lines.jpg"; sourceTree = ""; }; - D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivateApis.swift; sourceTree = ""; }; + D04BAF1058D2599E6E8ABBA6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.info; path = Info.plist; sourceTree = ""; }; D04BAF249324297C07E31164 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = ""; }; + D04BAF6F617FCA44D1F75B60 /* alt_tab_macos.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = alt_tab_macos.entitlements; sourceTree = ""; }; D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; - F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HyperlinkLabel.swift; sourceTree = ""; }; - F0298E42A818112B290FF6C7 /* TextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -102,6 +177,7 @@ buildActionMask = 2147483647; files = ( 4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */, + 76D02BB22BFE7C9E0056008D /* Pods_alt_tab_macos.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,15 +188,51 @@ isa = PBXGroup; children = ( 4807A6C523A9CD190052A53E /* SkyLight.framework */, + C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */, ); name = Frameworks; sourceTree = ""; }; + 4A443501F57D4759B190C07E /* Pods */ = { + isa = PBXGroup; + children = ( + BF12DEA89785CA78B0FE2706 /* Pods-alt-tab-macos.debug.xcconfig */, + 38C426DAE17708EDD4FDADBF /* Pods-alt-tab-macos.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D04BA0D80B24E72B9B981A1D /* logic */ = { + isa = PBXGroup; + children = ( + D04BA8276B3D3905E80B1739 /* Keyboard.swift */, + D04BACD976030676FD0761D5 /* Windows.swift */, + D04BA9B93823398A542FF7A0 /* Preferences.swift */, + D04BA68C2561D9EE4FD851B8 /* Screen.swift */, + D04BA7C6F2519091717F4B4E /* SystemPermissions.swift */, + D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */, + D04BA015A45DE7AFDC9794FE /* Window.swift */, + D04BA10777505D8A67ABD186 /* Application.swift */, + D04BA282BB16C1554595A968 /* Applications.swift */, + D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */, + D04BACABD048E62EBE4576CC /* DebugProfile.swift */, + ); + path = logic; + sourceTree = ""; + }; + D04BA12944432EA45A7548CE /* es.lproj */ = { + isa = PBXGroup; + children = ( + D04BAAEC2847830A3991F8D1 /* InfoPlist.strings */, + D04BA717693DA18CB74BAED1 /* Localizable.strings */, + ); + path = es.lproj; + sourceTree = ""; + }; D04BA1463D2A17038222BB84 = { isa = PBXGroup; children = ( D04BA1A461BBB5A9BE15E015 /* Products */, - D04BAD48B702C4B1DC543557 /* alt-tab-macos */, D04BAD40CE2D3A8AAC3819D0 /* .gitignore */, D04BA0E1C5DBC07108AC2F54 /* README.md */, D04BAD1F5F5D4427DEA16682 /* docs */, @@ -128,33 +240,52 @@ D04BA1D80F4EEF2A91BAD29C /* release.config.js */, D04BAC02D60EF22D9CC7D969 /* commitlint.config.js */, D04BA4336B6004A0A99849AD /* package.json */, - D04BA92541D46EA4F6943A72 /* package-lock.json */, D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */, - D04BA703DCD38D9757093312 /* ci */, + D04BA703DCD38D9757093312 /* scripts */, D04BA459034C1885CA43A807 /* LICENCE.md */, D04BA2C9EF33A646D0977195 /* .github */, 4807A6C423A9CD190052A53E /* Frameworks */, + 4A443501F57D4759B190C07E /* Pods */, + D04BA2A4F257F4DCE1421758 /* Podfile.lock */, + D04BA0E071D2EDFDB9A20523 /* Podfile */, + D04BAAF760E3A8A22BDA84D6 /* appcast.xml */, + D04BA6251F309645757C6000 /* src */, + D04BAF1058D2599E6E8ABBA6 /* Info.plist */, + D04BAF6F617FCA44D1F75B60 /* alt_tab_macos.entitlements */, + D04BAA17F1B492591AAAA9A7 /* config */, + D04BA9CA03317B315B267E21 /* resources */, + D04BA926AF41D226EA1ACBA1 /* CONTRIBUTING.md */, ); sourceTree = ""; }; D04BA1A461BBB5A9BE15E015 /* Products */ = { isa = PBXGroup; children = ( - D04BA82F792DF53958D92572 /* alt-tab-macos.app */, + D04BA82F792DF53958D92572 /* AltTab.app */, ); name = Products; sourceTree = ""; }; - D04BA22D2CA2755FA5902C34 /* api-wrappers */ = { + D04BA229883E5D540D00232D /* ui */ = { isa = PBXGroup; children = ( - D04BA40A4291E4F310527DBF /* AXUIElement.swift */, - D04BA78E3B4E73B40DB77174 /* CGWindow.swift */, - D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */, - D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */, - D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */, + D04BAAB92261FC04854FDDE9 /* App.swift */, + D04BA62E30174C336E4080FA /* main-window */, + D04BABCD12CA72B06290C248 /* preferences-window */, + D04BAD905546AA93E5117B0A /* Menubar.swift */, + D04BA26C75F76C277653C932 /* FeedbackWindow.swift */, + D04BA2CBF267F267225CA671 /* generic-components */, ); - path = "api-wrappers"; + path = ui; + sourceTree = ""; + }; + D04BA2641C1A385861945B7C /* en.lproj */ = { + isa = PBXGroup; + children = ( + D04BA5C2BB394F1624DD5B45 /* InfoPlist.strings */, + D04BACCBE5F97BE9B6CA645B /* Localizable.strings */, + ); + path = en.lproj; sourceTree = ""; }; D04BA2C9EF33A646D0977195 /* .github */ = { @@ -165,6 +296,26 @@ path = .github; sourceTree = ""; }; + D04BA2CBF267F267225CA671 /* generic-components */ = { + isa = PBXGroup; + children = ( + D04BA6D57A1456C07318B8EA /* GridView.swift */, + D04BAA41B9A74189B065D856 /* text */, + ); + path = "generic-components"; + sourceTree = ""; + }; + D04BA3FA52AA40B827A72F10 /* tabs */ = { + isa = PBXGroup; + children = ( + D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */, + D04BA4A26987F67DD94C827F /* AboutTab.swift */, + D04BACE22DC907F03D193075 /* GeneralTab.swift */, + D04BAD60C97E609A759E721E /* UpdatesTab.swift */, + ); + path = tabs; + sourceTree = ""; + }; D04BA502DEE6E54E9987BAB7 /* windows-10 */ = { isa = PBXGroup; children = ( @@ -186,12 +337,50 @@ path = "windows-10"; sourceTree = ""; }; - D04BA5A0E9C82F7579CD2B78 /* resources */ = { + D04BA5BB6FB4E7D8F7AD357C /* api-wrappers */ = { isa = PBXGroup; children = ( - D04BABC654F40BE74DA25BC7 /* SF-Pro-Text-Regular.otf */, + D04BA6F823BC0EDA9AA4B80A /* AXUIElement.swift */, + D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */, + D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */, + D04BA5C401AFA55CC67C7188 /* PrivateApis.swift */, + D04BA03200F5A8FC0CD03607 /* CGWindowID.swift */, + D04BA8DB8AA7E5570DAC568A /* Sysctl.swift */, ); - path = resources; + path = "api-wrappers"; + sourceTree = ""; + }; + D04BA61DD16A622D398FED3E /* fr.lproj */ = { + isa = PBXGroup; + children = ( + D04BA61693F710CD7BD054D7 /* InfoPlist.strings */, + D04BA3B51D05213404938366 /* Localizable.strings */, + ); + path = fr.lproj; + sourceTree = ""; + }; + D04BA6251F309645757C6000 /* src */ = { + isa = PBXGroup; + children = ( + D04BA5BB6FB4E7D8F7AD357C /* api-wrappers */, + D04BA70746DEEC3D30B43F81 /* main.swift */, + D04BA0D80B24E72B9B981A1D /* logic */, + D04BA229883E5D540D00232D /* ui */, + ); + path = src; + sourceTree = ""; + }; + D04BA62E30174C336E4080FA /* main-window */ = { + isa = PBXGroup; + children = ( + D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */, + D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */, + D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */, + D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */, + D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */, + D04BA258B56193958D60978A /* CollectionViewItemView.swift */, + ); + path = "main-window"; sourceTree = ""; }; D04BA63877FC8FB11C43C3D2 /* alt-tab-macos */ = { @@ -205,45 +394,72 @@ path = "alt-tab-macos"; sourceTree = ""; }; - D04BA703DCD38D9757093312 /* ci */ = { + D04BA703DCD38D9757093312 /* scripts */ = { isa = PBXGroup; children = ( - D04BA7ECCE728582D9ECA613 /* determine_version.sh */, - D04BA4B5292629AA6B560216 /* package_release.sh */, - D04BAC6AFC7F06D1A567F27A /* set_version_in_app.sh */, + D04BA7ECCE728582D9ECA613 /* determine_version_and_changelog.sh */, + D04BA4B5292629AA6B560216 /* package_and_notarize_release.sh */, + D04BAC6AFC7F06D1A567F27A /* replace_environment_variables_in_app.sh */, + D04BAE93A5854C501639C640 /* update_homebrew_cask.sh */, + D04BA5E819181CB83C5602C7 /* generate_selfsigned_codesign_certificate.sh */, + D04BA0AAAE82C72855DBBA26 /* update_appcast.sh */, + D04BABFEC8F9DF41BB7A449E /* import_codesign_certificate_into_keychain.sh */, + D04BA123744B0C27E9F54B05 /* codesign_sparkle_embedded_apps.sh */, ); - path = ci; + path = scripts; sourceTree = ""; }; - D04BAA1C553891551B903DA7 /* logic */ = { + D04BA7568B7165A7726306A7 /* icons */ = { isa = PBXGroup; children = ( - D04BA35456DA0DDA74F9687E /* Keyboard.swift */, - D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */, - D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */, - D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */, - D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */, - D04BA5EB5ED248C8C22CC672 /* Spaces.swift */, - D04BAE80772D25834E440975 /* TrackedWindow.swift */, + D04BA399F1DF2C61FC2C9599 /* menubar-icon@2x.png */, + D04BAE2DC036FD84446E1AE6 /* menubar-icon.svg */, + D04BA1C3E42AC44CA2C5D3D8 /* app-icon.svg */, + D04BAE5D665680CB4B13CA26 /* app-icon.icns */, ); - path = logic; + path = icons; sourceTree = ""; }; - D04BAAA28315301A906DD201 /* ui */ = { + D04BA85F16881B290C4A33CD /* de.lproj */ = { isa = PBXGroup; children = ( - D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */, - D04BA56355579F78776E6D51 /* Cell.swift */, - D04BA90C6C36DB1D65BC2B66 /* Application.swift */, - D04BA02F476DE30C4647886C /* PreferencesPanel.swift */, - D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */, - D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */, - D04BAD32E130E4A061DC8332 /* Labels.swift */, - F0298E42A818112B290FF6C7 /* TextField.swift */, - F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */, - D04BAED53465957807CBF8B2 /* FontIcon.swift */, + D04BA5DE6536C4A6558FD80C /* InfoPlist.strings */, + D04BAE7F55A4A3EC5F6D311A /* Localizable.strings */, ); - path = ui; + path = de.lproj; + sourceTree = ""; + }; + D04BA9CA03317B315B267E21 /* resources */ = { + isa = PBXGroup; + children = ( + D04BA0CDCFF1F0B1A77E3E86 /* SF-Pro-Text-Regular.otf */, + D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */, + D04BAC2B70BC798C57B0B492 /* l10n */, + D04BA7568B7165A7726306A7 /* icons */, + ); + path = resources; + sourceTree = ""; + }; + D04BAA17F1B492591AAAA9A7 /* config */ = { + isa = PBXGroup; + children = ( + D04BA6EF1954DCE80E539142 /* base.xcconfig */, + D04BA62D5EACAAAD7BCD3E79 /* release.xcconfig */, + D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */, + ); + path = config; + sourceTree = ""; + }; + D04BAA41B9A74189B065D856 /* text */ = { + isa = PBXGroup; + children = ( + D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */, + D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */, + D04BA44F7B5E58A08416706B /* TextField.swift */, + D04BA7C836A8CE8C0B8D128B /* TextArea.swift */, + D04BA53992F116E5E704CAB3 /* BaseLabel.swift */, + ); + path = text; sourceTree = ""; }; D04BAAB62DCBD7AF93B7EF89 /* img */ = { @@ -255,26 +471,35 @@ path = img; sourceTree = ""; }; - D04BAD1F5F5D4427DEA16682 /* docs */ = { + D04BABCD12CA72B06290C248 /* preferences-window */ = { isa = PBXGroup; children = ( - D04BAAB62DCBD7AF93B7EF89 /* img */, + D04BA47FF1B7838CF4814538 /* PreferencesWindow.swift */, + D04BAE757BB2B605234FBF58 /* TabViewController.swift */, + D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */, + D04BA27C87B86C4484A5B15B /* TabViewItem.swift */, + D04BA3FA52AA40B827A72F10 /* tabs */, ); - path = docs; + path = "preferences-window"; sourceTree = ""; }; - D04BAD48B702C4B1DC543557 /* alt-tab-macos */ = { + D04BAC2B70BC798C57B0B492 /* l10n */ = { isa = PBXGroup; children = ( - D04BAD1C9F215BCCD3B620AC /* alt_tab_macos.entitlements */, - D04BAAA28315301A906DD201 /* ui */, - D04BA7B6AAB0812631BBC7A2 /* Info.plist */, - D04BAA44C837F3A67403B9DB /* main.swift */, - D04BAA1C553891551B903DA7 /* logic */, - D04BA22D2CA2755FA5902C34 /* api-wrappers */, - D04BA5A0E9C82F7579CD2B78 /* resources */, + D04BA2641C1A385861945B7C /* en.lproj */, + D04BA61DD16A622D398FED3E /* fr.lproj */, + D04BA85F16881B290C4A33CD /* de.lproj */, + D04BA12944432EA45A7548CE /* es.lproj */, ); - path = "alt-tab-macos"; + path = l10n; + sourceTree = ""; + }; + D04BAD1F5F5D4427DEA16682 /* docs */ = { + isa = PBXGroup; + children = ( + D04BAAB62DCBD7AF93B7EF89 /* img */, + ); + path = docs; sourceTree = ""; }; D04BAE05121D54E4FF6AEDAF /* workflows */ = { @@ -292,9 +517,12 @@ isa = PBXNativeTarget; buildConfigurationList = D04BA4D71CBB2FA4B9947B10 /* Build configuration list for PBXNativeTarget "alt-tab-macos" */; buildPhases = ( + 5968B81A43F20B6ECD92C7F7 /* [CP] Check Pods Manifest.lock */, D04BAD01F4BCEDF8B539AFD2 /* Sources */, D04BA82F32FB183F65DC3E42 /* Frameworks */, D04BA96F3DC99263120BCD21 /* Resources */, + 7641B7923B36478FBF4D7CCD /* [CP] Embed Pods Frameworks */, + 48B68D6C23F6412C009BF4AD /* ShellScript */, ); buildRules = ( ); @@ -302,7 +530,7 @@ ); name = "alt-tab-macos"; productName = "alt-tab-macos"; - productReference = D04BA82F792DF53958D92572 /* alt-tab-macos.app */; + productReference = D04BA82F792DF53958D92572 /* AltTab.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -311,15 +539,18 @@ D04BA59EB5ED6F005E8F4407 /* Project object */ = { isa = PBXProject; attributes = { + LastUpgradeCheck = 1130; ORGANIZATIONNAME = lwouis; }; buildConfigurationList = D04BA83C9495F252D87CDF0F /* Build configuration list for PBXProject "alt-tab-macos" */; compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; + developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( - English, en, + fr, + de, + es, ); mainGroup = D04BA1463D2A17038222BB84; productRefGroup = D04BA1A461BBB5A9BE15E015 /* Products */; @@ -336,82 +567,224 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - D04BA8373D4DE452C0C081ED /* SF-Pro-Text-Regular.otf in Resources */, + D04BA570E7806F28741B1472 /* SF-Pro-Text-Regular.otf in Resources */, + D04BAEE31B6FFCDC779E6C17 /* MainMenu.xib in Resources */, + D04BA276B3241D440F65B149 /* InfoPlist.strings in Resources */, + D04BA084CD1236EC78D90A01 /* Localizable.strings in Resources */, + D04BAF12DF5D15B9D7D316A4 /* InfoPlist.strings in Resources */, + D04BA34AC850A273AB288B1E /* Localizable.strings in Resources */, + D04BA8D6055F6F2E42C976EA /* InfoPlist.strings in Resources */, + D04BAC3169D54014CBA9ACE5 /* Localizable.strings in Resources */, + D04BA100BD0F47828EB649FF /* InfoPlist.strings in Resources */, + D04BA3C24F4F644EA91DE38C /* Localizable.strings in Resources */, + D04BACE5702CEB1819866875 /* menubar-icon@2x.png in Resources */, + D04BAC61DCCB79B5D436F022 /* app-icon.icns in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 48B68D6C23F6412C009BF4AD /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "scripts/codesign_sparkle_embedded_apps.sh\n"; + showEnvVarsInLog = 0; + }; + 5968B81A43F20B6ECD92C7F7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-alt-tab-macos-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7641B7923B36478FBF4D7CCD /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/LetsMove/LetsMove.framework", + "${PODS_ROOT}/Sparkle/Sparkle.framework", + "${PODS_ROOT}/Sparkle/Sparkle.framework.dSYM", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LetsMove.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework", + "${DWARF_DSYM_FOLDER_PATH}/Sparkle.framework.dSYM", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ D04BAD01F4BCEDF8B539AFD2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D04BA960DDD1D32A3019C835 /* CollectionViewCenterFlowLayout.swift in Sources */, - D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */, - D04BA20D4A240843293B3B52 /* Cell.swift in Sources */, - D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */, - D04BA278D9EFA568C8D18A4C /* TrackedWindows.swift in Sources */, - D04BA3261C7DA5F48310E654 /* Application.swift in Sources */, - D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */, - D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */, - D04BA9119E2329DB5A35B3C7 /* ThumbnailsPanel.swift in Sources */, - D04BA8EBC0365A019A27C7EA /* Screen.swift in Sources */, - D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */, - D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */, - D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */, - F029861A378EC1417106FEC3 /* TextField.swift in Sources */, - F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */, - D04BA308162F8043F8561D03 /* AXUIElement.swift in Sources */, - D04BA0496ACF1427B6E9D369 /* CGWindow.swift in Sources */, - D04BAE369A14C3126A1606FE /* HelperExtensions.swift in Sources */, - D04BAE2E8E9B9898A4DF9B3B /* FontIcon.swift in Sources */, - D04BA4D356055A39B97712DE /* PrivateApis.swift in Sources */, - D04BA6B6B703DCEFE892D5A4 /* Spaces.swift in Sources */, - D04BA2CBF0EFA04CC80EC1BC /* TrackedWindow.swift in Sources */, - D04BA57FB9EF1373D59A1AA7 /* CGWindowID.swift in Sources */, + D04BAFBC862BA5FE0294EA7A /* AXUIElement.swift in Sources */, + D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */, + D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */, + D04BAADED6FE28D42924AEBF /* PrivateApis.swift in Sources */, + D04BABED81800E18732912CC /* CGWindowID.swift in Sources */, + D04BA26A691D56031FCCF00C /* Sysctl.swift in Sources */, + D04BA8480A8FF466CA89DA5B /* main.swift in Sources */, + D04BA5F99B45DC13B9E9DD91 /* Keyboard.swift in Sources */, + D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */, + D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */, + D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */, + D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */, + D04BABEECBC6D922298BC93A /* Spaces.swift in Sources */, + D04BA6187A91A847844B6ABB /* Window.swift in Sources */, + D04BA737008AA2CD4E230A21 /* Application.swift in Sources */, + D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */, + D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */, + D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */, + D04BAB68B7B8D1B548BC3AD5 /* App.swift in Sources */, + D04BA76DDB00FC50D203D62C /* CollectionViewFlowLayout.swift in Sources */, + D04BA6C953494839648107D1 /* CollectionViewItem.swift in Sources */, + D04BAB048DE698E013577C51 /* ThumbnailsPanel.swift in Sources */, + D04BA69D47B5E60A6AD9CBD9 /* CollectionViewItemTitle.swift in Sources */, + D04BA1B133D53572D7B312C2 /* CollectionViewItemFontIcon.swift in Sources */, + D04BA1CEC6B9C8945FEC8740 /* CollectionViewItemView.swift in Sources */, + D04BA3BFB0CDF4ED343914B2 /* PreferencesWindow.swift in Sources */, + D04BA691CB6082A3C39CBC89 /* TabViewController.swift in Sources */, + D04BA14D93726795A6937832 /* LabelAndControl.swift in Sources */, + D04BADBCF20CD72057E7CF09 /* TabViewItem.swift in Sources */, + D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */, + D04BAD1BE9DC22C48C53D195 /* AboutTab.swift in Sources */, + D04BA15A1B0C4871EA7CB899 /* GeneralTab.swift in Sources */, + D04BA8092885B40CE3527370 /* UpdatesTab.swift in Sources */, + D04BAD451966B43720120D2E /* Menubar.swift in Sources */, + D04BA2E64C59D96F6EB27D9D /* FeedbackWindow.swift in Sources */, + D04BA76A74267B1346D23687 /* GridView.swift in Sources */, + D04BAB4EB890853B5B9B2C61 /* BoldLabel.swift in Sources */, + D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */, + D04BAF25E67A5B31CF7676DB /* TextField.swift in Sources */, + D04BAD2A7F2E8BF64EE982E9 /* TextArea.swift in Sources */, + D04BA7F86F1926FBE31F44BF /* BaseLabel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + D04BA3B51D05213404938366 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BA0E11E83F177B07EE19C /* fr */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + D04BA5C2BB394F1624DD5B45 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BA5D2FD6E26D82721C574 /* en */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + D04BA5DE6536C4A6558FD80C /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BA7957E5E02FB09BCE10C /* de */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + D04BA61693F710CD7BD054D7 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BAA9E0539EE620D08F63F /* fr */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + D04BA717693DA18CB74BAED1 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BAD241A6928F45355B315 /* es */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + D04BAAEC2847830A3991F8D1 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BACB97A5895839BCB14BD /* es */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + D04BACCBE5F97BE9B6CA645B /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BA7C25BD7B68BCEEE38B5 /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + D04BAE7F55A4A3EC5F6D311A /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + D04BA662DF59DF9A97FDA598 /* de */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ D04BA49BCED00029C5289244 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D04BA62D5EACAAAD7BCD3E79 /* release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = "alt-tab-macos/alt_tab_macos.entitlements"; - COMBINE_HIDPI_IMAGES = YES; - FRAMEWORK_SEARCH_PATHS = /System/Library/PrivateFrameworks; - INFOPLIST_FILE = "alt-tab-macos/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.12; - PRODUCT_BUNDLE_IDENTIFIER = "com.lwouis.alt-tab-macos"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; }; name = Release; }; D04BA6FB4EC72C6A126E86D7 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = "alt-tab-macos/alt_tab_macos.entitlements"; - COMBINE_HIDPI_IMAGES = YES; - FRAMEWORK_SEARCH_PATHS = /System/Library/PrivateFrameworks; - INFOPLIST_FILE = "alt-tab-macos/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.12; - PRODUCT_BUNDLE_IDENTIFIER = "com.lwouis.alt-tab-macos"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; }; name = Debug; }; D04BA801581D700C583B53FB /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -440,7 +813,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -459,11 +831,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.12; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -472,7 +843,6 @@ D04BA9150F87B08AB0ACE4AE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -501,7 +871,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -514,10 +883,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.12; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - SDKROOT = macosx; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; }; name = Release; diff --git a/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Debug.xcscheme b/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Debug.xcscheme new file mode 100644 index 00000000..930b3a64 --- /dev/null +++ b/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Debug.xcscheme @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Release.xcscheme b/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Release.xcscheme new file mode 100644 index 00000000..59814bc2 --- /dev/null +++ b/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Release.xcscheme @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alt-tab-macos.xcworkspace/contents.xcworkspacedata b/alt-tab-macos.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..538edcbc --- /dev/null +++ b/alt-tab-macos.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/alt-tab-macos/alt_tab_macos.entitlements b/alt-tab-macos/alt_tab_macos.entitlements deleted file mode 100644 index b7d85c34..00000000 --- a/alt-tab-macos/alt_tab_macos.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - - diff --git a/alt-tab-macos/api-wrappers/AXUIElement.swift b/alt-tab-macos/api-wrappers/AXUIElement.swift deleted file mode 100644 index a079218e..00000000 --- a/alt-tab-macos/api-wrappers/AXUIElement.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Cocoa -import Foundation - -// This list of keys is not exhaustive; it contains only the values used by this app -// full public list: ApplicationServices.HIServices.AXAttributeConstants.swift -// Note that the String value is transformed by the getters (e.g. kAXWindowsAttribute -> AXWindows) -enum AXAttributeKey: String { - case windows = "AXWindows" - case minimized = "AXMinimized" - case focusedWindow = "AXFocusedWindow" - case subrole = "AXSubrole" -} - -extension AXUIElement { - func value(_ key: AXAttributeKey, _ target: T, _ type: AXValueType) -> T? { - if let a = attribute(key, AXValue.self) { - var value = target - AXValueGetValue(a, type, &value) - return value - } - return nil - } - - func attribute(_ key: AXAttributeKey, _ type: T.Type) -> T? { - var value: AnyObject? - let result = AXUIElementCopyAttributeValue(self, key.rawValue as CFString, &value) - if result == .success, let value = value as? T { - return value - } - return nil - } - - func cgId() -> CGWindowID { - var id = CGWindowID(0) - _AXUIElementGetWindow(self, &id) - return id - } - - func focusedWindow() -> AXUIElement? { - return attribute(.focusedWindow, AXUIElement.self) - } - - func isActualWindow() -> Bool { - let subrole = self.attribute(.subrole, String.self) - return subrole != nil && subrole != "AXUnknown" - } - - func windows() -> [AXUIElement]? { - return attribute(.windows, [AXUIElement].self) - } - - func window(_ id: CGWindowID) -> AXUIElement? { - return windows()?.first(where: { return id == $0.cgId() }) - } - - func isMinimized() -> Bool { - return attribute(.minimized, Bool.self) == true - } - - func focus(_ id: CGWindowID) { - // implementation notes: the following sequence of actions repeats some calls. This is necessary for - // minimized windows on other spaces, and focuses windows faster (e.g. the Security & Privacy window) - // macOS bug: when switching to a System Preferences window in another space, it switches to that space, - // but quickly switches back to another window in that space - // You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug - var elementConnection = UInt32(0) - CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection) - var psn = ProcessSerialNumber() - CGSGetConnectionPSN(elementConnection, &psn) - AXUIElementPerformAction(self, kAXRaiseAction as CFString) - makeKeyWindow(psn, id) - _SLPSSetFrontProcessWithOptions(&psn, id, .userGenerated) - makeKeyWindow(psn, id) - AXUIElementPerformAction(self, kAXRaiseAction as CFString) - } - - // The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468 - func makeKeyWindow(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void { - var wid_ = wid - var psn_ = psn - - var bytes1 = [UInt8](repeating: 0, count: 0xf8) - bytes1[0x04] = 0xF8 - bytes1[0x08] = 0x01 - bytes1[0x3a] = 0x10 - - var bytes2 = [UInt8](repeating: 0, count: 0xf8) - bytes2[0x04] = 0xF8 - bytes2[0x08] = 0x02 - bytes2[0x3a] = 0x10 - - memcpy(&bytes1[0x3c], &wid_, MemoryLayout.size) - memset(&bytes1[0x20], 0xFF, 0x10) - memcpy(&bytes2[0x3c], &wid_, MemoryLayout.size) - memset(&bytes2[0x20], 0xFF, 0x10) - - SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes1)).pointee)) - SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes2)).pointee)) - } -} diff --git a/alt-tab-macos/api-wrappers/CGWindow.swift b/alt-tab-macos/api-wrappers/CGWindow.swift deleted file mode 100644 index 964f166b..00000000 --- a/alt-tab-macos/api-wrappers/CGWindow.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Cocoa -import Foundation - -typealias CGWindow = [CGWindowKey.RawValue: Any] - -extension CGWindow { - static func windows(_ option: CGWindowListOption) -> [CGWindow] { - return CGWindowListCopyWindowInfo([.excludeDesktopElements, option], kCGNullWindowID) as! [CGWindow] - } - - func value(_ key: CGWindowKey, _ type: T.Type) -> T? { - return self[key.rawValue] as? T - } - - // workaround: filtering this criteria seems to remove non-windows UI elements - func isNotMenubarOrOthers() -> Bool { - return value(.layer, Int.self) == 0 - } - - // workaround: some apps like chrome use a window to implement the search popover - func isReasonablyBig() -> Bool { - let windowBounds = CGRect(dictionaryRepresentation: value(.bounds, CFDictionary.self)!)! - return windowBounds.width > Preferences.minimumWindowSize && windowBounds.height > Preferences.minimumWindowSize - } -} - -// This list of keys is not exhaustive; it contains only the values used by this app -// full public list: CoreGraphics.CGWindow.swift -enum CGWindowKey: String { - case number = "kCGWindowNumber" - case layer = "kCGWindowLayer" - case bounds = "kCGWindowBounds" - case ownerPID = "kCGWindowOwnerPID" - case ownerName = "kCGWindowOwnerName" - case name = "kCGWindowName" -} diff --git a/alt-tab-macos/api-wrappers/HelperExtensions.swift b/alt-tab-macos/api-wrappers/HelperExtensions.swift deleted file mode 100644 index 7fbe44c1..00000000 --- a/alt-tab-macos/api-wrappers/HelperExtensions.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import Cocoa - -extension CGFloat { - // add CGFloat constructor from String - init?(_ string: String) { - guard let number = NumberFormatter().number(from: string) else { - return nil - } - self.init(number.floatValue) - } -} - -extension Optional { - // add throw-on-nil method on Optional - func orThrow() throws -> Wrapped { - switch self { - case .some(let value): - return value - case .none: - Thread.callStackSymbols.forEach { print($0) } - throw NSError.make(domain: "Optional", message: "Optional contained nil") - } - } -} - -extension String { - // add String constructor from CGFloat that round up at 1 decimal - init?(_ cgFloat: CGFloat) { - let formatter = NumberFormatter() - formatter.maximumFractionDigits = 1 - guard let string = formatter.string(from: cgFloat as NSNumber) else { - return nil - } - self.init(string) - } -} - -extension NSView { - // add recursive lookup in subviews for specific type - func findNestedViews(subclassOf: T.Type) -> [T] { - return recursiveSubviews.compactMap { $0 as? T } - } - - var recursiveSubviews: [NSView] { - return subviews + subviews.flatMap { $0.recursiveSubviews } - } -} - -extension NSError { - // add convenience to NSError - class func make(domain: String, message: String, code: Int = 9999) -> NSError { - return NSError( - domain: domain, - code: code, - userInfo: [NSLocalizedDescriptionKey: message, NSLocalizedFailureReasonErrorKey: message] - ) - } -} - -extension Collection { - // recursive flatMap - func joined() -> [Any] { - return flatMap { ($0 as? [Any])?.joined() ?? [$0] } - } -} diff --git a/alt-tab-macos/logic/Preferences.swift b/alt-tab-macos/logic/Preferences.swift deleted file mode 100644 index 2fb7cacb..00000000 --- a/alt-tab-macos/logic/Preferences.swift +++ /dev/null @@ -1,183 +0,0 @@ -import Foundation -import Cocoa -import Carbon.HIToolbox.Events - -class Preferences { - static var defaults: [String: String] = [ - "maxScreenUsage": "80", - "maxThumbnailsPerRow": "4", - "iconSize": "32", - "fontHeight": "15", - "tabKeyCode": String(kVK_Tab), - "metaKey": metaKeyMacro.macros[0].label, - "windowDisplayDelay": "0", - "theme": themeMacro.macros[0].label, - "showOnScreen": showOnScreenMacro.macros[0].label, - "hideSpaceNumberLabels": String(false) - ] - static var rawValues = [String: String]() - static var minimumWindowSize = CGFloat(200) - static var emptyThumbnailWidth = CGFloat(200) - static var emptyThumbnailHeight = CGFloat(emptyThumbnailWidth * 9 / 16) - static var fontColor = NSColor.white - static var windowMaterial = NSVisualEffectView.Material.dark - static var windowPadding = CGFloat(23) - static var interItemPadding = CGFloat(4) - static var fontIconSize = CGFloat(20) - static var cellPadding = CGFloat(6) - static var cellBorderWidth: CGFloat? - static var cellCornerRadius: CGFloat? - static var maxScreenUsage: CGFloat? - static var maxThumbnailsPerRow: CGFloat? - static var iconSize: CGFloat? - static var fontHeight: CGFloat? - static var tabKeyCode: UInt16? - static var highlightBorderColor: NSColor? - static var highlightBackgroundColor: NSColor? - static var metaKeyCodes: [UInt16]? - static var metaModifierFlag: NSEvent.ModifierFlags? - static var windowDisplayDelay: DispatchTimeInterval? - static var windowCornerRadius: CGFloat? - static var font: NSFont? - static var showOnScreen: ShowOnScreenPreference? - static var hideSpaceNumberLabels = false - static var themeMacro = MacroPreferenceHelper<(CGFloat, CGFloat, CGFloat, NSColor, NSColor)>([ - MacroPreference(" macOS", (0, 5, 20, .clear, NSColor(red: 0, green: 0, blue: 0, alpha: 0.3))), - MacroPreference("❖ Windows 10", (2, 0, 0, .white, .clear)) - ]) - static var metaKeyMacro = MacroPreferenceHelper<([Int], NSEvent.ModifierFlags)>([ - MacroPreference("⌥ option", ([kVK_Option, kVK_RightOption], .option)), - MacroPreference("⌃ control", ([kVK_Control, kVK_RightControl], .control)), - MacroPreference("⌘ command", ([kVK_Command, kVK_RightCommand], .command)) - ]) - static var showOnScreenMacro = MacroPreferenceHelper([ - MacroPreference("Main screen", ShowOnScreenPreference.MAIN), - MacroPreference("Screen including mouse", ShowOnScreenPreference.MOUSE), - ]) - - private static let defaultsFile = fileFromPreferencesFolder("alt-tab-macos-defaults.json") - private static let userFile = fileFromPreferencesFolder("alt-tab-macos.json") - - static func loadFromDiskAndUpdateValues() { - do { - try saveDefaultsToDisk() - let preferencesExist = FileManager.default.fileExists(atPath: userFile.path) - if !preferencesExist { - try FileManager.default.copyItem(at: defaultsFile, to: userFile) - } - rawValues = try loadFromDisk(userFile) - if preferencesExist { - let compatiblePreferences = rawValues.filter { defaults[$0.key] != nil } - rawValues = defaults.merging(compatiblePreferences) { (_, new) in new } - } - try rawValues.forEach { try updateAndValidateFromString($0.key, $0.value) } - if preferencesExist { - try saveRawToDisk() - } - } catch { - debugPrint("Error loading preferences", error) - if (FileManager.default.fileExists(atPath: userFile.path)) { - try! FileManager.default.removeItem(at: userFile) - } - loadFromDiskAndUpdateValues() - } - } - - static func updateAndValidateFromString(_ valueName: String, _ value: String) throws { - switch valueName { - case "maxScreenUsage": - maxScreenUsage = try CGFloat(CGFloat(value).orThrow() / 100) - case "maxThumbnailsPerRow": - maxThumbnailsPerRow = try CGFloat(value).orThrow() - case "iconSize": - iconSize = try CGFloat(value).orThrow() - case "fontHeight": - fontHeight = try CGFloat(value).orThrow() - font = NSFont.systemFont(ofSize: fontHeight!) - case "tabKeyCode": - tabKeyCode = try UInt16(value).orThrow() - case "metaKey": - let p = try metaKeyMacro.labelToMacro[value].orThrow() - metaKeyCodes = p.preferences.0.map { UInt16($0) } - metaModifierFlag = p.preferences.1 - case "theme": - let p = try themeMacro.labelToMacro[value].orThrow() - cellBorderWidth = p.preferences.0 - cellCornerRadius = p.preferences.1 - windowCornerRadius = p.preferences.2 - highlightBorderColor = p.preferences.3 - highlightBackgroundColor = p.preferences.4 - case "windowDisplayDelay": - windowDisplayDelay = DispatchTimeInterval.milliseconds(try Int(value).orThrow()) - case "showOnScreen": - let p = try showOnScreenMacro.labelToMacro[value].orThrow() - showOnScreen = p.preferences - case "hideSpaceNumberLabels": - hideSpaceNumberLabels = try Bool(value).orThrow() - default: - throw NSError.make(domain: "Preferences", message: "Tried to update an unknown preference: '\(valueName)' = '\(value)'") - } - rawValues[valueName] = value - } - - static func saveRawToDisk() throws { - try saveToDisk(rawValues, userFile) - } - - private static func preferencesVersion(_ url: URL) throws -> Int { - return try Int(loadFromDisk(url)["version"] ?? "0").orThrow() - } - - private static func loadFromDisk(_ url: URL) throws -> [String: String] { - return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: url)) - } - - private static func saveDefaultsToDisk() throws { - try saveToDisk(defaults, defaultsFile) - } - - private static func saveToDisk(_ values: [String: String], _ path: URL) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - try encoder - .encode(values) - .write(to: path) - } - - private static func fileFromPreferencesFolder(_ fileName: String) -> URL { - return FileManager.default - .urls(for: .libraryDirectory, in: .userDomainMask) - .first! - .appendingPathComponent("Preferences", isDirectory: true) - .appendingPathComponent(fileName) - } -} - -struct MacroPreference { - let label: String - let preferences: T - - init(_ label: String, _ preferences: T) { - self.label = label - self.preferences = preferences - } -} - -class MacroPreferenceHelper { - let macros: [MacroPreference] - var labels = [String]() - var labelToMacro = [String: MacroPreference]() - - init(_ array: [MacroPreference]) { - self.macros = array - array.forEach { - labelToMacro[$0.label] = $0 - labels.append($0.label) - } - } -} - -enum ShowOnScreenPreference { - case MAIN - case MOUSE -} diff --git a/alt-tab-macos/logic/Spaces.swift b/alt-tab-macos/logic/Spaces.swift deleted file mode 100644 index 9a144f61..00000000 --- a/alt-tab-macos/logic/Spaces.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Cocoa -import Foundation - -class Spaces { - static var singleSpace = true - static var currentSpaceId = CGSSpaceID(1) - static var currentSpaceIndex = SpaceIndex(1) - - static func allIdsAndIndexes() -> [(CGSSpaceID, SpaceIndex)] { - return (CGSCopyManagedDisplaySpaces(cgsMainConnectionId) as! [NSDictionary]) - .map { return $0["Spaces"] }.joined().enumerated() - .map { (($0.element as! NSDictionary)["id64"]! as! CGSSpaceID, $0.offset + 1) } - } - - static func windowsInSpaces(_ spaceIds: [CGSSpaceID]) -> [CGWindowID] { - var set_tags = UInt64(0) - var clear_tags = UInt64(0) - return CGSCopyWindowsWithOptionsAndTags(cgsMainConnectionId, 0, spaceIds as CFArray, 2, &set_tags, &clear_tags) as! [CGWindowID] - } -} - -typealias SpaceIndex = Int diff --git a/alt-tab-macos/logic/TrackedWindow.swift b/alt-tab-macos/logic/TrackedWindow.swift deleted file mode 100644 index cb35fe5e..00000000 --- a/alt-tab-macos/logic/TrackedWindow.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Cocoa -import Foundation - -class TrackedWindow { - var cgWindow: CGWindow - var id: CGWindowID - var title: String - var thumbnail: NSImage? - var icon: NSImage? - var app: NSRunningApplication - var axApp: AXUIElement - var axWindow: AXUIElement? - var isHidden: Bool - var isMinimized: Bool - var spaceId: CGSSpaceID? - var spaceIndex: SpaceIndex? - var rank: WindowRank? - - init(_ cgWindow: CGWindow, _ cgId: CGWindowID, _ app: NSRunningApplication, _ axApp: AXUIElement, _ isHidden: Bool, _ isMinimized: Bool, _ axWindow: AXUIElement?, _ spaceId: CGSSpaceID?, _ spaceIndex: SpaceIndex?, _ rank: WindowRank?) { - self.cgWindow = cgWindow - self.id = cgId - let cgTitle = cgWindow.value(.name, String.self) - let cgOwnerName = cgWindow.value(.ownerName, String.self) - // for some reason Google Chrome uses a unicode 0-width no-break space character in their empty window title - self.title = cgTitle != nil && cgTitle != "" && cgTitle != "" ? cgTitle! : cgOwnerName ?? "" - self.app = app - self.axApp = axApp - self.icon = self.app.icon - if let cgImage = cgId.screenshot() { - self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) - } - self.axWindow = axWindow - self.isHidden = isHidden - self.isMinimized = isMinimized - self.spaceId = spaceId - self.spaceIndex = spaceIndex - self.rank = rank - } - - func focus() { - let onCurrentSpace = axWindow != nil - if !onCurrentSpace { - axWindow = id.AXUIElementOfOtherSpaceWindow(axApp) - } - axWindow?.focus(id) - } -} diff --git a/alt-tab-macos/logic/TrackedWindows.swift b/alt-tab-macos/logic/TrackedWindows.swift deleted file mode 100644 index ac00634c..00000000 --- a/alt-tab-macos/logic/TrackedWindows.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Cocoa -import Foundation - -class TrackedWindows { - static var list = [TrackedWindow]() - static var focusedWindowIndex = Int(0) - - static func focusedWindow() -> TrackedWindow? { - return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil - } - - static func moveFocusedWindowIndex(_ step: Int) -> Int { - return focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count - } - - static func refreshList(_ step: Int) { - list.removeAll() - focusedWindowIndex = 0 - let spaces = Spaces.allIdsAndIndexes() - Spaces.currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.mainUuid()) - Spaces.currentSpaceIndex = spaces.first { $0.0 == Spaces.currentSpaceId }!.1 - filterAndAddToList(mapWindowsWithRankAndSpace(spaces)) - isSingleSpace() - sortList() - } - - private class func isSingleSpace() { - if list.count > 0 { - let firstSpaceIndex = list[0].spaceIndex - for window in list { - if window.spaceIndex != nil && window.spaceIndex != firstSpaceIndex { - Spaces.singleSpace = false - return - } - } - } - Spaces.singleSpace = true - } - - private static func mapWindowsWithRankAndSpace(_ spaces: [(CGSSpaceID, SpaceIndex)]) -> WindowsMap { - var windowSpaceMap: [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank?)] = [:] - for (spaceId, spaceIndex) in spaces { - Spaces.windowsInSpaces([spaceId]).forEach { - windowSpaceMap[$0] = (spaceId, spaceIndex, nil) - } - } - Spaces.windowsInSpaces(spaces.map { $0.0 }).enumerated().forEach { - windowSpaceMap[$0.element]!.2 = $0.offset - } - return windowSpaceMap as! WindowsMap - } - - private static func sortList() { - list.sort(by: { - if $0.rank == nil { - return false - } - if $1.rank == nil { - return true - } - return $0.rank! < $1.rank! - }) - } - - private static func filterAndAddToList(_ windowsMap: WindowsMap) { - // order and short-circuit of checks in this method is important for performance - for cgWindow in CGWindow.windows(.optionAll) { - guard let cgId = cgWindow.value(.number, CGWindowID.self), - let ownerPid = cgWindow.value(.ownerPID, pid_t.self), - let app = NSRunningApplication(processIdentifier: ownerPid), - cgWindow.isNotMenubarOrOthers(), - cgWindow.isReasonablyBig() else { - continue - } - let axApp = cgId.AXUIElementApplication(ownerPid) - let (spaceId, spaceIndex, rank) = windowsMap[cgId] ?? (nil, nil, nil) - if let (isMinimized, isHidden, axWindow) = filter(cgId, spaceId, app, axApp) { - list.append(TrackedWindow(cgWindow, cgId, app, axApp, isHidden, isMinimized, axWindow, spaceId, spaceIndex, rank)) - } - } - } - - private static func filter(_ cgId: CGWindowID, _ spaceId: CGSSpaceID?, _ app: NSRunningApplication, _ axApp: AXUIElement) -> (Bool, Bool, AXUIElement?)? { - // window is in another space - if spaceId != nil && spaceId != Spaces.currentSpaceId { - return (false, false, nil) - } - // window is in the current space, or is hidden/minimized - if let axWindow = axApp.window(cgId), axWindow.isActualWindow() { - if spaceId != nil { - return (false, false, axWindow) - } - if app.isHidden { - return (axWindow.isMinimized(), true, axWindow) - } - if axWindow.isMinimized() { - return (true, false, axWindow) - } - } - return nil - } -} - -typealias WindowRank = Int -typealias WindowsMap = [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)] \ No newline at end of file diff --git a/alt-tab-macos/ui/Application.swift b/alt-tab-macos/ui/Application.swift deleted file mode 100644 index 15d5e6bc..00000000 --- a/alt-tab-macos/ui/Application.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -import Cocoa - -let cgsMainConnectionId = CGSMainConnectionID() - -class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { - static let name = "AltTab" - var statusItem: NSStatusItem? - var thumbnailsPanel: ThumbnailsPanel? - var preferencesPanel: PreferencesPanel? - var uiWorkShouldBeDone = true - var isFirstSummon = true - var appIsBeingUsed = false - - override init() { - super.init() - delegate = self - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - func applicationDidFinishLaunching(_ aNotification: Notification) { - SystemPermissions.ensureScreenRecordingCheckboxIsChecked() - SystemPermissions.ensureAccessibilityCheckboxIsChecked() - Preferences.loadFromDiskAndUpdateValues() - statusItem = StatusItem.make(self) - initPreferencesDependentComponents() - Keyboard.listenToGlobalEvents(self) - warmUpThumbnailPanel() - } - - // running this code on startup avoid having the very first invocation be slow for the user - private func warmUpThumbnailPanel() { - thumbnailsPanel!.computeThumbnails(Screen.preferred()) - } - - // we put application code here which should be executed on init() and Preferences change - func initPreferencesDependentComponents() { - thumbnailsPanel = ThumbnailsPanel(self) - } - - func hideUi() { - debugPrint("hideUi") - thumbnailsPanel!.orderOut(nil) - appIsBeingUsed = false - isFirstSummon = true - } - - func focusTarget() { - debugPrint("focusTarget") - if appIsBeingUsed { - debugPrint("focusTarget: appIsBeingUsed") - focusSelectedWindow(TrackedWindows.focusedWindow()) - } - } - - @objc - func showPreferencesPanel() { - if preferencesPanel == nil { - preferencesPanel = PreferencesPanel() - } - Screen.showPanel(preferencesPanel!, Screen.preferred(), .appleCentered) - } - - @objc - func showUi() { - uiWorkShouldBeDone = true - showUiOrCycleSelection(0) - } - - func cycleSelection(_ step: Int) { - TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step) - self.thumbnailsPanel!.highlightCellAt(step) - } - - func showUiOrCycleSelection(_ step: Int) { - debugPrint("showUiOrCycleSelection", step) - appIsBeingUsed = true - if isFirstSummon { - debugPrint("showUiOrCycleSelection: isFirstSummon") - isFirstSummon = false - TrackedWindows.refreshList(step) - if TrackedWindows.list.count == 0 { - appIsBeingUsed = false - isFirstSummon = true - return - } - TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step) - let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) - if uiWorkShouldBeDone { self.thumbnailsPanel!.computeThumbnails(currentScreen); debugPrint("computeThumbnails") } - if uiWorkShouldBeDone { self.thumbnailsPanel!.highlightCellAt(step); debugPrint("highlightCellAt") } - if uiWorkShouldBeDone { Screen.showPanel(self.thumbnailsPanel!, currentScreen, .appleCentered); debugPrint("showPanel") } - } else { - debugPrint("showUiOrCycleSelection: !isFirstSummon") - cycleSelection(step) - } - } - - func focusSelectedWindow(_ window: TrackedWindow?) { - hideUi() - DispatchQueue.global(qos: .userInteractive).async { window?.focus() } - } -} diff --git a/alt-tab-macos/ui/Cell.swift b/alt-tab-macos/ui/Cell.swift deleted file mode 100644 index 6bcb753f..00000000 --- a/alt-tab-macos/ui/Cell.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Cocoa -import WebKit - -typealias MouseDownCallback = (TrackedWindow) -> Void -typealias MouseMovedCallback = (Cell) -> Void - -class Cell: NSCollectionViewItem { - var thumbnail = NSImageView() - var appIcon = NSImageView() - var label = CellTitle(Preferences.fontHeight!) - var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white) - var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white) - var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white) - var openWindow: TrackedWindow? - var mouseDownCallback: MouseDownCallback? - var mouseMovedCallback: MouseMovedCallback? - - override func loadView() { - let hStackView = makeHStackView() - let vStackView = makeVStackView(hStackView) - let shadow = Cell.makeShadow(.gray) - thumbnail.shadow = shadow - appIcon.shadow = shadow - view = vStackView - } - - override func mouseMoved(with event: NSEvent) { - mouseMovedCallback!(self) - } - - override func mouseDown(with theEvent: NSEvent) { - mouseDownCallback!(openWindow!) - } - - override var isSelected: Bool { - didSet { - view.layer!.backgroundColor = isSelected ? Preferences.highlightBackgroundColor!.cgColor : .clear - view.layer!.borderColor = isSelected ? Preferences.highlightBorderColor!.cgColor : .clear - } - } - - func updateWithNewContent(_ element: TrackedWindow, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) { - openWindow = element - thumbnail.image = element.thumbnail - let (width, height) = Cell.computeDownscaledSize(element.thumbnail, screen) - thumbnail.image?.size = NSSize(width: width, height: height) - thumbnail.frame.size = NSSize(width: width, height: height) - appIcon.image = element.icon - appIcon.image?.size = NSSize(width: Preferences.iconSize!, height: Preferences.iconSize!) - appIcon.frame.size = NSSize(width: Preferences.iconSize!, height: Preferences.iconSize!) - label.string = element.title - // workaround: setting string on NSTextView change the font (most likely a Cocoa bug) - label.font = Preferences.font! - hiddenIcon.isHidden = !openWindow!.isHidden - minimizedIcon.isHidden = !openWindow!.isMinimized - spaceIcon.isHidden = element.spaceIndex == nil || Spaces.singleSpace || Preferences.hideSpaceNumberLabels - if !spaceIcon.isHidden { - spaceIcon.setNumber(UInt32(element.spaceIndex!)) - } - let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.interItemPadding) - label.textContainer!.size.width = thumbnail.frame.width - Preferences.iconSize! - Preferences.interItemPadding - fontIconWidth - self.mouseDownCallback = mouseDownCallback - self.mouseMovedCallback = mouseMovedCallback - if view.trackingAreas.count > 0 { - view.removeTrackingArea(view.trackingAreas[0]) - } - view.addTrackingArea(NSTrackingArea(rect: view.bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil)) - } - - static func computeDownscaledSize(_ image: NSImage?, _ screen: NSScreen) -> (Int, Int) { - if let image_ = image { - let imageRatio = image_.size.width / image_.size.height - let thumbnailMaxSize = Screen.thumbnailMaxSize(screen) - let thumbnailWidth = Int(floor(thumbnailMaxSize.height * imageRatio)) - if thumbnailWidth <= Int(thumbnailMaxSize.width) { - return (thumbnailWidth, Int(thumbnailMaxSize.height)) - } else { - return (Int(thumbnailMaxSize.width), Int(floor(thumbnailMaxSize.width / imageRatio))) - } - } - return (Int(Preferences.emptyThumbnailWidth), Int((Preferences.emptyThumbnailHeight))) - } - - static func makeShadow(_ color: NSColor) -> NSShadow { - let shadow = NSShadow() - shadow.shadowColor = color - shadow.shadowOffset = .zero - shadow.shadowBlurRadius = 1 - return shadow - } - - private func makeHStackView() -> NSStackView { - let hStackView = NSStackView() - hStackView.spacing = Preferences.interItemPadding - hStackView.addView(appIcon, in: .leading) - hStackView.addView(label, in: .leading) - hStackView.addView(hiddenIcon, in: .leading) - hStackView.addView(minimizedIcon, in: .leading) - hStackView.addView(spaceIcon, in: .leading) - return hStackView - } - - private func makeVStackView(_ hStackView: NSStackView) -> NSStackView { - let vStackView = NSStackView() - vStackView.wantsLayer = true - vStackView.layer!.backgroundColor = .clear - vStackView.layer!.cornerRadius = Preferences.cellCornerRadius! - vStackView.layer!.borderWidth = Preferences.cellBorderWidth! - vStackView.layer!.borderColor = .clear - vStackView.edgeInsets = NSEdgeInsets(top: Preferences.cellPadding, left: Preferences.cellPadding, bottom: Preferences.cellPadding, right: Preferences.cellPadding) - vStackView.orientation = .vertical - vStackView.spacing = Preferences.interItemPadding - vStackView.addView(hStackView, in: .leading) - vStackView.addView(thumbnail, in: .leading) - return vStackView - } -} diff --git a/alt-tab-macos/ui/CollectionViewCenterFlowLayout.swift b/alt-tab-macos/ui/CollectionViewCenterFlowLayout.swift deleted file mode 100644 index a47f2d8c..00000000 --- a/alt-tab-macos/ui/CollectionViewCenterFlowLayout.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Cocoa - -class CollectionViewCenterFlowLayout: NSCollectionViewFlowLayout { - var currentScreen: NSScreen? - - override func layoutAttributesForElements(in rect: CGRect) -> [NSCollectionViewLayoutAttributes] { - let attributes = super.layoutAttributesForElements(in: rect) - if attributes.isEmpty { - return attributes - } - var currentRow: [NSCollectionViewLayoutAttributes] = [] - var currentRowY = CGFloat(0) - var currentRowWidth = CGFloat(0) - var previousRowMaxY = CGFloat(0) - var currentRowMaxY = CGFloat(0) - var widestRow = CGFloat(0) - var totalHeight = CGFloat(0) - attributes.enumerated().forEach { - let isNewRow = abs($1.frame.origin.y - currentRowY) > Screen.thumbnailMaxSize(currentScreen!).height - if isNewRow { - computeOriginXForAllItems(currentRowWidth - minimumInteritemSpacing, previousRowMaxY, currentRow) - currentRow.removeAll() - currentRowY = $1.frame.origin.y - currentRowWidth = 0 - previousRowMaxY += currentRowMaxY + minimumLineSpacing - currentRowMaxY = 0 - } - currentRow.append($1) - currentRowWidth += $1.frame.size.width + minimumInteritemSpacing - widestRow = max(widestRow, currentRowWidth) - currentRowMaxY = max(currentRowMaxY, $1.frame.size.height) - if $0 == attributes.count - 1 { - computeOriginXForAllItems(currentRowWidth - minimumInteritemSpacing, previousRowMaxY, currentRow) - totalHeight = previousRowMaxY + currentRowMaxY - } - } - collectionView!.setFrameSize(NSSize(width: widestRow - minimumInteritemSpacing, height: totalHeight)) - return attributes - } - - func computeOriginXForAllItems(_ currentRowWidth: CGFloat, _ previousRowMaxHeight: CGFloat, _ currentRow: [NSCollectionViewLayoutAttributes]) { - var marginLeft = floor((collectionView!.frame.size.width - currentRowWidth) / 2) - currentRow.forEach { - $0.frame.origin.x = marginLeft - $0.frame.origin.y = previousRowMaxHeight - marginLeft += $0.frame.size.width + minimumInteritemSpacing - } - } -} diff --git a/alt-tab-macos/ui/PreferencesPanel.swift b/alt-tab-macos/ui/PreferencesPanel.swift deleted file mode 100644 index 902ebe4d..00000000 --- a/alt-tab-macos/ui/PreferencesPanel.swift +++ /dev/null @@ -1,276 +0,0 @@ -import Cocoa -import Foundation - -class PreferencesPanel: NSPanel, NSWindowDelegate { - let panelWidth = CGFloat(496) - let panelHeight = CGFloat(256) // auto expands to content height (but does not auto shrink) - let panelPadding = CGFloat(40) - var labelWidth: CGFloat { - return (panelWidth - panelPadding) * CGFloat(0.45) - } - var windowCloseRequested = false - - override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { - let initialRect = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight) - super.init(contentRect: initialRect, styleMask: style, backing: backingStoreType, defer: flag) - title = Application.name + " Preferences" - hidesOnDeactivate = false - contentView = makeContentView() - } - - public func windowShouldClose(_ sender: NSWindow) -> Bool { - windowCloseRequested = true - challengeNextInvalidEditableTextField() - return attachedSheet == nil // depends if user is challenged with a sheet - } - - private func challengeNextInvalidEditableTextField() { - let invalidFields = (contentView? - .findNestedViews(subclassOf: TextField.self) - .filter({ !$0.isValid() }) - ) - let focusedField = invalidFields?.filter({ $0.currentEditor() != nil }).first - let fieldToNotify = focusedField ?? invalidFields?.first - fieldToNotify?.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: fieldToNotify)) - - if fieldToNotify != focusedField { - makeFirstResponder(fieldToNotify) - } - } - - private func makeContentView() -> NSView { - let wrappingView = NSStackView(views: makePreferencesViews()) - let contentView = NSView() - contentView.addSubview(wrappingView) - - // visual setup - wrappingView.orientation = .vertical - wrappingView.alignment = .left - wrappingView.spacing = panelPadding * 0.3 - wrappingView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: panelPadding * 0.5).isActive = true - wrappingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: panelPadding * -0.5).isActive = true - wrappingView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: panelPadding * 0.5).isActive = true - wrappingView.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: panelPadding * -0.5).isActive = true - - return contentView - } - - private func makePreferencesViews() -> [NSView] { - // TODO: make the validators be a part of each Preference - let tabKeyCodeValidator: ((String) -> Bool) = { - guard let int = Int($0) else { - return false - } - // non-special keys (mac & pc keyboards): https://eastmanreference.com/complete-list-of-applescript-key-codes - var whitelistedKeycodes: [Int] = Array(0...53) - whitelistedKeycodes.append(contentsOf: [65, 67, 69, 75, 76, 78, ]) - whitelistedKeycodes.append(contentsOf: Array(81...89)) - whitelistedKeycodes.append(contentsOf: [91, 92, 115, 116, 117, 119, 121]) - whitelistedKeycodes.append(contentsOf: Array(123...126)) - return whitelistedKeycodes.contains(int) - } - - return [ - makeLabelWithDropdown("Alt key", rawName: "metaKey", values: Preferences.metaKeyMacro.labels), - makeLabelWithInput("Tab key", rawName: "tabKeyCode", width: 33, suffixText: "KeyCodes Reference", suffixUrl: "https://eastmanreference.com/complete-list-of-applescript-key-codes", validator: tabKeyCodeValidator), - makeHorizontalSeparator(), - makeLabelWithDropdown("Theme", rawName: "theme", values: Preferences.themeMacro.labels), - makeLabelWithSlider("Max screen usage", rawName: "maxScreenUsage", minValue: 10, maxValue: 100, numberOfTickMarks: 0, unitText: "%"), - makeLabelWithSlider("Max thumbnails per row", rawName: "maxThumbnailsPerRow", minValue: 3, maxValue: 16, numberOfTickMarks: 0), - makeLabelWithSlider("Apps icon size", rawName: "iconSize", minValue: 12, maxValue: 64, numberOfTickMarks: 0, unitText: "px"), - makeLabelWithSlider("Window font size", rawName: "fontHeight", minValue: 12, maxValue: 64, numberOfTickMarks: 0, unitText: "px"), - makeLabelWithCheckbox("Hide space number labels", rawName: "hideSpaceNumberLabels"), - makeHorizontalSeparator(), - makeLabelWithSlider("Window apparition delay", rawName: "windowDisplayDelay", minValue: 0, maxValue: 2000, numberOfTickMarks: 0, unitText: "ms"), - makeLabelWithDropdown("Show on", rawName: "showOnScreen", values: Preferences.showOnScreenMacro.labels) - ] - } - - private func makeHorizontalSeparator() -> NSView { - let view = NSBox() - view.boxType = .separator - - return view - } - - private func makeLabelWithInput(_ labelText: String, rawName: String, width: CGFloat? = nil, suffixText: String? = nil, suffixUrl: String? = nil, validator: ((String) -> Bool)? = nil) -> NSStackView { - let input = TextField(Preferences.rawValues[rawName]!) - input.validationHandler = validator - input.delegate = input - input.visualizeValidationState() - if width != nil { - input.widthAnchor.constraint(equalToConstant: width!).isActive = true - } - - return makeLabelWithProvidedControl(labelText, rawName: rawName, control: input, suffixText: suffixText, suffixUrl: suffixUrl) - } - - private func makeLabelWithCheckbox(_ labelText: String, rawName: String) -> NSStackView { - let checkbox = NSButton.init(checkboxWithTitle: "", target: nil, action: nil) - setControlValue(checkbox, Preferences.rawValues[rawName]!) - return makeLabelWithProvidedControl(labelText, rawName: rawName, control: checkbox) - } - - private func makeLabelWithDropdown(_ labelText: String, rawName: String, values: [String], suffixText: String? = nil) -> NSStackView { - let popUp = NSPopUpButton() - popUp.addItems(withTitles: values) - popUp.selectItem(withTitle: Preferences.rawValues[rawName]!) - - return makeLabelWithProvidedControl(labelText, rawName: rawName, control: popUp, suffixText: suffixText) - } - - private func makeLabelWithSlider(_ labelText: String, rawName: String, minValue: Double, maxValue: Double, numberOfTickMarks: Int, unitText: String = "") -> NSStackView { - let value = Preferences.rawValues[rawName]! - let suffixText = value + unitText - let slider = NSSlider() - slider.minValue = minValue - slider.maxValue = maxValue - slider.stringValue = value - slider.numberOfTickMarks = numberOfTickMarks - slider.allowsTickMarkValuesOnly = numberOfTickMarks > 1 - slider.tickMarkPosition = .below - slider.isContinuous = true - - return makeLabelWithProvidedControl(labelText, rawName: rawName, control: slider, suffixText: suffixText, suffixWidth: 60) - } - - private func makeLabelWithProvidedControl(_ labelText: String?, rawName: String, control: NSControl, suffixText: String? = nil, suffixWidth: CGFloat? = nil, suffixUrl: String? = nil) -> NSStackView { - let label = NSTextField(wrappingLabelWithString: (labelText != nil ? labelText! + ": " : "")) - label.alignment = .right - label.widthAnchor.constraint(equalToConstant: labelWidth).isActive = true - label.identifier = NSUserInterfaceItemIdentifier(rawName + ControlIdentifierDiscriminator.LABEL.rawValue) - label.isSelectable = false - - control.identifier = NSUserInterfaceItemIdentifier(rawName) - control.target = self - control.action = #selector(controlWasChanged) - let containerView = NSStackView(views: [label, control]) - - if suffixText != nil { - let suffix = makeSuffix(controlName: rawName, text: suffixText!, width: suffixWidth, url: suffixUrl) - containerView.addView(suffix, in: .leading) - } - - return containerView - } - - private func makeSuffix(controlName: String, text: String, width: CGFloat? = nil, url: String? = nil) -> NSTextField { - let suffix: NSTextField - if url == nil { - suffix = NSTextField(labelWithString: text) - } else { - suffix = HyperlinkLabel(labelWithUrl: text, nsUrl: NSURL(string: url!)!) - } - suffix.textColor = .gray - suffix.identifier = NSUserInterfaceItemIdentifier(controlName + ControlIdentifierDiscriminator.SUFFIX.rawValue) - if width != nil { - suffix.widthAnchor.constraint(equalToConstant: width!).isActive = true - } - - return suffix - } - - private func updateSuffixWithValue(_ control: NSControl, _ value: String) { - let suffixIdentifierPredicate = { (view: NSView) -> Bool in - view.identifier?.rawValue == control.identifier!.rawValue + ControlIdentifierDiscriminator.SUFFIX.rawValue - } - - if let suffixView: NSTextField = control.superview?.subviews.first(where: suffixIdentifierPredicate) as? NSTextField { - let regex = try! NSRegularExpression(pattern: "^[0-9]+") // first decimal - let range = NSMakeRange(0, suffixView.stringValue.count) - suffixView.stringValue = regex.stringByReplacingMatches(in: suffixView.stringValue, range: range, withTemplate: value) - } - } - - @objc - private func controlWasChanged(senderControl: NSControl) { - let key: String = senderControl.identifier!.rawValue - let previousValue: String = Preferences.rawValues[key]! - let newValue: String = getControlValue(senderControl) - let invalidTextField = senderControl is TextField && !(senderControl as! TextField).isValid() - - if (invalidTextField && !windowCloseRequested) || (newValue == previousValue && !invalidTextField) { - return - } - - updateControlExtras(senderControl, newValue) - - do { - // TODO: remove conditional as soon a Preference does validation on its own - if invalidTextField && windowCloseRequested { - throw NSError.make(domain: "Preferences", message: "Please enter a valid value for '" + key + "'") - } - try Preferences.updateAndValidateFromString(key, newValue) - (NSApp as! Application).initPreferencesDependentComponents() - try Preferences.saveRawToDisk() - } catch let error { - debugPrint("PreferencesPanel: save: error", key, newValue, error) - showSaveErrorSheetModal(error as NSError, senderControl, key, previousValue) // allows recursive call by user choice - } - } - - private func showSaveErrorSheetModal(_ nsError: NSError, _ control: NSControl, _ key: String, _ previousValue: String) { - let alert = NSAlert() - alert.messageText = "Could not save Preference" - alert.informativeText = nsError.localizedDescription + "\n" - alert.addButton(withTitle: "Edit") - alert.addButton(withTitle: "Cancel") - alert.addButton(withTitle: "Check again") - - alert.beginSheetModal(for: self, completionHandler: { (modalResponse: NSApplication.ModalResponse) -> Void in - if modalResponse == NSApplication.ModalResponse.alertFirstButtonReturn { - debugPrint("PreferencesPanel: save: error: user choice: edit") - self.windowCloseRequested = false - } - if modalResponse == NSApplication.ModalResponse.alertSecondButtonReturn { - debugPrint("PreferencesPanel: save: error: user choice: cancel -> revert value and eventually close window") - try! Preferences.updateAndValidateFromString(key, previousValue) - self.setControlValue(control, previousValue) - self.updateControlExtras(control, previousValue) - if self.windowCloseRequested { - self.close() - } - } - if modalResponse == NSApplication.ModalResponse.alertThirdButtonReturn { - debugPrint("PreferencesPanel: save: error: user choice: check again") - self.controlWasChanged(senderControl: control) - } - }) - } - - private func getControlValue(_ control: NSControl) -> String { - if control is NSPopUpButton { - return (control as! NSPopUpButton).titleOfSelectedItem! - } else if control is NSSlider { - return String(format: "%.0f", control.doubleValue) // we are only interested in decimals of the provided double - } else if control is NSButton { - return String((control as! NSButton).state == NSButton.StateValue.on) - } else { - return control.stringValue - } - } - - private func setControlValue(_ control: NSControl, _ value: String) { - if control is NSPopUpButton { - (control as! NSPopUpButton).selectItem(withTitle: value) - } else if control is NSTextField { - control.stringValue = value - (control as! NSTextField).delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: control)) - } else if control is NSButton { - (control as! NSButton).state = Bool(value) ?? false ? NSButton.StateValue.on : NSButton.StateValue.off - } else { - control.stringValue = value - } - } - - private func updateControlExtras(_ control: NSControl, _ value: String) { - if control is NSSlider { - updateSuffixWithValue(control as! NSSlider, value) - } - } -} - -enum ControlIdentifierDiscriminator: String { - case LABEL = "_label" - case SUFFIX = "_suffix" -} diff --git a/alt-tab-macos/ui/StatusItem.swift b/alt-tab-macos/ui/StatusItem.swift deleted file mode 100644 index 27995e4b..00000000 --- a/alt-tab-macos/ui/StatusItem.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Cocoa - -class StatusItem { - static func make(_ application: Application) -> NSStatusItem { - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button!.title = Application.name - item.menu = NSMenu() - item.menu!.addItem( - withTitle: "Show", - action: #selector(application.showUi), - keyEquivalent: "s" - ) - item.menu!.addItem( - withTitle: "Preferences…", - action: #selector(application.showPreferencesPanel), - keyEquivalent: ",") - item.menu!.addItem( - withTitle: "Quit \(Application.name) #VERSION#", - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q") - return item - } -} diff --git a/alt-tab-macos/ui/ThumbnailsPanel.swift b/alt-tab-macos/ui/ThumbnailsPanel.swift deleted file mode 100644 index 9cf88b46..00000000 --- a/alt-tab-macos/ui/ThumbnailsPanel.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Cocoa - -class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout { - var backgroundView: NSVisualEffectView? - var collectionView_: NSCollectionView! - var application: Application? - let cellId = NSUserInterfaceItemIdentifier("Cell") - var currentScreen: NSScreen? - - override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { - super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) - } - - convenience init(_ application: Application) { - self.init() - self.application = application - isFloatingPanel = true - animationBehavior = .none - hidesOnDeactivate = false - hasShadow = false - titleVisibility = .hidden - styleMask.remove(.titled) - backgroundColor = .clear - collectionView_ = makeCollectionView() - backgroundView = makeBackgroundView() - contentView!.addSubview(backgroundView!) - // highest level possible; this allows the app to go on top of context menus - level = .screenSaver - } - - private func makeBackgroundView() -> NSVisualEffectView { - let backgroundView = NSVisualEffectView() - backgroundView.translatesAutoresizingMaskIntoConstraints = false - backgroundView.material = Preferences.windowMaterial - backgroundView.state = .active - backgroundView.wantsLayer = true - backgroundView.layer!.cornerRadius = Preferences.windowCornerRadius! - backgroundView.addSubview(collectionView_) - return backgroundView - } - - func makeCollectionView() -> NSCollectionView { - let collectionView_ = NSCollectionView() - collectionView_.dataSource = self - collectionView_.delegate = self - collectionView_.collectionViewLayout = makeLayout() - collectionView_.backgroundColors = [.clear] - collectionView_.isSelectable = true - collectionView_.allowsMultipleSelection = false - collectionView_.register(Cell.self, forItemWithIdentifier: cellId) - return collectionView_ - } - - func makeLayout() -> CollectionViewCenterFlowLayout { - let layout = CollectionViewCenterFlowLayout() - layout.estimatedItemSize = NSSize(width: Preferences.emptyThumbnailWidth, height: Preferences.emptyThumbnailHeight) - layout.minimumInteritemSpacing = 5 - layout.minimumLineSpacing = 5 - return layout - } - - func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { - return TrackedWindows.list.count - } - - func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { - let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! Cell - item.updateWithNewContent(TrackedWindows.list[indexPath.item], application!.focusSelectedWindow, application!.thumbnailsPanel!.highlightCell, currentScreen!) - return item - } - - func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { - if indexPath.item < TrackedWindows.list.count { - let (width, height) = Cell.computeDownscaledSize(TrackedWindows.list[indexPath.item].thumbnail, currentScreen!) - return NSSize(width: CGFloat(width) + Preferences.cellPadding * 2, height: CGFloat(height) + max(Preferences.fontHeight!, Preferences.iconSize!) + Preferences.interItemPadding + Preferences.cellPadding * 2) - } - return .zero - } - - func highlightCellAt(_ step: Int) { - collectionView_!.selectItems(at: [IndexPath(item: TrackedWindows.focusedWindowIndex, section: 0)], scrollPosition: .top) - collectionView_!.deselectItems(at: [IndexPath(item: TrackedWindows.moveFocusedWindowIndex(-step), section: 0)]) - } - - func highlightCell(_ cell: Cell) { - let newIndex = collectionView_.indexPath(for: cell)! - if TrackedWindows.focusedWindowIndex != newIndex.item { - collectionView_!.selectItems(at: [newIndex], scrollPosition: .top) - collectionView_!.deselectItems(at: [IndexPath(item: TrackedWindows.focusedWindowIndex, section: 0)]) - TrackedWindows.focusedWindowIndex = newIndex.item - } - } - - func computeThumbnails(_ currentScreen: NSScreen) { - self.currentScreen = currentScreen - (collectionView_.collectionViewLayout as! CollectionViewCenterFlowLayout).currentScreen = currentScreen - collectionView_!.setFrameSize(Screen.thumbnailPanelMaxSize(currentScreen)) - collectionView_!.collectionViewLayout!.invalidateLayout() - collectionView_!.reloadData() - collectionView_!.layoutSubtreeIfNeeded() - setContentSize(NSSize(width: collectionView_!.frame.size.width + Preferences.windowPadding * 2, height: collectionView_!.frame.size.height + Preferences.windowPadding * 2)) - backgroundView!.setFrameSize(frame.size) - collectionView_!.setFrameOrigin(NSPoint(x: Preferences.windowPadding, y: Preferences.windowPadding)) - } -} diff --git a/alt_tab_macos.entitlements b/alt_tab_macos.entitlements new file mode 100644 index 00000000..e10f5b1d --- /dev/null +++ b/alt_tab_macos.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.disable-library-validation + + com.apple.security.files.user-selected.read-only + + + diff --git a/appcast.xml b/appcast.xml new file mode 100644 index 00000000..766b41e0 --- /dev/null +++ b/appcast.xml @@ -0,0 +1,9 @@ + + + + alt-tab-macos + en + + + + diff --git a/ci/determine_version.sh b/ci/determine_version.sh deleted file mode 100755 index ac1b5023..00000000 --- a/ci/determine_version.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -semanticRelease=$(npx semantic-release --dry-run --ci false) -version=$(echo "$semanticRelease" | sed -nE 's/.+The next release version is (.+)/\1/p') - -echo "$version" > VERSION.txt diff --git a/ci/package_release.sh b/ci/package_release.sh deleted file mode 100755 index 53adaad8..00000000 --- a/ci/package_release.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -appName="AltTab" -version="$(cat VERSION.txt)" - -cd build/Release -mv "alt-tab-macos.app" "$appName.app" -zip -r "$appName-$version.zip" "$appName.app" -tar czf "$appName-$version.tar.gz" "$appName.app" diff --git a/ci/set_version_in_app.sh b/ci/set_version_in_app.sh deleted file mode 100755 index d296a3f0..00000000 --- a/ci/set_version_in_app.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -version="$(cat VERSION.txt)" -# set the version for the app menubar menu text -sed -i '' -e "s/#VERSION#/$version/" alt-tab-macos/ui/StatusItem.swift -# set the version in the app meta-data for the AppStore and app "Get Info" panel -sed -i '' -e "s/#VERSION#/$version/" alt-tab-macos/Info.plist diff --git a/config/base.xcconfig b/config/base.xcconfig new file mode 100644 index 00000000..2252d987 --- /dev/null +++ b/config/base.xcconfig @@ -0,0 +1,12 @@ +// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6 + +PRODUCT_NAME = AltTab +PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos +MACOSX_DEPLOYMENT_TARGET = 10.12 +SWIFT_VERSION = 4.2 +INFOPLIST_FILE = Info.plist +CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements +IDEDerivedDataPathOverride = DerivedData +FRAMEWORK_SEARCH_PATHS[config=*] = $(inherited) /System/Library/PrivateFrameworks // for SkyLight.framework +ENABLE_HARDENED_RUNTIME = YES // for notarization +OTHER_CODE_SIGN_FLAGS = --timestamp --deep --options runtime // for notarization diff --git a/config/debug.xcconfig b/config/debug.xcconfig new file mode 100644 index 00000000..16172c77 --- /dev/null +++ b/config/debug.xcconfig @@ -0,0 +1,7 @@ +// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6 + +#include "Pods/Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.debug.xcconfig" +#include "base.xcconfig" + +CODE_SIGN_IDENTITY = Local Self-Signed +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG // for macro `#if DEBUG` diff --git a/config/release.xcconfig b/config/release.xcconfig new file mode 100644 index 00000000..f9c373d8 --- /dev/null +++ b/config/release.xcconfig @@ -0,0 +1,8 @@ +// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6 + +#include "Pods/Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.release.xcconfig" +#include "base.xcconfig" + +CODE_SIGN_IDENTITY = Developer ID Application: Louis Pontoise (QXD7GW8FHY) +CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO // for notarization +COCOAPODS_PARALLEL_CODE_SIGN = YES // codesign pods faster diff --git a/package-lock.json b/package-lock.json index 6ae4eaf8..20f1b43c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2380,9 +2380,9 @@ "dev": true }, "marked": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", - "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.8.0.tgz", + "integrity": "sha512-MyUe+T/Pw4TZufHkzAfDj6HarCBWia2y27/bhuYkTaiUnfDYFnCP3KUN+9oM7Wi6JA2rymtVYbQu3spE0GCmxQ==", "dev": true }, "marked-terminal": { @@ -6669,6 +6669,12 @@ "yallist": "^3.0.2" } }, + "marked": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", + "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==", + "dev": true + }, "p-limit": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", diff --git a/package.json b/package.json index 572d330a..5e95a119 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@semantic-release/changelog": "^3.0.4", "@semantic-release/git": "^7.0.16", "husky": "^3.0.4", + "marked": "^0.8.0", "semantic-release": "^15.13.24" } } diff --git a/release.config.js b/release.config.js index 7a9de881..8200cedd 100644 --- a/release.config.js +++ b/release.config.js @@ -40,7 +40,10 @@ module.exports = { '@semantic-release/changelog', [ '@semantic-release/git', { - 'assets': ['CHANGELOG.md'], + 'assets': [ + 'CHANGELOG.md', + 'appcast.xml' + ], }, ], ], diff --git a/resources/MainMenu.xib b/resources/MainMenu.xib new file mode 100644 index 00000000..5c2e2380 --- /dev/null +++ b/resources/MainMenu.xib @@ -0,0 +1,675 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alt-tab-macos/resources/SF-Pro-Text-Regular.otf b/resources/SF-Pro-Text-Regular.otf similarity index 100% rename from alt-tab-macos/resources/SF-Pro-Text-Regular.otf rename to resources/SF-Pro-Text-Regular.otf diff --git a/resources/icons/app-icon.icns b/resources/icons/app-icon.icns new file mode 100644 index 00000000..0b8d312e Binary files /dev/null and b/resources/icons/app-icon.icns differ diff --git a/resources/icons/app-icon.svg b/resources/icons/app-icon.svg new file mode 100644 index 00000000..07f5076a --- /dev/null +++ b/resources/icons/app-icon.svg @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/icons/menubar-icon.svg b/resources/icons/menubar-icon.svg new file mode 100644 index 00000000..3e499400 --- /dev/null +++ b/resources/icons/menubar-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/icons/menubar-icon@2x.png b/resources/icons/menubar-icon@2x.png new file mode 100644 index 00000000..95ef7c39 Binary files /dev/null and b/resources/icons/menubar-icon@2x.png differ diff --git a/resources/l10n/de.lproj/InfoPlist.strings b/resources/l10n/de.lproj/InfoPlist.strings new file mode 100644 index 00000000..2c81ed0f --- /dev/null +++ b/resources/l10n/de.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "GPL-3.0 license"; diff --git a/resources/l10n/de.lproj/Localizable.strings b/resources/l10n/de.lproj/Localizable.strings new file mode 100644 index 00000000..5599a3f9 Binary files /dev/null and b/resources/l10n/de.lproj/Localizable.strings differ diff --git a/resources/l10n/en.lproj/InfoPlist.strings b/resources/l10n/en.lproj/InfoPlist.strings new file mode 100644 index 00000000..2c81ed0f --- /dev/null +++ b/resources/l10n/en.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "GPL-3.0 license"; diff --git a/resources/l10n/en.lproj/Localizable.strings b/resources/l10n/en.lproj/Localizable.strings new file mode 100644 index 00000000..d8a2d77f Binary files /dev/null and b/resources/l10n/en.lproj/Localizable.strings differ diff --git a/resources/l10n/es.lproj/InfoPlist.strings b/resources/l10n/es.lproj/InfoPlist.strings new file mode 100644 index 00000000..4d2581f1 --- /dev/null +++ b/resources/l10n/es.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Licencia GPL-3.0"; diff --git a/resources/l10n/es.lproj/Localizable.strings b/resources/l10n/es.lproj/Localizable.strings new file mode 100644 index 00000000..c8bb75d5 Binary files /dev/null and b/resources/l10n/es.lproj/Localizable.strings differ diff --git a/resources/l10n/fr.lproj/InfoPlist.strings b/resources/l10n/fr.lproj/InfoPlist.strings new file mode 100644 index 00000000..1caec4f9 --- /dev/null +++ b/resources/l10n/fr.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Licence GPL-3.0"; + diff --git a/resources/l10n/fr.lproj/Localizable.strings b/resources/l10n/fr.lproj/Localizable.strings new file mode 100644 index 00000000..0e84cd6b --- /dev/null +++ b/resources/l10n/fr.lproj/Localizable.strings @@ -0,0 +1,164 @@ +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"About" = "À propos"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Alt key" = "Touche Alt"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Apparition delay" = "Délai d'apparition"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Appearance" = "Apparence"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Auto-install updates periodically" = "Auto-installer les mises-à-jour périodiquement"; + +/*Cancel button*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Cancel" = "Annuler"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Check again" = "Essayer encore"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Check for updates now…" = "Chercher une mise-à-jour…"; + +/*No comment provided by engineer.*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Check for updates periodically" = "Chercher une mise-à-jour périodiquement"; + +/*No comment provided by engineer.*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Check for updates…" = "Chercher une mise-à-jour"; + +/*No comment provided by engineer.*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Could not save Preference" = "La préférence n’a pas pu être sauvée"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Don't check for updates periodically" = "Ne pas chercher de mise-à-jour périodiquement"; + +/*No comment provided by engineer.*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Edit" = "Éditer"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Hide space number labels" = "Cacher les numéraux des Espaces"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"I think the app could be improved with…" = "Je pense que l’app pourrait être améliorée avec…"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"KeyCodes Reference" = "Référence des codes clavier"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Latest releases" = "Dernières versions"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Max size on screen" = "Taille max à l’écran"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Max windows per row" = "Max de fenêtres par ligne"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Min rows of windows" = "Min de lignes de fenêtres"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Min windows per row" = "Min de fenêtres par ligne"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Optional: email (if you want a reply)" = "Optionnel : email (si vous voulez une réponse)"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Preferences…" = "Préférences…"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Quit" = "Quitter"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Send" = "Envoyer"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Send debug profile (CPU, memory, etc)" = "Envoyer un profile technique (processeur, mémoire, etc)"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Send feedback" = "Partager un retour d’expérience"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Send feedback…" = "Partager un retour d’expérience…"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Share improvement ideas, or report bugs" = "Partagez des idées d’amélioration, ou signalez des bugs"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Shortcuts" = "Raccourcis"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Show" = "Afficher"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Show on" = "Afficher sur"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Source code repository" = "Repo du code source"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Tab key" = "Touche Tab"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Theme" = "Thème"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Updates" = "Mises-à-jour"; + +/*No comment provided by engineer.*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Updates policy:" = "Politique de mise-à-jour :"; + +/*No comment provided by engineer.*/ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Version" = "Version"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"View existing discussions" = "Voir les discussions existantes"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Window app icon size" = "Taille des icones des applications"; + +/* No comment provided by engineer. */ +/*src/resources/l10n/en.lproj/Localizable.strings*/ +"Window title font size" = "Taille du titres des fenêtres"; + diff --git a/scripts/codesign_sparkle_embedded_apps.sh b/scripts/codesign_sparkle_embedded_apps.sh new file mode 100755 index 00000000..bfdc85a3 --- /dev/null +++ b/scripts/codesign_sparkle_embedded_apps.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -exu + +# codesign --deep is only 1 level deep. It misses Sparkle embedded app AutoUpdate +# this build phase script works around the issue + +codesign --verbose --force --sign "$CODE_SIGN_IDENTITY" $OTHER_CODE_SIGN_FLAGS "${PODS_ROOT}/Sparkle/Sparkle.framework/Resources/Autoupdate.app" diff --git a/scripts/determine_version_and_changelog.sh b/scripts/determine_version_and_changelog.sh new file mode 100755 index 00000000..193c89c3 --- /dev/null +++ b/scripts/determine_version_and_changelog.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -exu + +semanticRelease=$(npx semantic-release --dry-run --ci false) +version=$(echo "$semanticRelease" | sed -nE 's/.+The next release version is (.+)/\1/p') +changelogDelta=$(echo "$semanticRelease" | sed -n '/Release note for version/,$p' | sed '1d') + +echo "$version" > $VERSION_FILE +echo "$changelogDelta" > $CHANGELOG_DELTA_FILE diff --git a/scripts/generate_selfsigned_codesign_certificate.sh b/scripts/generate_selfsigned_codesign_certificate.sh new file mode 100755 index 00000000..df839cc1 --- /dev/null +++ b/scripts/generate_selfsigned_codesign_certificate.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -exu + +certificateFile="codesign" +certificatePassword=$(openssl rand -base64 12) + +# certificate request (see https://apple.stackexchange.com/q/359997) +cat >$certificateFile.conf < Code Signing > "Always Trust" +security add-trusted-cert -d -r trustRoot -p codeSign $certificateFile.crt diff --git a/scripts/import_codesign_certificate_into_keychain.sh b/scripts/import_codesign_certificate_into_keychain.sh new file mode 100755 index 00000000..214ad903 --- /dev/null +++ b/scripts/import_codesign_certificate_into_keychain.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -exu + +certificateFile="codesign" +keychain="alt-tab-macos.keychain" +keychainPassword="travis" + +# create a keychain +security create-keychain -p $keychainPassword $keychain +# make keychain default so xcodebuild uses it +security default-keychain -s $keychain +# unlock keychain +security unlock-keychain -p $keychainPassword $keychain +# Recreate the certificate from the secure environment variable +echo "$APPLE_P12_CERTIFICATE" | base64 --decode > $certificateFile.p12 +# import p12 into Keychain +security import $certificateFile.p12 -P "$APPLE_P12_CERTIFICATE_PASSWORD" -T /usr/bin/codesign +security set-key-partition-list -S apple-tool:,apple: -s -k $keychainPassword $keychain diff --git a/scripts/package_and_notarize_release.sh b/scripts/package_and_notarize_release.sh new file mode 100755 index 00000000..0699ff95 --- /dev/null +++ b/scripts/package_and_notarize_release.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -exu + +version="$(cat $VERSION_FILE)" +appFile="$APP_NAME.app" +zipName="$APP_NAME-$version.zip" +bundleId="$(awk -F ' = ' '/PRODUCT_BUNDLE_IDENTIFIER/ { print $2; }' < config/base.xcconfig)" + +cd "$XCODE_BUILD_PATH" +ditto -c -k --keepParent "$appFile" "$zipName" + +# request notarization +requestUuid=$(xcrun altool \ + --notarize-app \ + --verbose \ + -ITunesTransport DAV \ + --primary-bundle-id "$bundleId" \ + --username "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --file "$zipName" 2>&1 | + tee /dev/tty | + awk '/RequestUUID/ { print $NF;exit; }') +if [[ $requestUuid == "" ]]; then exit 1; fi + +# poll notarization status until success/invalid, or 15min have passed +requestStatus="in progress" +timeoutCounter=0 +until [[ "$requestStatus" == "success" ]] || [[ "$requestStatus" == "invalid" ]] || [[ $timeoutCounter -eq 1500 ]]; do + sleep 10 + timeoutCounter=$((timeoutCounter+10)) + set +e + requestLogs=$(xcrun altool \ + --notarization-info "$requestUuid" \ + --username "$APPLE_ID" \ + --password "$APPLE_PASSWORD" 2>&1) + set -e + requestStatus=$(echo "$requestLogs" | awk -F ': ' '/Status:/ { print $2; }') +done +if [[ $requestStatus != "success" ]]; then + echo "$requestLogs" | awk -F ': ' '/LogFileURL:/ { print $2; }' | xargs curl + exit 1 +fi + +# staple build +xcrun stapler staple "$appFile" +ditto -c -k --keepParent "$appFile" "$zipName" diff --git a/scripts/replace_environment_variables_in_app.sh b/scripts/replace_environment_variables_in_app.sh new file mode 100755 index 00000000..a117983e --- /dev/null +++ b/scripts/replace_environment_variables_in_app.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -exu + +version="$(cat $VERSION_FILE)" + +sed -i '' -e "s/#VERSION#/$version/" Info.plist +sed -i '' -e "s/#FEEDBACK_TOKEN#/$FEEDBACK_TOKEN/" Info.plist diff --git a/scripts/update_appcast.sh b/scripts/update_appcast.sh new file mode 100755 index 00000000..8d75b613 --- /dev/null +++ b/scripts/update_appcast.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -exu + +version="$(cat $VERSION_FILE)" +changelogDelta="$(npx marked < $CHANGELOG_DELTA_FILE)" +date="$(date +'%a, %d %b %Y %H:%M:%S %z')" +minimumSystemVersion="$(awk -F ' = ' '/MACOSX_DEPLOYMENT_TARGET/ { print $2; }' < config/base.xcconfig)" +zipName="$APP_NAME-$version.zip" +edSignatureAndLength=$(Pods/Sparkle/bin/sign_update -s $SPARKLE_ED_PRIVATE_KEY "$XCODE_BUILD_PATH/$zipName") + +echo " + + Version $version + $date + $minimumSystemVersion + + + + +" > ITEM.txt + +sed -i '' -e "/<\/language>/r ITEM.txt" appcast.xml diff --git a/scripts/update_homebrew_cask.sh b/scripts/update_homebrew_cask.sh new file mode 100755 index 00000000..7c95e161 --- /dev/null +++ b/scripts/update_homebrew_cask.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -exu + +version="$(cat $VERSION_FILE)" + +cask-repair --cask-version "$version" alt-tab diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift new file mode 100644 index 00000000..9a98d1f8 --- /dev/null +++ b/src/api-wrappers/AXUIElement.swift @@ -0,0 +1,90 @@ +import Cocoa + +extension AXUIElement { + func cgWindowId() -> CGWindowID { + var id = CGWindowID(0) + _AXUIElementGetWindow(self, &id) + return id + } + + func pid() -> pid_t { + var pid = pid_t(0) + AXUIElementGetPid(self, &pid) + return pid + } + + func isActualWindow(_ isAppHidden: Bool = false) -> Bool { + // TODO: TotalFinder and XtraFinder double-window hacks (see #84) + // Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips) + // Some non-windows have title: nil (e.g. some OS elements) + // Minimized windows or windows of a hidden app have subrole "AXDialog" + // Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow" + return title() != nil && (["AXStandardWindow", "AXDialog"].contains(subrole()) || isMinimized() || isAppHidden) && isOnNormalLevel() + } + + func isOnNormalLevel() -> Bool { + return cgWindowId().level() == CGWindowLevelForKey(.normalWindow) + } + + func title() -> String? { + return attribute(kAXTitleAttribute, String.self) + } + + func windows() -> [AXUIElement]? { + return attribute(kAXWindowsAttribute, [AXUIElement].self) + } + + func isMinimized() -> Bool { + return attribute(kAXMinimizedAttribute, Bool.self) == true + } + + func isHidden() -> Bool { + return attribute(kAXHiddenAttribute, Bool.self) == true + } + + func focusedWindow() -> AXUIElement? { + return attribute(kAXFocusedWindowAttribute, AXUIElement.self) + } + + func subrole() -> String? { + return attribute(kAXSubroleAttribute, String.self) + } + + func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ attemptsCount: Int = 0) { + if let runningApplication = runningApplication, Applications.appsInSubscriptionRetryLoop.first(where: { $0 == String(runningApplication.processIdentifier) + String(notification) }) == nil { return } + if let wid = wid, Windows.windowsInSubscriptionRetryLoop.first(where: { $0 == String(wid) + String(notification) }) == nil { return } + let result = AXObserverAddNotification(axObserver, self, notification as CFString, pointer) + if result == .success || result == .notificationUnsupported || result == .notificationAlreadyRegistered { + callback?() + if let runningApplication = runningApplication { + Application.stopSubscriptionRetries(notification, runningApplication) + } + if let wid = wid { + Window.stopSubscriptionRetries(notification, wid) + } + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10), execute: { [weak self] in + guard let self = self else { return } + self.subscribeWithRetry(axObserver, notification, pointer, callback, runningApplication, wid, attemptsCount + 1) + }) + } + + private func attribute(_ key: String, _ type: T.Type) -> T? { + var value: AnyObject? + let result = AXUIElementCopyAttributeValue(self, key as CFString, &value) + if result == .success, let value = value as? T { + return value + } + return nil + } + + private func value(_ key: String, _ target: T, _ type: AXValueType) -> T? { + if let a = attribute(key, AXValue.self) { + var value = target + AXValueGetValue(a, type, &value) + return value + } + return nil + } +} diff --git a/src/api-wrappers/CGWindow.swift b/src/api-wrappers/CGWindow.swift new file mode 100644 index 00000000..20244321 --- /dev/null +++ b/src/api-wrappers/CGWindow.swift @@ -0,0 +1,51 @@ +import Cocoa + +typealias CGWindow = [CFString: Any] + +extension CGWindow { + static func windows(_ option: CGWindowListOption) -> [CGWindow] { + return CGWindowListCopyWindowInfo([.excludeDesktopElements, option], kCGNullWindowID) as! [CGWindow] + } + + static func isMissionControlActive() -> Bool { + // when Mission Control is active, the Dock process spawns some windows. We observe this side-effect and infer + for window in windows(.optionOnScreenOnly) { + guard window.ownerName() == "Dock", window.title() == nil else { continue } + return true + } + return false + } + + // workaround: filtering this criteria seems to remove non-windows UI elements + func isNotMenubarOrOthers() -> Bool { + return layer() == 0 + } + + func id() -> CGWindowID? { + return value(kCGWindowNumber, CGWindowID.self) + } + + func layer() -> Int? { + return value(kCGWindowLayer, Int.self) + } + + func bounds() -> CFDictionary? { + return value(kCGWindowBounds, CFDictionary.self) + } + + func ownerPID() -> pid_t? { + return value(kCGWindowOwnerPID, pid_t.self) + } + + func ownerName() -> String? { + return value(kCGWindowOwnerName, String.self) + } + + func title() -> String? { + return value(kCGWindowName, String.self) + } + + private func value(_ key: CFString, _ type: T.Type) -> T? { + return self[key] as? T + } +} diff --git a/alt-tab-macos/api-wrappers/CGWindowID.swift b/src/api-wrappers/CGWindowID.swift similarity index 64% rename from alt-tab-macos/api-wrappers/CGWindowID.swift rename to src/api-wrappers/CGWindowID.swift index f6d4bee9..e7a94fb9 100644 --- a/alt-tab-macos/api-wrappers/CGWindowID.swift +++ b/src/api-wrappers/CGWindowID.swift @@ -1,16 +1,18 @@ import Cocoa -import Foundation extension CGWindowID { - func AXUIElementApplication(_ ownerPid: pid_t) -> AXUIElement { - return AXUIElementCreateApplication(ownerPid) + func title() -> String? { + return cgProperty("kCGSWindowTitle", String.self) } - func AXUIElementOfOtherSpaceWindow(_ axApp: AXUIElement) -> AXUIElement? { - CGSAddWindowsToSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId]) - let axWindow = axApp.window(self) - CGSRemoveWindowsFromSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId]) - return axWindow + func level() -> CGWindowLevel { + var level = CGWindowLevel(0) + CGSGetWindowLevel(cgsMainConnectionId, self, &level) + return level + } + + func spaces() -> [CGSSpaceID] { + return CGSCopySpacesForWindows(cgsMainConnectionId, CGSSpaceMask.all.rawValue, [self] as CFArray) as! [CGSSpaceID] } func screenshot() -> CGImage? { @@ -32,6 +34,12 @@ extension CGWindowID { // CGSCaptureWindowsContentsToRectWithOptions(cgsMainConnectionId, &windowId_, true, .zero, [.windowCaptureNominalResolution, .captureIgnoreGlobalClipShape], &image) // return image } + + private func cgProperty(_ key: String, _ type: T.Type) -> T? { + var value: AnyObject? + CGSCopyWindowProperty(cgsMainConnectionId, self, key as CFString, &value) + return value as? T + } } //class Testt { diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift new file mode 100644 index 00000000..4f34087d --- /dev/null +++ b/src/api-wrappers/HelperExtensions.swift @@ -0,0 +1,166 @@ +import Cocoa + +extension CGFloat { + // add CGFloat constructor from String + init?(_ string: String) { + guard let number = NumberFormatter().number(from: string) else { + return nil + } + self.init(number.floatValue) + } +} + +extension Optional { + // add throw-on-nil method on Optional + func orThrow() throws -> Wrapped { + switch self { + case .some(let value): + return value + case .none: + Thread.callStackSymbols.forEach { print($0) } + throw NSError.make(domain: "Optional", message: "Optional contained nil") + } + } +} + +extension String { + // add String constructor from CGFloat that round up at 1 decimal + init?(_ cgFloat: CGFloat) { + let formatter = NumberFormatter() + formatter.maximumFractionDigits = 1 + guard let string = formatter.string(from: cgFloat as NSNumber) else { + return nil + } + self.init(string) + } +} + +extension NSView { + // add recursive lookup in subviews for specific type + func findNestedViews(subclassOf: T.Type) -> [T] { + return recursiveSubviews.compactMap { $0 as? T } + } + + var recursiveSubviews: [NSView] { + return subviews + subviews.flatMap { $0.recursiveSubviews } + } +} + +extension NSError { + // add convenience to NSError + class func make(domain: String, message: String, code: Int = 9999) -> NSError { + return NSError( + domain: domain, + code: code, + userInfo: [NSLocalizedDescriptionKey: message, NSLocalizedFailureReasonErrorKey: message] + ) + } +} + +extension Collection { + // recursive flatMap + func joined() -> [Any] { + return flatMap { ($0 as? [Any])?.joined() ?? [$0] } + } +} + +// removing an objc KVO observer if there is none throws an exception +extension NSObject { + func safeRemoveObserver(_ observer: NSObject, _ key: String) { + guard observationInfo != nil else { return } + removeObserver(observer, forKeyPath: key) + } +} + +extension Array where Element == Window { + func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? { + // `CFEqual` is safer than comparing `CGWindowID` because it will succeed even if the window is deallocated + // by the OS, in which case the `CGWindowID` will be `-1` + return firstIndex(where: { CFEqual($0.axUiElement, element) }) + } + + func firstWindowThatMatches(_ element: AXUIElement) -> Window? { + guard let index = firstIndexThatMatches(element) else { return nil } + return self[index] + } +} + +extension NSView { + // constrain size to fittingSize + func fit() { + widthAnchor.constraint(equalToConstant: fittingSize.width).isActive = true + heightAnchor.constraint(equalToConstant: fittingSize.height).isActive = true + } + + // constrain size to provided width and height + func fit(_ width: CGFloat, _ height: CGFloat) { + widthAnchor.constraint(equalToConstant: width).isActive = true + heightAnchor.constraint(equalToConstant: height).isActive = true + } +} + +extension Array { + // forEach with each iteration run concurrently on the global queue + func forEachAsync(fn: @escaping (Element) -> Void) { + let group = DispatchGroup() + for element in self { + group.enter() + DispatchQueue.global(qos: .userInteractive).async(group: group) { + fn(element) + group.leave() + } + } + group.wait() + } +} + +// allow using a closure for NSControl action, instead of selector +class SelectorWrapper { + let selector: Selector + let closure: (T) -> Void + + init(withClosure closure: @escaping (T) -> Void) { + self.selector = #selector(callClosure) + self.closure = closure + } + + @objc + private func callClosure(sender: AnyObject) { + closure(sender as! T) + } +} + +fileprivate var handle: Int = 0 + +typealias ActionClosure = (NSControl) -> Void + +extension NSControl { + var onAction: ActionClosure? { + get { + return nil + } + set { + if let newValue = newValue { + let selectorWrapper = SelectorWrapper(withClosure: newValue) + objc_setAssociatedObject(self, &handle, selectorWrapper, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + action = selectorWrapper.selector + target = selectorWrapper + } else { + action = nil + target = nil + } + } + } +} + +extension NSImage { + // copy and resize an image using high quality interpolation + func resizedCopy(_ width: CGFloat, _ height: CGFloat) -> NSImage { + let img = NSImage(size: CGSize(width: width, height: height)) + img.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + draw(in: NSMakeRect(0, 0, width, height), from: NSMakeRect(0, 0, size.width, size.height), operation: .copy, fraction: 1) + img.unlockFocus() + return img + } +} diff --git a/alt-tab-macos/api-wrappers/PrivateApis.swift b/src/api-wrappers/PrivateApis.swift similarity index 88% rename from alt-tab-macos/api-wrappers/PrivateApis.swift rename to src/api-wrappers/PrivateApis.swift index 98809701..7a615c2c 100644 --- a/alt-tab-macos/api-wrappers/PrivateApis.swift +++ b/src/api-wrappers/PrivateApis.swift @@ -1,5 +1,4 @@ import Cocoa -import Foundation // Private APIs are APIs that we can build the app against, but they are not supported or documented by Apple // We can see their names as symbols in the SDK (see https://github.com/lwouis/MacOSX-SDKs) @@ -107,6 +106,32 @@ func SLPSPostEventRecordTo(_ psn: inout ProcessSerialNumber, _ bytes: inout UInt @_silgen_name("_AXUIElementGetWindow") @discardableResult func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) -> AXError +// returns the provided CGWindow property for the provided CGWindowID +// * macOS 10.10+ +@_silgen_name("CGSCopyWindowProperty") @discardableResult +func CGSCopyWindowProperty(_ cid: CGSConnectionID, _ wid: CGWindowID, _ property: CFString, _ value: inout CFTypeRef?) -> CGError + +enum CGSSpaceMask: Int { + case current = 5 + case other = 6 + case all = 7 +} + +// get the CGSSpaceIDs for the given windows (CGWindowIDs) +// * macOS 10.10+ +@_silgen_name("CGSCopySpacesForWindows") +func CGSCopySpacesForWindows(_ cid: CGSConnectionID, _ mask: CGSSpaceMask.RawValue, _ wids: CFArray) -> CFArray + +// returns window level (see definition in CGWindowLevel.h) of provided window +// * macOS 10.10+ +@_silgen_name("CGSGetWindowLevel") @discardableResult +func CGSGetWindowLevel(_ cid: CGSConnectionID, _ wid: CGWindowID, _ level: inout CGWindowLevel) -> CGError + +// returns status of the checkbox in System Preferences > Security & Privacy > Privacy > Screen Recording +// returns 1 if checked or 0 if unchecked; also prompts the user the first time if unchecked +@_silgen_name("SLSRequestScreenCaptureAccess") @discardableResult +func SLSRequestScreenCaptureAccess() -> UInt8 + @@ -131,21 +156,11 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) //@_silgen_name("GetProcessPID") //func GetProcessPID(_ psn: inout ProcessSerialNumber, _ pid: inout pid_t) -> Void // -//// seems like it takes the normal CG keys, so might as well use the public API -//// * macOS 10.10+ -//@_silgen_name("CGSCopyWindowProperty") @discardableResult -//func CGSCopyWindowProperty(_ cid: CGSConnectionID, _ wid: CGWindowID, _ key: CFString, _ output: inout CFString) -> CGError -// //// crashed the app with SIGSEGV //// * macOS 10.10+ //@_silgen_name("CGSGetWindowType") @discardableResult //func CGSGetWindowType(_ wid: CGWindowID, _ type: inout UInt32) -> CGError // -//// seems to always return 0, for windows in the same space, other space, minimized, etc -//// * macOS 10.10+ -//@_silgen_name("CGSGetWindowLevel") @discardableResult -//func CGSGetWindowLevel(_ cid: CGSConnectionID, _ wid: CGWindowID, _ level: inout UInt32) -> CGError -// //// * macOS 10.12+ //@_silgen_name("CGSProcessAssignToSpace") @discardableResult //func CGSProcessAssignToSpace(_ cid: CGSConnectionID, _ pid: pid_t, _ sid: CGSSpaceID) -> CGError @@ -154,6 +169,17 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) //// * macOS 10.10+ //@_silgen_name("CGSManagedDisplaySetCurrentSpace") //func CGSManagedDisplaySetCurrentSpace(_ cid: CGSConnectionID, _ display: CFString, _ sid: CGSSpaceID) -> Void +// +//// show provided spaces on top of the current space. It show windows from the provided spaces in the current space. Very weird behaviour and graphical glitch will happen when triggering Mission Control +//// * macOS 10.10+ +//@_silgen_name("CGSShowSpaces") +//func CGSShowSpaces(_ cid: CGSConnectionID, _ sids: NSArray) -> Void +// +//// hides provided spaces from the current space +//// * macOS 10.10+ +//@_silgen_name("CGSHideSpaces") +//func CGSHideSpaces(_ cid: CGSConnectionID, _ sids: NSArray) -> Void + // //// get space for window //// * macOS 10.10+ @@ -186,18 +212,6 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) //@_silgen_name("CGSGetWorkspaceWindowList") @discardableResult //func CGSGetWorkspaceWindowList(_ cid: CGSConnectionID, _ workspaceNumber: CGSSpaceID, _ count: Int, _ list: [Int], _ outCount: [Int]) -> OSStatus // -//struct CGSSpaceMask: OptionSet { -// let rawValue: UInt32 -// static let includesCurrent = CGSSpaceMask(rawValue: 1 << 0) -// static let includesOthers = CGSSpaceMask(rawValue: 1 << 1) -// static let includesUser = CGSSpaceMask(rawValue: 1 << 2) -//} -// -//// get the CGSSpaceIDs for the given windows (CGWindowIDs). It's more efficient (i.e. fewer calls to the OS) to get windows of each space instead of space of each window -//// * macOS 10.10+ -//@_silgen_name("CGSCopySpacesForWindows") -//func CGSCopySpacesForWindows(_ connection: CGSConnectionID, _ mask: CGSSpaceMask, _ wids: CFArray) -> CFArray -// //enum CGSSpaceType { // case user // case fullscreen @@ -216,8 +230,8 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) //func CGSProcessAssignToAllSpaces(_ cid: CGSConnectionID, _ pid: pid_t) -> CGError // //enum SpaceManagementMode: Int { -// case separate = 1 -// case notSeparate = 0 +// case checked = 1 +// case unchecked = 0 //} // //// returns the status of the "Displays have separate Spaces" system Preference @@ -225,11 +239,6 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) //@_silgen_name("CGSGetSpaceManagementMode") //func CGSGetSpaceManagementMode(_ cid: CGSConnectionID) -> SpaceManagementMode // -//// get spaces for the provided window_list -//// * macOS 10.10+ -//@_silgen_name("CGSCopySpacesForWindows") -//func CGSCopySpacesForWindows(_ cid: CGSConnectionID, _ selector: Int, _ window_list: CFArray) -> CFArray -// //// The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468 //func windowManagerDeferWindowRaise(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void { // var wid_ = wid diff --git a/src/api-wrappers/Sysctl.swift b/src/api-wrappers/Sysctl.swift new file mode 100644 index 00000000..a3732db3 --- /dev/null +++ b/src/api-wrappers/Sysctl.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct Sysctl { + static func run(_ name: String) -> String { + return run(name, { $0.baseAddress.flatMap { String(validatingUTF8: $0) } }) ?? "" + } + + static func run(_ name: String, _ type: T.Type) -> T? { + return run(name, { $0.baseAddress?.withMemoryRebound(to: T.self, capacity: 1) { $0.pointee } }) + } + + private static func run(_ name: String, _ fn: (UnsafeBufferPointer) -> R?) -> R? { + return keys(name).flatMap { keys in data(keys)?.withUnsafeBufferPointer() { fn($0) } } + } + + private static func data(_ keys: [Int32]) -> [Int8]? { + return keys.withUnsafeBufferPointer() { keysPointer in + var requiredSize = 0 + let preFlightResult = Darwin.sysctl(UnsafeMutablePointer(mutating: keysPointer.baseAddress), UInt32(keys.count), nil, &requiredSize, nil, 0) + if preFlightResult != 0 { + return nil + } + let data = Array(repeating: 0, count: requiredSize) + let result = data.withUnsafeBufferPointer() { dataBuffer -> Int32 in + return Darwin.sysctl(UnsafeMutablePointer(mutating: keysPointer.baseAddress), UInt32(keys.count), UnsafeMutableRawPointer(mutating: dataBuffer.baseAddress), &requiredSize, nil, 0) + } + if result != 0 { + return nil + } + return data + } + } + + private static func keys(_ name: String) -> [Int32]? { + var keysBufferSize = Int(CTL_MAXNAME) + var keysBuffer = Array(repeating: 0, count: keysBufferSize) + _ = keysBuffer.withUnsafeMutableBufferPointer { (lbp: inout UnsafeMutableBufferPointer) in + name.withCString { (nbp: UnsafePointer) in + sysctlnametomib(nbp, lbp.baseAddress, &keysBufferSize) + } + } + if keysBuffer.count > keysBufferSize { + keysBuffer.removeSubrange(keysBufferSize.. Void { + let application = Unmanaged.fromOpaque(applicationPointer!).takeUnretainedValue() + let app = App.shared as! App + let type = notificationName as String + debugPrint("OS event", type, element.title() ?? "nil") + switch type { + case kAXApplicationActivatedNotification: eventApplicationActivated(app, element) + case kAXApplicationHiddenNotification, kAXApplicationShownNotification: eventApplicationHiddenOrShown(app, element, type) + case kAXWindowCreatedNotification: eventWindowCreated(app, element, application) + case kAXFocusedWindowChangedNotification: eventFocusedWindowChanged(app, element) + default: return + } +} + +private func eventApplicationActivated(_ app: App, _ element: AXUIElement) { + guard !app.appIsBeingUsed, + let appFocusedWindow = element.focusedWindow(), + let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return } + Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) +} + +private func eventApplicationHiddenOrShown(_ app: App, _ element: AXUIElement, _ type: String) { + for window in Windows.list { + guard CFEqual(window.application.axUiElement!, element) else { continue } + window.isHidden = type == kAXApplicationHiddenNotification + } + app.refreshOpenUi() +} + +private func eventWindowCreated(_ app: App, _ element: AXUIElement, _ application: Application) { + guard element.isActualWindow() else { return } + // a window being un-minimized can trigger kAXWindowCreatedNotification + guard Windows.list.firstIndexThatMatches(element) == nil else { return } + let window = Window(element, application) + Windows.list.insert(window, at: 0) + Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground() + // TODO: find a better way to get thumbnail of the new window + window.refreshThumbnail() + app.refreshOpenUi() +} + +private func eventFocusedWindowChanged(_ app: App, _ element: AXUIElement) { + guard !app.appIsBeingUsed, + let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } + Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) +} \ No newline at end of file diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift new file mode 100644 index 00000000..06d18017 --- /dev/null +++ b/src/logic/Applications.swift @@ -0,0 +1,88 @@ +import Cocoa + +class Applications { + static var list = [Application]() + static var appsObserver = RunningApplicationsObserver() + static var appsInSubscriptionRetryLoop = [String]() + + static func observeNewWindows() { + for app in list { + guard app.runningApplication.isFinishedLaunching else { continue } + app.observeNewWindows() + } + } + + static func initialDiscovery() { + addInitialRunningApplications() + observeRunningApplications() + addInitialRunningApplicationsWindows() + } + + static func addInitialRunningApplications() { + addRunningApplications(NSWorkspace.shared.runningApplications) + } + + static func observeRunningApplications() { + NSWorkspace.shared.addObserver(Applications.appsObserver, forKeyPath: "runningApplications", options: [.old, .new], context: nil) + } + + static func addInitialRunningApplicationsWindows() { + // on initial launch, we use private APIs to bring windows from other spaces into the current space, observe them, then remove them from the current space + let spaces = Spaces.otherSpaces() + if spaces.count == 0 { + Windows.sortByLevel() + return + } + let windows = Spaces.windowsInSpaces(spaces).filter { window in + return Windows.list.first(where: { $0.cgWindowId == window }) == nil + } + if windows.count > 0 { + CGSAddWindowsToSpaces(cgsMainConnectionId, windows as NSArray, [Spaces.currentSpaceId]) + Applications.observeNewWindows() + Windows.sortByLevel() + CGSRemoveWindowsFromSpaces(cgsMainConnectionId, windows as NSArray, [Spaces.currentSpaceId]) + return + } + Windows.sortByLevel() + } + + static func addRunningApplications(_ runningApps: [NSRunningApplication]) { + for app in filterApplications(runningApps) { + Applications.list.append(Application(app)) + } + } + + static func removeRunningApplications(_ runningApps: [NSRunningApplication]) { + for runningApp in runningApps { + Applications.list.removeAll(where: { $0.runningApplication.isEqual(runningApp) }) + Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) }) + } + guard Windows.list.count > 0 else { (App.shared as! App).hideUi(); return } + // TODO: implement of more sophisticated way to decide which thumbnail gets focused on app quit + Windows.focusedWindowIndex = 1 + (App.shared as! App).refreshOpenUi() + } + + private static func filterApplications(_ apps: [NSRunningApplication]) -> [NSRunningApplication] { + // it would be nice to filter with $0.activationPolicy != .prohibited (see https://stackoverflow.com/a/26002033/2249756) + // however some daemon processes can sometimes create windows, so we can't filter them out (e.g. CopyQ is .prohibited for some reason) + return apps.filter { $0.bundleIdentifier != nil && $0.bundleIdentifier != NSRunningApplication.current.bundleIdentifier } + } +} + +class RunningApplicationsObserver: NSObject { + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + let type = NSKeyValueChange(rawValue: change![.kindKey]! as! UInt) + switch type { + case .insertion: + let apps = change![.newKey] as! [NSRunningApplication] + debugPrint("OS event", "apps launched", apps.map { ($0.processIdentifier, $0.bundleIdentifier) }) + Applications.addRunningApplications(apps) + case .removal: + let apps = change![.oldKey] as! [NSRunningApplication] + debugPrint("OS event", "apps quit", apps.map { ($0.processIdentifier, $0.bundleIdentifier) }) + Applications.removeRunningApplications(apps) + default: return + } + } +} diff --git a/src/logic/DebugProfile.swift b/src/logic/DebugProfile.swift new file mode 100644 index 00000000..24211440 --- /dev/null +++ b/src/logic/DebugProfile.swift @@ -0,0 +1,70 @@ +import Cocoa +import Darwin + +class DebugProfile { + static let intraSeparator = ": " + static let interSeparator = ", " + static let bulletPoint = "* " + static let nestedSeparator = "\n " + bulletPoint + + static func make() -> String { + ([ + // app + ("App version", App.version), + ("App preferences", appPreferences()), + ("Applications count", String(Applications.list.count)), + ("Windows", appWindows()), + // os + ("OS version", ProcessInfo.processInfo.operatingSystemVersionString), + ("OS architecture", Sysctl.run("hw.machine")), + ("Locale", Locale.current.debugDescription), + ("Spaces count", String((CGSCopyManagedDisplaySpaces(cgsMainConnectionId) as! [NSDictionary]).map { $0["Spaces"] }.count)), + // hardware + ("Hardware model", Sysctl.run("hw.model")), + ("Displays count", String(NSScreen.screens.count)), + ("CPU model", Sysctl.run("machdep.cpu.brand_string")), + ("Memory size", ByteCountFormatter.string(fromByteCount: Int64(ProcessInfo.processInfo.physicalMemory), countStyle: .file)), + // TODO: add gpu model(s) + // hardware utilization + ("Active CPU count", Sysctl.run("hw.activecpu", UInt.self).flatMap { String($0) } ?? ""), + ("Current CPU frequency", Sysctl.run("hw.cpufrequency", Int.self).map { String(format: "%.1f", Double($0) / Double(1_000_000_000)) + " Ghz" } ?? ""), + // TODO: CPU utilization + // TODO: Active GPU + // TODO: GPU utilization + // TODO: Memory utilization + // TODO: disk space to detect disk pressure + // TODO: thermals to check if overheating + // TODO: battery to check if low-energy mode / throttling + + ] as [(String, String)]) + .map { bulletPoint + $0.0 + intraSeparator + $0.1 } + .joined(separator: "\n") + } + + private static func appPreferences() -> String { + return nestedSeparator + Preferences.all + .sorted { $0.0 < $1.0 } + .map { $0.key + intraSeparator + Preferences.getAsString($0.key)! } + .joined(separator: nestedSeparator) + } + + private static func appWindows() -> String { + return nestedSeparator + Windows.list + .sorted { $0.cgWindowId < $1.cgWindowId } + .map { appWindow($0) } + .joined(separator: nestedSeparator) + } + + private static func appWindow(_ window: Window) -> String { + return "{" + ([ + ("isMinimized", String(window.isMinimized)), + ("isHidden", String(window.isHidden)), + ("isOnAllSpaces", String(window.isOnAllSpaces)), + ("spaceId", window.spaceId.flatMap { String($0) } ?? ""), + ("spaceIndex", window.spaceIndex.flatMap { String($0) } ?? ""), + ] as [(String, String)]) + .map { $0.0 + intraSeparator + $0.1 } + .joined(separator: interSeparator) + + "}" + } +} diff --git a/src/logic/DispatchQueues.swift b/src/logic/DispatchQueues.swift new file mode 100644 index 00000000..7b812b7e --- /dev/null +++ b/src/logic/DispatchQueues.swift @@ -0,0 +1,6 @@ +import Foundation + +class DispatchQueues { + static let focusActions = DispatchQueue(label: "focusActions", qos: .userInteractive) + static let keyboardEvents = DispatchQueue(label: "keyboardEvents", qos: .userInteractive) +} diff --git a/alt-tab-macos/logic/Keyboard.swift b/src/logic/Keyboard.swift similarity index 57% rename from alt-tab-macos/logic/Keyboard.swift rename to src/logic/Keyboard.swift index fed480c7..52109ed6 100644 --- a/alt-tab-macos/logic/Keyboard.swift +++ b/src/logic/Keyboard.swift @@ -2,18 +2,15 @@ import Cocoa import Carbon.HIToolbox.Events class Keyboard { - static let backgroundQueue = DispatchQueue(label: "uiQueue", qos: .userInteractive, autoreleaseFrequency: .never) - - static func listenToGlobalEvents(_ delegate: Application) { + static func listenToGlobalEvents(_ delegate: App) { listenToGlobalKeyboardEvents(delegate) } } var eventTap: CFMachPort? -func listenToGlobalKeyboardEvents(_ delegate: Application) { - Keyboard.backgroundQueue.async { - Thread.current.name = "uiQueue-thread" +func listenToGlobalKeyboardEvents(_ app: App) { + DispatchQueues.keyboardEvents.async { let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) }) // CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass eventTap = CGEvent.tapCreate( @@ -22,15 +19,15 @@ func listenToGlobalKeyboardEvents(_ delegate: Application) { options: .defaultTap, eventsOfInterest: eventMask, callback: keyboardHandler, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(delegate).toOpaque())) + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(app).toOpaque())) let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) CGEvent.tapEnable(tap: eventTap!, enable: true) + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) CFRunLoopRun() } } -func dispatchWork(_ application: Application, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged? { +func dispatchWork(_ application: App, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged? { application.uiWorkShouldBeDone = uiWorkShouldBeDone DispatchQueue.main.async { fn() @@ -38,30 +35,30 @@ func dispatchWork(_ application: Application, _ uiWorkShouldBeDone: Bool, _ fn: return nil // previously focused app should not receive keys } -func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, delegate_: UnsafeMutableRawPointer?) -> Unmanaged? { - let application = Unmanaged.fromOpaque(delegate_!).takeUnretainedValue() +func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, appPointer: UnsafeMutableRawPointer?) -> Unmanaged? { + let app = Unmanaged.fromOpaque(appPointer!).takeUnretainedValue() if type == .keyDown || type == .keyUp || type == .flagsChanged { if let event = NSEvent(cgEvent: event_) { let isTab = event.keyCode == Preferences.tabKeyCode - let isMetaChanged = Preferences.metaKeyCodes!.contains(event.keyCode) - let isMetaDown = event.modifierFlags.contains(Preferences.metaModifierFlag!) + let isMetaChanged = Preferences.metaKeyCodes.contains(event.keyCode) + let isMetaDown = event.modifierFlags.contains(Preferences.metaModifierFlag) let isRightArrow = event.keyCode == kVK_RightArrow let isLeftArrow = event.keyCode == kVK_LeftArrow let isEscape = event.keyCode == kVK_Escape - if type == .keyDown && isEscape && application.appIsBeingUsed { - return dispatchWork(application, false, { application.hideUi() }) + if type == .keyDown && isEscape && app.appIsBeingUsed { + return dispatchWork(app, false, { app.hideUi() }) } else if isMetaDown && type == .keyDown { if isTab && event.modifierFlags.contains(.shift) { - return dispatchWork(application, true, { application.showUiOrCycleSelection(-1) }) + return dispatchWork(app, true, { app.showUiOrCycleSelection(-1) }) } else if isTab { - return dispatchWork(application, true, { application.showUiOrCycleSelection(1) }) - } else if isRightArrow && application.appIsBeingUsed { - return dispatchWork(application, true, { application.cycleSelection(1) }) - } else if isLeftArrow && application.appIsBeingUsed { - return dispatchWork(application, true, { application.cycleSelection(-1) }) + return dispatchWork(app, true, { app.showUiOrCycleSelection(1) }) + } else if isRightArrow && app.appIsBeingUsed { + return dispatchWork(app, true, { app.cycleSelection(1) }) + } else if isLeftArrow && app.appIsBeingUsed { + return dispatchWork(app, true, { app.cycleSelection(-1) }) } } else if isMetaChanged && !isMetaDown { - return dispatchWork(application, false, { application.focusTarget() }) + return dispatchWork(app, false, { app.focusTarget() }) } } } else if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift new file mode 100644 index 00000000..48729e83 --- /dev/null +++ b/src/logic/Preferences.swift @@ -0,0 +1,115 @@ +import Cocoa +import Carbon.HIToolbox.Events + +let defaults = UserDefaults.standard + +class Preferences { + // default values + static var defaultValues: [String : Any] = [ + "maxScreenUsage": Float(80), + "minCellsPerRow": Float(5), + "maxCellsPerRow": Float(10), + "minRows": Float(3), + "iconSize": Float(32), + "fontHeight": Float(15), + "tabKeyCode": kVK_Tab, + "windowDisplayDelay": 0, + "metaKey": MacroPreferences.metaKeyList.keys.first!, + "theme": MacroPreferences.themeList.keys.first!, + "showOnScreen": MacroPreferences.showOnScreenList.keys.first!, + "hideSpaceNumberLabels": false, + "startAtLogin": true, + ] + + // constant values + // not exposed as preferences now but may be in the future, probably through macro preferences + static var windowMaterial: NSVisualEffectView.Material { .dark } + static var fontColor: NSColor { .white } + static var windowPadding: CGFloat { 23 } + static var interCellPadding: CGFloat { 5 } + static var intraCellPadding: CGFloat { 5 } + static var fontIconSize: CGFloat { 20 } + + // persisted values + static var maxScreenUsage: CGFloat { CGFloat(defaults.float(forKey: "maxScreenUsage") / 100) } + static var minCellsPerRow: CGFloat { CGFloat(defaults.float(forKey: "minCellsPerRow")) } + static var maxCellsPerRow: CGFloat { CGFloat(defaults.float(forKey: "maxCellsPerRow")) } + static var minRows: CGFloat { CGFloat(defaults.float(forKey: "minRows")) } + static var iconSize: CGFloat { CGFloat(defaults.float(forKey: "iconSize")) } + static var fontHeight: CGFloat { CGFloat(defaults.float(forKey: "fontHeight")) } + static var tabKeyCode: UInt16 { UInt16(defaults.integer(forKey: "tabKeyCode")) } + static var windowDisplayDelay: DispatchTimeInterval { DispatchTimeInterval.milliseconds(defaults.integer(forKey: "windowDisplayDelay")) } + static var hideSpaceNumberLabels: Bool { defaults.bool(forKey: "hideSpaceNumberLabels") } + static var startAtLogin: Bool { defaults.bool(forKey: "startAtLogin") } + + // macro values + static var theme: Theme { MacroPreferences.themeList[defaults.string(forKey: "theme")!]! } + static var metaKey: MetaKey { MacroPreferences.metaKeyList[defaults.string(forKey: "metaKey")!]! } + static var showOnScreen: ShowOnScreenPreference { MacroPreferences.showOnScreenList[defaults.string(forKey: "showOnScreen")!]! } + + // derived values + static var cellBorderWidth: CGFloat { theme.cellBorderWidth } + static var cellCornerRadius: CGFloat { theme.cellCornerRadius } + static var windowCornerRadius: CGFloat { theme.windowCornerRadius } + static var highlightBorderColor: NSColor { theme.highlightBorderColor } + static var highlightBackgroundColor: NSColor { theme.highlightBackgroundColor } + static var metaKeyCodes: [UInt16] { metaKey.keyCodes.map { UInt16($0) } } + static var metaModifierFlag: NSEvent.ModifierFlags { metaKey.modifierFlag } + static var font: NSFont { NSFont.systemFont(ofSize: fontHeight) } + + static func registerDefaults() { + defaults.register(defaults: defaultValues) + } + + static func get(_ key: String) -> Any? { + defaults.object(forKey: key) + } + + static func getAsString(_ key: String) -> String? { + defaults.string(forKey: key) + } + + static func set(_ key: String, _ value: Any?) { + defaults.set(value, forKey: key) + } + + static var all: [String: Any] { defaults.dictionaryRepresentation() } +} + +struct Theme { + let label: String + let cellBorderWidth: CGFloat + let cellCornerRadius: CGFloat + let windowCornerRadius: CGFloat + let highlightBorderColor: NSColor + let highlightBackgroundColor: NSColor +} + +struct MetaKey { + let label: String + let keyCodes: [Int] + let modifierFlag: NSEvent.ModifierFlags +} + +enum ShowOnScreenPreference { + case main + case mouse +} + +// macros are collection of values derived from a single key +// we don't want to store every value in UserDefaults as the user could change them and contradict the macro +class MacroPreferences { + static let themeList = [ + " macOS": Theme(label: " macOS", cellBorderWidth: 0, cellCornerRadius: 5, windowCornerRadius: 20, highlightBorderColor: .clear, highlightBackgroundColor: NSColor(red: 0, green: 0, blue: 0, alpha: 0.4)), + "❖ Windows 10": Theme(label: "❖ Windows 10", cellBorderWidth: 2, cellCornerRadius: 0, windowCornerRadius: 0, highlightBorderColor: .white, highlightBackgroundColor: .clear), + ] + static let metaKeyList = [ + "⌥ option": MetaKey(label: "⌥ option", keyCodes: [kVK_Option, kVK_RightOption], modifierFlag: .option), + "⌃ control": MetaKey(label: "⌃ control", keyCodes: [kVK_Control, kVK_RightControl], modifierFlag: .control), + "⌘ command": MetaKey(label: "⌘ command", keyCodes: [kVK_Command, kVK_RightCommand], modifierFlag: .command) + ] + static let showOnScreenList = [ + "Main screen": ShowOnScreenPreference.main, + "Screen including mouse": ShowOnScreenPreference.mouse, + ] +} diff --git a/alt-tab-macos/logic/Screen.swift b/src/logic/Screen.swift similarity index 52% rename from alt-tab-macos/logic/Screen.swift rename to src/logic/Screen.swift index 41c85732..a0f72d4e 100644 --- a/alt-tab-macos/logic/Screen.swift +++ b/src/logic/Screen.swift @@ -1,13 +1,10 @@ -import Foundation import Cocoa class Screen { static func preferred() -> NSScreen { - switch Preferences.showOnScreen! { - case .MOUSE: - return withMouse() ?? NSScreen.main!; // .main as fall-back - case .MAIN: - return NSScreen.main!; + switch Preferences.showOnScreen { + case .mouse: return withMouse() ?? NSScreen.main!; // .main as fall-back + case .main: return NSScreen.main!; } } @@ -15,26 +12,12 @@ class Screen { return NSScreen.screens.first { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) } } - static func thumbnailMaxSize(_ screen: NSScreen) -> NSSize { - let frame = screen.visibleFrame - let width = (frame.width * Preferences.maxScreenUsage! - Preferences.windowPadding * 2) / Preferences.maxThumbnailsPerRow! - Preferences.interItemPadding - let height = width * (frame.height / frame.width) - return NSSize(width: width, height: height) - } - - static func thumbnailPanelMaxSize(_ screen: NSScreen) -> NSSize { - let frame = screen.visibleFrame - return NSSize(width: frame.width * Preferences.maxScreenUsage!, height: frame.height * Preferences.maxScreenUsage!) - } - - static func showPanel(_ panel: NSPanel, _ screen: NSScreen, _ alignment: VerticalAlignment) { + static func repositionPanel(_ window: NSWindow, _ screen: NSScreen, _ alignment: VerticalAlignment) { let screenFrame = screen.visibleFrame - let panelFrame = panel.frame + let panelFrame = window.frame let x = screenFrame.minX + max(screenFrame.width - panelFrame.width, 0) * 0.5 let y = screenFrame.minY + max(screenFrame.height - panelFrame.height, 0) * alignment.rawValue - panel.setFrameOrigin(NSPoint(x: x, y: y)) - panel.makeKeyAndOrderFront(nil) - Application.shared.arrangeInFront(nil) + window.setFrameOrigin(NSPoint(x: x, y: y)) } static func mainUuid() -> CFString { diff --git a/src/logic/Spaces.swift b/src/logic/Spaces.swift new file mode 100644 index 00000000..86212e14 --- /dev/null +++ b/src/logic/Spaces.swift @@ -0,0 +1,52 @@ +import Cocoa + +class Spaces { + static var currentSpaceId = CGSSpaceID(1) + static var currentSpaceIndex = SpaceIndex(1) + static var visitedSpaces = [CGSSpaceID: Bool]() + static var isSingleSpace = true + + static func observeSpaceChanges() { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: nil, using: { _ in + updateCurrentSpace() + Applications.observeNewWindows() + let app = App.shared as! App + guard app.appIsBeingUsed else { return } + app.reopenUi() + }) + } + + static func initialDiscovery() { + updateCurrentSpace() + updateIsSingleSpace() + observeSpaceChanges() + } + + static func updateCurrentSpace() { + currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.mainUuid()) + currentSpaceIndex = allIdsAndIndexes().first { $0.0 == currentSpaceId }!.1 + debugPrint("Current space", currentSpaceId) + } + + static func allIdsAndIndexes() -> [(CGSSpaceID, SpaceIndex)] { + return (CGSCopyManagedDisplaySpaces(cgsMainConnectionId) as! [NSDictionary]) + .map { $0["Spaces"] }.joined().enumerated() + .map { (($0.element as! NSDictionary)["id64"]! as! CGSSpaceID, $0.offset + 1) } + } + + static func otherSpaces() -> [CGSSpaceID] { + return allIdsAndIndexes().filter { $0.0 != currentSpaceId }.map { $0.0 } + } + + static func windowsInSpaces(_ spaceIds: [CGSSpaceID]) -> [CGWindowID] { + var set_tags = UInt64(0) + var clear_tags = UInt64(0) + return CGSCopyWindowsWithOptionsAndTags(cgsMainConnectionId, 0, spaceIds as CFArray, 2, &set_tags, &clear_tags) as! [CGWindowID] + } + + static func updateIsSingleSpace() { + isSingleSpace = allIdsAndIndexes().count == 1 + } +} + +typealias SpaceIndex = Int diff --git a/alt-tab-macos/logic/SystemPermissions.swift b/src/logic/SystemPermissions.swift similarity index 79% rename from alt-tab-macos/logic/SystemPermissions.swift rename to src/logic/SystemPermissions.swift index a7b67615..e8228026 100644 --- a/alt-tab-macos/logic/SystemPermissions.swift +++ b/src/logic/SystemPermissions.swift @@ -1,28 +1,26 @@ -import Foundation import Cocoa -// macOS has some privacy restrictions. The user needs to grant certain permissions, app by app, in System Preferences > Security & Privacy > Privacy +// macOS has some privacy restrictions. The user needs to grant certain permissions, app by app, in System Preferences > Security & Privacy class SystemPermissions { - // macOS 10.9+ static func ensureAccessibilityCheckboxIsChecked() { + guard #available(OSX 10.9, *) else { return } if !AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) { debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Accessibility.", "Please authorize and re-launch.", "See https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx", separator: "\n") - NSApp.terminate(self) + App.shared.terminate(self) } } - // macOS 10.15+ static func ensureScreenRecordingCheckboxIsChecked() { - let firstWindow = CGWindow.windows(.optionOnScreenOnly)[0] - if let cgId = firstWindow.value(.number, CGWindowID.self), cgId.screenshot() == nil { + guard #available(OSX 10.15, *) else { return } + if SLSRequestScreenCaptureAccess() != 1 { debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Screen Recording.", "Please authorize and re-launch.", "See https://dropshare.zendesk.com/hc/en-us/articles/360033453434-Enabling-Screen-Recording-Permission-on-macOS-Catalina-10-15-", separator: "\n") - NSApp.terminate(self) + App.shared.terminate(self) } } } diff --git a/src/logic/Window.swift b/src/logic/Window.swift new file mode 100644 index 00000000..1446d7dd --- /dev/null +++ b/src/logic/Window.swift @@ -0,0 +1,153 @@ +import Cocoa + +class Window { + var cgWindowId: CGWindowID + var title: String + var thumbnail: NSImage? + var icon: NSImage? + var isHidden: Bool + var isMinimized: Bool + var isOnAllSpaces: Bool + var spaceId: CGSSpaceID? + var spaceIndex: SpaceIndex? + var axUiElement: AXUIElement + var application: Application + var axObserver: AXObserver? + + static let notifications = [ + kAXUIElementDestroyedNotification, + kAXTitleChangedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification, + ] + + static func stopSubscriptionRetries(_ notification: String, _ cgWindowId: CGWindowID) { + Windows.windowsInSubscriptionRetryLoop.removeAll { $0 == (String(cgWindowId) + String(notification)) } + } + + init(_ axUiElement: AXUIElement, _ application: Application) { + // TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values + self.axUiElement = axUiElement + self.application = application + self.cgWindowId = axUiElement.cgWindowId() + self.icon = application.runningApplication.icon + self.isHidden = application.runningApplication.isHidden + self.isMinimized = axUiElement.isMinimized() + self.spaceId = Spaces.currentSpaceId + self.spaceIndex = Spaces.currentSpaceIndex + self.isOnAllSpaces = false + self.title = Window.bestEffortTitle(axUiElement, cgWindowId, application) + debugPrint("Adding window", cgWindowId, title, application.runningApplication.bundleIdentifier ?? "nil", Spaces.currentSpaceId, Spaces.currentSpaceIndex) + observeEvents() + } + + deinit { + // some windows never finish launching; subscription retries should be stopped to avoid infinite loops + Window.notifications.forEach { Window.stopSubscriptionRetries($0, cgWindowId) } + } + + private func observeEvents() { + AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver) + guard let axObserver = axObserver else { return } + for notification in Window.notifications { + Windows.windowsInSubscriptionRetryLoop.append(String(cgWindowId) + String(notification)) + axUiElement.subscribeWithRetry(axObserver, notification, nil, nil, nil, cgWindowId) + } + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) + } + + func refreshThumbnail() { + guard (App.shared as! App).appIsBeingUsed, + let cgImage = cgWindowId.screenshot() else { return } + thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } + + func focus() { + // implementation notes: the following sequence of actions repeats some calls. This is necessary for + // minimized windows on other spaces, and focuses windows faster (e.g. the Security & Privacy window) + // macOS bug: when switching to a System Preferences window in another space, it switches to that space, + // but quickly switches back to another window in that space + // You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug + DispatchQueues.focusActions.async { [weak self] in + guard let self = self else { return } + var elementConnection = UInt32(0) + CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection) + var psn = ProcessSerialNumber() + CGSGetConnectionPSN(elementConnection, &psn) + AXUIElementPerformAction(self.axUiElement, kAXRaiseAction as CFString) + self.makeKeyWindow(psn) + _SLPSSetFrontProcessWithOptions(&psn, self.cgWindowId, .userGenerated) + self.makeKeyWindow(psn) + AXUIElementPerformAction(self.axUiElement, kAXRaiseAction as CFString) + } + } + + // The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468 + func makeKeyWindow(_ psn: ProcessSerialNumber) -> Void { + var psn_ = psn + var bytes1 = [UInt8](repeating: 0, count: 0xf8) + bytes1[0x04] = 0xF8 + bytes1[0x08] = 0x01 + bytes1[0x3a] = 0x10 + var bytes2 = [UInt8](repeating: 0, count: 0xf8) + bytes2[0x04] = 0xF8 + bytes2[0x08] = 0x02 + bytes2[0x3a] = 0x10 + memcpy(&bytes1[0x3c], &cgWindowId, MemoryLayout.size) + memset(&bytes1[0x20], 0xFF, 0x10) + memcpy(&bytes2[0x3c], &cgWindowId, MemoryLayout.size) + memset(&bytes2[0x20], 0xFF, 0x10) + SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes1)).pointee)) + SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes2)).pointee)) + } + + // for some windows (e.g. Slack), the AX API doesn't return a title; we try CG API; finally we resort to the app name + static func bestEffortTitle(_ axUiElement: AXUIElement, _ cgWindowId: CGWindowID, _ application: Application) -> String { + if let axTitle = axUiElement.title(), !axTitle.isEmpty { + return axTitle + } + if let cgTitle = cgWindowId.title(), !cgTitle.isEmpty { + return cgTitle + } + return application.runningApplication.localizedName ?? "" + } +} + +private func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, _: UnsafeMutableRawPointer?) -> Void { + let type = notificationName as String + let app = App.shared as! App + debugPrint("OS event", type, element.title() ?? "nil") + switch type { + case kAXUIElementDestroyedNotification: eventWindowDestroyed(app, element) + case kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification: eventWindowMiniaturizedOrDeminiaturized(app, element, type) + case kAXTitleChangedNotification: eventWindowTitleChanged(app, element) + default: return + } +} + +private func eventWindowDestroyed(_ app: App, _ element: AXUIElement) { + guard let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } + Windows.list.remove(at: existingIndex) + guard Windows.list.count > 0 else { app.hideUi(); return } + Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex) + app.refreshOpenUi() +} + +private func eventWindowMiniaturizedOrDeminiaturized(_ app: App, _ element: AXUIElement, _ type: String) { + guard let window = Windows.list.firstWindowThatMatches(element) else { return } + window.isMinimized = type == kAXWindowMiniaturizedNotification + // TODO: find a better way to get thumbnail of the new window (when AltTab is triggered min/demin animation) + window.refreshThumbnail() + app.refreshOpenUi() +} + +private func eventWindowTitleChanged(_ app: App, _ element: AXUIElement) { + guard let window = Windows.list.firstWindowThatMatches(element), + let newTitle = window.axUiElement.title(), + newTitle != window.title else { return } + window.title = newTitle + window.refreshThumbnail() + app.refreshOpenUi() +} + + diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift new file mode 100644 index 00000000..530f61c7 --- /dev/null +++ b/src/logic/Windows.swift @@ -0,0 +1,66 @@ +import Cocoa + +class Windows { + // order in the array is important: most-recently-used elements are first + static var list = [Window]() + static var focusedWindowIndex = Array.Index(0) + static var windowsInSubscriptionRetryLoop = [String]() + + static func focusedWindow() -> Window? { + return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil + } + + static func cycleFocusedWindowIndex(_ step: Array.Index) { + focusedWindowIndex = focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count + } + + static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Array.Index) { + if focusedWindowIndex <= destroyedWindowIndex { + focusedWindowIndex -= 1 + return + } + } + + static func moveFocusedWindowIndexAfterWindowCreatedInBackground() { + focusedWindowIndex += 1 + } + + static func updateSpaces() { + let spacesMap = Spaces.allIdsAndIndexes() + list.forEachAsync { window in + let spaceIds = window.cgWindowId.spaces() + if spaceIds.count == 1 { + window.spaceId = spaceIds.first! + window.spaceIndex = spacesMap.first { $0.0 == spaceIds.first! }!.1 + } else if spaceIds.count > 1 { + window.spaceId = Spaces.currentSpaceId + window.spaceIndex = Spaces.currentSpaceIndex + window.isOnAllSpaces = true + } + } + } + + static func sortByLevel() { + var windowLevelMap = [CGWindowID: Int]() + for (index, cgWindowId) in Spaces.windowsInSpaces([Spaces.currentSpaceId]).enumerated() { + windowLevelMap[cgWindowId] = index + } + var sortedTuples = Windows.list.map { (windowLevelMap[$0.cgWindowId], $0) } + sortedTuples.sort(by: { + if $0.0 == nil { + return false + } + if $1.0 == nil { + return true + } + return $0.0! < $1.0! + }) + Windows.list = sortedTuples.map { $0.1 } + } + + static func refreshAllThumbnails() { + list.forEachAsync { window in + window.refreshThumbnail() + } + } +} diff --git a/alt-tab-macos/main.swift b/src/main.swift similarity index 54% rename from alt-tab-macos/main.swift rename to src/main.swift index b2c44a5b..2a84c6fe 100644 --- a/alt-tab-macos/main.swift +++ b/src/main.swift @@ -1,5 +1,5 @@ import AppKit autoreleasepool { - Application.shared.run() + App.shared.run() } diff --git a/src/ui/App.swift b/src/ui/App.swift new file mode 100644 index 00000000..5ef32d6c --- /dev/null +++ b/src/ui/App.swift @@ -0,0 +1,157 @@ +import Cocoa +import Darwin +import LetsMove + +let cgsMainConnectionId = CGSMainConnectionID() + +class App: NSApplication, NSApplicationDelegate { + static let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String + static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String + static let licence = Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright") as! String + static let repository = "https://github.com/lwouis/alt-tab-macos" + static let url = URL(fileURLWithPath: Bundle.main.bundlePath) as CFURL + var statusItem: NSStatusItem? + var thumbnailsPanel: ThumbnailsPanel? + var preferencesWindow: PreferencesWindow? + var feedbackWindow: FeedbackWindow? + var uiWorkShouldBeDone = true + var isFirstSummon = true + var appIsBeingUsed = false + + override init() { + super.init() + delegate = self + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + func applicationDidFinishLaunching(_ aNotification: Notification) { + #if !DEBUG + PFMoveToApplicationsFolderIfNecessary() + #endif + SystemPermissions.ensureAccessibilityCheckboxIsChecked() + SystemPermissions.ensureScreenRecordingCheckboxIsChecked() + Preferences.registerDefaults() + statusItem = Menubar.make(self) + loadMainMenuXib() + initPreferencesDependentComponents() + Spaces.initialDiscovery() + Applications.initialDiscovery() + Keyboard.listenToGlobalEvents(self) + preferencesWindow = PreferencesWindow() + UpdatesTab.observeUserDefaults() + } + + // keyboard shortcuts are broken without a menu. We generated the default menu from XCode and load it + // see https://stackoverflow.com/a/3746058/2249756 + private func loadMainMenuXib() { + var menuObjects: NSArray? + Bundle.main.loadNibNamed("MainMenu", owner: self, topLevelObjects: &menuObjects) + menu = menuObjects?.first(where: { $0 is NSMenu }) as? NSMenu + } + + // we put application code here which should be executed on init() and Preferences change + func initPreferencesDependentComponents() { + thumbnailsPanel = ThumbnailsPanel(self) + } + + func hideUi() { + debugPrint("hideUi") + thumbnailsPanel!.orderOut(nil) + appIsBeingUsed = false + isFirstSummon = true + } + + func focusTarget() { + debugPrint("focusTarget") + if appIsBeingUsed { + debugPrint("focusTarget: appIsBeingUsed") + let window = Windows.focusedWindow() + focusSelectedWindow(window) + } + } + + @objc + func checkForUpdatesNow(_ sender: NSMenuItem) { + UpdatesTab.checkForUpdatesNow(sender) + } + + @objc + func showPreferencesPanel() { + Screen.repositionPanel(preferencesWindow!, Screen.preferred(), .appleCentered) + preferencesWindow?.show() + } + + @objc + func showFeedbackPanel() { + if feedbackWindow == nil { + feedbackWindow = FeedbackWindow() + } + Screen.repositionPanel(feedbackWindow!, Screen.preferred(), .appleCentered) + feedbackWindow?.show() + } + + @objc + func showUi() { + uiWorkShouldBeDone = true + showUiOrCycleSelection(0) + } + + func cycleSelection(_ step: Int) { + Windows.cycleFocusedWindowIndex(step) + thumbnailsPanel!.highlightCell() + } + + func showUiOrCycleSelection(_ step: Int) { + debugPrint("showUiOrCycleSelection", step) + appIsBeingUsed = true + if isFirstSummon { + debugPrint("showUiOrCycleSelection: isFirstSummon") + isFirstSummon = false + if Windows.list.count == 0 || CGWindow.isMissionControlActive() { + appIsBeingUsed = false + isFirstSummon = true + return + } + // TODO: find a way to update isSingleSpace by listening to space creation, instead of on every trigger + Spaces.updateIsSingleSpace() + // TODO: find a way to update space index when windows are moved to another space, instead of on every trigger + Windows.updateSpaces() + // TODO: find a way to update thumbnails by listening to content change, instead of every trigger. Or better, switch to video + Windows.refreshAllThumbnails() + Windows.focusedWindowIndex = 0 + Windows.cycleFocusedWindowIndex(step) + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: { [weak self] in + guard let self = self else { return } + self.refreshOpenUi() + if self.uiWorkShouldBeDone { self.thumbnailsPanel?.show() } + }) + } else { + debugPrint("showUiOrCycleSelection: !isFirstSummon") + cycleSelection(step) + } + } + + func reopenUi() { + thumbnailsPanel!.orderOut(nil) + Windows.refreshAllThumbnails() + refreshOpenUi() + thumbnailsPanel!.show() + } + + func refreshOpenUi() { + guard appIsBeingUsed else { return } + let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) + if uiWorkShouldBeDone { thumbnailsPanel!.refreshCollectionView(currentScreen, uiWorkShouldBeDone); debugPrint("refreshCollectionView") } + if uiWorkShouldBeDone { thumbnailsPanel!.highlightCell(); debugPrint("highlightCellAt") } + if uiWorkShouldBeDone { Screen.repositionPanel(thumbnailsPanel!, currentScreen, .appleCentered); debugPrint("repositionPanel") } + } + + func focusSelectedWindow(_ window: Window?) { + hideUi() + guard !CGWindow.isMissionControlActive() else { return } + window?.focus() + } +} diff --git a/src/ui/FeedbackWindow.swift b/src/ui/FeedbackWindow.swift new file mode 100644 index 00000000..5b7eca84 --- /dev/null +++ b/src/ui/FeedbackWindow.swift @@ -0,0 +1,109 @@ +import Cocoa + +class FeedbackWindow: NSWindow { + static let token = Bundle.main.object(forInfoDictionaryKey: "FeedbackToken") as! String + var body: TextArea! + var email: TextArea! + var sendButton: NSButton! + var debugProfile: NSButton! + + override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { + super.init(contentRect: .zero, styleMask: style, backing: backingStoreType, defer: flag) + setupWindow() + setupView() + } + + func show() { + App.shared.activate(ignoringOtherApps: true) + makeKeyAndOrderFront(nil) + } + + private func setupWindow() { + title = NSLocalizedString("Send feedback", comment: "") + hidesOnDeactivate = false + isReleasedWhenClosed = false + styleMask.insert([.miniaturizable, .closable]) + } + + private func setupView() { + let appIcon = NSImageView(image: NSImage(named: "app-icon")!.resizedCopy(80, 80)) + appIcon.imageScaling = .scaleNone + let appText = NSStackView(views: [ + BoldLabel(NSLocalizedString("Share improvement ideas, or report bugs", comment: "")), + HyperlinkLabel(NSLocalizedString("View existing discussions", comment: ""), App.repository + "/issues"), + ]) + appText.orientation = .vertical + appText.alignment = .left + appText.spacing = GridView.interPadding / 2 + let header = NSStackView(views: [appIcon, appText]) + header.spacing = GridView.interPadding + sendButton = NSButton(title: NSLocalizedString("Send", comment: ""), target: nil, action: #selector(sendCallback)) + sendButton.keyEquivalent = "\r" + sendButton.isEnabled = false + let buttons = NSStackView(views: [ + NSButton(title: NSLocalizedString("Cancel", comment: ""), target: nil, action: #selector(cancelCallback)), + sendButton, + ]) + buttons.spacing = GridView.interPadding + body = TextArea(80, 12, NSLocalizedString("I think the app could be improved with…", comment: ""), { + self.sendButton.isEnabled = !self.body.stringValue.isEmpty + }) + email = TextArea(80, 1, NSLocalizedString("Optional: email (if you want a reply)", comment: "")) + debugProfile = NSButton(checkboxWithTitle: NSLocalizedString("Send debug profile (CPU, memory, etc)", comment: ""), target: nil, action: nil) + debugProfile.state = .on + let view = GridView.make([ + [header], + [body], + [email], + [debugProfile], + [buttons], + ]) + view.cell(atColumnIndex: 0, rowIndex: 4).xPlacement = .trailing + setContentSize(view.fittingSize) + contentView = view + } + + @objc + private func cancelCallback() { + close() + } + + @objc + private func sendCallback() { + URLSession.shared.dataTask(with: prepareRequest(), completionHandler: { data, response, error in + if error != nil || response == nil || (response as! HTTPURLResponse).statusCode != 201 { + debugPrint("HTTP call failed:", response ?? "nil", error ?? "nil") + } + }).resume() + close() + } + + private func prepareRequest() -> URLRequest { + var request = URLRequest(url: URL(string: "https://api.github.com/repos/lwouis/alt-tab-macos/issues")!) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + // access token of the alt-tab-macos-bot github account, with scope repo > public_repo + request.addValue("token " + FeedbackWindow.token, forHTTPHeaderField: "Authorization") + request.httpBody = try! JSONSerialization.data(withJSONObject: [ + "title": "[In-app feedback]", + "body": assembleBody() + ]) + return request + } + + private func assembleBody() -> String { + var result = "" + result += "_This issue was opened by a bot after a user submitted feedback through the in-app form._" + if !email.stringValue.isEmpty { + result += "\n\n__From:__ " + email.stringValue + } + result += "\n\n__Message:__" + result += "\n\n> " + body.stringValue.replacingOccurrences(of: "\n", with: "\n> ") + if debugProfile.state == .on { + result += "\n\n__Debug profile:__" + result += "\n\n" + DebugProfile.make() + } + return result + } +} diff --git a/src/ui/Menubar.swift b/src/ui/Menubar.swift new file mode 100644 index 00000000..66bd57fd --- /dev/null +++ b/src/ui/Menubar.swift @@ -0,0 +1,35 @@ +import Cocoa + +class Menubar { + static func make(_ app: App) -> NSStatusItem { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let image = NSImage(named: "menubar-icon") + image!.isTemplate = true + item.button!.image = image + item.button!.imageScaling = .scaleProportionallyUpOrDown + item.menu = NSMenu() + item.menu!.addItem( + withTitle: NSLocalizedString("Show", comment: ""), + action: #selector(app.showUi), + keyEquivalent: "" + ) + item.menu!.addItem( + withTitle: NSLocalizedString("Preferences…", comment: ""), + action: #selector(app.showPreferencesPanel), + keyEquivalent: ",") + item.menu!.addItem( + withTitle: NSLocalizedString("Check for updates…", comment: ""), + action: #selector(app.checkForUpdatesNow), + keyEquivalent: "") + item.menu!.addItem( + withTitle: NSLocalizedString("Send feedback…", comment: ""), + action: #selector(app.showFeedbackPanel), + keyEquivalent: "") + item.menu!.addItem(NSMenuItem.separator()) + item.menu!.addItem( + withTitle: NSLocalizedString("Quit", comment: "") + " " + App.name, + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + return item + } +} diff --git a/src/ui/generic-components/GridView.swift b/src/ui/generic-components/GridView.swift new file mode 100644 index 00000000..dc187b56 --- /dev/null +++ b/src/ui/generic-components/GridView.swift @@ -0,0 +1,18 @@ +import Cocoa + +class GridView { + static let padding = CGFloat(20) + static let interPadding = CGFloat(10) + + static func make(_ controls: [[NSView]]) -> NSGridView { + let gridView = NSGridView(views: controls) + gridView.yPlacement = .fill + gridView.columnSpacing = interPadding + gridView.rowSpacing = interPadding + gridView.column(at: 0).leadingPadding = padding + gridView.column(at: gridView.numberOfColumns - 1).trailingPadding = padding + gridView.row(at: 0).topPadding = padding + gridView.row(at: gridView.numberOfRows - 1).bottomPadding = padding + return gridView + } +} diff --git a/src/ui/generic-components/text/BaseLabel.swift b/src/ui/generic-components/text/BaseLabel.swift new file mode 100644 index 00000000..f99b8f70 --- /dev/null +++ b/src/ui/generic-components/text/BaseLabel.swift @@ -0,0 +1,24 @@ +import Cocoa + +class BaseLabel: NSTextView { + convenience init(_ text: String) { + self.init(frame: .zero) + string = text + setup() + } + + convenience init(_ frame: NSRect, _ container: NSTextContainer?) { + self.init(frame: frame, textContainer: container) + setup() + } + + private func setup() { + drawsBackground = true + backgroundColor = .clear + isSelectable = false + isEditable = false + enabledTextCheckingTypes = 0 + layoutManager!.ensureLayout(for: textContainer!) + frame = layoutManager!.usedRect(for: textContainer!) + } +} diff --git a/src/ui/generic-components/text/BoldLabel.swift b/src/ui/generic-components/text/BoldLabel.swift new file mode 100644 index 00000000..91cdc372 --- /dev/null +++ b/src/ui/generic-components/text/BoldLabel.swift @@ -0,0 +1,11 @@ +import Cocoa + +class BoldLabel: NSTextField { + convenience init(_ string: String) { + self.init(labelWithString: string) + allowsEditingTextAttributes = true + attributedStringValue = NSAttributedString(string: string, attributes: [ + NSAttributedString.Key.font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), + ]) + } +} diff --git a/alt-tab-macos/ui/HyperlinkLabel.swift b/src/ui/generic-components/text/HyperlinkLabel.swift similarity index 62% rename from alt-tab-macos/ui/HyperlinkLabel.swift rename to src/ui/generic-components/text/HyperlinkLabel.swift index f6b0b4ca..890f183d 100644 --- a/alt-tab-macos/ui/HyperlinkLabel.swift +++ b/src/ui/generic-components/text/HyperlinkLabel.swift @@ -1,21 +1,18 @@ import Cocoa class HyperlinkLabel: NSTextField { - - public convenience init(labelWithUrl stringValue: String, nsUrl: NSURL) { - self.init(labelWithString: stringValue) + convenience init(_ string: String, _ url: String) { + self.init(labelWithString: string) isSelectable = true allowsEditingTextAttributes = true - let linkTextAttributes: [NSAttributedString.Key: Any] = [ - NSAttributedString.Key.link: nsUrl as Any, + attributedStringValue = NSAttributedString(string: string, attributes: [ + NSAttributedString.Key.link: NSURL(string: url)!, NSAttributedString.Key.font: NSFont.labelFont(ofSize: NSFont.systemFontSize), - ] - - attributedStringValue = NSAttributedString(string: stringValue, attributes: linkTextAttributes) + ]) } // the whole point for this sub-class: always display a pointing-hand cursor (not only when the TextField is focused) override func resetCursorRects() { addCursorRect(bounds, cursor: NSCursor.pointingHand) } -} \ No newline at end of file +} diff --git a/src/ui/generic-components/text/TextArea.swift b/src/ui/generic-components/text/TextArea.swift new file mode 100644 index 00000000..69cdb6c3 --- /dev/null +++ b/src/ui/generic-components/text/TextArea.swift @@ -0,0 +1,48 @@ +import Cocoa + +class TextArea: NSTextField, NSTextFieldDelegate { + static let padding = CGFloat(5) + var callback: (() -> Void)! + + convenience init(_ nCharactersWide: CGFloat, _ nLinesHigh: Int, _ placeholder: String, _ callback: (() -> Void)? = nil) { + self.init(frame: .zero) + self.callback = callback + delegate = self + cell = TextFieldCell(placeholder, nLinesHigh == 1) + fit(font!.xHeight * nCharactersWide + TextArea.padding * 2, fittingSize.height * CGFloat(nLinesHigh) + TextArea.padding * 2) + } + + func controlTextDidChange(_ notification: Notification) { + callback?() + } + + // enter key inserts new line instead of submitting + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard commandSelector == #selector(NSResponder.insertNewline) else { return false } + textView.insertNewlineIgnoringFieldEditor(self) + return true + } +} + +// subclassing NSTextFieldCell is done uniquely to add padding +class TextFieldCell: NSTextFieldCell { + convenience init(_ placeholder: String, _ usesSingleLineMode: Bool) { + self.init() + isBordered = true + isBezeled = true + isEditable = true + font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + stringValue = "" + placeholderString = placeholder + self.usesSingleLineMode = usesSingleLineMode + } + + override func drawingRect(forBounds rect: NSRect) -> NSRect { + return super.drawingRect(forBounds: NSMakeRect( + rect.origin.x + TextArea.padding, + rect.origin.y + TextArea.padding, + rect.size.width - TextArea.padding * 2, + rect.size.height - TextArea.padding * 2 + )) + } +} diff --git a/alt-tab-macos/ui/TextField.swift b/src/ui/generic-components/text/TextField.swift similarity index 85% rename from alt-tab-macos/ui/TextField.swift rename to src/ui/generic-components/text/TextField.swift index 9e34f60f..f20ea544 100644 --- a/alt-tab-macos/ui/TextField.swift +++ b/src/ui/generic-components/text/TextField.swift @@ -1,11 +1,12 @@ import Cocoa class TextField: NSTextField, NSTextFieldDelegate { - var validationHandler: ((String)->Bool)? - public convenience init(_ value: String) { + convenience init(_ value: String) { self.init(string: value) + usesSingleLineMode = true + font = .labelFont(ofSize: NSFont.systemFontSize) wantsLayer = true layer?.borderWidth = 1 } diff --git a/src/ui/main-window/CollectionViewFlowLayout.swift b/src/ui/main-window/CollectionViewFlowLayout.swift new file mode 100644 index 00000000..92868e43 --- /dev/null +++ b/src/ui/main-window/CollectionViewFlowLayout.swift @@ -0,0 +1,62 @@ +import Cocoa + +class CollectionViewFlowLayout: NSCollectionViewFlowLayout { + var currentScreen: NSScreen? + var widestRow: CGFloat? + var totalHeight: CGFloat? + + override func layoutAttributesForElements(in rect: CGRect) -> [NSCollectionViewLayoutAttributes] { + let attributes_ = super.layoutAttributesForElements(in: rect) + guard !attributes_.isEmpty else { return attributes_ } + let attributes = NSArray(array: attributes_, copyItems: true) as! [NSCollectionViewLayoutAttributes] + var currentRow: [NSCollectionViewLayoutAttributes] = [] + var currentRowY = CGFloat(0) + var currentRowWidth = CGFloat(0) + var previousRowMaxHeight = CGFloat(0) + var currentRowMaxHeight = CGFloat(0) + var widestRow = CGFloat(0) + var totalHeight = CGFloat(0) + for (index, attribute) in attributes.enumerated() { + let isNewRow = abs(attribute.frame.origin.y - currentRowY) > CollectionViewItemView.height(currentScreen!) + if isNewRow { + currentRowWidth -= Preferences.interCellPadding + widestRow = max(widestRow, currentRowWidth) + setCenteredPositionForPreviousRowCells(currentRowWidth, previousRowMaxHeight, currentRow) + currentRow.removeAll() + currentRowY = attribute.frame.origin.y + currentRowWidth = 0 + previousRowMaxHeight += currentRowMaxHeight + Preferences.interCellPadding + currentRowMaxHeight = 0 + } + currentRow.append(attribute) + currentRowWidth += attribute.frame.size.width + Preferences.interCellPadding + currentRowMaxHeight = max(currentRowMaxHeight, attribute.frame.size.height) + if index == attributes.count - 1 { + currentRowWidth -= Preferences.interCellPadding + widestRow = max(widestRow, currentRowWidth) + totalHeight = previousRowMaxHeight + currentRowMaxHeight + setCenteredPositionForPreviousRowCells(currentRowWidth, previousRowMaxHeight, currentRow) + } + } + shiftCenteredElementToTheLeft(attributes, widestRow, totalHeight) + self.widestRow = widestRow + self.totalHeight = totalHeight + return attributes + } + + private func shiftCenteredElementToTheLeft(_ attributes: [NSCollectionViewLayoutAttributes], _ widestRow: CGFloat, _ totalHeight: CGFloat) { + let horizontalMargin = ((collectionView!.frame.size.width - widestRow) / 2).rounded() + for attribute in attributes { + attribute.frame.origin.x -= horizontalMargin + } + } + + private func setCenteredPositionForPreviousRowCells(_ currentRowWidth: CGFloat, _ previousRowMaxHeight: CGFloat, _ currentRow: [NSCollectionViewLayoutAttributes]) { + var marginLeft = (collectionView!.frame.size.width - currentRowWidth) / 2 + for attribute in currentRow { + attribute.frame.origin.x = marginLeft + attribute.frame.origin.y = previousRowMaxHeight + marginLeft += attribute.frame.size.width + Preferences.interCellPadding + } + } +} diff --git a/src/ui/main-window/CollectionViewItem.swift b/src/ui/main-window/CollectionViewItem.swift new file mode 100644 index 00000000..67f59671 --- /dev/null +++ b/src/ui/main-window/CollectionViewItem.swift @@ -0,0 +1,18 @@ +import Cocoa +import WebKit + +class CollectionViewItem: NSCollectionViewItem { + var view_: CollectionViewItemView { view as! CollectionViewItemView } + + override func loadView() { + view = CollectionViewItemView() + view.wantsLayer = true + } + + override var isSelected: Bool { + didSet { + view.layer!.backgroundColor = isSelected ? Preferences.highlightBackgroundColor.cgColor : .clear + view.layer!.borderColor = isSelected ? Preferences.highlightBorderColor.cgColor : .clear + } + } +} diff --git a/alt-tab-macos/ui/FontIcon.swift b/src/ui/main-window/CollectionViewItemFontIcon.swift similarity index 86% rename from alt-tab-macos/ui/FontIcon.swift rename to src/ui/main-window/CollectionViewItemFontIcon.swift index 13497fe4..a949d8c0 100644 --- a/alt-tab-macos/ui/FontIcon.swift +++ b/src/ui/main-window/CollectionViewItemFontIcon.swift @@ -7,14 +7,11 @@ class FontIcon: CellTitle { static let sfSymbolCircledDotSign = "􀍷" static let sfSymbolCircledNumber0 = "􀀸" static let sfSymbolCircledNumber10 = "􀓵" + static let sfSymbolCircledStart = "􀕬" - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - init(_ text: String, _ size: CGFloat, _ color: NSColor) { + convenience init(_ text: String, _ size: CGFloat, _ color: NSColor) { // This helps SF symbols display vertically centered and not clipped at the bottom - super.init(size, 3) + self.init(size, 3) string = text font = NSFont(name: "SF Pro Text", size: size) textColor = color @@ -28,6 +25,10 @@ class FontIcon: CellTitle { string = String(UnicodeScalar(baseCharacter.unicodeScalars.first!.value + offset)!) } + func setStar() { + string = FontIcon.sfSymbolCircledStart + } + private func baseCharacterAndOffset(_ number: UInt32) -> (String, UInt32) { if number <= 9 { // numbers alternate between empty and full circles; we skip the full circles diff --git a/alt-tab-macos/ui/Labels.swift b/src/ui/main-window/CollectionViewItemTitle.swift similarity index 53% rename from alt-tab-macos/ui/Labels.swift rename to src/ui/main-window/CollectionViewItemTitle.swift index ff91ce33..63c63159 100644 --- a/alt-tab-macos/ui/Labels.swift +++ b/src/ui/main-window/CollectionViewItemTitle.swift @@ -1,39 +1,9 @@ import Cocoa -class BaseLabel: NSTextView { - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - init(_ text: String) { - super.init(frame: .zero) - string = text - } - - override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { - super.init(frame: frameRect, textContainer: container) - _init() - } - - private func _init() { - drawsBackground = true - backgroundColor = .clear - isSelectable = false - isEditable = false - font = Preferences.font - enabledTextCheckingTypes = 0 - } -} - class CellTitle: BaseLabel { - let magicOffset: CGFloat - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + var magicOffset = CGFloat(0) - init(_ size: CGFloat, _ magicOffset: CGFloat = 0) { - self.magicOffset = magicOffset + convenience init(_ size: CGFloat, _ magicOffset: CGFloat = 0) { let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() textStorage.addLayoutManager(layoutManager) @@ -41,9 +11,10 @@ class CellTitle: BaseLabel { textContainer.maximumNumberOfLines = 1 textContainer.lineFragmentPadding = 0 layoutManager.addTextContainer(textContainer) - super.init(frame: .zero, textContainer: textContainer) + self.init(NSRect.zero, textContainer) + self.magicOffset = magicOffset textColor = Preferences.fontColor - shadow = Cell.makeShadow(.darkGray) + shadow = CollectionViewItemView.makeShadow(.darkGray) defaultParagraphStyle = makeParagraphStyle(size) heightAnchor.constraint(equalToConstant: size + magicOffset).isActive = true } diff --git a/src/ui/main-window/CollectionViewItemView.swift b/src/ui/main-window/CollectionViewItemView.swift new file mode 100644 index 00000000..632a040e --- /dev/null +++ b/src/ui/main-window/CollectionViewItemView.swift @@ -0,0 +1,167 @@ +import Cocoa + +class CollectionViewItemView: NSStackView { + var window_: Window? + var thumbnail = NSImageView() + var appIcon = NSImageView() + var label = CellTitle(Preferences.fontHeight) + var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white) + var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white) + var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white) + var mouseDownCallback: MouseDownCallback! + var mouseMovedCallback: MouseMovedCallback! + var dragAndDropTimer: Timer? + + convenience init() { + self.init(frame: .zero) + let hStackView = makeHStackView() + setupView(hStackView) + let shadow = CollectionViewItemView.makeShadow(.gray) + thumbnail.shadow = shadow + appIcon.shadow = shadow + observeDragAndDrop() + } + + private func observeDragAndDrop() { + // NSImageView instances are registered to drag-and-drop by default + thumbnail.unregisterDraggedTypes() + appIcon.unregisterDraggedTypes() + // we only handle URLs (i.e. not text, image, or other draggable things) + registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)]) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + mouseMovedCallback() + return .link + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in + self.mouseDownCallback() + }) + return .link + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + dragAndDropTimer?.invalidate() + dragAndDropTimer = nil + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL] + let appUrl = window_!.application.runningApplication.bundleURL! + let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:]) + (App.shared as! App).hideUi() + return open != nil + } + + override func mouseMoved(with event: NSEvent) { + mouseMovedCallback() + } + + override func mouseDown(with theEvent: NSEvent) { + mouseDownCallback() + } + + func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) { + window_ = element + thumbnail.image = element.thumbnail + let (thumbnailWidth, thumbnailHeight) = CollectionViewItemView.thumbnailSize(element.thumbnail, screen) + let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded()) + thumbnail.image?.size = thumbnailSize + thumbnail.frame.size = thumbnailSize + appIcon.image = element.icon + let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize) + appIcon.image?.size = appIconSize + appIcon.frame.size = appIconSize + label.string = element.title + // workaround: setting string on NSTextView change the font (most likely a Cocoa bug) + label.font = Preferences.font + hiddenIcon.isHidden = !window_!.isHidden + minimizedIcon.isHidden = !window_!.isMinimized + spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels + if !spaceIcon.isHidden { + if element.isOnAllSpaces { + spaceIcon.setStar() + } else { + spaceIcon.setNumber(UInt32(element.spaceIndex!)) + } + } + let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding) + label.textContainer!.size.width = frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth + subviews.first!.frame.size = frame.size + self.mouseDownCallback = mouseDownCallback + self.mouseMovedCallback = mouseMovedCallback + if trackingAreas.count > 0 { + removeTrackingArea(trackingAreas[0]) + } + addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil)) + } + + static func makeShadow(_ color: NSColor) -> NSShadow { + let shadow = NSShadow() + shadow.shadowColor = color + shadow.shadowOffset = .zero + shadow.shadowBlurRadius = 1 + return shadow + } + + static func downscaleFactor() -> CGFloat { + let nCellsBeforePotentialOverflow = Preferences.minRows * Preferences.minCellsPerRow + guard CGFloat(Windows.list.count) > nCellsBeforePotentialOverflow else { return 1 } + // TODO: replace this buggy heuristic with a correct implementation of downscaling + return nCellsBeforePotentialOverflow / (nCellsBeforePotentialOverflow + (sqrt(CGFloat(Windows.list.count) - nCellsBeforePotentialOverflow) * 2)) + } + + static func widthMax(_ screen: NSScreen) -> CGFloat { + return (ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor() + } + + static func widthMin(_ screen: NSScreen) -> CGFloat { + return (ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor() + } + + static func height(_ screen: NSScreen) -> CGFloat { + return (ThumbnailsPanel.heightMax(screen) / Preferences.minRows - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor() + } + + static func width(_ image: NSImage?, _ screen: NSScreen) -> CGFloat { + return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, CollectionViewItemView.widthMin(screen)) + } + + static func thumbnailSize(_ image: NSImage?, _ screen: NSScreen) -> (CGFloat, CGFloat) { + guard let image = image else { return (0, 0) } + let thumbnailHeightMax = CollectionViewItemView.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize + let thumbnailWidthMax = CollectionViewItemView.widthMax(screen) - Preferences.intraCellPadding * 2 + let thumbnailHeight = min(image.size.height, thumbnailHeightMax) + let thumbnailWidth = min(image.size.width, thumbnailWidthMax) + let imageRatio = image.size.width / image.size.height + let thumbnailRatio = thumbnailWidth / thumbnailHeight + if thumbnailRatio > imageRatio { + return (image.size.width * thumbnailHeight / image.size.height, thumbnailHeight) + } + return (thumbnailWidth, image.size.height * thumbnailWidth / image.size.width) + } + + private func makeHStackView() -> NSStackView { + let hStackView = NSStackView() + hStackView.spacing = Preferences.intraCellPadding + hStackView.setViews([appIcon, label, hiddenIcon, minimizedIcon, spaceIcon], in: .leading) + return hStackView + } + + private func setupView(_ hStackView: NSStackView) { + wantsLayer = true + layer!.backgroundColor = .clear + layer!.cornerRadius = Preferences.cellCornerRadius + layer!.borderWidth = Preferences.cellBorderWidth + layer!.borderColor = .clear + edgeInsets = NSEdgeInsets(top: Preferences.intraCellPadding, left: Preferences.intraCellPadding, bottom: Preferences.intraCellPadding, right: Preferences.intraCellPadding) + orientation = .vertical + spacing = Preferences.intraCellPadding + setViews([hStackView, thumbnail], in: .leading) + } +} + +typealias MouseDownCallback = () -> Void +typealias MouseMovedCallback = () -> Void diff --git a/src/ui/main-window/ThumbnailsPanel.swift b/src/ui/main-window/ThumbnailsPanel.swift new file mode 100644 index 00000000..56b380ec --- /dev/null +++ b/src/ui/main-window/ThumbnailsPanel.swift @@ -0,0 +1,117 @@ +import Cocoa + +class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout { + var backgroundView: NSVisualEffectView! + var collectionView: NSCollectionView! + var app: App? + let cellId = NSUserInterfaceItemIdentifier("Cell") + var currentScreen: NSScreen? + + convenience init(_ app: App) { + self.init() + self.app = app + isFloatingPanel = true + animationBehavior = .none + hidesOnDeactivate = false + hasShadow = false + titleVisibility = .hidden + styleMask.remove(.titled) + backgroundColor = .clear + makeCollectionView() + backgroundView = ThumbnailsPanel.makeBackgroundView() + backgroundView.addSubview(collectionView) + contentView!.addSubview(backgroundView) + // 2nd highest level possible; this allows the app to go on top of context menus + // highest level is .screenSaver but makes drag and drop on top the main window impossible + level = .popUpMenu + // helps filter out this window from the thumbnails + setAccessibilitySubrole(.unknown) + } + + func show() { + makeKeyAndOrderFront(nil) + } + + static func makeBackgroundView() -> NSVisualEffectView { + let backgroundView = NSVisualEffectView() + backgroundView.translatesAutoresizingMaskIntoConstraints = false + backgroundView.material = Preferences.windowMaterial + backgroundView.state = .active + backgroundView.wantsLayer = true + backgroundView.layer!.cornerRadius = Preferences.windowCornerRadius + return backgroundView + } + + func makeCollectionView() { + collectionView = NSCollectionView() + collectionView.dataSource = self + collectionView.delegate = self + collectionView.collectionViewLayout = makeLayout() + collectionView.backgroundColors = [.clear] + collectionView.isSelectable = true + collectionView.allowsMultipleSelection = false + collectionView.register(CollectionViewItem.self, forItemWithIdentifier: cellId) + } + + private func makeLayout() -> CollectionViewFlowLayout { + let layout = CollectionViewFlowLayout() + layout.minimumInteritemSpacing = Preferences.interCellPadding + layout.minimumLineSpacing = Preferences.interCellPadding + return layout + } + + func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { + return Windows.list.count + } + + func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { + let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! CollectionViewItem + item.view_.updateRecycledCellWithNewContent(Windows.list[indexPath.item], + { self.app!.focusSelectedWindow(item.view_.window_) }, + { self.app!.thumbnailsPanel!.highlightCell(item) }, + currentScreen!) + return item + } + + func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { + guard indexPath.item < Windows.list.count else { return .zero } + return NSSize(width: CollectionViewItemView.width(Windows.list[indexPath.item].thumbnail, currentScreen!).rounded(), height: CollectionViewItemView.height(currentScreen!).rounded()) + } + + func highlightCell() { + collectionView.deselectAll(nil) + collectionView.selectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)], scrollPosition: .top) + } + + func highlightCell(_ cell: CollectionViewItem) { + let newIndex = collectionView.indexPath(for: cell)! + if Windows.focusedWindowIndex != newIndex.item { + collectionView.selectItems(at: [newIndex], scrollPosition: .top) + collectionView.deselectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)]) + Windows.focusedWindowIndex = newIndex.item + } + } + + func refreshCollectionView(_ screen: NSScreen, _ uiWorkShouldBeDone: Bool) { + if uiWorkShouldBeDone { self.currentScreen = screen } + let layout = collectionView.collectionViewLayout as! CollectionViewFlowLayout + if uiWorkShouldBeDone { layout.currentScreen = screen } + if uiWorkShouldBeDone { layout.invalidateLayout() } + if uiWorkShouldBeDone { collectionView.setFrameSize(NSSize(width: ThumbnailsPanel.widthMax(screen).rounded(), height: ThumbnailsPanel.heightMax(screen).rounded())) } + if uiWorkShouldBeDone { collectionView.reloadData() } + if uiWorkShouldBeDone { collectionView.layoutSubtreeIfNeeded() } + if uiWorkShouldBeDone { collectionView.setFrameSize(NSSize(width: layout.widestRow!, height: layout.totalHeight!)) } + let windowSize = NSSize(width: layout.widestRow! + Preferences.windowPadding * 2, height: layout.totalHeight! + Preferences.windowPadding * 2) + if uiWorkShouldBeDone { setContentSize(windowSize) } + if uiWorkShouldBeDone { backgroundView!.setFrameSize(windowSize) } + if uiWorkShouldBeDone { collectionView.setFrameOrigin(NSPoint(x: Preferences.windowPadding, y: Preferences.windowPadding)) } + } + + static func widthMax(_ screen: NSScreen) -> CGFloat { + return screen.frame.width * Preferences.maxScreenUsage - Preferences.windowPadding * 2 + } + + static func heightMax(_ screen: NSScreen) -> CGFloat { + return screen.frame.height * Preferences.maxScreenUsage - Preferences.windowPadding * 2 + } +} diff --git a/src/ui/preferences-window/LabelAndControl.swift b/src/ui/preferences-window/LabelAndControl.swift new file mode 100644 index 00000000..1884870a --- /dev/null +++ b/src/ui/preferences-window/LabelAndControl.swift @@ -0,0 +1,118 @@ +import Cocoa + +class LabelAndControl: NSObject { + static func makeLabelWithInput(_ labelText: String, _ rawName: String, _ width: CGFloat, _ suffixText: String? = nil, _ suffixUrl: String? = nil, _ validator: ((String) -> Bool)? = nil) -> [NSView] { + let input = TextField(Preferences.getAsString(rawName)!) + input.validationHandler = validator + input.delegate = input + input.visualizeValidationState() + input.fit(width, input.fittingSize.height) + let views = makeLabelWithProvidedControl(labelText, rawName, input) + return [views[0], NSStackView(views: [views[1], makeSuffix(rawName, suffixText!, suffixUrl)])] + } + + static func makeLabelWithCheckbox(_ labelText: String, _ rawName: String, extraAction: ActionClosure? = nil) -> [NSView] { + let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + setControlValue(checkbox, Preferences.getAsString(rawName)!) + return makeLabelWithProvidedControl(labelText, rawName, checkbox, extraAction: extraAction) + } + + static func makeLabelWithDropdown(_ labelText: String, _ rawName: String, _ values: [String], _ suffixText: String? = nil) -> [NSView] { + let popUp = NSPopUpButton() + popUp.addItems(withTitles: values) + popUp.selectItem(withTitle: Preferences.getAsString(rawName)!) + return makeLabelWithProvidedControl(labelText, rawName, popUp, suffixText) + } + + static func makeLabelWithSlider(_ labelText: String, _ rawName: String, _ minValue: Double, _ maxValue: Double, _ numberOfTickMarks: Int, _ allowsTickMarkValuesOnly: Bool, _ unitText: String = "") -> [NSView] { + let value = Preferences.getAsString(rawName)! + let suffixText = value + " " + unitText + let slider = NSSlider() + slider.minValue = minValue + slider.maxValue = maxValue + slider.stringValue = value +// slider.numberOfTickMarks = numberOfTickMarks +// slider.allowsTickMarkValuesOnly = allowsTickMarkValuesOnly +// slider.tickMarkPosition = .below + slider.isContinuous = true + return makeLabelWithProvidedControl(labelText, rawName, slider, suffixText) + } + + static func makeLabelWithProvidedControl(_ labelText: String?, _ rawName: String, _ control: NSControl, _ suffixText: String? = nil, _ suffixUrl: String? = nil, extraAction: ActionClosure? = nil) -> [NSView] { + let label = makeLabel(labelText, rawName) + control.identifier = NSUserInterfaceItemIdentifier(rawName) + control.onAction = { + PreferencesWindow.controlWasChanged($0) + extraAction?($0) + } + return [label, control, suffixText != nil ? makeSuffix(rawName, suffixText!, suffixUrl) : NSView()] + } + + private static func makeLabel(_ labelText: String?, _ rawName: String) -> NSTextField { + let label = NSTextField(wrappingLabelWithString: labelText != nil ? labelText! + ": " : "") + label.fit() + label.alignment = .right + label.identifier = NSUserInterfaceItemIdentifier(rawName + ControlIdentifierDiscriminator.LABEL.rawValue) + return label + } + + private static func makeSuffix(_ controlName: String, _ text: String, _ url: String? = nil) -> NSTextField { + let suffix: NSTextField + if url == nil { + suffix = NSTextField(labelWithString: text) + } else { + suffix = HyperlinkLabel(text, url!) + } + suffix.textColor = .gray + suffix.identifier = NSUserInterfaceItemIdentifier(controlName + ControlIdentifierDiscriminator.SUFFIX.rawValue) + suffix.fit() + return suffix + } + + static func getControlValue(_ control: NSControl) -> String { + if control is NSPopUpButton { + return (control as! NSPopUpButton).titleOfSelectedItem! + } else if control is NSSlider { + return String(format: "%.0f", control.doubleValue) // we are only interested in decimals of the provided double + } else if control is NSButton { + return String((control as! NSButton).state == NSButton.StateValue.on) + } else { + return control.stringValue + } + } + + static func setControlValue(_ control: NSControl, _ value: String) { + if control is NSPopUpButton { + (control as! NSPopUpButton).selectItem(withTitle: value) + } else if control is NSTextField { + control.stringValue = value + (control as! NSTextField).delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: control)) + } else if control is NSButton { + (control as! NSButton).state = Bool(value) ?? false ? NSButton.StateValue.on : NSButton.StateValue.off + } else { + control.stringValue = value + } + } + + static func updateControlExtras(_ control: NSControl, _ value: String) { + if control is NSSlider { + updateSuffixWithValue(control as! NSSlider, value) + } + } + + private static func updateSuffixWithValue(_ control: NSControl, _ value: String) { + let suffixIdentifierPredicate = { (view: NSView) -> Bool in + view.identifier?.rawValue == control.identifier!.rawValue + ControlIdentifierDiscriminator.SUFFIX.rawValue + } + if let suffixView: NSTextField = control.superview?.subviews.first(where: suffixIdentifierPredicate) as? NSTextField { + let regex = try! NSRegularExpression(pattern: "^[0-9]+") // first decimal + let range = NSMakeRange(0, suffixView.stringValue.count) + suffixView.stringValue = regex.stringByReplacingMatches(in: suffixView.stringValue, range: range, withTemplate: value) + } + } +} + +enum ControlIdentifierDiscriminator: String { + case LABEL = "_label" + case SUFFIX = "_suffix" +} diff --git a/src/ui/preferences-window/PreferencesWindow.swift b/src/ui/preferences-window/PreferencesWindow.swift new file mode 100644 index 00000000..10292090 --- /dev/null +++ b/src/ui/preferences-window/PreferencesWindow.swift @@ -0,0 +1,38 @@ +import Cocoa + +class PreferencesWindow: NSWindow { + let tabViewController = TabViewController() + + override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { + super.init(contentRect: .zero, styleMask: style, backing: backingStoreType, defer: flag) + setupWindow() + setupTabViews() + } + + func show() { + App.shared.activate(ignoringOtherApps: true) + makeKeyAndOrderFront(nil) + } + + @objc static func controlWasChanged(_ senderControl: NSControl) { + let newValue = LabelAndControl.getControlValue(senderControl) + LabelAndControl.updateControlExtras(senderControl, newValue) + Preferences.set(senderControl.identifier!.rawValue, newValue) + (App.shared as! App).initPreferencesDependentComponents() + } + + private func setupWindow() { + hidesOnDeactivate = false + isReleasedWhenClosed = false + styleMask.insert([.miniaturizable, .closable]) + } + + private func setupTabViews() { + contentViewController = tabViewController + tabViewController.tabStyle = .toolbar + tabViewController.addTabViewItem(GeneralTab.make()) + tabViewController.addTabViewItem(AppearanceTab.make()) + tabViewController.addTabViewItem(UpdatesTab.make()) + tabViewController.addTabViewItem(AboutTab.make()) + } +} diff --git a/src/ui/preferences-window/TabViewController.swift b/src/ui/preferences-window/TabViewController.swift new file mode 100644 index 00000000..6e434624 --- /dev/null +++ b/src/ui/preferences-window/TabViewController.swift @@ -0,0 +1,18 @@ +import Cocoa + +class TabViewController: NSTabViewController { + override func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) { + super.tabView(tabView, didSelect: tabViewItem) + guard let tabViewItem = tabViewItem, let window = view.window else { return } + window.title = tabViewItem.label + resizeWindowToFit(tabViewItem, window) + } + + private func resizeWindowToFit(_ tabViewItem: NSTabViewItem, _ window: NSWindow) { + let contentFrame = window.frameRect(forContentRect: NSRect(origin: .zero, size: tabViewItem.view!.frame.size)) + let toolbarHeight = window.frame.size.height - contentFrame.size.height + let newOrigin = NSPoint(x: window.frame.origin.x, y: window.frame.origin.y + toolbarHeight) + let newFrame = NSRect(origin: newOrigin, size: contentFrame.size) + window.setFrame(newFrame, display: false, animate: true) + } +} \ No newline at end of file diff --git a/src/ui/preferences-window/TabViewItem.swift b/src/ui/preferences-window/TabViewItem.swift new file mode 100644 index 00000000..b7282c3b --- /dev/null +++ b/src/ui/preferences-window/TabViewItem.swift @@ -0,0 +1,12 @@ +import Cocoa + +class TabViewItem { + static func make(_ label: String, _ imageNAme: NSImage.Name, _ view: NSView) -> NSTabViewItem { + let viewController = NSViewController() + let tabViewItem = NSTabViewItem(viewController: viewController) + viewController.view = view + tabViewItem.label = label + tabViewItem.image = NSImage(named: imageNAme) + return tabViewItem + } +} diff --git a/src/ui/preferences-window/tabs/AboutTab.swift b/src/ui/preferences-window/tabs/AboutTab.swift new file mode 100644 index 00000000..98d53faa --- /dev/null +++ b/src/ui/preferences-window/tabs/AboutTab.swift @@ -0,0 +1,40 @@ +import Cocoa + +class AboutTab: NSObject { + static func make() -> NSTabViewItem { + return TabViewItem.make(NSLocalizedString("About", comment: ""), NSImage.infoName, makeView()) + } + + static func makeView() -> NSGridView { + let appIcon = NSImageView(image: NSImage(named: "app-icon")!.resizedCopy(150, 150)) + appIcon.imageScaling = .scaleNone + let appText = NSStackView(views: [ + BoldLabel(App.name), + NSTextField(wrappingLabelWithString: NSLocalizedString("Version", comment: "") + " " + App.version), + NSTextField(wrappingLabelWithString: App.licence), + HyperlinkLabel(NSLocalizedString("Source code repository", comment: ""), App.repository), + HyperlinkLabel(NSLocalizedString("Latest releases", comment: ""), App.repository + "/releases"), + ]) + appText.orientation = .vertical + appText.alignment = .left + appText.spacing = GridView.interPadding / 2 + let rowToSeparate = 3 + appText.views[rowToSeparate].topAnchor.constraint(equalTo: appText.views[rowToSeparate - 1].bottomAnchor, constant: GridView.interPadding).isActive = true + let appInfo = NSStackView(views: [appIcon, appText]) + appInfo.spacing = GridView.interPadding + let view = GridView.make([ + [appInfo], + [NSButton(title: NSLocalizedString("Send feedback…", comment: ""), target: self, action: #selector(feedbackCallback))], + ]) + let sendFeedbackCell = view.cell(atColumnIndex: 0, rowIndex: 1) + sendFeedbackCell.xPlacement = .center + sendFeedbackCell.row!.topPadding = GridView.interPadding + view.fit() + return view + } + + @objc + static func feedbackCallback() { + (App.shared as! App).showFeedbackPanel() + } +} diff --git a/src/ui/preferences-window/tabs/AppearanceTab.swift b/src/ui/preferences-window/tabs/AppearanceTab.swift new file mode 100644 index 00000000..262ab210 --- /dev/null +++ b/src/ui/preferences-window/tabs/AppearanceTab.swift @@ -0,0 +1,28 @@ +import Cocoa + +class AppearanceTab { + private static let rowHeight = CGFloat(20) + + static func make() -> NSTabViewItem { + return TabViewItem.make(NSLocalizedString("Appearance", comment: ""), NSImage.colorPanelName, makeView()) + } + + private static func makeView() -> NSGridView { + let view = GridView.make([ + LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Theme", comment: ""), "theme", MacroPreferences.themeList.values.map { $0.label }), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Max size on screen", comment: ""), "maxScreenUsage", 10, 100, 10, true, "%"), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Min windows per row", comment: ""), "minCellsPerRow", 1, 20, 20, true), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Max windows per row", comment: ""), "maxCellsPerRow", 1, 40, 20, true), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Min rows of windows", comment: ""), "minRows", 1, 20, 20, true), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Window app icon size", comment: ""), "iconSize", 0, 64, 11, false, "px"), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Window title font size", comment: ""), "fontHeight", 0, 64, 11, false, "px"), + LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Show on", comment: ""), "showOnScreen", Array(MacroPreferences.showOnScreenList.keys)), + LabelAndControl.makeLabelWithSlider(NSLocalizedString("Apparition delay", comment: ""), "windowDisplayDelay", 0, 2000, 11, false, "ms"), + LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hide space number labels", comment: ""), "hideSpaceNumberLabels"), + ]) + view.column(at: 0).xPlacement = .trailing + view.rowAlignment = .lastBaseline + view.fit() + return view + } +} diff --git a/src/ui/preferences-window/tabs/GeneralTab.swift b/src/ui/preferences-window/tabs/GeneralTab.swift new file mode 100644 index 00000000..5a58e96c --- /dev/null +++ b/src/ui/preferences-window/tabs/GeneralTab.swift @@ -0,0 +1,59 @@ +import Cocoa + +class GeneralTab { + private static let rowHeight = CGFloat(22) // height of the "Tab key" input + + static func make() -> NSTabViewItem { + return TabViewItem.make(NSLocalizedString("General", comment: ""), NSImage.preferencesGeneralName, makeView()) + } + + private static func makeView() -> NSGridView { + // TODO: make the validators be a part of each Preference + let tabKeyCodeValidator: ((String) -> Bool) = { + guard let int = Int($0) else { + return false + } + // non-special keys (mac & pc keyboards): https://eastmanreference.com/complete-list-of-applescript-key-codes + var whitelistedKeycodes: [Int] = Array(0...53) + whitelistedKeycodes.append(contentsOf: [65, 67, 69, 75, 76, 78, ]) + whitelistedKeycodes.append(contentsOf: Array(81...89)) + whitelistedKeycodes.append(contentsOf: [91, 92, 115, 116, 117, 119, 121]) + whitelistedKeycodes.append(contentsOf: Array(123...126)) + return whitelistedKeycodes.contains(int) + } + + let startAtLogin = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Start at login", comment: ""), "startAtLogin", extraAction: startAtLoginCallback) + let view = GridView.make([ + startAtLogin, + LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Alt key", comment: ""), "metaKey", MacroPreferences.metaKeyList.values.map { $0.label }), + LabelAndControl.makeLabelWithInput(NSLocalizedString("Tab key", comment: ""), "tabKeyCode", 33, NSLocalizedString("KeyCodes Reference", comment: ""), "https://eastmanreference.com/complete-list-of-applescript-key-codes", tabKeyCodeValidator), + ]) + view.column(at: 0).xPlacement = .trailing + view.rowAlignment = .lastBaseline + view.fit() + setLoginItemIfCheckboxIsOn(startAtLogin[1] as! NSButton) + return view + } + + private static func setLoginItemIfCheckboxIsOn(_ startAtLoginCheckbox: NSButton) { + if startAtLoginCheckbox.state == .on { + startAtLoginCallback(startAtLoginCheckbox) + } + } + + // adding/removing login item depending on the checkbox state + @available(OSX, deprecated: 10.11) + @objc static func startAtLoginCallback(_ sender: NSControl) { + let loginItems = LSSharedFileListCreate(nil, kLSSharedFileListSessionLoginItems.takeRetainedValue(), nil).takeRetainedValue() + let loginItemsSnapshot = LSSharedFileListCopySnapshot(loginItems, nil).takeRetainedValue() as! [LSSharedFileListItem] + if (sender as! NSButton).state == .on { + LSSharedFileListInsertItemURL(loginItems, kLSSharedFileListItemBeforeFirst.takeRetainedValue(), nil, nil, App.url, nil, nil) + } else { + loginItemsSnapshot.forEach { + if CFEqual(LSSharedFileListItemCopyResolvedURL($0, 0, nil).takeRetainedValue(), App.url) { + LSSharedFileListItemRemove(loginItems, $0) + } + } + } + } +} diff --git a/src/ui/preferences-window/tabs/UpdatesTab.swift b/src/ui/preferences-window/tabs/UpdatesTab.swift new file mode 100644 index 00000000..40a4d6aa --- /dev/null +++ b/src/ui/preferences-window/tabs/UpdatesTab.swift @@ -0,0 +1,73 @@ +import Cocoa +import Sparkle + +class UpdatesTab: NSObject { + static var dontPeriodicallyCheck: NSButton! + static var periodicallyCheck: NSButton! + static var periodicallyInstall: NSButton! + static var policyObserver = PolicyObserver() + // this helps prevent double-dipping (i.e. user updates the UI > changes the preference > updates the UI) + static var policyLock = false + + static func make() -> NSTabViewItem { + return TabViewItem.make(NSLocalizedString("Updates", comment: ""), NSImage.refreshTemplateName, makeView()) + } + + static func observeUserDefaults() { + UserDefaults.standard.addObserver(UpdatesTab.policyObserver, forKeyPath: "SUAutomaticallyUpdate", options: [.initial, .new], context: nil) + UserDefaults.standard.addObserver(UpdatesTab.policyObserver, forKeyPath: "SUEnableAutomaticChecks", options: [.initial, .new], context: nil) + } + + static private func makeView() -> NSGridView { + dontPeriodicallyCheck = NSButton(radioButtonWithTitle: NSLocalizedString("Don't check for updates periodically", comment: ""), target: self, action: #selector(updatePolicyCallback)) + dontPeriodicallyCheck.fit() + periodicallyCheck = NSButton(radioButtonWithTitle: NSLocalizedString("Check for updates periodically", comment: ""), target: self, action: #selector(updatePolicyCallback)) + periodicallyCheck.fit() + periodicallyInstall = NSButton(radioButtonWithTitle: NSLocalizedString("Auto-install updates periodically", comment: ""), target: self, action: #selector(updatePolicyCallback)) + periodicallyInstall.fit() + let policyLabel = NSTextField(wrappingLabelWithString: NSLocalizedString("Updates policy:", comment: "")) + let policies = NSStackView(views: [dontPeriodicallyCheck, periodicallyCheck, periodicallyInstall]) + policies.alignment = .left + policies.orientation = .vertical + policies.spacing = GridView.interPadding / 2 + let view = GridView.make([ + [policyLabel, policies], + [NSButton(title: NSLocalizedString("Check for updates now…", comment: ""), target: self, action: #selector(checkForUpdatesNow))], + ]) + view.cell(atColumnIndex: 0, rowIndex: 0).xPlacement = .trailing + let row1 = view.row(at: 1) + row1.mergeCells(in: NSRange(location: 0, length: 2)) + row1.topPadding = GridView.interPadding + row1.cell(at: 0).xPlacement = .center + view.fit() + return view + } + + @objc + static func checkForUpdatesNow(_ sender: Any) { + SUUpdater.shared().checkForUpdates(sender) + } + + @objc + static func updatePolicyCallback() { + policyLock = true + SUUpdater.shared().automaticallyDownloadsUpdates = periodicallyInstall.state == .on + SUUpdater.shared().automaticallyChecksForUpdates = periodicallyInstall.state == .on || periodicallyCheck.state == .on + policyLock = false + } +} + +class PolicyObserver: NSObject { + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard !UpdatesTab.policyLock else { return } + if SUUpdater.shared().automaticallyDownloadsUpdates { + UpdatesTab.periodicallyInstall.state = .on + // Sparkle UI "Automatically download and install updates in the future" doesn't activate periodical checks; we do it manually + SUUpdater.shared().automaticallyChecksForUpdates = true + } else if SUUpdater.shared().automaticallyChecksForUpdates { + UpdatesTab.periodicallyCheck.state = .on + } else { + UpdatesTab.dontPeriodicallyCheck.state = .on + } + } +}