├── .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 | 
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 | 
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 | 
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 | 
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
--------------------------------------------------------------------------------