mirror of
https://github.com/nicoverbruggen/NVAppUpdater.git
synced 2025-08-10 18:50:07 +02:00
Add UpdateCheck and some supporting code
This commit is contained in:
@@ -5,6 +5,7 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "AppUpdater",
|
name: "AppUpdater",
|
||||||
|
defaultLocalization: "en"
|
||||||
platforms: [.macOS(.v11)],
|
platforms: [.macOS(.v11)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "AppUpdater", targets: ["AppUpdater"]),
|
.library(name: "AppUpdater", targets: ["AppUpdater"]),
|
||||||
|
52
README.md
52
README.md
@@ -16,7 +16,55 @@ Here's how it works:
|
|||||||
|
|
||||||
- The self-updater will download the .zip file and validate it using the checksum provided in the manifest file. If the checksum is valid, the app is (re)placed in `/Applications` and finally launched.
|
- The self-updater will download the .zip file and validate it using the checksum provided in the manifest file. If the checksum is valid, the app is (re)placed in `/Applications` and finally launched.
|
||||||
|
|
||||||
## Example
|
## Checking for updates
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
To check for updates in your main target you need to meet a few conditions:
|
||||||
|
|
||||||
|
1. You must have a self-updater app that can be executed separately. You must build and embed this app as part of the main target. It's relatively easy to do this, see the *Self-Updater* section below for instructions on how to make the self-updater work correctly.
|
||||||
|
|
||||||
|
2. You must declare where the temporary directory is for the updater.
|
||||||
|
|
||||||
|
3. You must have a CaskFile and zip hosted which will both be downloaded and checked if the user searches for updates.
|
||||||
|
|
||||||
|
### Making a manifest file
|
||||||
|
|
||||||
|
AppUpdater uses the same format Cask files for Homebrew use. A valid CaskFile looks like this, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
cask 'my-app' do
|
||||||
|
version '1.0_95'
|
||||||
|
sha256 '1b39bf7977120222e5bea2c35414f76d1bb39a882334488642e1d4e87a48c874'
|
||||||
|
|
||||||
|
url 'https://my-app.test/latest/app.zip'
|
||||||
|
name 'My App'
|
||||||
|
homepage 'https://myapp.test'
|
||||||
|
|
||||||
|
app 'My App.app'
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
You must calculate the SHA-256 hash of the .zip file, since that will be validated. The app will also look at the version number to compare to the installed version.
|
||||||
|
|
||||||
|
The version number uses the following format: VERSION_BUILD. (So for this example we are looking at a manifest of My App, version 1.0, build 95.)
|
||||||
|
|
||||||
|
You must always place the CaskFile at the same URL, and you will specify where to find this file via the `caskUrl` parameter as part of searching for updates.
|
||||||
|
|
||||||
|
### How to perform an update check
|
||||||
|
|
||||||
|
To check for updates, simply create a new `UpdateCheck` instance with the correct configuration, and call `perform()`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
await UpdateCheck(
|
||||||
|
selfUpdaterName: "MyApp Self-Updater.app",
|
||||||
|
selfUpdaterDirectory: "~/.config/com.example.my-app/updater",
|
||||||
|
caskUrl: URL(string: "https://my-app.test/latest/build.rb")!,
|
||||||
|
promptOnFailure: true
|
||||||
|
).perform()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Self-Updater
|
||||||
|
|
||||||
As a separate target (for a macOS app), you need to add the following file:
|
As a separate target (for a macOS app), you need to add the following file:
|
||||||
|
|
||||||
@@ -33,3 +81,5 @@ let delegate = SelfUpdater(
|
|||||||
NSApplication.shared.delegate = delegate
|
NSApplication.shared.delegate = delegate
|
||||||
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
|
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You must then make sure that this app is included as a sub-app for the main target. It needs to be referenced correctly as part of the `selfUpdaterName` parameter of `UpdateCheck` (see the previous section).
|
@@ -41,17 +41,17 @@ open class SelfUpdater: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func installUpdate() async {
|
func installUpdate() async {
|
||||||
print("===========================================")
|
Log.text("===========================================")
|
||||||
print("\(Executable.name), version \(Executable.fullVersion)")
|
Log.text("\(Executable.name), version \(Executable.fullVersion)")
|
||||||
print("Using AppUpdater by Nico Verbruggen")
|
Log.text("Using AppUpdater by Nico Verbruggen")
|
||||||
print("===========================================")
|
Log.text("===========================================")
|
||||||
|
|
||||||
print("Configured for \(self.appName) bundles: \(self.bundleIdentifiers)")
|
Log.text("Configured for \(self.appName) bundles: \(self.bundleIdentifiers)")
|
||||||
|
|
||||||
self.updaterPath = self.baseUpdaterPath
|
self.updaterPath = self.baseUpdaterPath
|
||||||
.replacingOccurrences(of: "~", with: NSHomeDirectory())
|
.replacingOccurrences(of: "~", with: NSHomeDirectory())
|
||||||
|
|
||||||
print("Updater directory set to: \(self.updaterPath)")
|
Log.text("Updater directory set to: \(self.updaterPath)")
|
||||||
|
|
||||||
self.manifestPath = "\(updaterPath)/update.json"
|
self.manifestPath = "\(updaterPath)/update.json"
|
||||||
|
|
||||||
@@ -75,15 +75,15 @@ open class SelfUpdater: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
private func parseManifest() async -> ReleaseManifest? {
|
private func parseManifest() async -> ReleaseManifest? {
|
||||||
// Read out the correct information from the manifest JSON
|
// Read out the correct information from the manifest JSON
|
||||||
print("Checking manifest file at \(manifestPath)...")
|
Log.text("Checking manifest file at \(manifestPath)...")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let manifestText = try String(contentsOfFile: manifestPath)
|
let manifestText = try String(contentsOfFile: manifestPath)
|
||||||
manifest = try JSONDecoder().decode(ReleaseManifest.self, from: manifestText.data(using: .utf8)!)
|
manifest = try JSONDecoder().decode(ReleaseManifest.self, from: manifestText.data(using: .utf8)!)
|
||||||
return manifest
|
return manifest
|
||||||
} catch {
|
} catch {
|
||||||
print("Parsing the manifest failed (or the manifest file doesn't exist)!")
|
Log.text("Parsing the manifest failed (or the manifest file doesn't exist)!")
|
||||||
await Alert.show(description: "The manifest file for a potential update was not found. Please try searching for updates again in \(appName).")
|
await Alert.upgradeFailure(description: "The manifest file for a potential update was not found. Please try searching for updates again in \(appName).")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -102,16 +102,16 @@ open class SelfUpdater: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
// Ensure the zip exists
|
// Ensure the zip exists
|
||||||
if filename.isEmpty {
|
if filename.isEmpty {
|
||||||
print("The update has not been downloaded. Sadly, that means that \(appName) cannot not updated!")
|
Log.text("The update has not been downloaded. Sadly, that means that \(appName) cannot not updated!")
|
||||||
await Alert.show(description: "The update could not be downloaded, or the file was not correctly written to disk. \n\nPlease try again. \n\n(Note that the download will time-out after 20 seconds, so for slow connections it is recommended to manually download the update.)")
|
await Alert.upgradeFailure(description: "The update could not be downloaded, or the file was not correctly written to disk. \n\nPlease try again. \n\n(Note that the download will time-out after 20 seconds, so for slow connections it is recommended to manually download the update.)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the checksum for the downloaded file
|
// Calculate the checksum for the downloaded file
|
||||||
let checksum = system("openssl dgst -sha256 \"\(updaterPath)/\(filename)\" | awk '{print $NF}'")
|
let checksum = system("openssl dgst -sha256 \"\(updaterPath)/\(filename)\" | awk '{Log.text $NF}'")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
// Compare the checksums
|
// Compare the checksums
|
||||||
print("""
|
Log.text("""
|
||||||
Comparing checksums...
|
Comparing checksums...
|
||||||
Expected SHA256: \(manifest.sha256)
|
Expected SHA256: \(manifest.sha256)
|
||||||
Actual SHA256: \(checksum)
|
Actual SHA256: \(checksum)
|
||||||
@@ -119,8 +119,8 @@ open class SelfUpdater: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
// Make sure the checksum matches before we do anything with the file
|
// Make sure the checksum matches before we do anything with the file
|
||||||
if checksum != manifest.sha256 {
|
if checksum != manifest.sha256 {
|
||||||
print("The checksums failed to match. Cancelling!")
|
Log.text("The checksums failed to match. Cancelling!")
|
||||||
await Alert.show(description: "The downloaded update failed checksum validation. Please try again. If this issue persists, there may be an issue with the server and I do not recommend upgrading.")
|
await Alert.upgradeFailure(description: "The downloaded update failed checksum validation. Please try again. If this issue persists, there may be an issue with the server and I do not recommend upgrading.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the path to the zip
|
// Return the path to the zip
|
||||||
@@ -137,7 +137,7 @@ open class SelfUpdater: NSObject, NSApplicationDelegate {
|
|||||||
// Make sure the updater directory exists
|
// Make sure the updater directory exists
|
||||||
var isDirectory: ObjCBool = true
|
var isDirectory: ObjCBool = true
|
||||||
if !FileManager.default.fileExists(atPath: "\(updaterPath)/extracted", isDirectory: &isDirectory) {
|
if !FileManager.default.fileExists(atPath: "\(updaterPath)/extracted", isDirectory: &isDirectory) {
|
||||||
await Alert.show(description: "The updater directory is missing. The automatic updater will quit. Make sure that `\(baseUpdaterPath)` is writeable.")
|
await Alert.upgradeFailure(description: "The updater directory is missing. The automatic updater will quit. Make sure that `\(baseUpdaterPath)` is writeable.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unzip the file
|
// Unzip the file
|
||||||
@@ -147,15 +147,15 @@ open class SelfUpdater: NSObject, NSApplicationDelegate {
|
|||||||
let app = system("ls \"\(updaterPath)/extracted\" | grep .app")
|
let app = system("ls \"\(updaterPath)/extracted\" | grep .app")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
print("Finished extracting: \(updaterPath)/extracted/\(app)")
|
Log.text("Finished extracting: \(updaterPath)/extracted/\(app)")
|
||||||
|
|
||||||
// Make sure the file was extracted
|
// Make sure the file was extracted
|
||||||
if app.isEmpty {
|
if app.isEmpty {
|
||||||
await Alert.show(description: "The downloaded file could not be extracted. The automatic updater will quit. Make sure that `\(baseUpdaterPath)` is writeable.")
|
await Alert.upgradeFailure(description: "The downloaded file could not be extracted. The automatic updater will quit. Make sure that `\(baseUpdaterPath)` is writeable.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the original app
|
// Remove the original app
|
||||||
print("Removing \(app) before replacing...")
|
Log.text("Removing \(app) before replacing...")
|
||||||
system_quiet("rm -rf \"/Applications/\(app)\"")
|
system_quiet("rm -rf \"/Applications/\(app)\"")
|
||||||
|
|
||||||
// Move the new app in place
|
// Move the new app in place
|
149
Sources/AppUpdater/API/UpdateCheck.swift
Normal file
149
Sources/AppUpdater/API/UpdateCheck.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 30/05/2024.
|
||||||
|
// Copyright © 2024 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
open class UpdateCheck
|
||||||
|
{
|
||||||
|
let caskUrl: URL
|
||||||
|
let promptOnFailure: Bool
|
||||||
|
let selfUpdaterName: String
|
||||||
|
let selfUpdaterDirectory: String
|
||||||
|
|
||||||
|
var caskFile: CaskFile!
|
||||||
|
var newerVersion: AppVersion!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new update check instance. Once created, you should call `perform` on this instance.
|
||||||
|
*
|
||||||
|
* - Parameter selfUpdaterName: The name of the self-updater .app file. For example, "App Self-Updater.app".
|
||||||
|
* This binary should exist as a resource of the current application.
|
||||||
|
*
|
||||||
|
* - Parameter selfUpdaterDirectory: The directory that is used by the self-updater. A file `update.json`
|
||||||
|
* will be placed in this directory and this should be correspond to the `baseUpdaterPath` in `SelfUpdater`.
|
||||||
|
*
|
||||||
|
* - Parameter caskUrl: The URL where the Cask file is expected to be located. Redirects will
|
||||||
|
* be followed when retrieving and validating the Cask file.
|
||||||
|
*
|
||||||
|
* - Parameter promptOnFailure: Whether user interaction is required when failing to check
|
||||||
|
* or no new update is found. A user usually expects a prompt if they manually searched
|
||||||
|
* for updates.
|
||||||
|
*/
|
||||||
|
public init(
|
||||||
|
selfUpdaterName: String,
|
||||||
|
selfUpdaterDirectory: String,
|
||||||
|
caskUrl: URL,
|
||||||
|
promptOnFailure: Bool
|
||||||
|
) {
|
||||||
|
self.selfUpdaterName = selfUpdaterName
|
||||||
|
self.selfUpdaterDirectory = selfUpdaterDirectory
|
||||||
|
self.caskUrl = caskUrl
|
||||||
|
self.promptOnFailure = promptOnFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Perform the check for a new version.
|
||||||
|
*/
|
||||||
|
public func perform() async {
|
||||||
|
guard let caskFile = await CaskFile.from(url: caskUrl) else {
|
||||||
|
Log.text("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.")
|
||||||
|
return await presentCouldNotRetrieveUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.caskFile = caskFile
|
||||||
|
|
||||||
|
let currentVersion = AppVersion.fromCurrentVersion()
|
||||||
|
|
||||||
|
guard let onlineVersion = AppVersion.from(caskFile.version) else {
|
||||||
|
Log.text("The version string from the CaskFile could not be read.")
|
||||||
|
return await presentCouldNotRetrieveUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.newerVersion = onlineVersion
|
||||||
|
|
||||||
|
Log.text("The latest version read from '\(caskUrl.lastPathComponent)' is: v\(onlineVersion.computerReadable).")
|
||||||
|
Log.text("The current version is v\(currentVersion.computerReadable).")
|
||||||
|
|
||||||
|
if onlineVersion > currentVersion {
|
||||||
|
await presentNewerVersionAvailable()
|
||||||
|
} else if promptOnFailure {
|
||||||
|
await presentVersionIsUpToDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alerts
|
||||||
|
|
||||||
|
private func presentCouldNotRetrieveUpdate() async {
|
||||||
|
Log.text("Could not retrieve update manifest!")
|
||||||
|
|
||||||
|
if promptOnFailure {
|
||||||
|
await Alert.confirm(
|
||||||
|
title: "Could not retrieve update information!",
|
||||||
|
description: "There was an issue retrieving information about possible updates. This could be a connection or server issue. Check your internet connection and try again later."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentVersionIsUpToDate() async {
|
||||||
|
Log.text("Version is up-to-date!")
|
||||||
|
|
||||||
|
if promptOnFailure {
|
||||||
|
await Alert.confirm(
|
||||||
|
title: "The app is up-to-date!",
|
||||||
|
description: "The version on the server is not newer than this version, so you're all good."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNewerVersionAvailable() async {
|
||||||
|
Log.text("A newer version is available!")
|
||||||
|
|
||||||
|
let current = AppVersion.fromCurrentVersion()
|
||||||
|
|
||||||
|
let outcome = await Alert.choose(
|
||||||
|
title: "An updated version of \(Executable.name) is available.",
|
||||||
|
description: """
|
||||||
|
Version \(newerVersion.version) is available for download.
|
||||||
|
(This is currently version \(current.version).)
|
||||||
|
|
||||||
|
Do you want to download and install this updated version?
|
||||||
|
""",
|
||||||
|
options: [
|
||||||
|
"Update Now",
|
||||||
|
"Cancel"
|
||||||
|
])
|
||||||
|
|
||||||
|
if outcome == .alertFirstButtonReturn {
|
||||||
|
launchSelfUpdater()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Functional
|
||||||
|
|
||||||
|
private func launchSelfUpdater() {
|
||||||
|
let updater = Bundle.main.resourceURL!.path + "/\(selfUpdaterName)"
|
||||||
|
|
||||||
|
system_quiet("mkdir -p \(selfUpdaterDirectory) 2> /dev/null")
|
||||||
|
|
||||||
|
let updaterDirectory = selfUpdaterDirectory
|
||||||
|
.replacingOccurrences(of: "~", with: NSHomeDirectory())
|
||||||
|
|
||||||
|
system_quiet("cp -R \"\(updater)\" \"\(updaterDirectory)/\(selfUpdaterName)\"")
|
||||||
|
|
||||||
|
try! "{ \"url\": \"\(caskFile.url)\", \"sha256\": \"\(caskFile.sha256)\" }".write(
|
||||||
|
to: URL(fileURLWithPath: "\(updaterDirectory)/update.json"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
NSWorkspace.shared.openApplication(
|
||||||
|
at: NSURL(fileURLWithPath: updater, isDirectory: true) as URL,
|
||||||
|
configuration: NSWorkspace.OpenConfiguration()
|
||||||
|
) { _, _ in
|
||||||
|
Log.text("The updater has been launched successfully!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -9,21 +9,55 @@ import Cocoa
|
|||||||
class Alert {
|
class Alert {
|
||||||
public static var appName: String = ""
|
public static var appName: String = ""
|
||||||
|
|
||||||
public static func show(description: String, shouldExit: Bool = true) async {
|
// MARK: - Specific Cases
|
||||||
await withUnsafeContinuation { continuation in
|
|
||||||
DispatchQueue.main.async {
|
@MainActor
|
||||||
let alert = NSAlert()
|
public static func upgradeFailure(description: String, shouldExit: Bool = true) async {
|
||||||
alert.messageText = "\(Alert.appName) could not be updated."
|
await confirm(
|
||||||
alert.informativeText = description
|
title: "\(Alert.appName) could not be updated.",
|
||||||
alert.addButton(withTitle: "OK")
|
description: description,
|
||||||
alert.alertStyle = .critical
|
alertStyle: .critical,
|
||||||
alert.runModal()
|
callback: {
|
||||||
if shouldExit {
|
exit(0)
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
continuation.resume()
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public static func confirm(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
alertStyle: NSAlert.Style = .informational,
|
||||||
|
callback: (() -> Void)? = nil
|
||||||
|
) async {
|
||||||
|
let alert = await NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = description
|
||||||
|
await alert.addButton(withTitle: "OK")
|
||||||
|
alert.alertStyle = alertStyle
|
||||||
|
await alert.runModal()
|
||||||
|
if callback != nil {
|
||||||
|
callback!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public static func choose(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
options: [String],
|
||||||
|
cancel: Bool = false
|
||||||
|
) async -> NSApplication.ModalResponse {
|
||||||
|
let alert = await NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = description
|
||||||
|
for option in options {
|
||||||
|
await alert.addButton(withTitle: option)
|
||||||
|
}
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
return await alert.runModal()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
120
Sources/AppUpdater/Support/AppVersion.swift
Normal file
120
Sources/AppUpdater/Support/AppVersion.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 26/05/2024.
|
||||||
|
// Copyright © 2024 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AppVersion: Comparable {
|
||||||
|
var version: String
|
||||||
|
var build: Int?
|
||||||
|
var suffix: String?
|
||||||
|
|
||||||
|
init(version: String, build: String?, suffix: String? = nil) {
|
||||||
|
self.version = version
|
||||||
|
self.build = build == nil ? nil : Int(build!)
|
||||||
|
self.suffix = suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func from(_ string: String) -> AppVersion? {
|
||||||
|
do {
|
||||||
|
let regex = try NSRegularExpression(
|
||||||
|
pattern: #"(?<version>(\d+)[.](\d+)([.](\d+))?)(-(?<suffix>[a-z]+)){0,1}((,|_)(?<build>\d+)){0,1}"#,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let match = regex.matches(
|
||||||
|
in: string,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(location: 0, length: string.count)
|
||||||
|
).first
|
||||||
|
|
||||||
|
guard let match = match else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var version: String = ""
|
||||||
|
var build: String?
|
||||||
|
var suffix: String?
|
||||||
|
|
||||||
|
if let versionRange = Range(match.range(withName: "version"), in: string) {
|
||||||
|
version = String(string[versionRange])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let buildRange = Range(match.range(withName: "build"), in: string) {
|
||||||
|
build = String(string[buildRange])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let suffixRange = Range(match.range(withName: "suffix"), in: string) {
|
||||||
|
suffix = String(string[suffixRange])
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppVersion(
|
||||||
|
version: version,
|
||||||
|
build: build,
|
||||||
|
suffix: suffix
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fromCurrentVersion() -> AppVersion {
|
||||||
|
return AppVersion.from("\(Executable.shortVersion)_\(Executable.bundleVersion)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
public var tagged: String {
|
||||||
|
if version.suffix(2) == ".0" && version.count > 3 {
|
||||||
|
return String(version.dropLast(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
public var computerReadable: String {
|
||||||
|
return "\(version)_\(build ?? 0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
public var humanReadable: String {
|
||||||
|
return "\(version) (\(build ?? 0))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comparable Protocol
|
||||||
|
|
||||||
|
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
||||||
|
if lhs.version < rhs.version {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhs.build ?? 0 < rhs.build ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
||||||
|
lhs.version == rhs.version
|
||||||
|
&& lhs.build == rhs.build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
static func ==(lhs: String, rhs: String) -> Bool {
|
||||||
|
return lhs.compare(rhs, options: .numeric) == .orderedSame
|
||||||
|
}
|
||||||
|
|
||||||
|
static func <(lhs: String, rhs: String) -> Bool {
|
||||||
|
return lhs.compare(rhs, options: .numeric) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
static func <=(lhs: String, rhs: String) -> Bool {
|
||||||
|
return lhs.compare(rhs, options: .numeric) == .orderedAscending || lhs.compare(rhs, options: .numeric) == .orderedSame
|
||||||
|
}
|
||||||
|
|
||||||
|
static func >(lhs: String, rhs: String) -> Bool {
|
||||||
|
return lhs.compare(rhs, options: .numeric) == .orderedDescending
|
||||||
|
}
|
||||||
|
|
||||||
|
static func >=(lhs: String, rhs: String) -> Bool {
|
||||||
|
return lhs.compare(rhs, options: .numeric) == .orderedDescending || lhs.compare(rhs, options: .numeric) == .orderedSame
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
70
Sources/AppUpdater/Support/CaskFile.swift
Normal file
70
Sources/AppUpdater/Support/CaskFile.swift
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 30/05/2024.
|
||||||
|
// Copyright © 2024 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CaskFile {
|
||||||
|
var properties: [String: String]
|
||||||
|
|
||||||
|
var name: String { return self.properties["name"]! }
|
||||||
|
var url: String { return self.properties["url"]! }
|
||||||
|
var sha256: String { return self.properties["sha256"]! }
|
||||||
|
var version: String { return self.properties["version"]! }
|
||||||
|
|
||||||
|
static func from(url: URL) -> CaskFile? {
|
||||||
|
var string: String?
|
||||||
|
|
||||||
|
if url.scheme == "file" {
|
||||||
|
string = try? String(contentsOf: url)
|
||||||
|
} else {
|
||||||
|
string = system("curl -s --max-time 10 '\(url.absoluteString)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let string else {
|
||||||
|
Log.text("The content of the URL for the CaskFile could not be retrieved")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = string.split(separator: "\n")
|
||||||
|
.map { line in
|
||||||
|
return line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
.filter { $0 != "" }
|
||||||
|
|
||||||
|
if lines.count < 4 {
|
||||||
|
Log.text("The CaskFile is <4 lines long, which is too short")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lines.first!.starts(with: "cask") || !lines.last!.starts(with: "end") {
|
||||||
|
Log.text("The CaskFile does not start with 'cask' or does not end with 'end'")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var props: [String: String] = [:]
|
||||||
|
|
||||||
|
let regex = try! NSRegularExpression(pattern: "(\\w+)\\s+'([^']+)'")
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
if let match = regex.firstMatch(
|
||||||
|
in: String(line),
|
||||||
|
range: NSRange(location: 0, length: line.utf16.count)
|
||||||
|
) {
|
||||||
|
let keyRange = match.range(at: 1)
|
||||||
|
let valueRange = match.range(at: 2)
|
||||||
|
let key = (line as NSString).substring(with: keyRange)
|
||||||
|
let value = (line as NSString).substring(with: valueRange)
|
||||||
|
props[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for required in ["version", "sha256", "url", "name"] where !props.keys.contains(required) {
|
||||||
|
Log.text("Property '\(required)' expected on CaskFile, assuming CaskFile is invalid")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return CaskFile(properties: props)
|
||||||
|
}
|
||||||
|
}
|
16
Sources/AppUpdater/Support/Log.swift
Normal file
16
Sources/AppUpdater/Support/Log.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 30/05/2024.
|
||||||
|
// Copyright © 2024 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
open class Log {
|
||||||
|
public static func text(_ text: String) {
|
||||||
|
self.handler(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var handler: (_ text: String) -> Void = { text in
|
||||||
|
print(text)
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user