├── .env.example ├── .github └── workflows │ ├── appcast.yml │ └── tests.yml ├── .gitignore ├── .ruby-version ├── AppCast ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── _config.yml ├── _includes │ ├── appcast_v1.inc │ └── appcast_v2.inc ├── _plugins │ ├── raise_error.rb │ └── signature_filter.rb ├── appcast.xml └── appcast_v2.xml ├── CHANGELOG.md ├── Configuration ├── README.md └── Release.xcconfig ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Screenshots ├── big_sur_dark_mode_1.png ├── big_sur_dark_mode_2.png ├── big_sur_dark_mode_6.png ├── big_sur_light_mode_1.png ├── big_sur_light_mode_2.png ├── big_sur_light_mode_6.png ├── download_application.png ├── icon.png ├── install_using_homebrew.png ├── launch_at_login.gif ├── move_to_applications.gif ├── notification_1.png ├── warning_1.png ├── warning_2.png └── warning_3.png ├── TestPlans └── UnitTests.xctestplan ├── ToolReleases.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── ToolReleases.xcscheme ├── ToolReleases ├── Bootstrap.swift ├── LocalNotificationProvider.swift ├── PopoverController.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Resources │ ├── Assets.xcassets │ │ ├── AboutIcon.imageset │ │ │ ├── Contents.json │ │ │ └── color-dev tool-icon.pdf │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ ├── 128.png │ │ │ ├── 16.png │ │ │ ├── 256-1.png │ │ │ ├── 256.png │ │ │ ├── 32-1.png │ │ │ ├── 32.png │ │ │ ├── 512-1.png │ │ │ ├── 512.png │ │ │ ├── 64.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── StatusBarIcon.imageset │ │ │ ├── Contents.json │ │ │ └── status_bar_icon_white.pdf │ └── Colors.xcassets │ │ ├── Contents.json │ │ └── forestgreen.colorset │ │ └── Contents.json ├── Screens │ ├── About │ │ ├── AboutView.swift │ │ └── AboutWindowController.swift │ ├── ContentView.swift │ ├── Debug │ │ └── DebugView.swift │ ├── Preferences │ │ ├── Notifications │ │ │ ├── NotificationPreferencesView.swift │ │ │ └── NotificationPreferencesWindowController.swift │ │ └── PreferencesView.swift │ └── ToolSummary │ │ ├── LastRefreshView.swift │ │ ├── Rows │ │ ├── ToolRowView.swift │ │ └── ToolRowViewModel.swift │ │ ├── SearchButton.swift │ │ ├── ToolSummaryView.swift │ │ └── ToolSummaryViewModel.swift ├── Support │ ├── AppDelegate.swift │ ├── Base.lproj │ │ └── Main.storyboard │ ├── Info.plist │ ├── Preferences.swift │ └── Updater.swift ├── ToolReleases.entitlements ├── Utils │ ├── Bundle+Version.swift │ ├── Calendar+DateComparison.swift │ ├── EventMonitor.swift │ ├── Logger+CustomInitializer.swift │ ├── NSApplication+Extension.swift │ ├── Notification+Extension.swift │ └── Search.swift └── Views │ └── BadgeView.swift ├── ToolReleasesCore ├── Models │ ├── RSSFeedItem.swift │ └── Tool.swift ├── ReleaseSimulator.swift ├── Support │ ├── Info.plist │ └── ToolReleasesCore.h ├── ToolLoader.swift ├── ToolProvider.swift └── Utils │ └── Logger+CustomInitializer.swift ├── ToolReleasesCoreTests ├── Support │ ├── Info.plist │ └── Tool+Stubs.swift ├── ToolProviderTests.swift └── ToolTests.swift ├── ToolReleasesTests ├── CalendarDateComparisonTests.swift ├── FilterTests.swift ├── KeywordSearchTests.swift ├── Support │ ├── Info.plist │ └── Tool+Stubs.swift └── ToolRowViewModelTests.swift ├── fastlane ├── Appfile ├── Fastfile └── README.md └── scripts └── sign_update /.env.example: -------------------------------------------------------------------------------- 1 | # This is an environment example file providing all the necessary environment variables. 2 | # To use this file, provide all necessary values and remove the `.example` from the file name. 3 | 4 | FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD= 5 | GITHUB_TOKEN= 6 | -------------------------------------------------------------------------------- /.github/workflows/appcast.yml: -------------------------------------------------------------------------------- 1 | name: Generate AppCast 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | jekyll: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2.4.0 14 | with: 15 | # If you're using actions/checkout@v2.4.0 you must set persist-credentials to false in most cases for the deployment to work correctly. 16 | persist-credentials: false 17 | 18 | - name: Cache 19 | uses: actions/cache@v2.1.7 20 | with: 21 | path: AppCast/vendor/bundle 22 | key: ${{ runner.os }}-gems-v1.0-${{ hashFiles('AppCast/Gemfile') }} 23 | restore-keys: | 24 | ${{ runner.os }}-gems- 25 | 26 | - name: Ruby 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: '3.1.0' 30 | 31 | - name: Bundler 32 | working-directory: AppCast 33 | env: 34 | BUNDLE_PATH: vendor/bundle 35 | run: | 36 | gem install bundler 37 | bundle install 38 | 39 | - name: Build 40 | working-directory: AppCast 41 | env: 42 | BUNDLE_PATH: vendor/bundle 43 | JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | run: bundle exec jekyll build 45 | 46 | - name: Publish 47 | uses: JamesIves/github-pages-deploy-action@releases/v3 48 | with: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | BRANCH: gh-pages 51 | FOLDER: AppCast/_site 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - develop 8 | paths: 9 | - 'TestPlans/**' 10 | - 'ToolReleases/**' 11 | - 'ToolReleasesCore/**' 12 | - 'ToolReleasesTests/**' 13 | - 'ToolReleasesCoreTests/**' 14 | 15 | jobs: 16 | test: 17 | name: Unit tests 18 | runs-on: macos-latest 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Set Xcode 25 | run: sudo xcode-select -switch /Applications/Xcode_13.2.1.app 26 | 27 | - name: Import Certificates 28 | uses: Apple-Actions/import-codesign-certs@v1 29 | with: 30 | p12-file-base64: ${{ secrets.CERTIFICATES_P12_B64 }} 31 | p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }} 32 | 33 | - name: Cache 34 | uses: actions/cache@v2 35 | with: 36 | path: .build 37 | key: ${{ runner.os }}-spm-v1.0-${{ hashFiles('**/Package.resolved') }} 38 | restore-keys: | 39 | ${{ runner.os }}-spm- 40 | 41 | - name: Resolve SPM packages 42 | run: xcodebuild -resolvePackageDependencies -clonedSourcePackagesDirPath .build 43 | 44 | - name: Clean 45 | run: | 46 | set -o pipefail 47 | xcodebuild -clonedSourcePackagesDirPath .build clean 48 | 49 | - name: Build 50 | run: | 51 | set -o pipefail 52 | xcodebuild -clonedSourcePackagesDirPath .build -scheme ToolReleases -destination platform=macOS build-for-testing | xcpretty 53 | 54 | - name: Test 55 | run: | 56 | set -o pipefail 57 | xcodebuild -clonedSourcePackagesDirPath .build -scheme ToolReleases -destination platform=macOS test-without-building | xcpretty 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/swift,macos,dotenv,fastlane,xcode,swiftpackagemanager 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,macos,dotenv,fastlane,xcode,swiftpackagemanager 4 | 5 | ### dotenv ### 6 | .env 7 | 8 | ### fastlane ### 9 | # fastlane - A streamlined workflow tool for Cocoa deployment 10 | # 11 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 12 | # screenshots whenever they are needed. 13 | # For more information about the recommended setup visit: 14 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 15 | 16 | # fastlane specific 17 | fastlane/report.xml 18 | 19 | # deliver temporary files 20 | fastlane/Preview.html 21 | 22 | # snapshot generated screenshots 23 | fastlane/screenshots/**/*.png 24 | fastlane/screenshots/screenshots.html 25 | 26 | # scan temporary files 27 | fastlane/test_output 28 | 29 | # Fastlane.swift runner binary 30 | fastlane/FastlaneRunner 31 | 32 | ### macOS ### 33 | # General 34 | .DS_Store 35 | .AppleDouble 36 | .LSOverride 37 | 38 | # Icon must end with two \r 39 | Icon 40 | 41 | 42 | # Thumbnails 43 | ._* 44 | 45 | # Files that might appear in the root of a volume 46 | .DocumentRevisions-V100 47 | .fseventsd 48 | .Spotlight-V100 49 | .TemporaryItems 50 | .Trashes 51 | .VolumeIcon.icns 52 | .com.apple.timemachine.donotpresent 53 | 54 | # Directories potentially created on remote AFP share 55 | .AppleDB 56 | .AppleDesktop 57 | Network Trash Folder 58 | Temporary Items 59 | .apdisk 60 | 61 | ### Swift ### 62 | # Xcode 63 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 64 | 65 | ## User settings 66 | xcuserdata/ 67 | 68 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 69 | *.xcscmblueprint 70 | *.xccheckout 71 | 72 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 73 | build/ 74 | DerivedData/ 75 | *.moved-aside 76 | *.pbxuser 77 | !default.pbxuser 78 | *.mode1v3 79 | !default.mode1v3 80 | *.mode2v3 81 | !default.mode2v3 82 | *.perspectivev3 83 | !default.perspectivev3 84 | 85 | ## Obj-C/Swift specific 86 | *.hmap 87 | 88 | ## App packaging 89 | *.ipa 90 | *.dSYM.zip 91 | *.dSYM 92 | 93 | ## Playgrounds 94 | timeline.xctimeline 95 | playground.xcworkspace 96 | 97 | # Swift Package Manager 98 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 99 | # Packages/ 100 | # Package.pins 101 | # Package.resolved 102 | # *.xcodeproj 103 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 104 | # hence it is not needed unless you have added a package configuration file to your project 105 | # .swiftpm 106 | 107 | .build/ 108 | 109 | # CocoaPods 110 | # We recommend against adding the Pods directory to your .gitignore. However 111 | # you should judge for yourself, the pros and cons are mentioned at: 112 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 113 | # Pods/ 114 | # Add this line if you want to avoid checking in source code from the Xcode workspace 115 | # *.xcworkspace 116 | 117 | # Carthage 118 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 119 | # Carthage/Checkouts 120 | 121 | Carthage/Build/ 122 | 123 | # Accio dependency management 124 | Dependencies/ 125 | .accio/ 126 | 127 | # fastlane 128 | # It is recommended to not store the screenshots in the git repo. 129 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 130 | # For more information about the recommended setup visit: 131 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 132 | 133 | 134 | # Code Injection 135 | # After new code Injection tools there's a generated folder /iOSInjectionProject 136 | # https://github.com/johnno1962/injectionforxcode 137 | 138 | iOSInjectionProject/ 139 | 140 | ### SwiftPackageManager ### 141 | Packages 142 | xcuserdata 143 | *.xcodeproj 144 | 145 | 146 | ### Xcode ### 147 | # Xcode 148 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 149 | 150 | 151 | 152 | 153 | ## Gcc Patch 154 | /*.gcno 155 | 156 | ### Xcode Patch ### 157 | *.xcodeproj/* 158 | !*.xcodeproj/project.pbxproj 159 | !*.xcodeproj/xcshareddata/ 160 | !*.xcworkspace/contents.xcworkspacedata 161 | **/xcshareddata/WorkspaceSettings.xcsettings 162 | 163 | # End of https://www.toptal.com/developers/gitignore/api/swift,macos,dotenv,fastlane,xcode,swiftpackagemanager -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.0 -------------------------------------------------------------------------------- /AppCast/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/jekyll 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=jekyll 4 | 5 | ### Jekyll ### 6 | _site/ 7 | .sass-cache/ 8 | .jekyll-cache/ 9 | .jekyll-metadata 10 | 11 | # End of https://www.toptal.com/developers/gitignore/api/jekyll -------------------------------------------------------------------------------- /AppCast/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "jekyll", "~> 4.2.1" 4 | gem "jekyll-github-metadata", group: :jekyll_plugins 5 | gem 'dotenv', '~> 2.7.6' 6 | -------------------------------------------------------------------------------- /AppCast/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | colorator (1.1.0) 7 | concurrent-ruby (1.1.9) 8 | dotenv (2.7.6) 9 | em-websocket (0.5.3) 10 | eventmachine (>= 0.12.9) 11 | http_parser.rb (~> 0) 12 | eventmachine (1.2.7) 13 | faraday (1.9.3) 14 | faraday-em_http (~> 1.0) 15 | faraday-em_synchrony (~> 1.0) 16 | faraday-excon (~> 1.1) 17 | faraday-httpclient (~> 1.0) 18 | faraday-multipart (~> 1.0) 19 | faraday-net_http (~> 1.0) 20 | faraday-net_http_persistent (~> 1.0) 21 | faraday-patron (~> 1.0) 22 | faraday-rack (~> 1.0) 23 | faraday-retry (~> 1.0) 24 | ruby2_keywords (>= 0.0.4) 25 | faraday-em_http (1.0.0) 26 | faraday-em_synchrony (1.0.0) 27 | faraday-excon (1.1.0) 28 | faraday-httpclient (1.0.1) 29 | faraday-multipart (1.0.3) 30 | multipart-post (>= 1.2, < 3) 31 | faraday-net_http (1.0.1) 32 | faraday-net_http_persistent (1.2.0) 33 | faraday-patron (1.0.0) 34 | faraday-rack (1.0.0) 35 | faraday-retry (1.0.3) 36 | ffi (1.15.5) 37 | forwardable-extended (2.6.0) 38 | http_parser.rb (0.8.0) 39 | i18n (1.8.11) 40 | concurrent-ruby (~> 1.0) 41 | jekyll (4.2.1) 42 | addressable (~> 2.4) 43 | colorator (~> 1.0) 44 | em-websocket (~> 0.5) 45 | i18n (~> 1.0) 46 | jekyll-sass-converter (~> 2.0) 47 | jekyll-watch (~> 2.0) 48 | kramdown (~> 2.3) 49 | kramdown-parser-gfm (~> 1.0) 50 | liquid (~> 4.0) 51 | mercenary (~> 0.4.0) 52 | pathutil (~> 0.9) 53 | rouge (~> 3.0) 54 | safe_yaml (~> 1.0) 55 | terminal-table (~> 2.0) 56 | jekyll-github-metadata (2.13.0) 57 | jekyll (>= 3.4, < 5.0) 58 | octokit (~> 4.0, != 4.4.0) 59 | jekyll-sass-converter (2.1.0) 60 | sassc (> 2.0.1, < 3.0) 61 | jekyll-watch (2.2.1) 62 | listen (~> 3.0) 63 | kramdown (2.3.1) 64 | rexml 65 | kramdown-parser-gfm (1.1.0) 66 | kramdown (~> 2.0) 67 | liquid (4.0.3) 68 | listen (3.7.1) 69 | rb-fsevent (~> 0.10, >= 0.10.3) 70 | rb-inotify (~> 0.9, >= 0.9.10) 71 | mercenary (0.4.0) 72 | multipart-post (2.1.1) 73 | octokit (4.22.0) 74 | faraday (>= 0.9) 75 | sawyer (~> 0.8.0, >= 0.5.3) 76 | pathutil (0.16.2) 77 | forwardable-extended (~> 2.6) 78 | public_suffix (4.0.6) 79 | rb-fsevent (0.11.0) 80 | rb-inotify (0.10.1) 81 | ffi (~> 1.0) 82 | rexml (3.2.5) 83 | rouge (3.27.0) 84 | ruby2_keywords (0.0.5) 85 | safe_yaml (1.0.5) 86 | sassc (2.4.0) 87 | ffi (~> 1.9) 88 | sawyer (0.8.2) 89 | addressable (>= 2.3.5) 90 | faraday (> 0.8, < 2.0) 91 | terminal-table (2.0.0) 92 | unicode-display_width (~> 1.1, >= 1.1.1) 93 | unicode-display_width (1.8.0) 94 | 95 | PLATFORMS 96 | arm64-darwin-21 97 | 98 | DEPENDENCIES 99 | dotenv (~> 2.7.6) 100 | jekyll (~> 4.2.1) 101 | jekyll-github-metadata 102 | 103 | BUNDLED WITH 104 | 2.3.3 105 | -------------------------------------------------------------------------------- /AppCast/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 10 | # 11 | # If you need help with YAML syntax, here are some quick references for you: 12 | # https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml 13 | # https://learnxinyminutes.com/docs/yaml/ 14 | # 15 | # Site settings 16 | # These are used to personalize your new site. If you look in the HTML files, 17 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 18 | # You can create any custom variable you would like, and they will be accessible 19 | # in the templates via {{ site.myvariable }}. 20 | 21 | title: ToolReleases.app 22 | description: >- # this means to ignore newlines until "baseurl:" 23 | baseurl: "" # the subpath of your site, e.g. /blog 24 | url: "" # the base hostname & protocol for your site, e.g. http://example.com 25 | 26 | # Build settings 27 | plugins: 28 | - "jekyll-github-metadata" 29 | 30 | # Exclude from processing. 31 | # The following items will not be processed, by default. 32 | # Any item listed under the `exclude:` key here will be automatically added to 33 | # the internal "default list". 34 | # 35 | # Excluded items can be processed by explicitly listing the directories or 36 | # their entries' file path in the `include:` list. 37 | # 38 | # exclude: 39 | # - .sass-cache/ 40 | # - .jekyll-cache/ 41 | # - gemfiles/ 42 | # - Gemfile 43 | # - Gemfile.lock 44 | # - node_modules/ 45 | # - vendor/bundle/ 46 | # - vendor/cache/ 47 | # - vendor/gems/ 48 | # - vendor/ruby/ 49 | -------------------------------------------------------------------------------- /AppCast/_includes/appcast_v1.inc: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ site.github.project_title }} 5 | Most recent changes with links to updates. 6 | en 7 | {% for release in site.github.releases -%} 8 | {% unless release.draft -%} 9 | {% unless release.prerelease and page.release_only -%} 10 | 11 | {{ release.name }} 12 | https://github.com/DeveloperMaris/ToolReleases 13 | 14 | {{ release.published_at | date_to_rfc822 }} 15 | {% for asset in release.assets limit:1 -%} 16 | {% assign signature = release.body | sparkle_signature -%} 17 | {% assign build_nums = asset.name | replace_first:'ToolReleases_v','' | replace_first:'.b',',' | remove_first:'.zip' | split:',' -%} 18 | {% if build_nums.size == 2 -%} 19 | {% assign version = build_nums[1] -%} 20 | {% assign short_version = build_nums[0] | remove_first:'v' -%} 21 | {% if short_version < '1.4' -%} 22 | 10.15 23 | {% else -%} 24 | 10.16 25 | {% endif -%} 26 | {% else -%} 27 | {% assign version = release.tag_name | remove_first:'v' -%} 28 | {% endif -%} 29 | {{ version }} 30 | {% if short_version -%} 31 | {{ short_version }} 32 | {% endif -%} 33 | 34 | {% endfor -%} 35 | 36 | {% endunless -%} 37 | {% endunless -%} 38 | {% endfor -%} 39 | 40 | 41 | -------------------------------------------------------------------------------- /AppCast/_includes/appcast_v2.inc: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ site.github.project_title }} 5 | Most recent changes with links to updates. 6 | en 7 | {% for release in site.github.releases -%} 8 | {% unless release.draft -%} 9 | {% for asset in release.assets limit:1 -%} 10 | {% assign build_nums = asset.name | replace_first:'ToolReleases_v','' | replace_first:'.b',',' | remove_first:'.zip' | split:',' -%} 11 | {% if build_nums.size != 2 -%} 12 | {{ "Incorrect asset details provided." | raise_error }} 13 | {% endif -%} 14 | {% assign version = build_nums[1] -%} 15 | {% assign short_version = build_nums[0] | remove_first:'v' -%} 16 | {% assign signature = release.body | sparkle_signature -%} 17 | {% unless short_version < '1.5' -%} 18 | {% if signature == "" -%} 19 | {{ "Sparkle signature not found." | raise_error }} 20 | {% endif -%} 21 | 22 | {{ release.name }} 23 | https://github.com/DeveloperMaris/ToolReleases 24 | 25 | {{ release.published_at | date_to_rfc822 }} 26 | 11 27 | {% if release.prerelease -%} 28 | beta 29 | {% endif -%} 30 | {{ version }} 31 | {{ short_version }} 32 | 33 | 34 | {% endunless -%} 35 | {% endfor -%} 36 | {% endunless -%} 37 | {% endfor -%} 38 | 39 | 40 | -------------------------------------------------------------------------------- /AppCast/_plugins/raise_error.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module ExceptionFilter 3 | def raise_error(msg) 4 | bad_file = @context.registers[:page]['path'] 5 | err_msg = "On #{bad_file}: #{msg}" 6 | raise err_msg 7 | end 8 | end 9 | end 10 | 11 | Liquid::Template.register_filter(Jekyll::ExceptionFilter) 12 | -------------------------------------------------------------------------------- /AppCast/_plugins/signature_filter.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module SignatureFilter 3 | def sparkle_signature(release_body) 4 | regex = //m 5 | match = release_body.match(regex) 6 | if match 7 | signature = match.named_captures["signature"] 8 | else 9 | signature = "" 10 | end 11 | signature 12 | end 13 | end 14 | end 15 | 16 | Liquid::Template.register_filter(Jekyll::SignatureFilter) 17 | -------------------------------------------------------------------------------- /AppCast/appcast.xml: -------------------------------------------------------------------------------- 1 | --- 2 | release_only: true 3 | --- 4 | {%include appcast_v1.inc %} 5 | -------------------------------------------------------------------------------- /AppCast/appcast_v2.xml: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | {%include appcast_v2.inc %} 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | - Local notification support for new releases; 2 | - New application update mechanism; 3 | - Bugfixes; 4 | -------------------------------------------------------------------------------- /Configuration/README.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Fastlane uses environment variables provided inside the local `.env` _([dotenv](https://docs.fastlane.tools/best-practices/keys/#dotenv))_ file which is not included inside the repository. 4 | 5 | An example of the local environment file can be found [here](../.env.example) 6 | -------------------------------------------------------------------------------- /Configuration/Release.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Release.xcconfig 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 12/11/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | // Configuration settings file format documentation can be found at: 10 | // https://help.apple.com/xcode/#/dev745c5c974 11 | 12 | // Release configuration needs to be set manually, providing the certificate signing identity. 13 | // It is necessary for Fastlane, so that it would sign the release build with Developer ID certificate, not the Developer certificate which in turn would fail automatic notarization. 14 | CODE_SIGN_IDENTITY = Developer ID Application 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'fastlane', '~> 2.201.1' 4 | gem 'dotenv', '~> 2.7.6' 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.5) 5 | rexml 6 | addressable (2.8.0) 7 | public_suffix (>= 2.0.2, < 5.0) 8 | artifactory (3.0.15) 9 | atomos (0.1.3) 10 | aws-eventstream (1.2.0) 11 | aws-partitions (1.549.0) 12 | aws-sdk-core (3.125.5) 13 | aws-eventstream (~> 1, >= 1.0.2) 14 | aws-partitions (~> 1, >= 1.525.0) 15 | aws-sigv4 (~> 1.1) 16 | jmespath (~> 1.0) 17 | aws-sdk-kms (1.53.0) 18 | aws-sdk-core (~> 3, >= 3.125.0) 19 | aws-sigv4 (~> 1.1) 20 | aws-sdk-s3 (1.111.2) 21 | aws-sdk-core (~> 3, >= 3.125.0) 22 | aws-sdk-kms (~> 1) 23 | aws-sigv4 (~> 1.4) 24 | aws-sigv4 (1.4.0) 25 | aws-eventstream (~> 1, >= 1.0.2) 26 | babosa (1.0.4) 27 | claide (1.1.0) 28 | colored (1.2) 29 | colored2 (3.1.2) 30 | commander (4.6.0) 31 | highline (~> 2.0.0) 32 | declarative (0.0.20) 33 | digest-crc (0.6.4) 34 | rake (>= 12.0.0, < 14.0.0) 35 | domain_name (0.5.20190701) 36 | unf (>= 0.0.5, < 1.0.0) 37 | dotenv (2.7.6) 38 | emoji_regex (3.2.3) 39 | excon (0.90.0) 40 | faraday (1.9.3) 41 | faraday-em_http (~> 1.0) 42 | faraday-em_synchrony (~> 1.0) 43 | faraday-excon (~> 1.1) 44 | faraday-httpclient (~> 1.0) 45 | faraday-multipart (~> 1.0) 46 | faraday-net_http (~> 1.0) 47 | faraday-net_http_persistent (~> 1.0) 48 | faraday-patron (~> 1.0) 49 | faraday-rack (~> 1.0) 50 | faraday-retry (~> 1.0) 51 | ruby2_keywords (>= 0.0.4) 52 | faraday-cookie_jar (0.0.7) 53 | faraday (>= 0.8.0) 54 | http-cookie (~> 1.0.0) 55 | faraday-em_http (1.0.0) 56 | faraday-em_synchrony (1.0.0) 57 | faraday-excon (1.1.0) 58 | faraday-httpclient (1.0.1) 59 | faraday-multipart (1.0.3) 60 | multipart-post (>= 1.2, < 3) 61 | faraday-net_http (1.0.1) 62 | faraday-net_http_persistent (1.2.0) 63 | faraday-patron (1.0.0) 64 | faraday-rack (1.0.0) 65 | faraday-retry (1.0.3) 66 | faraday_middleware (1.2.0) 67 | faraday (~> 1.0) 68 | fastimage (2.2.6) 69 | fastlane (2.201.1) 70 | CFPropertyList (>= 2.3, < 4.0.0) 71 | addressable (>= 2.8, < 3.0.0) 72 | artifactory (~> 3.0) 73 | aws-sdk-s3 (~> 1.0) 74 | babosa (>= 1.0.3, < 2.0.0) 75 | bundler (>= 1.12.0, < 3.0.0) 76 | colored 77 | commander (~> 4.6) 78 | dotenv (>= 2.1.1, < 3.0.0) 79 | emoji_regex (>= 0.1, < 4.0) 80 | excon (>= 0.71.0, < 1.0.0) 81 | faraday (~> 1.0) 82 | faraday-cookie_jar (~> 0.0.6) 83 | faraday_middleware (~> 1.0) 84 | fastimage (>= 2.1.0, < 3.0.0) 85 | gh_inspector (>= 1.1.2, < 2.0.0) 86 | google-apis-androidpublisher_v3 (~> 0.3) 87 | google-apis-playcustomapp_v1 (~> 0.1) 88 | google-cloud-storage (~> 1.31) 89 | highline (~> 2.0) 90 | json (< 3.0.0) 91 | jwt (>= 2.1.0, < 3) 92 | mini_magick (>= 4.9.4, < 5.0.0) 93 | multipart-post (~> 2.0.0) 94 | naturally (~> 2.2) 95 | optparse (~> 0.1.1) 96 | plist (>= 3.1.0, < 4.0.0) 97 | rubyzip (>= 2.0.0, < 3.0.0) 98 | security (= 0.1.3) 99 | simctl (~> 1.6.3) 100 | terminal-notifier (>= 2.0.0, < 3.0.0) 101 | terminal-table (>= 1.4.5, < 2.0.0) 102 | tty-screen (>= 0.6.3, < 1.0.0) 103 | tty-spinner (>= 0.8.0, < 1.0.0) 104 | word_wrap (~> 1.0.0) 105 | xcodeproj (>= 1.13.0, < 2.0.0) 106 | xcpretty (~> 0.3.0) 107 | xcpretty-travis-formatter (>= 0.0.3) 108 | gh_inspector (1.1.3) 109 | google-apis-androidpublisher_v3 (0.15.0) 110 | google-apis-core (>= 0.4, < 2.a) 111 | google-apis-core (0.4.2) 112 | addressable (~> 2.5, >= 2.5.1) 113 | googleauth (>= 0.16.2, < 2.a) 114 | httpclient (>= 2.8.1, < 3.a) 115 | mini_mime (~> 1.0) 116 | representable (~> 3.0) 117 | retriable (>= 2.0, < 4.a) 118 | rexml 119 | webrick 120 | google-apis-iamcredentials_v1 (0.10.0) 121 | google-apis-core (>= 0.4, < 2.a) 122 | google-apis-playcustomapp_v1 (0.7.0) 123 | google-apis-core (>= 0.4, < 2.a) 124 | google-apis-storage_v1 (0.11.0) 125 | google-apis-core (>= 0.4, < 2.a) 126 | google-cloud-core (1.6.0) 127 | google-cloud-env (~> 1.0) 128 | google-cloud-errors (~> 1.0) 129 | google-cloud-env (1.5.0) 130 | faraday (>= 0.17.3, < 2.0) 131 | google-cloud-errors (1.2.0) 132 | google-cloud-storage (1.36.0) 133 | addressable (~> 2.8) 134 | digest-crc (~> 0.4) 135 | google-apis-iamcredentials_v1 (~> 0.1) 136 | google-apis-storage_v1 (~> 0.1) 137 | google-cloud-core (~> 1.6) 138 | googleauth (>= 0.16.2, < 2.a) 139 | mini_mime (~> 1.0) 140 | googleauth (1.1.0) 141 | faraday (>= 0.17.3, < 2.0) 142 | jwt (>= 1.4, < 3.0) 143 | memoist (~> 0.16) 144 | multi_json (~> 1.11) 145 | os (>= 0.9, < 2.0) 146 | signet (>= 0.16, < 2.a) 147 | highline (2.0.3) 148 | http-cookie (1.0.4) 149 | domain_name (~> 0.5) 150 | httpclient (2.8.3) 151 | jmespath (1.5.0) 152 | json (2.6.1) 153 | jwt (2.3.0) 154 | memoist (0.16.2) 155 | mini_magick (4.11.0) 156 | mini_mime (1.1.2) 157 | multi_json (1.15.0) 158 | multipart-post (2.0.0) 159 | nanaimo (0.3.0) 160 | naturally (2.2.1) 161 | optparse (0.1.1) 162 | os (1.1.4) 163 | plist (3.6.0) 164 | public_suffix (4.0.6) 165 | rake (13.0.6) 166 | representable (3.1.1) 167 | declarative (< 0.1.0) 168 | trailblazer-option (>= 0.1.1, < 0.2.0) 169 | uber (< 0.2.0) 170 | retriable (3.1.2) 171 | rexml (3.2.5) 172 | rouge (2.0.7) 173 | ruby2_keywords (0.0.5) 174 | rubyzip (2.3.2) 175 | security (0.1.3) 176 | signet (0.16.0) 177 | addressable (~> 2.8) 178 | faraday (>= 0.17.3, < 2.0) 179 | jwt (>= 1.5, < 3.0) 180 | multi_json (~> 1.10) 181 | simctl (1.6.8) 182 | CFPropertyList 183 | naturally 184 | terminal-notifier (2.0.0) 185 | terminal-table (1.8.0) 186 | unicode-display_width (~> 1.1, >= 1.1.1) 187 | trailblazer-option (0.1.2) 188 | tty-cursor (0.7.1) 189 | tty-screen (0.8.1) 190 | tty-spinner (0.9.3) 191 | tty-cursor (~> 0.7) 192 | uber (0.1.0) 193 | unf (0.1.4) 194 | unf_ext 195 | unf_ext (0.0.8) 196 | unicode-display_width (1.8.0) 197 | webrick (1.7.0) 198 | word_wrap (1.0.0) 199 | xcodeproj (1.21.0) 200 | CFPropertyList (>= 2.3.3, < 4.0) 201 | atomos (~> 0.1.3) 202 | claide (>= 1.0.2, < 2.0) 203 | colored2 (~> 3.1) 204 | nanaimo (~> 0.3.0) 205 | rexml (~> 3.2.4) 206 | xcpretty (0.3.0) 207 | rouge (~> 2.0.7) 208 | xcpretty-travis-formatter (1.0.1) 209 | xcpretty (~> 0.2, >= 0.0.7) 210 | 211 | PLATFORMS 212 | ruby 213 | 214 | DEPENDENCIES 215 | dotenv (~> 2.7.6) 216 | fastlane (~> 2.201.1) 217 | 218 | BUNDLED WITH 219 | 2.1.4 220 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Maris Lagzdins 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Tool Releases 4 | 5 | Be informed about the latest Apple tool releases (including *Beta* releases) with just one click from your computer. 6 | 7 | This application for free retrieves, notifies and shows you the latest Apple tool releases (including *Beta* releases) from publicly available Apple [RSS feed](https://developer.apple.com/news/releases/rss/releases.rss) in a formatted list on your status bar so that you would not miss any new releases coming out. 8 | 9 | # Screenshots 10 | 11 | 12 | 13 | 14 | 15 | ## Notification 16 | 17 | 18 | 19 | ## Notification badge on the status icon 20 | 21 | 22 | 23 | # Requirements 24 | 25 | * macOS 11 (Big Sur) 26 | 27 | # Features 28 | 29 | * Displays a list of newest Apple tool releases; 30 | * Automatic background refresh approximately once per hour; 31 | * Notifies about the new releases [with a local notification](#notification); 32 | * Notifies about the new releases by [adding a badge to the status bar icon](#notification-badge-on-the-status-icon); 33 | * Possibility to search for a specific tool by keywords (multiple keyword groups can be separated with **semicolons**, for example, *"iOS beta; macOS Big Sur"*) 34 | * *(Note: Application will still notify you of any new tool releases, regardless of your currently searched keywords)* 35 | 36 | # Install 37 | 38 | ## 1. Using [Homebrew](https://brew.sh) 39 | 40 | 1. Open Terminal. 41 | 42 | 2. Execute command `brew install --cask toolreleases`. 43 | 44 | ![Install app using Homebrew](./Screenshots/install_using_homebrew.png) 45 | 46 | 3. Open Launchpad and launch the **ToolReleases** app. 47 | 48 | ## 2. Manual installation 49 | 50 | 1. Open [latest release page](https://github.com/DeveloperMaris/ToolReleases/releases/latest) and download the application binary **ToolReleases_vX.X.X.bX.zip**. 51 | 52 | ![Download the applications](./Screenshots/download_application.png) 53 | 54 | 2. Unarchive the **ToolReleases_vX.X.X.bX.zip** (it's possible, that downloaded file will be automatically unarchived, then you can skip this step). 55 | 56 | 3. Move the **ToolReleases.app** file to **Applications** directory. 57 | 58 | ![Move to Applications](./Screenshots/move_to_applications.gif) 59 | 60 | 4. Launch the **ToolReleases.app** file. 61 | 62 | ### Additional installation steps 63 | 64 | macOS includes a technology called Gatekeeper, that's designed to ensure that only trusted software runs on your Mac, [more info here](https://support.apple.com/en-us/HT202491). 65 | 66 | If app doesn't start right away after launching **ToolReleases.app** file and this warning appears: 67 | 68 | 69 | 70 | 1. Open **System Preferences** and navigate to **Security & Privacy** General tab. 71 | 72 | 2. At the bottom you will see something like *"ToolReleases" was blocked from use because it is not from an identified developer*, click on **Open Anyway**. 73 | 74 | 75 | 76 | 3. Prompt should appear (if not, launch the **ToolReleases.app** file again) and press **Open**. 77 | 78 | 79 | 80 | *Note: these steps will be necessary only for the first application launch.* 81 | 82 | # Launch at Login 83 | 84 | If you want that **ToolReleases** application would automatically launch at login, then: 85 | 86 | 1. Open **System Preferences** and navigate to **Users & Groups**. 87 | 2. Select *user* on the left side of the settings. 88 | 3. Select **Login Items** tab 89 | 4. Click on the **+** button 90 | 5. Select **Applications** directory on the left side. 91 | 6. Search and select the **ToolReleases.app** and click **Add** 92 | 93 | ![Launch at Login](./Screenshots/launch_at_login.gif) 94 | 95 | # Updates 96 | 97 | Future application updates are available through the in-application settings. 98 | 99 | # Licence 100 | 101 | `Tool Releases` is released under the BSD 3-Clause License. See [LICENSE](LICENSE) for details. 102 | -------------------------------------------------------------------------------- /Screenshots/big_sur_dark_mode_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/big_sur_dark_mode_1.png -------------------------------------------------------------------------------- /Screenshots/big_sur_dark_mode_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/big_sur_dark_mode_2.png -------------------------------------------------------------------------------- /Screenshots/big_sur_dark_mode_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/big_sur_dark_mode_6.png -------------------------------------------------------------------------------- /Screenshots/big_sur_light_mode_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/big_sur_light_mode_1.png -------------------------------------------------------------------------------- /Screenshots/big_sur_light_mode_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/big_sur_light_mode_2.png -------------------------------------------------------------------------------- /Screenshots/big_sur_light_mode_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/big_sur_light_mode_6.png -------------------------------------------------------------------------------- /Screenshots/download_application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/download_application.png -------------------------------------------------------------------------------- /Screenshots/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/icon.png -------------------------------------------------------------------------------- /Screenshots/install_using_homebrew.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/install_using_homebrew.png -------------------------------------------------------------------------------- /Screenshots/launch_at_login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/launch_at_login.gif -------------------------------------------------------------------------------- /Screenshots/move_to_applications.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/move_to_applications.gif -------------------------------------------------------------------------------- /Screenshots/notification_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/notification_1.png -------------------------------------------------------------------------------- /Screenshots/warning_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/warning_1.png -------------------------------------------------------------------------------- /Screenshots/warning_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/warning_2.png -------------------------------------------------------------------------------- /Screenshots/warning_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/Screenshots/warning_3.png -------------------------------------------------------------------------------- /TestPlans/UnitTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "87B77756-C674-4AC1-B67F-813A1DB441CB", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:ToolReleases.xcodeproj", 18 | "identifier" : "B510D0BC248B756C003FAEC0", 19 | "name" : "ToolReleasesCoreTests" 20 | } 21 | }, 22 | { 23 | "target" : { 24 | "containerPath" : "container:ToolReleases.xcodeproj", 25 | "identifier" : "B510D0AB248A344D003FAEC0", 26 | "name" : "ToolReleasesTests" 27 | } 28 | } 29 | ], 30 | "version" : 1 31 | } 32 | -------------------------------------------------------------------------------- /ToolReleases.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ToolReleases.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ToolReleases.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "FeedKit", 6 | "repositoryURL": "https://github.com/nmdias/FeedKit.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "68493a33d862c33c9a9f67ec729b3b7df1b20ade", 10 | "version": "9.1.2" 11 | } 12 | }, 13 | { 14 | "package": "Sparkle", 15 | "repositoryURL": "https://github.com/sparkle-project/Sparkle", 16 | "state": { 17 | "branch": null, 18 | "revision": "286edd1fa22505a9e54d170e9fd07d775ea233f2", 19 | "version": "2.1.0" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /ToolReleases.xcodeproj/xcshareddata/xcschemes/ToolReleases.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 62 | 63 | 64 | 65 | 67 | 73 | 74 | 75 | 77 | 83 | 84 | 85 | 86 | 87 | 98 | 100 | 106 | 107 | 108 | 109 | 115 | 117 | 123 | 124 | 125 | 126 | 128 | 129 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /ToolReleases/Bootstrap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bootstrap.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 19/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Combine 11 | import os.log 12 | import SwiftUI 13 | import ToolReleasesCore 14 | import UserNotifications 15 | 16 | class Bootstrap { 17 | static private let logger = Logger(category: "Bootstrap") 18 | 19 | private let notificationCenter: NotificationCenter = .default 20 | private let localNotificationProvider = LocalNotificationProvider() 21 | 22 | private var cancellables = Set() 23 | 24 | private lazy var preferences = Preferences() 25 | 26 | /// Manages tool information 27 | private lazy var toolProvider: ToolProvider = { 28 | let loader = ToolLoader() 29 | let provider = ToolProvider(loader: loader) 30 | return provider 31 | }() 32 | 33 | /// Manages in-app updates 34 | private lazy var updater: Updater = { 35 | let updater = Updater(preferences: preferences) 36 | updater.startBackgroundChecks() 37 | return updater 38 | }() 39 | 40 | private(set) lazy var popover: PopoverController = { 41 | let popover = PopoverController() 42 | return popover 43 | }() 44 | 45 | func start() { 46 | let vc = makeInitialViewController() 47 | 48 | popover.configureStatusBarView() 49 | popover.setContentViewController(vc) 50 | 51 | localNotificationProvider.requestNotificationAuthorizationIfNecessary() 52 | 53 | toolProvider.enableAutomaticUpdates() 54 | subscribeForToolUpdates() 55 | subscribeForPopoverAppearNotification() 56 | 57 | // To make the first app launch faster, just pre-fetch all the available tool releases. 58 | toolProvider.fetch() 59 | } 60 | } 61 | 62 | private extension Bootstrap { 63 | func makeInitialViewController() -> NSViewController { 64 | let view = ContentView() 65 | .environmentObject(updater) 66 | .environmentObject(preferences) 67 | .environmentObject(toolProvider) 68 | 69 | return NSHostingController(rootView: view) 70 | } 71 | 72 | func subscribeForToolUpdates() { 73 | toolProvider.newToolsPublisher 74 | .filter { $0.isEmpty == false } 75 | .sink { [weak self] tools in 76 | guard let self = self else { 77 | return 78 | } 79 | 80 | guard self.popover.isPopoverShown == false else { 81 | return 82 | } 83 | 84 | self.popover.showBadge() 85 | 86 | if self.preferences.isNotificationsEnabled { 87 | self.localNotificationProvider.addNotification(about: tools) { success in 88 | if success { 89 | Self.logger.debug("Notification added") 90 | } else { 91 | Self.logger.debug("Notification adding failed") 92 | } 93 | } 94 | } 95 | } 96 | .store(in: &cancellables) 97 | } 98 | 99 | func subscribeForPopoverAppearNotification() { 100 | notificationCenter 101 | .publisher(for: .windowWillAppear) 102 | .sink { [weak self] _ in 103 | self?.removeNotifications() 104 | } 105 | .store(in: &cancellables) 106 | } 107 | 108 | func removeNotifications() { 109 | popover.removeBadge() 110 | localNotificationProvider.removeAllNotifications() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /ToolReleases/LocalNotificationProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalNotificationController.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 17/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ToolReleasesCore 11 | import UserNotifications 12 | 13 | struct LocalNotificationProvider { 14 | private static let notificationID = "tool-releases-new-versions-available" 15 | 16 | private let center: UNUserNotificationCenter 17 | private let delegateQueue: DispatchQueue 18 | 19 | init(center: UNUserNotificationCenter = .current(), delegateQueue: DispatchQueue = .main) { 20 | self.center = center 21 | self.delegateQueue = delegateQueue 22 | } 23 | 24 | /// Request notification authorization in case the authorization status is not determined. 25 | func requestNotificationAuthorizationIfNecessary() { 26 | center.getNotificationSettings { settings in 27 | if settings.authorizationStatus == .notDetermined { 28 | requestNotifications { _ in 29 | // Do nothing... 30 | } 31 | } 32 | } 33 | } 34 | 35 | func addNotification(about tools: [Tool], completion: @escaping (Bool) -> Void) { 36 | center.getNotificationSettings { settings in 37 | switch settings.authorizationStatus { 38 | case .notDetermined: 39 | requestNotifications { success in 40 | if success { 41 | placeNotification(for: tools, completion: completion) 42 | } else { 43 | delegateQueue.async { 44 | completion(false) 45 | } 46 | } 47 | } 48 | 49 | case .authorized: 50 | placeNotification(for: tools, completion: completion) 51 | 52 | default: 53 | delegateQueue.async { 54 | completion(false) 55 | } 56 | } 57 | } 58 | } 59 | 60 | func removeAllNotifications() { 61 | center.removeAllPendingNotificationRequests() 62 | center.removeAllDeliveredNotifications() 63 | } 64 | } 65 | 66 | private extension LocalNotificationProvider { 67 | func requestNotifications(completion: @escaping (Bool) -> Void) { 68 | center.requestAuthorization(options: [.alert, .sound]) { granted, _ in 69 | completion(granted) 70 | } 71 | } 72 | 73 | func placeNotification(for tools: [Tool], completion: @escaping (Bool) -> Void) { 74 | let content = UNMutableNotificationContent() 75 | content.title = "New versions available!" 76 | content.sound = .default 77 | 78 | switch tools.count { 79 | case 0: 80 | assertionFailure("Do not display notification when there are no new tools.") 81 | delegateQueue.async { 82 | completion(false) 83 | } 84 | 85 | case 1: 86 | content.subtitle = "\(tools[0].shortTitle)" 87 | 88 | case 2: 89 | content.subtitle = "\(tools[0].shortTitle) and \(tools[1].shortTitle)" 90 | 91 | default: 92 | content.subtitle = "\(tools[0].shortTitle), \(tools[1].shortTitle) and more..." 93 | } 94 | 95 | let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) 96 | 97 | let id = makeNotificationID(for: tools) 98 | let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) 99 | center.add(request) { error in 100 | delegateQueue.async { 101 | if error == nil { 102 | completion(true) 103 | } else { 104 | completion(false) 105 | } 106 | } 107 | } 108 | } 109 | 110 | func makeNotificationID(for tools: [Tool]) -> String { 111 | "\(Self.notificationID)-\(tools.hashValue)" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ToolReleases/PopoverController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopoverController.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 19/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Foundation 11 | import os.log 12 | import SwiftUI 13 | 14 | class PopoverController { 15 | static private let logger = Logger(category: "PopoverController") 16 | 17 | private let notificationCenter: NotificationCenter 18 | 19 | private lazy var statusItem: NSStatusItem = { 20 | NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) 21 | }() 22 | 23 | private lazy var popover: NSPopover = { 24 | let popover = NSPopover() 25 | popover.contentSize = NSSize(width: 400, height: 400) 26 | popover.animates = false 27 | return popover 28 | }() 29 | 30 | private lazy var badge: NSView = { 31 | let view = NSView() 32 | view.translatesAutoresizingMaskIntoConstraints = false 33 | view.wantsLayer = true 34 | view.layer?.cornerRadius = 5 35 | view.layer?.borderWidth = 0.8 36 | view.layer?.borderColor = NSColor.black.cgColor 37 | view.layer?.backgroundColor = NSColor(named: "forestgreen")?.cgColor 38 | view.layer?.masksToBounds = true 39 | return view 40 | }() 41 | 42 | /// Listens for events which could close the popover. 43 | /// 44 | /// Events are received only when user generates 45 | /// those events outside of the main application 46 | /// window. 47 | private lazy var eventMonitor: EventMonitor = { 48 | EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in 49 | self?.closePopover() 50 | } 51 | }() 52 | 53 | var isPopoverShown: Bool { 54 | popover.isShown 55 | } 56 | 57 | init(notificationCenter: NotificationCenter = .default) { 58 | self.notificationCenter = notificationCenter 59 | } 60 | 61 | func configureStatusBarView() { 62 | guard let statusBarButton = statusItem.button else { 63 | fatalError("Status item does not exist") 64 | } 65 | 66 | badge.isHidden = true 67 | 68 | let statusBarImage = NSImage(named: "StatusBarIcon") 69 | statusBarImage?.size = NSSize(width: 20, height: 20) 70 | 71 | statusBarButton.image = statusBarImage 72 | statusBarButton.target = self 73 | statusBarButton.action = #selector(togglePopover) 74 | statusBarButton.addSubview(badge) 75 | 76 | NSLayoutConstraint.activate([ 77 | badge.trailingAnchor.constraint(equalTo: statusBarButton.trailingAnchor, constant: -2), 78 | badge.bottomAnchor.constraint(equalTo: statusBarButton.bottomAnchor, constant: -2), 79 | badge.widthAnchor.constraint(equalToConstant: 10), 80 | badge.heightAnchor.constraint(equalToConstant: 10) 81 | ]) 82 | } 83 | 84 | func setContentViewController(_ controller: NSViewController) { 85 | popover.contentViewController = controller 86 | } 87 | 88 | @objc 89 | func togglePopover(_ sender: Any?) { 90 | if popover.isShown { 91 | closePopover() 92 | } else { 93 | showPopover() 94 | } 95 | } 96 | 97 | func showPopover() { 98 | guard let button = statusItem.button else { 99 | Self.logger.error("Status item does not exist, cannot show popover") 100 | return 101 | } 102 | 103 | guard popover.isShown == false else { 104 | return 105 | } 106 | 107 | notificationCenter.post(name: .windowWillAppear, object: nil) 108 | eventMonitor.start() 109 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) 110 | } 111 | 112 | func closePopover() { 113 | guard popover.isShown == true else { 114 | return 115 | } 116 | 117 | popover.performClose(nil) 118 | eventMonitor.stop() 119 | notificationCenter.post(name: .windowDidDisappear, object: nil) 120 | } 121 | 122 | func showBadge() { 123 | badge.isHidden = false 124 | } 125 | 126 | func removeBadge() { 127 | badge.isHidden = true 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /ToolReleases/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AboutIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "color-dev tool-icon.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AboutIcon.imageset/color-dev tool-icon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AboutIcon.imageset/color-dev tool-icon.pdf -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/256-1.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/32-1.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/512-1.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32-1.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256-1.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512-1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/StatusBarIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "status_bar_icon_white.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true, 14 | "template-rendering-intent" : "template" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ToolReleases/Resources/Assets.xcassets/StatusBarIcon.imageset/status_bar_icon_white.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/ToolReleases/Resources/Assets.xcassets/StatusBarIcon.imageset/status_bar_icon_white.pdf -------------------------------------------------------------------------------- /ToolReleases/Resources/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ToolReleases/Resources/Colors.xcassets/forestgreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "34", 9 | "green" : "139", 10 | "red" : "34" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.133", 27 | "green" : "0.873", 28 | "red" : "0.133" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ToolReleases/Screens/About/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 30/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AboutView: View { 12 | @ObservedObject private var preferences: Preferences 13 | 14 | init(preferences: Preferences) { 15 | _preferences = ObservedObject(wrappedValue: preferences) 16 | } 17 | 18 | var body: some View { 19 | VStack(spacing: 10) { 20 | Spacer() 21 | 22 | VStack(spacing: 5) { 23 | Image("AboutIcon") 24 | .resizable() 25 | .frame(width: 64, height: 64) 26 | Text("Tool Releases") 27 | .font(.headline) 28 | .onTapGesture(count: 5) { 29 | preferences.isBetaUpdatesEnabled.toggle() 30 | } 31 | 32 | Text("v\(preferences.appVersion), build \(preferences.buildVersion)") 33 | .font(.caption) 34 | .foregroundColor(.secondary) 35 | 36 | Text("Copyright © 2022 Maris Lagzdins. All rights reserved.") 37 | .font(.caption) 38 | .italic() 39 | } 40 | 41 | Divider() 42 | 43 | VStack(spacing: 2) { 44 | Text("Application uses Apple Inc. publicly available RSS feed to collect and display information from it.") 45 | Text("Collected content isn't modified in any way.") 46 | Text("Collected content copyrights belongs to Apple Inc.") 47 | } 48 | 49 | Divider() 50 | 51 | HStack(spacing: 3) { 52 | Text("To report an issue, please visit") 53 | Link("github.com", destination: URL(string: "https://github.com/DeveloperMaris/ToolReleases/issues")!) 54 | } 55 | 56 | Spacer() 57 | } 58 | } 59 | } 60 | 61 | struct AboutView_Previews: PreviewProvider { 62 | static var previews: some View { 63 | AboutView(preferences: Preferences()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ToolReleases/Screens/About/AboutWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutViewContainer.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 30/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftUI 11 | 12 | class AboutWindowController: NSWindowController { 13 | let aboutView: AboutView 14 | 15 | init(aboutView: AboutView) { 16 | self.aboutView = aboutView 17 | 18 | let window = NSWindow( 19 | contentRect: CGRect(origin: .zero, size: CGSize(width: 650, height: 300)), 20 | styleMask: [.titled, .closable], 21 | backing: .buffered, 22 | defer: false 23 | ) 24 | window.title = "About" 25 | window.level = .normal 26 | window.contentView = NSHostingView(rootView: aboutView) 27 | window.center() 28 | 29 | super.init(window: window) 30 | } 31 | 32 | @available(*, unavailable) 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | override func showWindow(_ sender: Any?) { 38 | NSApp.activate(ignoringOtherApps: true) 39 | super.showWindow(sender) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 06/03/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ToolReleasesCore 11 | 12 | struct ContentView: View { 13 | @EnvironmentObject private var provider: ToolProvider 14 | 15 | var body: some View { 16 | ToolSummaryView(provider: provider) 17 | } 18 | } 19 | 20 | struct ContentView_Previews: PreviewProvider { 21 | static var previews: some View { 22 | ContentView() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ToolReleases/Screens/Debug/DebugView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DebugView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 21/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | #if DEBUG 10 | 11 | import SwiftUI 12 | import ToolReleasesCore 13 | 14 | struct DebugView: View { 15 | @EnvironmentObject private var provider: ToolProvider 16 | 17 | var body: some View { 18 | VStack { 19 | Text("Debug Menu") 20 | 21 | Divider() 22 | 23 | Button(action: simulate1NewRelease) { 24 | Text("Make 1 new release after 3 sec.") 25 | } 26 | 27 | Button(action: simulate2NewReleases) { 28 | Text("Make 2 new release after 3 sec.") 29 | } 30 | 31 | Button(action: simulate3NewReleases) { 32 | Text("Make 3 new release after 3 sec.") 33 | } 34 | } 35 | } 36 | 37 | private func simulate1NewRelease() { 38 | simulateNewReleases(1) 39 | } 40 | 41 | private func simulate2NewReleases() { 42 | simulateNewReleases(2) 43 | } 44 | 45 | private func simulate3NewReleases() { 46 | simulateNewReleases(3) 47 | } 48 | 49 | private func simulateNewReleases(_ count: Int) { 50 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 51 | provider.simulateNewReleases(count) 52 | } 53 | } 54 | } 55 | 56 | struct DebugView_Previews: PreviewProvider { 57 | static var previews: some View { 58 | DebugView() 59 | } 60 | } 61 | 62 | #endif 63 | -------------------------------------------------------------------------------- /ToolReleases/Screens/Preferences/Notifications/NotificationPreferencesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationPreferencesView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 28/01/2022. 6 | // Copyright © 2022 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NotificationPreferencesView: View { 12 | @ObservedObject private var preferences: Preferences 13 | 14 | init(preferences: Preferences) { 15 | _preferences = ObservedObject(wrappedValue: preferences) 16 | } 17 | 18 | var body: some View { 19 | Form { 20 | Section { 21 | Toggle("Allow Notifications", isOn: $preferences.isNotificationsEnabled) 22 | .toggleStyle(.switch) 23 | } footer: { 24 | Text("Allows the app to show a notification each time new tool releases are detected.") 25 | .font(.footnote) 26 | .foregroundColor(Color(.secondaryLabelColor)) 27 | } 28 | } 29 | .padding(.horizontal, 20) 30 | .frame(width: 350, height: 100) 31 | } 32 | } 33 | 34 | struct NotificationPreferencesView_Previews: PreviewProvider { 35 | static var previews: some View { 36 | NotificationPreferencesView(preferences: Preferences()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ToolReleases/Screens/Preferences/Notifications/NotificationPreferencesWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationPreferencesWindowController.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 28/01/2022. 6 | // Copyright © 2022 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftUI 11 | 12 | class NotificationPreferencesWindowController: NSWindowController { 13 | let view: NotificationPreferencesView 14 | 15 | init(view: NotificationPreferencesView) { 16 | self.view = view 17 | 18 | let window = NSWindow( 19 | contentRect: CGRect(origin: .zero, size: CGSize(width: 400, height: 400)), 20 | styleMask: [.titled, .closable], 21 | backing: .buffered, 22 | defer: false 23 | ) 24 | 25 | window.title = "Tool Release Notifications" 26 | window.level = .normal 27 | window.contentView = NSHostingView(rootView: view) 28 | window.center() 29 | 30 | super.init(window: window) 31 | } 32 | 33 | @available(*, unavailable) 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | override func showWindow(_ sender: Any?) { 39 | NSApp.activate(ignoringOtherApps: true) 40 | super.showWindow(sender) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ToolReleases/Screens/Preferences/PreferencesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferencesView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 10/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import os.log 10 | import SwiftUI 11 | 12 | struct PreferencesView: View { 13 | @EnvironmentObject private var updater: Updater 14 | @EnvironmentObject private var preferences: Preferences 15 | 16 | @ViewBuilder 17 | private var menu: some View { 18 | let menu = Menu { 19 | Button("About", action: showAbout) 20 | Button("Notifications", action: showNotificationPreferences) 21 | Button(updateMenuString, action: checkForUpdates) 22 | Divider() 23 | Button("Quit", action: quit) 24 | 25 | if preferences.isBetaVersion || preferences.isBetaUpdatesEnabled { 26 | Divider() 27 | } 28 | 29 | if preferences.isBetaVersion { 30 | Text("Beta version \(preferences.appVersion)") 31 | } 32 | 33 | if preferences.isBetaUpdatesEnabled { 34 | Button("Disable Beta updates") { 35 | preferences.isBetaUpdatesEnabled = false 36 | } 37 | } 38 | } label: { 39 | Label("Settings", systemImage: "gear") 40 | .imageScale(.medium) 41 | .labelStyle(.iconOnly) 42 | } 43 | 44 | if #available(macOS 12, *) { 45 | menu.menuIndicator(.hidden) 46 | } else { 47 | menu 48 | } 49 | } 50 | 51 | var updateMenuString: String { 52 | if updater.isUpdateAvailable { 53 | return "Update is available!" 54 | } else { 55 | return "Check for Updates" 56 | } 57 | } 58 | 59 | var body: some View { 60 | ZStack(alignment: .topTrailing) { 61 | // Menu appears as a dropdown box with an icon 62 | // and a space for the text. If we want to hide the 63 | // text, it still shows an empty space for the 64 | // text by default, so we need to set a custom 65 | // frame size, so that the icon would only be 66 | // visible. 67 | menu 68 | .frame(width: 20, height: 20) 69 | .menuStyle(.borderlessButton) 70 | .foregroundColor(Color(.labelColor)) 71 | 72 | BadgeView() 73 | .opacity(updater.isUpdateAvailable ? 1 : 0) 74 | .allowsHitTesting(false) 75 | } 76 | } 77 | 78 | func quit() { 79 | NSApp.terminate(nil) 80 | } 81 | 82 | func showAbout() { 83 | let view = AboutView(preferences: preferences) 84 | let controller = AboutWindowController(aboutView: view) 85 | 86 | closeMainWindow() 87 | controller.showWindow(nil) 88 | } 89 | 90 | func showNotificationPreferences() { 91 | let view = NotificationPreferencesView(preferences: preferences) 92 | let controller = NotificationPreferencesWindowController(view: view) 93 | 94 | closeMainWindow() 95 | controller.showWindow(nil) 96 | } 97 | 98 | func checkForUpdates() { 99 | closeMainWindow() 100 | updater.checkForUpdates() 101 | } 102 | 103 | private func closeMainWindow() { 104 | if let delegate = NSApp.delegate as? AppDelegate { 105 | delegate.closePopover() 106 | } 107 | } 108 | } 109 | 110 | struct PreferencesView_Previews: PreviewProvider { 111 | static var previews: some View { 112 | PreferencesView() 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ToolSummary/LastRefreshView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LastRefreshView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 09/07/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LastRefreshView: View { 12 | var isRefreshing: Bool 13 | var lastRefreshDate: Date? 14 | var handler: () -> Void 15 | 16 | private static let formatter: DateFormatter = { 17 | let formatter = DateFormatter() 18 | 19 | formatter.dateStyle = .short 20 | formatter.timeStyle = .short 21 | 22 | return formatter 23 | }() 24 | 25 | private var formattedLastRefreshDate: String { 26 | if let date = lastRefreshDate { 27 | return "Last refresh: \(Self.formatter.string(from: date))" 28 | } 29 | 30 | return "Data hasn't loaded yet" 31 | } 32 | 33 | var body: some View { 34 | HStack { 35 | Text(formattedLastRefreshDate) 36 | .font(.caption) 37 | .foregroundColor(.secondary) 38 | 39 | Spacer() 40 | 41 | Group { 42 | if isRefreshing { 43 | ProgressView() 44 | .progressViewStyle(.circular) 45 | .scaleEffect(0.5) 46 | } else { 47 | Button(action: handler) { 48 | Label("Reload", systemImage: "arrow.clockwise") 49 | .labelStyle(.iconOnly) 50 | } 51 | .buttonStyle(.borderless) 52 | .foregroundColor(Color(.labelColor)) 53 | .disabled(isRefreshing) 54 | } 55 | } 56 | .frame(width: 16, height: 16) 57 | } 58 | } 59 | } 60 | 61 | struct LastRefreshView_Previews: PreviewProvider { 62 | static var previews: some View { 63 | LastRefreshView(isRefreshing: false, lastRefreshDate: Date()) { } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ToolSummary/Rows/ToolRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReleasedToolRow.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 09/05/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import os.log 11 | import SwiftUI 12 | import ToolReleasesCore 13 | 14 | struct ToolRowView: View { 15 | @StateObject var viewModel: ViewModel 16 | 17 | var body: some View { 18 | HStack { 19 | Text(viewModel.tool.title) 20 | .font(.system(size: 12, weight: .medium, design: .default)) 21 | .lineLimit(nil) 22 | 23 | Spacer() 24 | 25 | Text(viewModel.relativeDate) 26 | .font(.system(size: 10, weight: viewModel.isRecentRelease == true ? .bold : .light, design: .default)) 27 | .foregroundColor(viewModel.isRecentRelease == true ? Color("forestgreen") : .secondary) 28 | .lineLimit(1) 29 | .help(viewModel.fullDate) 30 | } 31 | .padding([.vertical], 4) 32 | .onAppear { 33 | viewModel.subscribeForTimerUpdates() 34 | } 35 | .onReceive(NotificationCenter.default.publisher(for: .windowWillAppear)) { _ in 36 | viewModel.subscribeForTimerUpdates() 37 | } 38 | .onReceive(NotificationCenter.default.publisher(for: .windowDidDisappear)) { _ in 39 | viewModel.unsubscribeFromTimerUpdates() 40 | } 41 | } 42 | 43 | init(tool: Tool, timer: Publishers.Autoconnect) { 44 | _viewModel = StateObject(wrappedValue: ViewModel(tool: tool, timer: timer)) 45 | } 46 | } 47 | 48 | struct ReleasedToolRow_Previews: PreviewProvider { 49 | static var previews: some View { 50 | let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 51 | return ToolRowView(tool: .example, timer: timer) 52 | .frame(width: 300) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ToolSummary/Rows/ToolRowViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolRowViewModel.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 02/06/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import os.log 12 | import ToolReleasesCore 13 | 14 | extension ToolRowView { 15 | class ViewModel: ObservableObject { 16 | static private let logger = Logger(category: "ToolRowView") 17 | 18 | private static let fullDateFormatter: DateFormatter = { 19 | let formatter = DateFormatter() 20 | formatter.dateStyle = .full 21 | formatter.timeStyle = .short 22 | return formatter 23 | }() 24 | 25 | private static let relativeDateFormatter: RelativeDateTimeFormatter = { 26 | let formatter = RelativeDateTimeFormatter() 27 | formatter.unitsStyle = .full 28 | formatter.dateTimeStyle = .numeric 29 | formatter.formattingContext = .standalone 30 | return formatter 31 | }() 32 | 33 | /// Provides a number of days, which counts as a recent releases and will 34 | /// be used to calculate how many tools needs to be highlighted. 35 | private static let recentReleaseDays = 4 36 | 37 | private var cancellableTimer: AnyCancellable? 38 | 39 | let tool: Tool 40 | let timer: Publishers.Autoconnect 41 | 42 | @Published var fullDate: String 43 | @Published var relativeDate: String 44 | @Published var isRecentRelease: Bool 45 | 46 | init(tool: Tool, timer: Publishers.Autoconnect) { 47 | self.tool = tool 48 | self.timer = timer 49 | self.fullDate = Self.fullDateFormatter.string(from: tool.date) 50 | self.relativeDate = "" 51 | self.isRecentRelease = false 52 | 53 | self.relativeDate = self.relativeDate(against: Date()) 54 | self.isRecentRelease = self.isRecentRelease(against: Date()) 55 | } 56 | 57 | func subscribeForTimerUpdates() { 58 | Self.logger.debug("Subscribe for timer updates, \(self.tool.title, privacy: .public)") 59 | 60 | cancellableTimer = timer.sink { [weak self] date in 61 | guard let self = self else { 62 | return 63 | } 64 | 65 | self.relativeDate = self.relativeDate(against: date) 66 | self.isRecentRelease = self.isRecentRelease(against: date) 67 | } 68 | } 69 | 70 | func unsubscribeFromTimerUpdates() { 71 | Self.logger.debug("Unsubscribe for timer updates, \(self.tool.title, privacy: .public)") 72 | 73 | cancellableTimer = nil 74 | } 75 | 76 | /// Compares date times and produces a localized relative date time string. 77 | /// 78 | /// Meant for comparing the Tool release date time with current date time and provide a localized string as "1 Hour Ago", etc. 79 | /// 80 | /// - Important: If the beginning date is Friday afternoon (11 PM) and the end date is Saturday morning (9AM), then it will 81 | /// be treated as a next day already. Method does not care if 24 hours have or have not passed, it checks the current days. 82 | /// - Parameters: 83 | /// - date: Beginning date of comparison. 84 | /// - relativeDate: End date of comparison. 85 | /// - calendar: Calendar is used to calculate precise dates. 86 | /// - Returns: Localized relative date time format. 87 | func string(for date: Date, relativeTo relativeDate: Date, calendar: Calendar = .current) -> String { 88 | if calendar.isDateComponent(.minute, from: date, to: relativeDate, lessThan: 1) { 89 | return "Just now" 90 | } else { 91 | // Remove exact time and leave only the date 92 | let sourceDateComponents = calendar.dateComponents([.day, .month, .year], from: date) 93 | let relativeDateComponents = calendar.dateComponents([.day, .month, .year], from: relativeDate) 94 | 95 | // Recreate the date object with only date available 96 | let sourceDateOnly = calendar.date(from: sourceDateComponents) ?? date 97 | let relativeDateOnly = calendar.date(from: relativeDateComponents) ?? relativeDate 98 | 99 | if calendar.isDateComponent(.day, from: sourceDateOnly, to: relativeDateOnly, lessThan: 1) { 100 | // Show exact hour count only if the tool was released in the same day 101 | return Self.relativeDateFormatter.localizedString(for: date, relativeTo: relativeDate).capitalized 102 | } else { 103 | // Take in count only the dates, not exact time 104 | return Self.relativeDateFormatter.localizedString(for: sourceDateOnly, relativeTo: relativeDateOnly).capitalized 105 | } 106 | } 107 | } 108 | 109 | deinit { 110 | unsubscribeFromTimerUpdates() 111 | } 112 | } 113 | } 114 | 115 | private extension ToolRowView.ViewModel { 116 | func relativeDate(against date: Date) -> String { 117 | string(for: tool.date, relativeTo: date) 118 | } 119 | 120 | func isRecentRelease(against date: Date) -> Bool { 121 | Calendar.current.isDateComponent(.day, from: tool.date, to: date, lessThan: Self.recentReleaseDays) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ToolSummary/SearchButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchButton.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 09/07/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SearchButton: View { 12 | var handler: () -> Void 13 | 14 | var body: some View { 15 | Button(action: handler) { 16 | Label("Search", systemImage: "magnifyingglass") 17 | .imageScale(.medium) 18 | .labelStyle(.iconOnly) 19 | } 20 | .frame(width: 20, height: 20) 21 | .buttonStyle(.borderless) 22 | .foregroundColor(Color(.labelColor)) 23 | } 24 | } 25 | 26 | struct SearchButton_Previews: PreviewProvider { 27 | static var previews: some View { 28 | SearchButton() { 29 | // do nothing 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ToolSummary/ToolSummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolSummaryView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 28/05/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import os.log 11 | import SwiftUI 12 | import ToolReleasesCore 13 | 14 | struct ToolSummaryView: View { 15 | static private let logger = Logger(category: "ToolSummaryView") 16 | 17 | @StateObject private var viewModel: ViewModel 18 | 19 | var body: some View { 20 | VStack(spacing: 0) { 21 | VStack { 22 | HStack(spacing: 16) { 23 | Picker("Select filter", selection: $viewModel.filter) { 24 | ForEach(ViewModel.Filter.allCases, id: \.self) { 25 | Text($0.description) 26 | } 27 | } 28 | .pickerStyle(.segmented) 29 | .labelsHidden() 30 | 31 | SearchButton { 32 | withAnimation { 33 | viewModel.isKeywordFilterEnabled.toggle() 34 | } 35 | } 36 | 37 | PreferencesView() 38 | #if DEBUG 39 | .contextMenu { 40 | DebugView() 41 | } 42 | #endif 43 | } 44 | 45 | if viewModel.isKeywordFilterEnabled { 46 | TextField("iOS; macOS beta", text: $viewModel.keywords) 47 | .textFieldStyle(.roundedBorder) 48 | .transition(.opacity) 49 | } 50 | } 51 | .padding() 52 | 53 | Divider() 54 | 55 | if viewModel.tools.isEmpty { 56 | Text("No releases available") 57 | .frame(maxWidth: .infinity, maxHeight: .infinity) 58 | .background(Color(.textBackgroundColor)) 59 | } else { 60 | List { 61 | ForEach(viewModel.tools) { tool in 62 | ToolRowView(tool: tool, timer: viewModel.timer) 63 | .onTapGesture { 64 | open(tool) 65 | } 66 | } 67 | } 68 | } 69 | 70 | Divider() 71 | 72 | LastRefreshView( 73 | isRefreshing: viewModel.isRefreshing, 74 | lastRefreshDate: viewModel.lastRefresh, 75 | handler: fetch 76 | ) 77 | .padding() 78 | } 79 | .background(Color(.windowBackgroundColor)) 80 | .onAppear { 81 | Self.logger.debug("Fetch tools on view appear") 82 | fetch() 83 | } 84 | } 85 | 86 | init(provider: ToolProvider) { 87 | let viewModel = ViewModel(provider: provider) 88 | _viewModel = StateObject(wrappedValue: viewModel) 89 | } 90 | } 91 | 92 | // MARK: - Private methods 93 | private extension ToolSummaryView { 94 | func open(_ tool: Tool) { 95 | if let url = tool.url { 96 | NSWorkspace.shared.open(url) 97 | } 98 | } 99 | 100 | func fetch() { 101 | viewModel.fetch() 102 | } 103 | } 104 | 105 | struct ToolSummaryView_Previews: PreviewProvider { 106 | static var previews: some View { 107 | ToolSummaryView(provider: .init(loader: .init())) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /ToolReleases/Screens/ToolSummary/ToolSummaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolSummaryViewModel.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 28/05/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import ToolReleasesCore 12 | 13 | extension ToolSummaryView { 14 | class ViewModel: ObservableObject { 15 | private let provider: ToolProvider 16 | private var cancellables: Set = [] 17 | 18 | @Published private(set) var tools: [Tool] 19 | @Published private(set) var isRefreshing: Bool 20 | @Published var keywords: String 21 | @Published var filter: Filter 22 | @Published var isKeywordFilterEnabled: Bool 23 | 24 | let timer: Publishers.Autoconnect 25 | var lastRefresh: Date? { provider.lastRefresh } 26 | 27 | init(provider: ToolProvider) { 28 | self.provider = provider 29 | self.tools = [] 30 | self.filter = .all 31 | self.keywords = "" 32 | self.isRefreshing = false 33 | self.timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect() 34 | self.isKeywordFilterEnabled = false 35 | 36 | provider.$tools 37 | .map { [weak self] tools in 38 | guard let self = self else { return [] } 39 | 40 | return self.filter( 41 | tools, 42 | filter: self.filter, 43 | includingKeywordFilter: self.isKeywordFilterEnabled, 44 | by: self.keywords 45 | ) 46 | } 47 | .sink { [weak self] tools in 48 | self?.tools = tools 49 | } 50 | .store(in: &cancellables) 51 | 52 | $keywords 53 | .dropFirst() 54 | .debounce(for: 0.3, scheduler: DispatchQueue.main) 55 | .map { [weak self] searchString in 56 | guard let self = self else { return [] } 57 | 58 | return self.filter( 59 | self.provider.tools, 60 | filter: self.filter, 61 | includingKeywordFilter: self.isKeywordFilterEnabled, 62 | by: String(searchString) 63 | ) 64 | } 65 | .sink { [weak self] tools in 66 | self?.tools = tools 67 | } 68 | .store(in: &cancellables) 69 | 70 | $filter 71 | .dropFirst() 72 | .map { [weak self] filter in 73 | guard let self = self else { return [] } 74 | 75 | return self.filter( 76 | self.provider.tools, 77 | filter: filter, 78 | includingKeywordFilter: self.isKeywordFilterEnabled, 79 | by: self.keywords 80 | ) 81 | } 82 | .sink { [weak self] tools in 83 | self?.tools = tools 84 | } 85 | .store(in: &cancellables) 86 | 87 | $isKeywordFilterEnabled 88 | .dropFirst() 89 | .map { [weak self] filterEnabled in 90 | guard let self = self else { return [] } 91 | 92 | return self.filter( 93 | self.provider.tools, 94 | filter: self.filter, 95 | includingKeywordFilter: filterEnabled, 96 | by: self.keywords 97 | ) 98 | } 99 | .sink { [weak self] tools in 100 | self?.tools = tools 101 | } 102 | .store(in: &cancellables) 103 | 104 | provider.$isRefreshing 105 | .sink { [weak self] isRefreshing in 106 | self?.isRefreshing = isRefreshing 107 | } 108 | .store(in: &cancellables) 109 | } 110 | 111 | func filter( 112 | _ tools: [Tool], 113 | filter: Filter, 114 | includingKeywordFilter: Bool = true, 115 | by keywords: String 116 | ) -> [Tool] { 117 | let keywordGroups = Search.transformKeywords(keywords) 118 | 119 | return tools 120 | .filtered(by: filter) 121 | .filter { 122 | includingKeywordFilter && keywordGroups.isEmpty == false 123 | ? $0.title.contains(keywordGroups) 124 | : true 125 | } 126 | .sorted { $0.date > $1.date } 127 | } 128 | 129 | func fetch() { 130 | provider.fetch() 131 | } 132 | } 133 | } 134 | 135 | extension ToolSummaryView.ViewModel { 136 | enum Filter: CaseIterable, CustomStringConvertible { 137 | case all, beta, release 138 | 139 | public var description: String { 140 | switch self { 141 | case .all: 142 | return "All" 143 | case .beta: 144 | return "Beta" 145 | case .release: 146 | return "Released" 147 | } 148 | } 149 | } 150 | } 151 | 152 | extension Array where Element == Tool { 153 | func filtered(by filter: ToolSummaryView.ViewModel.Filter) -> [Element] { 154 | switch filter { 155 | case .all: 156 | return self 157 | case .beta: 158 | return self.filter { $0.isBeta || $0.isReleaseCandidate } 159 | case .release: 160 | return self.filter { $0.isRelease } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /ToolReleases/Support/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 06/03/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Combine 11 | import os.log 12 | import SwiftUI 13 | import ToolReleasesCore 14 | import UserNotifications 15 | 16 | @NSApplicationMain 17 | class AppDelegate: NSObject, NSApplicationDelegate { 18 | private var bootstrap: Bootstrap! 19 | 20 | func applicationDidFinishLaunching(_ aNotification: Notification) { 21 | UNUserNotificationCenter.current().delegate = self 22 | 23 | bootstrap = Bootstrap() 24 | bootstrap.start() 25 | } 26 | 27 | func closePopover() { 28 | bootstrap.popover.closePopover() 29 | } 30 | } 31 | 32 | // MARK: - UNUserNotificationCenterDelegate 33 | extension AppDelegate: UNUserNotificationCenterDelegate { 34 | func userNotificationCenter( 35 | _ center: UNUserNotificationCenter, 36 | didReceive response: UNNotificationResponse, 37 | withCompletionHandler completionHandler: @escaping () -> Void 38 | ) { 39 | if response.actionIdentifier == UNNotificationDefaultActionIdentifier { 40 | // The user launched the app 41 | bootstrap.popover.showPopover() 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ToolReleases/Support/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | ITSAppUsesNonExemptEncryption 24 | 25 | LSApplicationCategoryType 26 | public.app-category.developer-tools 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | LSUIElement 30 | 31 | NSHumanReadableCopyright 32 | Copyright © 2022 Maris Lagzdins. All rights reserved. 33 | NSMainStoryboardFile 34 | Main 35 | NSPrincipalClass 36 | NSApplication 37 | NSSupportsAutomaticTermination 38 | 39 | NSSupportsSuddenTermination 40 | 41 | SUEnableInstallerLauncherService 42 | 43 | SUFeedURL 44 | https://developermaris.github.io/ToolReleases/appcast_v2.xml 45 | SUPublicEDKey 46 | iTuIRfSGfUqjp+W51Hejdll8ZepmtibsB2K1KVFlPAM= 47 | 48 | 49 | -------------------------------------------------------------------------------- /ToolReleases/Support/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 27/01/2022. 6 | // Copyright © 2022 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class Preferences: ObservableObject { 12 | private enum Key: String { 13 | case isBetaUpdatesEnabled 14 | case isNotificationsEnabled 15 | } 16 | 17 | private let defaults: UserDefaults 18 | 19 | let appVersion: String 20 | let buildVersion: String 21 | let isBetaVersion: Bool 22 | 23 | @Published var isBetaUpdatesEnabled: Bool { 24 | didSet { defaults.set(isBetaUpdatesEnabled, forKey: Key.isBetaUpdatesEnabled.rawValue) } 25 | } 26 | 27 | @Published var isNotificationsEnabled: Bool { 28 | didSet { defaults.set(isNotificationsEnabled, forKey: Key.isNotificationsEnabled.rawValue) } 29 | } 30 | 31 | init(defaults: UserDefaults = .standard) { 32 | // Register defaults 33 | UserDefaults.standard.register(defaults: [ 34 | Key.isBetaUpdatesEnabled.rawValue: false, 35 | Key.isNotificationsEnabled.rawValue: true 36 | ]) 37 | 38 | self.defaults = defaults 39 | 40 | appVersion = Bundle.main.appVersion! 41 | buildVersion = Bundle.main.buildVersion! 42 | 43 | isBetaVersion = appVersion.contains("beta") 44 | isBetaUpdatesEnabled = defaults.bool(forKey: Key.isBetaUpdatesEnabled.rawValue) 45 | isNotificationsEnabled = defaults.bool(forKey: Key.isNotificationsEnabled.rawValue) 46 | 47 | if isBetaVersion { 48 | // If the current build is a beta version, 49 | // then force the beta updates to be enabled. 50 | isBetaUpdatesEnabled = true 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ToolReleases/Support/Updater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Updater.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 20/07/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import os.log 12 | import Sparkle 13 | 14 | final class Updater: NSObject, ObservableObject { 15 | static private let logger = Logger(category: "Updater") 16 | 17 | private let preferences: Preferences 18 | private let betaChannels: Set 19 | 20 | /// A time interval for the automatic app update checks. 21 | private let automaticUpdateInterval: TimeInterval 22 | 23 | private var updateCancellable: AnyCancellable? 24 | private var updaterController: SPUStandardUpdaterController! 25 | 26 | @Published public private(set) var isUpdateAvailable = false 27 | 28 | init(preferences: Preferences) { 29 | self.preferences = preferences 30 | self.betaChannels = Set(["beta"]) 31 | 32 | #if DEBUG 33 | self.automaticUpdateInterval = 10 // 10 seconds 34 | #else 35 | self.automaticUpdateInterval = 3_600 // 1 hour 36 | #endif 37 | 38 | super.init() 39 | 40 | self.updaterController = SPUStandardUpdaterController( 41 | startingUpdater: true, 42 | updaterDelegate: self, 43 | userDriverDelegate: nil 44 | ) 45 | 46 | Self.logger.debug("Update check interval set to \(self.automaticUpdateInterval, privacy: .public)") 47 | } 48 | 49 | func checkForUpdates() { 50 | Self.logger.debug("Explicitly check for an app update") 51 | updaterController.checkForUpdates(nil) 52 | } 53 | 54 | func checkForUpdatesSilently() { 55 | Self.logger.debug("Silently check for an app update") 56 | updaterController.updater.checkForUpdateInformation() 57 | } 58 | 59 | func startBackgroundChecks() { 60 | Self.logger.debug("Start automatic background update checks") 61 | updateCancellable = Timer 62 | .publish(every: automaticUpdateInterval, on: .main, in: .default) 63 | .autoconnect() 64 | .sink { [weak self] _ in 65 | self?.checkForUpdatesSilently() 66 | } 67 | } 68 | 69 | func stopBackgroundChecks() { 70 | Self.logger.debug("Stop automatic background update checks") 71 | updateCancellable = nil 72 | } 73 | } 74 | 75 | extension Updater: SPUUpdaterDelegate { 76 | func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { 77 | Self.logger.debug( 78 | """ 79 | New version available, / 80 | version / 81 | \(item.displayVersionString ?? "n/a", privacy: .public)./ 82 | \(item.versionString, privacy: .public) 83 | """ 84 | ) 85 | isUpdateAvailable = true 86 | } 87 | 88 | func updaterDidNotFindUpdate(_ updater: SPUUpdater) { 89 | Self.logger.debug("New version is not available.") 90 | isUpdateAvailable = false 91 | } 92 | 93 | func allowedChannels(for updater: SPUUpdater) -> Set { 94 | return preferences.isBetaUpdatesEnabled ? betaChannels : [] 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /ToolReleases/ToolReleases.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | com.apple.security.temporary-exception.mach-lookup.global-name 10 | 11 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 12 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ToolReleases/Utils/Bundle+Version.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Version.swift 3 | // ToolReleasesCore 4 | // 5 | // Created by Maris Lagzdins on 10/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | extension Bundle { 12 | var appVersion: String? { 13 | object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String 14 | } 15 | 16 | var buildVersion: String? { 17 | object(forInfoDictionaryKey: "CFBundleVersion") as? String 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ToolReleases/Utils/Calendar+DateComparison.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Calendar+DateComparison.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 05/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Calendar { 12 | /// Compares start and end date component value and determines if it is smaller that the provided value. 13 | /// - Parameters: 14 | /// - component: Which component to compare. 15 | /// - start: The starting date. 16 | /// - end: The ending date. 17 | /// - value: Component value to compare against. 18 | /// - Returns: Is the date component value less than the provided value. 19 | func isDateComponent( 20 | _ component: Calendar.Component, 21 | from start: Date, 22 | to end: Date, 23 | lessThan value: Int 24 | ) -> Bool { 25 | let components = dateComponents([component], from: start, to: end) 26 | 27 | guard let componentValue = components.value(for: component) else { 28 | return false 29 | } 30 | 31 | return abs(componentValue) < value 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ToolReleases/Utils/EventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventMonitor.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 06/03/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | // Source: https://www.raywenderlich.com/450-menus-and-popovers-in-menu-bar-apps-for-macos 9 | // 10 | 11 | import Cocoa 12 | 13 | public class EventMonitor { 14 | private var monitor: Any? 15 | private let mask: NSEvent.EventTypeMask 16 | private let handler: (NSEvent?) -> Void 17 | 18 | public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) { 19 | self.mask = mask 20 | self.handler = handler 21 | } 22 | 23 | deinit { 24 | stop() 25 | } 26 | 27 | public func start() { 28 | monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) 29 | } 30 | 31 | public func stop() { 32 | if monitor != nil { 33 | NSEvent.removeMonitor(monitor!) 34 | monitor = nil 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ToolReleases/Utils/Logger+CustomInitializer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OSLog.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 06/03/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | 12 | extension Logger { 13 | init(category: String) { 14 | // swiftlint:disable:next force_unwrapping 15 | self.init(subsystem: Bundle.main.bundleIdentifier!, category: "ToolReleases.\(category)") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ToolReleases/Utils/NSApplication+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSApplication+Extension.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 26/01/2022. 6 | // Copyright © 2022 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | extension NSApplication { 12 | static var isBetaVersion: Bool { Bundle.main.appVersion?.contains("beta") ?? false } 13 | } 14 | -------------------------------------------------------------------------------- /ToolReleases/Utils/Notification+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+Extension.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 26/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Notification.Name { 12 | static let windowWillAppear = Notification.Name(rawValue: "ToolRelease.Notification.WindowWillAppear") 13 | static let windowDidDisappear = Notification.Name(rawValue: "ToolRelease.Notification.WindowDidDisappear") 14 | } 15 | -------------------------------------------------------------------------------- /ToolReleases/Utils/Search.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Search.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 10/07/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Search { 12 | struct Group: Equatable { 13 | struct Keyword: Equatable { 14 | var string: String 15 | 16 | init(_ string: String) { 17 | self.string = string 18 | } 19 | } 20 | 21 | var keywords: [Keyword] 22 | 23 | init(_ keywords: [Keyword]) { 24 | self.keywords = keywords 25 | } 26 | } 27 | 28 | /// Transforms keyword string into an Array containing multiple groups of search phrases. 29 | /// 30 | /// Each group can contain one or more search phrases. 31 | /// Groups are separated by the ";" symbol and each group search phrases are separated by a whitespace. 32 | /// 33 | /// Examples: 34 | /// 35 | /// Provided string : *"iOS Beta; Xcode"* 36 | /// 37 | /// Group1: *["iOS", "Beta"]* 38 | /// 39 | /// Group2: *["Xcode"]* 40 | /// 41 | /// - Parameter keywords: Multiple search keywords which are translated into groups of search phrases 42 | /// - Returns: 2 dimensional array where 1st level represents a collection of groups and 2nd level represents search phrases for a specific group 43 | static func transformKeywords(_ keywords: String) -> [Group] { 44 | let keywords = keywords.trimmingCharacters(in: .whitespacesAndNewlines) 45 | guard keywords.isEmpty == false else { 46 | return [] 47 | } 48 | 49 | // Keywords can contain string like: "iOS Beta; Xcode 11; " 50 | // Create an array: ["iOS Beta", "Xcode 11", " "] 51 | let array = keywords.split(separator: ";") 52 | 53 | guard array.isEmpty == false else { 54 | return [] 55 | } 56 | 57 | return array 58 | // Create subarrays: [[Keyword("iOS"), Keyword("Beta")], [Keyword("Xcode"), Keyword("11")], []] 59 | .map { $0.split(separator: " ").map { Group.Keyword(String($0)) } } 60 | // Remove empty arrays: [[Keyword("iOS"), Keyword("Beta")], [Keyword("Xcode"), Keyword("11")]] 61 | .filter { $0.isEmpty == false } 62 | // Maps to: [Group([Keyword("iOS"), Keyword("Beta")]), Group([Keyword("Xcode"), Keyword("11")])] 63 | .map(Group.init) 64 | } 65 | } 66 | 67 | extension String { 68 | func contains(_ groups: [Search.Group]) -> Bool { 69 | groups.contains { self.contains($0.keywords) } 70 | } 71 | 72 | func contains(_ keywords: [Search.Group.Keyword]) -> Bool { 73 | let string = self.lowercased() 74 | return keywords 75 | .map { $0.string.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) } 76 | .allSatisfy(string.contains) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /ToolReleases/Views/BadgeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BadgeView.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 10/08/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BadgeView: View { 12 | var body: some View { 13 | ZStack { 14 | Circle() 15 | .foregroundColor(.red) 16 | 17 | Text("•") 18 | .foregroundColor(.white) 19 | .font(.system(size: 6)) 20 | } 21 | .frame(width: 10, height: 10) 22 | } 23 | } 24 | 25 | struct BadgeView_Previews: PreviewProvider { 26 | static var previews: some View { 27 | BadgeView() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ToolReleasesCore/Models/RSSFeedItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RSSFeedItem.swift 3 | // ToolReleasesCore 4 | // 5 | // Created by Maris Lagzdins on 04/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import FeedKit 10 | 11 | extension RSSFeedItem: CustomDebugStringConvertible { 12 | public var debugDescription: String { 13 | """ 14 | RSSFeedItem { 15 | guid: \(guid?.value ?? ""), 16 | title: \(title ?? ""), 17 | link: \(link ?? ""), 18 | description: \(description ?? ""), 19 | pubDate: \(pubDate?.description ?? ""), 20 | } 21 | """ 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ToolReleasesCore/Models/Tool.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tool.swift 3 | // ToolReleasesCore 4 | // 5 | // Created by Maris Lagzdins on 25/04/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import FeedKit 10 | import Foundation 11 | 12 | public struct Tool: Identifiable, Equatable, Hashable { 13 | public let id: String 14 | public let title: String 15 | public let shortTitle: String 16 | public let description: String? 17 | public let url: URL? 18 | public let date: Date 19 | public let isBeta: Bool 20 | public let isReleaseCandidate: Bool 21 | public let isRelease: Bool 22 | 23 | public init(id: String, title: String, date: Date, url: URL?, description: String?) { 24 | self.id = id 25 | self.title = title 26 | self.shortTitle = Self.makeShortTitle(from: title) 27 | self.date = date 28 | self.url = url 29 | self.description = description 30 | 31 | let isBeta = Self.isBetaTool(title: title) 32 | let isRC = Self.isReleaseCandidateTool(title: title) 33 | 34 | self.isBeta = isBeta 35 | self.isReleaseCandidate = isRC 36 | self.isRelease = isBeta == false && isRC == false 37 | } 38 | 39 | public init?(_ item: RSSFeedItem) { 40 | guard let title = item.title?.trimmingCharacters(in: .whitespaces) else { 41 | return nil 42 | } 43 | 44 | guard Self.isTool(title) else { 45 | return nil 46 | } 47 | 48 | guard let date = item.pubDate else { 49 | return nil 50 | } 51 | 52 | self.id = title 53 | self.title = title 54 | self.shortTitle = Self.makeShortTitle(from: title) 55 | self.description = item.description?.trimmingCharacters(in: .whitespacesAndNewlines) 56 | self.date = date 57 | 58 | if let stringUrl = item.link, let url = URL(string: stringUrl) { 59 | self.url = url 60 | } else { 61 | self.url = nil 62 | } 63 | 64 | let isBeta = Self.isBetaTool(title: title) 65 | let isRC = Self.isReleaseCandidateTool(title: title) 66 | 67 | self.isBeta = isBeta 68 | self.isReleaseCandidate = isRC 69 | self.isRelease = isBeta == false && isRC == false 70 | } 71 | 72 | internal static func isTool(_ title: String) -> Bool { 73 | let range = NSRange(location: 0, length: title.utf16.count) 74 | let regex = try! NSRegularExpression(pattern: #"^.+\(.+\)$"#) 75 | return regex.firstMatch(in: title, options: [], range: range) != nil 76 | } 77 | } 78 | 79 | private extension Tool { 80 | static func isBetaTool(title: String) -> Bool { 81 | let keywords = [" beta", "beta "] 82 | return keywords.contains(where: title.lowercased().contains) 83 | } 84 | 85 | static func isReleaseCandidateTool(title: String) -> Bool { 86 | let keywords = ["release candidate", " rc", "rc "] 87 | return keywords.contains(where: title.lowercased().contains) 88 | } 89 | 90 | static func makeShortTitle(from string: String) -> String { 91 | String(string.prefix { $0 != "(" }).trimmingCharacters(in: .whitespacesAndNewlines) 92 | } 93 | } 94 | 95 | public extension Tool { 96 | static let example: Tool = { 97 | let components = DateComponents(second: -30) 98 | let date = Calendar.current.date(byAdding: components, to: Date())! 99 | 100 | return Tool( 101 | id: "iOS 14.0 (1234567890)", 102 | title: "iOS 14.0 (1234567890)", 103 | date: date, 104 | url: URL(string: "wwww.apple.com"), 105 | description: "New release of iOS 14.0" 106 | ) 107 | }() 108 | } 109 | 110 | /* 111 | Example: 112 | macOS Catalina 10.15.5 beta 2 (19F62f) 113 | https://developer.apple.com/news/releases/?id=04162020a 114 | https://developer.apple.com/news/releases/?id=04162020a 115 | macOS Catalina 10.15.5 beta 2 (19F62f) 116 | Thu, 16 Apr 2020 10:00:00 PDT 117 | 118 | */ 119 | -------------------------------------------------------------------------------- /ToolReleasesCore/ReleaseSimulator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FakeUpdateController.swift 3 | // ToolReleases 4 | // 5 | // Created by Maris Lagzdins on 21/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | #if DEBUG 10 | 11 | import Foundation 12 | 13 | class ReleaseSimulator { 14 | enum ToolName: String, CaseIterable { 15 | case iOS, iPadOS, macOS, tvOS, watchOS, Xcode 16 | } 17 | 18 | func makeRandomRelease() -> Tool { 19 | let tool = ToolName.allCases.randomElement()! 20 | let isBeta = Bool.random() 21 | let version = String.random(6) 22 | 23 | let title: String 24 | 25 | if isBeta { 26 | title = "\(tool) 00.\(Int.random(in: 1...99)).\(Int.random(in: 1...99)) beta (\(version))" 27 | } else { 28 | title = "\(tool) 00.\(Int.random(in: 1...99)).\(Int.random(in: 1...99)) (\(version))" 29 | } 30 | 31 | return Tool( 32 | id: title, 33 | title: title, 34 | date: Date(), 35 | url: URL(string: "https://www.example.com"), 36 | description: "Lorem Ipsum." 37 | ) 38 | } 39 | } 40 | 41 | fileprivate extension String { 42 | static func random(_ length: Int) -> String { 43 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 44 | var s = "" 45 | for _ in 0 ..< length { 46 | s.append(letters.randomElement()!) 47 | } 48 | return s 49 | } 50 | } 51 | 52 | #endif 53 | -------------------------------------------------------------------------------- /ToolReleasesCore/Support/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | ITSAppUsesNonExemptEncryption 22 | 23 | NSHumanReadableCopyright 24 | Copyright © 2020 Maris Lagzdins. All rights reserved. 25 | 26 | 27 | -------------------------------------------------------------------------------- /ToolReleasesCore/Support/ToolReleasesCore.h: -------------------------------------------------------------------------------- 1 | // 2 | // ToolReleasesCore.h 3 | // ToolReleasesCore 4 | // 5 | // Created by Maris Lagzdins on 25/04/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for ToolReleasesCore. 12 | FOUNDATION_EXPORT double ToolReleasesCoreVersionNumber; 13 | 14 | //! Project version string for ToolReleasesCore. 15 | FOUNDATION_EXPORT const unsigned char ToolReleasesCoreVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /ToolReleasesCore/ToolLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolLoader.swift 3 | // ToolReleasesCore 4 | // 5 | // Created by Maris Lagzdins on 19/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import FeedKit 10 | import Foundation 11 | import os.log 12 | 13 | public class ToolLoader { 14 | static private let logger = Logger(category: "ToolLoader") 15 | 16 | private let url = URL(string: "https://developer.apple.com/news/releases/rss/releases.rss")! 17 | private let parser: FeedParser 18 | var queue: DispatchQueue = .global(qos: .userInitiated) 19 | 20 | public init() { 21 | self.parser = FeedParser(URL: url) 22 | } 23 | 24 | func load(closure: @escaping ([Tool]) -> Void) { 25 | parser.parseAsync(queue: queue) { result in 26 | switch result { 27 | case .success(let feed): 28 | guard let items = feed.rssFeed?.items else { 29 | Self.logger.debug("Tool fetching failed, no information available.") 30 | closure([]) 31 | return 32 | } 33 | 34 | let tools = items.compactMap(Tool.init) 35 | 36 | Self.logger.debug("Tools fetched successfully: \(tools.description, privacy: .public).") 37 | closure(tools) 38 | 39 | case .failure(let error): 40 | Self.logger.debug("Tool fetching failed, \(error.localizedDescription, privacy: .public).") 41 | closure([]) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ToolReleasesCore/ToolProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolProvider.swift 3 | // ToolReleasesCore 4 | // 5 | // Created by Maris Lagzdins on 25/04/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import FeedKit 11 | import Foundation 12 | import os.log 13 | 14 | public class ToolProvider: ObservableObject { 15 | static private let logger = Logger(category: "ToolProvider") 16 | 17 | private let loader: ToolLoader 18 | private var cancellableAutomaticUpdates: AnyCancellable? 19 | private var autoCheckTimeInterval: TimeInterval = 3_600 // 1 hour 20 | // Last refresh date, meant for internal use only. 21 | private var internalLastRefresh: Date? 22 | 23 | private var newToolsSubject = PassthroughSubject<[Tool], Never>() 24 | 25 | internal let privateQueue: DispatchQueue 26 | internal let delegateQueue: DispatchQueue = .main 27 | 28 | @Published public private(set) var tools: [Tool] = [] 29 | @Published public private(set) var lastRefresh: Date? 30 | @Published public private(set) var isRefreshing = false 31 | 32 | public lazy var newToolsPublisher: AnyPublisher<[Tool], Never> = newToolsSubject 33 | .receive(on: delegateQueue) 34 | .eraseToAnyPublisher() 35 | 36 | public init(loader: ToolLoader) { 37 | self.privateQueue = DispatchQueue( 38 | label: "com.developermaris.ToolReleases.Core", 39 | qos: .userInitiated 40 | ) 41 | 42 | self.loader = loader 43 | loader.queue = privateQueue 44 | } 45 | 46 | public func fetch() { 47 | guard isRefreshing == false else { 48 | return 49 | } 50 | 51 | Self.logger.debug("Fetch tools") 52 | 53 | delegateQueue.async { [weak self] in 54 | self?.isRefreshing = true 55 | } 56 | 57 | loader.load { [weak self] tools in 58 | self?.privateQueue.async { 59 | defer { 60 | self?.delegateQueue.async { 61 | self?.isRefreshing = false 62 | } 63 | } 64 | 65 | self?.process(tools) 66 | } 67 | } 68 | } 69 | 70 | public func enableAutomaticUpdates() { 71 | Self.logger.debug("\(#function, privacy: .public)") 72 | 73 | cancellableAutomaticUpdates = Timer 74 | .publish(every: autoCheckTimeInterval, tolerance: autoCheckTimeInterval / 4, on: .main, in: .common) 75 | .autoconnect() 76 | .sink { [weak self] input in 77 | guard let self = self else { return } 78 | 79 | guard self.isRefreshing == false else { 80 | Self.logger.debug("Skip automatic refresh, it's already in progress.") 81 | return 82 | } 83 | 84 | Self.logger.debug("Fetch tools automatically") 85 | self.fetch() 86 | } 87 | } 88 | 89 | public func unsubscribeFromAutomaticUpdates() { 90 | cancellableAutomaticUpdates = nil 91 | } 92 | } 93 | 94 | // MARK: - Private methods 95 | private extension ToolProvider { 96 | /// Process tool list, compare it to the previously available tools and determine if there are new releases available. 97 | /// 98 | /// If new releases are available, also initiate a local notification. 99 | /// - Parameter tools: Tool list 100 | func process(_ tools: [Tool]) { 101 | dispatchPrecondition(condition: .onQueue(privateQueue)) 102 | 103 | /// Will contain the newly added tools compared to the previous tool list. 104 | var newTools: [Tool] = [] 105 | 106 | // If the application just started, 107 | // the `lastRefresh` will be `nil`, 108 | // indicating that there is no 109 | // previous tool list available. 110 | // In this case, do not check for new 111 | // tool releases. 112 | if internalLastRefresh != nil { 113 | let changes = tools.difference(from: self.tools) 114 | changes.insertions.forEach { change in 115 | guard case let .insert(_, tool, _) = change else { return } 116 | newTools.append(tool) 117 | } 118 | } 119 | 120 | internalLastRefresh = Date() 121 | 122 | delegateQueue.async { [weak self] in 123 | guard let self = self else { return } 124 | 125 | // Refresh the date, when the last 126 | // response has been received. 127 | self.lastRefresh = Date() 128 | 129 | // Update tool list by providing all 130 | // available tools. 131 | self.tools = tools 132 | 133 | Self.logger.debug("Tools processed successfully") 134 | 135 | if newTools.isEmpty == false { 136 | Self.logger.debug("New releases available: \(newTools.description, privacy: .public)") 137 | 138 | self.newToolsSubject.send(newTools) 139 | } 140 | } 141 | } 142 | } 143 | 144 | #if DEBUG 145 | 146 | // MARK: Debug methods 147 | extension ToolProvider { 148 | /// A method which randomly generates a new tool versions 149 | /// and adds it to the current tool list. 150 | /// - Warning: This method should be used only for debug purposes. 151 | /// - Parameter count: New tool release count. 152 | public func simulateNewReleases(_ count: Int = 1) { 153 | privateQueue.async { [weak self] in 154 | guard let self = self else { return } 155 | 156 | Self.logger.debug("Simulate \(count, privacy: .public) new tool releases.") 157 | 158 | var tools = self.tools 159 | let simulator = ReleaseSimulator() 160 | 161 | for _ in 0.. 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /ToolReleasesCoreTests/Support/Tool+Stubs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tool+Stubs.swift 3 | // ToolReleasesCoreTests 4 | // 5 | // Created by Maris Lagzdins on 20/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ToolReleasesCore 11 | 12 | // MARK: - Helpers 13 | extension Tool { 14 | static let stub = Tool.make(withTitle: "iOS 15.3 beta (19D5026g)") 15 | 16 | static func make(withTitle title: String) -> Self { 17 | Tool( 18 | id: "https://developer.apple.com/news/releases/?id=1234567890a", 19 | title: title, 20 | date: Date(), 21 | url: URL(string: "www.example.com"), 22 | description: "Tool Description" 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ToolReleasesCoreTests/ToolProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolProviderTests.swift 3 | // ToolReleasesCoreTests 4 | // 5 | // Created by Maris Lagzdins on 20/12/2021. 6 | // Copyright © 2021 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import ToolReleasesCore 11 | 12 | final class ToolProviderTests: XCTestCase { 13 | func testFetchData() throws { 14 | // Given 15 | let tools = [Tool.stub] 16 | let loader = ToolLoaderMock() 17 | loader.mockTools = { tools } 18 | let sut = ToolProvider(loader: loader) 19 | 20 | let exp = expectation(description: "Tools are changed.") 21 | let cancelable = sut.$tools.dropFirst().sink { tools in 22 | exp.fulfill() 23 | } 24 | 25 | // When 26 | sut.fetch() 27 | 28 | // Then 29 | wait(for: [exp], timeout: 1) 30 | XCTAssertEqual(sut.tools, tools) 31 | } 32 | } 33 | 34 | class ToolLoaderMock: ToolLoader { 35 | var mockTools: (() -> [Tool])? 36 | 37 | override func load(closure: @escaping ([Tool]) -> Void) { 38 | closure(mockTools?() ?? []) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ToolReleasesCoreTests/ToolTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolTests.swift 3 | // ToolReleasesCoreTests 4 | // 5 | // Created by Maris Lagzdins on 06/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import FeedKit 10 | @testable 11 | import ToolReleasesCore 12 | import XCTest 13 | 14 | final class ToolTests: XCTestCase { 15 | 16 | // MARK: - Initialization 17 | 18 | func testToolInit() { 19 | // When 20 | let sut = Tool( 21 | id: UUID().uuidString, 22 | title: "Tool title", 23 | date: Date(), 24 | url: URL(string: "www.example.com"), 25 | description: "Tool Description" 26 | ) 27 | 28 | // Then 29 | XCTAssertNotNil(sut) 30 | } 31 | 32 | func testToolSuccessfulInitWithRSSFeedItem() { 33 | // Given 34 | let item = RSSFeedItem() 35 | item.guid = .default 36 | item.title = "iOS 13.0 (1)" 37 | item.link = "www.example.com" 38 | item.description = "iOS Release" 39 | item.pubDate = Date() 40 | 41 | // When 42 | let sut = Tool(item) 43 | 44 | // Then 45 | XCTAssertNotNil(sut) 46 | } 47 | 48 | func testToolInitWithRSSFeedItemWithoutGuid() { 49 | // Given 50 | let item = RSSFeedItem() 51 | item.guid = nil 52 | item.title = "iOS 13.0 (1)" 53 | item.link = "www.example.com" 54 | item.description = "iOS Release" 55 | item.pubDate = Date() 56 | 57 | // When 58 | let sut = Tool(item) 59 | 60 | // Then 61 | XCTAssertNotNil(sut) 62 | } 63 | 64 | func testToolInitWithRSSFeedItemWithEmptyGuid() { 65 | // Given 66 | let item = RSSFeedItem() 67 | item.guid = RSSFeedItemGUID() 68 | item.title = "iOS 13.0 (1)" 69 | item.link = "www.example.com" 70 | item.description = "iOS Release" 71 | item.pubDate = Date() 72 | 73 | // When 74 | let sut = Tool(item) 75 | 76 | // Then 77 | XCTAssertNotNil(sut) 78 | } 79 | 80 | func testToolInitWithRSSFeedItemWithoutTitle() { 81 | // Given 82 | let item = RSSFeedItem() 83 | item.guid = .default 84 | item.title = nil 85 | item.link = "www.example.com" 86 | item.description = "iOS Release" 87 | item.pubDate = Date() 88 | 89 | // When 90 | let sut = Tool(item) 91 | 92 | // Then 93 | XCTAssertNil(sut) 94 | } 95 | 96 | func testToolInitWithRSSFeedItemWithoutURL() { 97 | // Given 98 | let item = RSSFeedItem() 99 | item.guid = .default 100 | item.title = "iOS 13.0 (1)" 101 | item.link = nil 102 | item.description = "iOS Release" 103 | item.pubDate = Date() 104 | 105 | // When 106 | let sut = Tool(item) 107 | 108 | // Then 109 | XCTAssertNotNil(sut) 110 | } 111 | 112 | func testToolInitWithRSSFeedItemWithoutDescription() { 113 | // Given 114 | let item = RSSFeedItem() 115 | item.guid = .default 116 | item.title = "iOS 13.0 (1)" 117 | item.link = "www.example.com" 118 | item.description = nil 119 | item.pubDate = Date() 120 | 121 | // When 122 | let sut = Tool(item) 123 | 124 | // Then 125 | XCTAssertNotNil(sut) 126 | } 127 | 128 | func testToolInitWithRSSFeedItemWithoutDate() { 129 | // Given 130 | let item = RSSFeedItem() 131 | item.guid = .default 132 | item.title = "iOS 13.0 (1)" 133 | item.link = "www.example.com" 134 | item.description = "iOS Release" 135 | item.pubDate = nil 136 | 137 | // When 138 | let sut = Tool(item) 139 | 140 | // Then 141 | XCTAssertNil(sut) 142 | } 143 | 144 | func testToolInitRemovesDescriptionWhitespace() throws { 145 | // Given 146 | let description = " iOS Release " 147 | 148 | let item = RSSFeedItem() 149 | item.guid = .default 150 | item.title = "iOS 13.0 (1)" 151 | item.link = "www.example.com" 152 | item.description = description 153 | item.pubDate = Date() 154 | 155 | // When 156 | let sut = try XCTUnwrap(Tool(item)) 157 | 158 | // Then 159 | XCTAssertNotEqual(sut.description, description) 160 | XCTAssertEqual(sut.description, description.trimmingCharacters(in: .whitespaces)) 161 | } 162 | 163 | func testToolInitDoesNotContainIncorrectURL() throws { 164 | // Given 165 | let url = "not a url" 166 | 167 | let item = RSSFeedItem() 168 | item.guid = .default 169 | item.title = "iOS 13.0 (1)" 170 | item.link = url 171 | item.description = "iOS Release" 172 | item.pubDate = Date() 173 | 174 | // When 175 | let sut = try XCTUnwrap(Tool(item)) 176 | 177 | // Then 178 | XCTAssertNil(sut.url) 179 | } 180 | 181 | // MARK: - Is Release 182 | 183 | func testToolIsRelease() { 184 | // Given 185 | let title = "iOS 13.0 (1)" 186 | 187 | // When 188 | let sut = Tool.make(withTitle: title) 189 | 190 | // Then 191 | XCTAssertTrue(sut.isRelease) 192 | } 193 | 194 | func testToolIsReleaseWithoutBuildNumber() { 195 | // Given 196 | let title = "iOS 13.0" 197 | 198 | // When 199 | let sut = Tool.make(withTitle: title) 200 | 201 | 202 | // Then 203 | XCTAssertTrue(sut.isRelease) 204 | } 205 | 206 | // MARK: - Is Beta 207 | 208 | func testToolIsBeta() { 209 | // Given 210 | let title = "iOS 13.0 beta (1)" 211 | 212 | // When 213 | let sut = Tool.make(withTitle: title) 214 | 215 | // Then 216 | XCTAssertTrue(sut.isBeta) 217 | } 218 | 219 | func testToolIsNotBeta() { 220 | // Given 221 | let title = "iOS 13.0 (1)" 222 | 223 | // When 224 | let sut = Tool.make(withTitle: title) 225 | 226 | // Then 227 | XCTAssertFalse(sut.isBeta) 228 | } 229 | 230 | func testToolIsBetaCapitalized() { 231 | // Given 232 | let title = "iOS 13.0 Beta (1)" 233 | 234 | // When 235 | let sut = Tool.make(withTitle: title) 236 | 237 | // Then 238 | XCTAssertTrue(sut.isBeta) 239 | } 240 | 241 | func testToolIsBetaContainingKeywordAtTheBeginning() { 242 | // Given 243 | let title = "Beta iOS 13.0 (1)" 244 | 245 | // When 246 | let sut = Tool.make(withTitle: title) 247 | 248 | // Then 249 | XCTAssertTrue(sut.isBeta) 250 | } 251 | 252 | func testToolIsBetaContainingKeywordAtTheEnd() { 253 | // Given 254 | let title = "iOS 13.0 (1) beta" 255 | 256 | // When 257 | let sut = Tool.make(withTitle: title) 258 | 259 | // Then 260 | XCTAssertTrue(sut.isBeta) 261 | } 262 | 263 | func testToolIncludingBetaInTitle() { 264 | // Given 265 | let title = "Betatron 13.0 (1)" 266 | 267 | // When 268 | let sut = Tool.make(withTitle: title) 269 | 270 | // Then 271 | XCTAssertFalse(sut.isBeta) 272 | } 273 | 274 | // MARK: - Is Release Candidate 275 | 276 | func testToolIsReleaseCandidate() { 277 | // Given 278 | let title = "iOS 13.0 release candidate (1)" 279 | 280 | // When 281 | let sut = Tool.make(withTitle: title) 282 | 283 | // Then 284 | XCTAssertTrue(sut.isReleaseCandidate) 285 | } 286 | 287 | func testToolIsNotReleaseCandidate() { 288 | // Given 289 | let title = "iOS 13.0 (1)" 290 | 291 | // When 292 | let sut = Tool.make(withTitle: title) 293 | 294 | // Then 295 | XCTAssertFalse(sut.isReleaseCandidate) 296 | } 297 | 298 | func testToolIsNotReleaseCandidateWithIncorrectSpelling() { 299 | // Given 300 | let title = "iOS 13.0 ReleaseCandidate (1)" 301 | 302 | // When 303 | let sut = Tool.make(withTitle: title) 304 | 305 | // Then 306 | XCTAssertFalse(sut.isReleaseCandidate) 307 | } 308 | 309 | func testToolIsReleaseCandidateCapitalized() { 310 | // Given 311 | let title = "iOS 13.0 Release Candidate (1)" 312 | 313 | // When 314 | let sut = Tool.make(withTitle: title) 315 | 316 | // Then 317 | XCTAssertTrue(sut.isReleaseCandidate) 318 | } 319 | 320 | func testToolIsReleaseCandidateContainingKeywordAtTheBeginning() { 321 | // Given 322 | let title = "Release Candidate iOS 13.0 (1)" 323 | 324 | // When 325 | let sut = Tool.make(withTitle: title) 326 | 327 | // Then 328 | XCTAssertTrue(sut.isReleaseCandidate) 329 | } 330 | 331 | func testToolIsReleaseCandidateContainingKeywordAtTheEnd() { 332 | // Given 333 | let title = "iOS 13.0 (1) Release Candidate" 334 | 335 | // When 336 | let sut = Tool.make(withTitle: title) 337 | 338 | // Then 339 | XCTAssertTrue(sut.isReleaseCandidate) 340 | } 341 | 342 | func testToolIsReleaseCandidateContainingShortKeywordInTheMiddle() { 343 | // Given 344 | let title = "iOS 13.0 RC (1)" 345 | 346 | // When 347 | let sut = Tool.make(withTitle: title) 348 | 349 | // Then 350 | XCTAssertTrue(sut.isReleaseCandidate) 351 | } 352 | 353 | func testToolIsReleaseCandidateContainingShortKeywordAtTheBeginning() { 354 | // Given 355 | let title = "RC iOS 13.0 (1)" 356 | 357 | // When 358 | let sut = Tool.make(withTitle: title) 359 | 360 | // Then 361 | XCTAssertTrue(sut.isReleaseCandidate) 362 | } 363 | 364 | func testToolIsReleaseCandidateContainingShortKeywordAtTheEnd() { 365 | // Given 366 | let title = "iOS 13.0 (1) RC" 367 | 368 | // When 369 | let sut = Tool.make(withTitle: title) 370 | 371 | // Then 372 | XCTAssertTrue(sut.isReleaseCandidate) 373 | } 374 | 375 | func testToolIsReleaseCandidateContainingShortKeywordInLowercase() { 376 | // Given 377 | let title = "iOS 13.0 rc (1)" 378 | 379 | // When 380 | let sut = Tool.make(withTitle: title) 381 | 382 | // Then 383 | XCTAssertTrue(sut.isReleaseCandidate) 384 | } 385 | 386 | func testToolIsReleaseCandidateContainingShortKeywordWithVersion() { 387 | // Given 388 | let title = "iOS 13.0 RC 2 (1)" 389 | 390 | // When 391 | let sut = Tool.make(withTitle: title) 392 | 393 | // Then 394 | XCTAssertTrue(sut.isReleaseCandidate) 395 | } 396 | 397 | func testToolIsNotReleaseCandidateContainingShortKeywordWithinDifferentKeywords() { 398 | // Given 399 | let title = "iOS 13.0 Overcomplicated (1)" 400 | 401 | // When 402 | let sut = Tool.make(withTitle: title) 403 | 404 | // Then 405 | XCTAssertFalse(sut.isReleaseCandidate) 406 | } 407 | 408 | // MARK: - Is a tool 409 | 410 | func testIsToolWithBuildNumber() { 411 | // Given 412 | let title = "iOS 13.0 (1)" 413 | 414 | // When 415 | let result = Tool.isTool(title) 416 | 417 | // Then 418 | XCTAssertTrue(result) 419 | } 420 | 421 | func testIsToolWithLargeBuildNumber() { 422 | // Given 423 | let title = "iOS 13.0 (1234567890)" 424 | 425 | // When 426 | let result = Tool.isTool(title) 427 | 428 | // Then 429 | XCTAssertTrue(result) 430 | } 431 | 432 | func testIsToolWithLargeRandomBuildNumber() { 433 | // Given 434 | let title = "iOS 13.0 (\(UUID()))" 435 | 436 | // When 437 | let result = Tool.isTool(title) 438 | 439 | // Then 440 | XCTAssertTrue(result, "Title is not a Tool title: \(title)") 441 | } 442 | 443 | func testIsNotToolWithoutBuildNumber() { 444 | // Given 445 | let title = "iOS 13.0" 446 | 447 | // When 448 | let result = Tool.isTool(title) 449 | 450 | // Then 451 | XCTAssertFalse(result) 452 | } 453 | 454 | func testIsNotToolWithUnfinishedBuildNumber() { 455 | // Given 456 | let title = "iOS 13.0 (1" 457 | 458 | // When 459 | let result = Tool.isTool(title) 460 | 461 | // Then 462 | XCTAssertFalse(result) 463 | } 464 | 465 | func testIsNotToolWithUnfinishedBuildNumber_2() { 466 | // Given 467 | let title = "iOS 13.0 1)" 468 | 469 | // When 470 | let result = Tool.isTool(title) 471 | 472 | // Then 473 | XCTAssertFalse(result) 474 | } 475 | 476 | func testBigSurBeta3RSSFeedItem() { 477 | // Given 478 | let guid = RSSFeedItemGUID() 479 | guid.value = "https://developer.apple.com/news/releases/?id=07222020e" 480 | 481 | let item = RSSFeedItem() 482 | item.guid = guid 483 | item.title = " macOS Big Sur 11 beta 3 (20A5323l) " 484 | item.link = "https://developer.apple.com/news/releases/?id=07222020e" 485 | item.description = "Users on macOS Big Sur 10.16 beta 1 or 2 will see the full install image for macOS Big Sur 11 beta 3 in the Software Update panel, rather than a smaller incremental update image. To download the incremental update image, click “More Info…” in the Software Update panel. Either image can be used to install beta 3" 486 | 487 | let dateString = "2020-07-22 20:30:00 +0000" 488 | let formatter = DateFormatter() 489 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" 490 | let date = formatter.date(from: dateString) 491 | 492 | item.pubDate = date 493 | 494 | // When 495 | let sut = Tool(item) 496 | 497 | // Then 498 | XCTAssertNotNil(sut) 499 | } 500 | 501 | // MARK: - Tool parameter validations 502 | 503 | func testShortTitleDoesNotContainVersionNumber() throws { 504 | // Given 505 | let longTitle = "iOS 15.3 beta (19D5026g)" 506 | let shortTitle = "iOS 15.3 beta" 507 | 508 | let item = RSSFeedItem() 509 | item.guid = .default 510 | item.title = "iOS 15.3 beta (19D5026g)" 511 | item.link = "www.example.com" 512 | item.description = "iOS Release" 513 | item.pubDate = Date() 514 | 515 | // When 516 | let sut = try XCTUnwrap(Tool(item)) 517 | 518 | // Then 519 | XCTAssertNotEqual(sut.shortTitle, sut.title) 520 | XCTAssertEqual(sut.title, longTitle) 521 | XCTAssertEqual(sut.shortTitle, shortTitle) 522 | } 523 | } 524 | 525 | // MARK: - Helpers 526 | fileprivate extension RSSFeedItemGUID { 527 | static var `default`: RSSFeedItemGUID { 528 | let guid = RSSFeedItemGUID() 529 | 530 | guid.value = "https://developer.apple.com/news/releases/?id=1234567890a" 531 | 532 | return guid 533 | } 534 | } 535 | -------------------------------------------------------------------------------- /ToolReleasesTests/CalendarDateComparisonTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarDateComparisonTests.swift 3 | // ToolReleasesTests 4 | // 5 | // Created by Maris Lagzdins on 05/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | @testable 10 | import ToolReleases 11 | import ToolReleasesCore 12 | import XCTest 13 | 14 | final class CalendarDateComparisonTests: XCTestCase { 15 | func testToollessThanOneHourAgo() { 16 | // Given 17 | let component = DateComponents(minute: 59) 18 | let date = Calendar.current.date(byAdding: component, to: Date())! 19 | 20 | // When 21 | let result = Calendar.current.isDateComponent(.hour, from: date, to: Date(), lessThan: 1) 22 | 23 | // then 24 | XCTAssertTrue(result) 25 | } 26 | 27 | func testToolReleasedMoreThanOneHourAgo() { 28 | // Given 29 | let component = DateComponents(minute: -61) 30 | let date = Calendar.current.date(byAdding: component, to: Date())! 31 | 32 | // When 33 | let result = Calendar.current.isDateComponent(.hour, from: date, to: Date(), lessThan: 1) 34 | 35 | // then 36 | XCTAssertFalse(result) 37 | } 38 | 39 | func testToollessThanOneDayAgo() { 40 | // Given 41 | let component = DateComponents(hour: -23) 42 | let date = Calendar.current.date(byAdding: component, to: Date())! 43 | 44 | // When 45 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 1) 46 | 47 | // then 48 | XCTAssertTrue(result) 49 | } 50 | 51 | func testToolReleasedMoreThanOneDayAgo() { 52 | // Given 53 | let component = DateComponents(day: -1, hour: -1) 54 | let date = Calendar.current.date(byAdding: component, to: Date())! 55 | 56 | // When 57 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 1) 58 | 59 | // then 60 | XCTAssertFalse(result) 61 | } 62 | 63 | func testToollessThanOneDayAgoInSecondPrecision() { 64 | // Given 65 | let component = DateComponents(day: -1, second: 1) 66 | let date = Calendar.current.date(byAdding: component, to: Date())! 67 | 68 | // When 69 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 1) 70 | 71 | // then 72 | XCTAssertTrue(result) 73 | } 74 | 75 | func testToolReleasedMoreThanOneDayAgoInSecondPrecision() { 76 | // Given 77 | let component = DateComponents(day: -1, second: -1) 78 | let date = Calendar.current.date(byAdding: component, to: Date())! 79 | 80 | // When 81 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 1) 82 | 83 | // then 84 | XCTAssertFalse(result) 85 | } 86 | 87 | func testToollessThanThreeDaysAgo() { 88 | // Given 89 | let component = DateComponents(day: -2) 90 | let date = Calendar.current.date(byAdding: component, to: Date())! 91 | 92 | // When 93 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 3) 94 | 95 | // then 96 | XCTAssertTrue(result) 97 | } 98 | 99 | func testToolReleasedMoreThanThreeDaysAgo() { 100 | // Given 101 | let component = DateComponents(day: -4) 102 | let date = Calendar.current.date(byAdding: component, to: Date())! 103 | 104 | // When 105 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 3) 106 | 107 | // then 108 | XCTAssertFalse(result) 109 | } 110 | 111 | func testToolReleasedInFuture() { 112 | // Given 113 | let component = DateComponents(month: 1) 114 | let date = Calendar.current.date(byAdding: component, to: Date())! 115 | 116 | // When 117 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 3) 118 | 119 | // then 120 | XCTAssertFalse(result) 121 | } 122 | 123 | func testToolReleasedInPast() { 124 | // Given 125 | let component = DateComponents(month: -1) 126 | let date = Calendar.current.date(byAdding: component, to: Date())! 127 | 128 | // When 129 | let result = Calendar.current.isDateComponent(.day, from: date, to: Date(), lessThan: 3) 130 | 131 | // then 132 | XCTAssertFalse(result) 133 | } 134 | 135 | func testToollessThanOneDayAgoInSecondPrecisionWithProvidedSinceDate() { 136 | // Given 137 | let component = DateComponents(day: -5, second: 1) 138 | let date = Calendar.current.date(byAdding: component, to: Date())! 139 | 140 | let endComponent = DateComponents(day: -4) 141 | let endDate = Calendar.current.date(byAdding: endComponent, to: Date())! 142 | 143 | // When 144 | // Calculations must track that the difference is 59 minutes. 145 | let result = Calendar.current.isDateComponent(.day, from: date, to: endDate, lessThan: 1) 146 | 147 | // then 148 | XCTAssertTrue(result) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /ToolReleasesTests/FilterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterTests.swift 3 | // ToolReleasesCoreTests 4 | // 5 | // Created by Maris Lagzdins on 06/06/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | @testable 10 | import ToolReleases 11 | import ToolReleasesCore 12 | import XCTest 13 | 14 | final class FilterTests: XCTestCase { 15 | var release1: Tool! 16 | var release2: Tool! 17 | var beta1: Tool! 18 | var beta2: Tool! 19 | var rc1: Tool! 20 | 21 | override func setUpWithError() throws { 22 | release1 = Tool.make(withTitle: "iOS 10.0 (1)") 23 | release2 = Tool.make(withTitle: "macOS 11.0 (2)") 24 | beta1 = Tool.make(withTitle: "watchOS 12.0 beta (3)") 25 | beta2 = Tool.make(withTitle: "watchOS 12.1.1 beta (4)") 26 | rc1 = Tool.make(withTitle: "tvOS 13.0 Release Candidate (4)") 27 | } 28 | 29 | override func tearDownWithError() throws { 30 | release1 = nil 31 | release2 = nil 32 | beta1 = nil 33 | rc1 = nil 34 | } 35 | 36 | func testFilterToolArrayForRelease() { 37 | // Given 38 | let tools: [Tool] = [release1, release2, beta1, beta2, rc1] 39 | 40 | // When 41 | let result = tools.filtered(by: .release) 42 | 43 | // Then 44 | XCTAssertEqual(result.count, 2) 45 | } 46 | 47 | func testFilterToolArrayForBeta() { 48 | // Given 49 | let tools: [Tool] = [release1, release2, beta1, beta2, rc1] 50 | 51 | // When 52 | let result = tools.filtered(by: .beta) 53 | 54 | // Then 55 | XCTAssertEqual(result.count, 3) 56 | } 57 | 58 | func testFilterToolArrayForAll() { 59 | // Given 60 | let tools: [Tool] = [release1, release2, beta1, beta2, rc1] 61 | 62 | // When 63 | let result = tools.filtered(by: .all) 64 | 65 | // Then 66 | XCTAssertEqual(result.count, 5) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ToolReleasesTests/KeywordSearchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeywordSearchTests.swift 3 | // ToolReleasesTests 4 | // 5 | // Created by Maris Lagzdins on 10/07/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | @testable 10 | import ToolReleases 11 | import ToolReleasesCore 12 | import XCTest 13 | 14 | final class SearchKeywordsTests: XCTestCase { 15 | func testKeywordTransformationReturnsCorrectly() { 16 | // Given 17 | let keywords = "iOS" 18 | let result = [ 19 | Search.Group([ 20 | Search.Group.Keyword("iOS") 21 | ]) 22 | ] 23 | 24 | // When 25 | let sut = Search.transformKeywords(keywords) 26 | 27 | // Then 28 | XCTAssertEqual(sut, result) 29 | } 30 | 31 | func testKeywordTransformationThrowsExceptionFromEmptyStringInput() { 32 | // Given 33 | let keywords = "" 34 | let result = [Search.Group]() 35 | 36 | // When 37 | let sut = Search.transformKeywords(keywords) 38 | 39 | // Then 40 | XCTAssertEqual(sut, result) 41 | } 42 | 43 | func testKeywordTransformationSplits2WordsBySpaces() { 44 | // Given 45 | let keywords = "iOS Beta" 46 | let result = [ 47 | Search.Group([ 48 | Search.Group.Keyword("iOS"), 49 | Search.Group.Keyword("Beta") 50 | ]) 51 | ] 52 | 53 | // When 54 | let sut = Search.transformKeywords(keywords) 55 | 56 | // Then 57 | XCTAssertEqual(sut, result) 58 | } 59 | 60 | func testKeywordTransformationSplits3WordsBySpaces() { 61 | // Given 62 | let keywords = "iOS 13 Beta" 63 | let result = [ 64 | Search.Group([ 65 | Search.Group.Keyword("iOS"), 66 | Search.Group.Keyword("13"), 67 | Search.Group.Keyword("Beta") 68 | ]) 69 | ] 70 | 71 | // When 72 | let sut = Search.transformKeywords(keywords) 73 | 74 | // Then 75 | XCTAssertEqual(sut, result) 76 | } 77 | 78 | func testKeywordTransformationSeparatesKeywordsInGroupsBySemicolon() { 79 | // Given 80 | let keywords = "iOS;Xcode" 81 | let result = [ 82 | Search.Group([ 83 | Search.Group.Keyword("iOS") 84 | ]), 85 | Search.Group([ 86 | Search.Group.Keyword("Xcode") 87 | ]) 88 | ] 89 | 90 | // When 91 | let sut = Search.transformKeywords(keywords) 92 | 93 | // Then 94 | XCTAssertEqual(sut, result) 95 | } 96 | 97 | func testKeywordTransformationSeparatesKeywordsInGroupsBySemicolonWithMultiplePhrases() { 98 | // Given 99 | let keywords = "iOS Beta;Xcode 11" 100 | let result = [ 101 | Search.Group([ 102 | Search.Group.Keyword("iOS"), 103 | Search.Group.Keyword("Beta") 104 | ]), 105 | Search.Group([ 106 | Search.Group.Keyword("Xcode"), 107 | Search.Group.Keyword("11") 108 | ]) 109 | ] 110 | 111 | // When 112 | let sut = Search.transformKeywords(keywords) 113 | 114 | // Then 115 | XCTAssertEqual(sut, result) 116 | } 117 | 118 | func testKeywordTransformationWithOnlyOneSemicolonThrowsException() { 119 | // Given 120 | let keywords = ";" 121 | let result = [Search.Group]() 122 | 123 | // When 124 | let sut = Search.transformKeywords(keywords) 125 | 126 | // Then 127 | XCTAssertEqual(sut, result) 128 | } 129 | 130 | func testKeywordTransformationWithOneSemicolonAndTextDoesNotCreateKeywordGroups_1() { 131 | // Given 132 | let keywords = "iOS;" 133 | let result = [ 134 | Search.Group([ 135 | Search.Group.Keyword("iOS") 136 | ]) 137 | ] 138 | 139 | // When 140 | let sut = Search.transformKeywords(keywords) 141 | 142 | // Then 143 | XCTAssertEqual(sut, result) 144 | } 145 | 146 | func testKeywordTransformationWithOneSemicolonAndTextDoesNotCreateKeywordGroups_2() { 147 | // Given 148 | let keywords = ";iOS" 149 | let result = [ 150 | Search.Group([ 151 | Search.Group.Keyword("iOS") 152 | ]) 153 | ] 154 | 155 | // When 156 | let sut = Search.transformKeywords(keywords) 157 | 158 | // Then 159 | XCTAssertEqual(sut, result) 160 | } 161 | 162 | func testKeywordTransformationWithMultipleSemicolonsWithoutTextThrowsException() { 163 | // Given 164 | let keywords = ";;;" 165 | let result = [Search.Group]() 166 | 167 | // When 168 | let sut = Search.transformKeywords(keywords) 169 | 170 | // Then 171 | XCTAssertEqual(sut, result) 172 | } 173 | 174 | func testKeywordTransformationWithMultipleSemicolonsAndTextCreatesOneKeywordGroup_1() { 175 | // Given 176 | let keywords = "iOS;;;" 177 | let result = [ 178 | Search.Group([ 179 | Search.Group.Keyword("iOS") 180 | ]) 181 | ] 182 | 183 | // When 184 | let sut = Search.transformKeywords(keywords) 185 | 186 | // Then 187 | XCTAssertEqual(sut, result) 188 | } 189 | 190 | func testKeywordTransformationWithMultipleSemicolonsAndTextCreatesOneKeywordGroup_2() { 191 | // Given 192 | let keywords = ";;;iOS" 193 | let result = [ 194 | Search.Group([ 195 | Search.Group.Keyword("iOS") 196 | ]) 197 | ] 198 | 199 | // When 200 | let sut = Search.transformKeywords(keywords) 201 | 202 | // Then 203 | XCTAssertEqual(sut, result) 204 | } 205 | 206 | func testKeywordTransformationWithMultipleSpacesDoesNotCreateMultipleEmptyPhrases() { 207 | // Given 208 | let keywords = "iOS Beta" 209 | let result = [ 210 | Search.Group([ 211 | Search.Group.Keyword("iOS"), 212 | Search.Group.Keyword("Beta") 213 | ]) 214 | ] 215 | 216 | // When 217 | let sut = Search.transformKeywords(keywords) 218 | 219 | // Then 220 | XCTAssertEqual(sut, result) 221 | } 222 | 223 | func testKeywordTransformationWithMultipleSpacesAfterSemicolonDoesNotCreateMultipleEmptyPhraseGroups() { 224 | // Given 225 | let keywords = "iOS Beta; " 226 | let result = [ 227 | Search.Group([ 228 | Search.Group.Keyword("iOS"), 229 | Search.Group.Keyword("Beta") 230 | ]) 231 | ] 232 | 233 | // When 234 | let sut = Search.transformKeywords(keywords) 235 | 236 | // Then 237 | XCTAssertEqual(sut, result) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /ToolReleasesTests/Support/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /ToolReleasesTests/Support/Tool+Stubs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tool.swift 3 | // ToolReleasesTests 4 | // 5 | // Created by Maris Lagzdins on 13/08/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ToolReleasesCore 11 | 12 | // MARK: - Helpers 13 | extension Tool { 14 | static let stub = Tool.make(withTitle: "iOS 15.3 beta (19D5026g)") 15 | 16 | static func make(withTitle title: String) -> Self { 17 | Tool( 18 | id: "https://developer.apple.com/news/releases/?id=1234567890a", 19 | title: title, 20 | date: Date(), 21 | url: URL(string: "www.example.com"), 22 | description: "Tool Description" 23 | ) 24 | } 25 | 26 | static func make(with date: Date) -> Self { 27 | Tool( 28 | id: "https://developer.apple.com/news/releases/?id=1234567890a", 29 | title: "Test tool (1234567890)", 30 | date: date, 31 | url: URL(string: "www.example.com"), 32 | description: "Tool Description" 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ToolReleasesTests/ToolRowViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolRowTests.swift 3 | // ToolReleasesTests 4 | // 5 | // Created by Maris Lagzdins on 13/08/2020. 6 | // Copyright © 2020 Maris Lagzdins. All rights reserved. 7 | // 8 | 9 | import Combine 10 | @testable 11 | import ToolReleases 12 | import ToolReleasesCore 13 | import XCTest 14 | 15 | final class ToolRowViewModelTests: XCTestCase { 16 | var timer: Publishers.Autoconnect! 17 | var actualDate: Date! 18 | 19 | override func setUp() { 20 | super.setUp() 21 | 22 | timer = Timer.publish(every: 3600, on: .main, in: .common).autoconnect() 23 | actualDate = { 24 | let calendar = Calendar.current 25 | let components = DateComponents(calendar: calendar, year: 2020, month: 8, day: 10, hour: 18, minute: 0, second: 0) 26 | return calendar.date(from: components)! 27 | }() 28 | } 29 | 30 | override func tearDown() { 31 | super.tearDown() 32 | 33 | timer.upstream.connect().cancel() 34 | timer = nil 35 | actualDate = nil 36 | } 37 | 38 | func testToolRowFormattedDateForToolReleases0SecondsAgo() { 39 | // Given 40 | let tool = Tool.make(with: actualDate) 41 | 42 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 43 | 44 | // When 45 | let result = sut.string(for: tool.date, relativeTo: actualDate) 46 | 47 | // then 48 | XCTAssertEqual(result, "Just now") 49 | } 50 | 51 | func testToolRowFormattedDateForToolReleases30SecondsAgo() { 52 | // Given 53 | let component = DateComponents(second: -30) 54 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 55 | let tool = Tool.make(with: date) 56 | 57 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 58 | 59 | // When 60 | let result = sut.string(for: tool.date, relativeTo: actualDate) 61 | 62 | // then 63 | XCTAssertEqual(result, "Just now") 64 | } 65 | 66 | func testToolRowFormattedDateForToolReleases59SecondsAgo() { 67 | // Given 68 | let component = DateComponents(second: -59) 69 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 70 | let tool = Tool.make(with: date) 71 | 72 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 73 | 74 | // When 75 | let result = sut.string(for: tool.date, relativeTo: actualDate) 76 | 77 | // then 78 | XCTAssertEqual(result, "Just now") 79 | } 80 | 81 | func testToolRowFormattedDateForToolReleases1MinuteAgo() { 82 | // Given 83 | let component = DateComponents(minute: -1) 84 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 85 | let tool = Tool.make(with: date) 86 | 87 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 88 | 89 | // When 90 | let result = sut.string(for: tool.date, relativeTo: actualDate) 91 | 92 | // then 93 | XCTAssertEqual(result, "1 Minute Ago") 94 | } 95 | 96 | func testToolRowFormattedDateForToolReleases1HourAgo() { 97 | // Given 98 | let component = DateComponents(hour: -1) 99 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 100 | let tool = Tool.make(with: date) 101 | 102 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 103 | 104 | // When 105 | let result = sut.string(for: tool.date, relativeTo: actualDate) 106 | 107 | // then 108 | XCTAssertEqual(result, "1 Hour Ago") 109 | } 110 | 111 | func testToolRowFormattedDateForToolReleases2HourAgo() { 112 | // Given 113 | let component = DateComponents(hour: -2) 114 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 115 | let tool = Tool.make(with: date) 116 | 117 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 118 | 119 | // When 120 | let result = sut.string(for: tool.date, relativeTo: actualDate) 121 | 122 | // then 123 | XCTAssertEqual(result, "2 Hours Ago") 124 | } 125 | 126 | func testToolRowFormattedDateForToolReleases16HourAgo() { 127 | // Given 128 | let component = DateComponents(hour: -16) 129 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 130 | let tool = Tool.make(with: date) 131 | 132 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 133 | 134 | // When 135 | let result = sut.string(for: tool.date, relativeTo: actualDate) 136 | 137 | // then 138 | XCTAssertEqual(result, "16 Hours Ago") 139 | } 140 | 141 | func testToolRowFormattedDateForToolReleasesAtMidnight() { 142 | // Given 143 | let component = DateComponents(hour: -18) 144 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 145 | let tool = Tool.make(with: date) 146 | 147 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 148 | 149 | // When 150 | let result = sut.string(for: tool.date, relativeTo: actualDate) 151 | 152 | // then 153 | XCTAssertEqual(result, "18 Hours Ago") 154 | } 155 | 156 | func testToolRowFormattedDateForToolReleases1SecondBeforeMidnight() { 157 | // Given 158 | let component = DateComponents(hour: -18, second: -1) 159 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 160 | let tool = Tool.make(with: date) 161 | 162 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 163 | 164 | // When 165 | let result = sut.string(for: tool.date, relativeTo: actualDate) 166 | 167 | // Then 168 | XCTAssertEqual(result, "1 Day Ago") 169 | } 170 | 171 | func testToolRowFormattedDateForToolReleases19HourAgo() { 172 | // Given 173 | let component = DateComponents(hour: -19) 174 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 175 | let tool = Tool.make(with: date) 176 | 177 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 178 | 179 | // When 180 | let result = sut.string(for: tool.date, relativeTo: actualDate) 181 | 182 | // then 183 | XCTAssertEqual(result, "1 Day Ago") 184 | } 185 | 186 | func testToolRowFormattedDateForToolReleases1DayAgo() { 187 | // Given 188 | let component = DateComponents(day: -1) 189 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 190 | let tool = Tool.make(with: date) 191 | 192 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 193 | 194 | // When 195 | let result = sut.string(for: tool.date, relativeTo: actualDate) 196 | 197 | // then 198 | XCTAssertEqual(result, "1 Day Ago") 199 | } 200 | 201 | func testToolRowFormattedDateForToolReleases6DaysAgo() { 202 | // Given 203 | let component = DateComponents(day: -6) 204 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 205 | let tool = Tool.make(with: date) 206 | 207 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 208 | 209 | // When 210 | let result = sut.string(for: tool.date, relativeTo: actualDate) 211 | 212 | // then 213 | XCTAssertEqual(result, "6 Days Ago") 214 | } 215 | 216 | func testToolRowFormattedDateForToolReleases6DaysAnd23HoursAgo() { 217 | // Given 218 | let component = DateComponents(day: -7, hour: 1) 219 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 220 | let tool = Tool.make(with: date) 221 | 222 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 223 | 224 | // When 225 | let result = sut.string(for: tool.date, relativeTo: actualDate) 226 | 227 | // then 228 | XCTAssertEqual(result, "1 Week Ago") 229 | } 230 | 231 | func testToolRowFormattedDateForToolReleases1WeekAgo() { 232 | // Given 233 | let component = DateComponents(day: -7) 234 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 235 | let tool = Tool.make(with: date) 236 | 237 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 238 | 239 | // When 240 | let result = sut.string(for: tool.date, relativeTo: actualDate) 241 | 242 | // then 243 | XCTAssertEqual(result, "1 Week Ago") 244 | } 245 | 246 | func testToolRowFormattedDateForToolReleases7DaysAnd1HoursAgo() { 247 | // Given 248 | let component = DateComponents(day: -7, hour: -1) 249 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 250 | let tool = Tool.make(with: date) 251 | 252 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 253 | 254 | // When 255 | let result = sut.string(for: tool.date, relativeTo: actualDate) 256 | 257 | // then 258 | XCTAssertEqual(result, "1 Week Ago") 259 | } 260 | 261 | func testToolRowFormattedDateForToolReleases2WeeksAgo() { 262 | // Given 263 | let component = DateComponents(day: -14) 264 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 265 | let tool = Tool.make(with: date) 266 | 267 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 268 | 269 | // When 270 | let result = sut.string(for: tool.date, relativeTo: actualDate) 271 | 272 | // then 273 | XCTAssertEqual(result, "2 Weeks Ago") 274 | } 275 | 276 | func testToolRowFormattedDateForToolReleases16DaysAgo() { 277 | // Given 278 | let component = DateComponents(day: -16) 279 | let date = Calendar.current.date(byAdding: component, to: actualDate)! 280 | let tool = Tool.make(with: date) 281 | 282 | let sut = ToolRowView.ViewModel(tool: tool, timer: timer) 283 | 284 | // When 285 | let result = sut.string(for: tool.date, relativeTo: actualDate) 286 | 287 | // then 288 | XCTAssertEqual(result, "2 Weeks Ago") 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.developermaris.ToolReleases") 2 | apple_id("maaris.lagzdins@gmail.com") 3 | itc_team_id("94EK2XEZUL") 4 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | default_platform(:mac) 2 | 3 | APP_BUILD_PATH = 'build' 4 | 5 | platform :mac do 6 | desc "Do all the necessary things to prepare and publish a new beta version (pre-release)" 7 | lane :beta do 8 | prepare_build 9 | github_release(is_prerelease: true) 10 | end 11 | 12 | desc "Do all the necessary things to prepare and publish a new release" 13 | lane :release do 14 | prepare_build 15 | github_release(is_prerelease: false) 16 | end 17 | 18 | desc "Prepare a new build" 19 | lane :prepare_build do 20 | build 21 | notarize( 22 | package: "#{APP_BUILD_PATH}/ToolReleases.app", 23 | print_log: true, 24 | verbose: true 25 | ) 26 | compress 27 | sign_update_for_sparkle 28 | release_hash_for_homebrew 29 | end 30 | 31 | desc "Prepare a new build" 32 | lane :prepare_build_without_notarization do 33 | build 34 | compress 35 | sign_update_for_sparkle 36 | release_hash_for_homebrew 37 | end 38 | 39 | desc "Build the app" 40 | private_lane :build do 41 | build_mac_app( 42 | scheme: "ToolReleases", 43 | configuration: "Release", 44 | output_directory: "#{APP_BUILD_PATH}", 45 | clean: true, 46 | skip_package_pkg: true, 47 | export_method: "developer-id" 48 | ) 49 | end 50 | 51 | desc "Create zip file with the application and provides the necessary name format (which is used by the Sparkle framework)." 52 | private_lane :compress do 53 | version = get_version_number(target: "ToolReleases") 54 | build = get_build_number() 55 | zip( 56 | path: "#{APP_BUILD_PATH}/ToolReleases.app", 57 | output_path: "#{APP_BUILD_PATH}/ToolReleases_v#{version}.b#{build}.zip", 58 | symlinks: true 59 | ) 60 | end 61 | 62 | private_lane :sign_update_for_sparkle do 63 | version = get_version_number(target: "ToolReleases") 64 | build = get_build_number() 65 | sparkle = sh("cd .. && ./scripts/sign_update #{APP_BUILD_PATH}/ToolReleases_v#{version}.b#{build}.zip") 66 | 67 | # Create a file .sparkle and write the edSignature in it. 68 | File.write("../#{APP_BUILD_PATH}/.sparkle", sparkle, mode: 'w') 69 | end 70 | 71 | desc "Return sha-256 hash from the application zip file. This is necessary for the Homebrew version." 72 | private_lane :release_hash_for_homebrew do 73 | version = get_version_number(target: "ToolReleases") 74 | build = get_build_number() 75 | hash = sh("cd .. && shasum -a 256 #{APP_BUILD_PATH}/ToolReleases_v#{version}.b#{build}.zip | awk '{print $1}'") 76 | 77 | File.write("../#{APP_BUILD_PATH}/.release_hash", hash, mode: 'w') 78 | end 79 | 80 | desc "Uploads the latest build to the GitHub Releases." 81 | private_lane :github_release do |options| 82 | version = get_version_number(target: "ToolReleases") 83 | build = get_build_number() 84 | commit = last_git_commit 85 | hash = commit[:commit_hash] # long sha of commit 86 | 87 | set_github_release( 88 | repository_name: "DeveloperMaris/ToolReleases", 89 | api_token: ENV["GITHUB_TOKEN"], 90 | name: "v#{version}", 91 | tag_name: "v#{version}", 92 | description: prepare_changelog, 93 | commitish: "#{hash}", 94 | upload_assets: ["#{APP_BUILD_PATH}/ToolReleases_v#{version}.b#{build}.zip"], 95 | is_prerelease: options[:is_prerelease] 96 | ) 97 | end 98 | end 99 | 100 | def prepare_changelog 101 | changelog = (File.read("../CHANGELOG.md") rescue "No changelog provided") 102 | sparkle = (File.read("../#{APP_BUILD_PATH}/.sparkle") rescue "").gsub("\n","") 103 | 104 | # We need to add the comment symbols so that the Sparkle public encryption key would be added to the changelog description, but not visible publicly. It is then used then to generate the correct appcast.xml file. 105 | 106 | "#{changelog}\n" 107 | end 108 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## Mac 17 | 18 | ### mac beta 19 | 20 | ```sh 21 | [bundle exec] fastlane mac beta 22 | ``` 23 | 24 | Do all the necessary things to prepare and publish a new beta version (pre-release) 25 | 26 | ### mac release 27 | 28 | ```sh 29 | [bundle exec] fastlane mac release 30 | ``` 31 | 32 | Do all the necessary things to prepare and publish a new release 33 | 34 | ### mac prepare_build 35 | 36 | ```sh 37 | [bundle exec] fastlane mac prepare_build 38 | ``` 39 | 40 | Prepare a new build 41 | 42 | ### mac prepare_build_without_notarization 43 | 44 | ```sh 45 | [bundle exec] fastlane mac prepare_build_without_notarization 46 | ``` 47 | 48 | Prepare a new build 49 | 50 | ---- 51 | 52 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 53 | 54 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 55 | 56 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 57 | -------------------------------------------------------------------------------- /scripts/sign_update: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeveloperMaris/ToolReleases/91b404c66953a1fc0d5dc23db454b68af4f93bb7/scripts/sign_update --------------------------------------------------------------------------------