From 9acf308ea6230c52853efab474d2068f76e08ad0 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Fri, 31 May 2024 00:30:47 +0200 Subject: [PATCH] Add UpdateCheck and some supporting code --- Package.swift | 1 + README.md | 52 +++++- .../AppUpdater/{ => API}/SelfUpdater.swift | 38 ++--- Sources/AppUpdater/API/UpdateCheck.swift | 149 ++++++++++++++++++ Sources/AppUpdater/Support/Alert.swift | 60 +++++-- Sources/AppUpdater/Support/AppVersion.swift | 120 ++++++++++++++ Sources/AppUpdater/Support/CaskFile.swift | 70 ++++++++ Sources/AppUpdater/Support/Log.swift | 16 ++ 8 files changed, 473 insertions(+), 33 deletions(-) rename Sources/AppUpdater/{ => API}/SelfUpdater.swift (72%) create mode 100644 Sources/AppUpdater/API/UpdateCheck.swift create mode 100644 Sources/AppUpdater/Support/AppVersion.swift create mode 100644 Sources/AppUpdater/Support/CaskFile.swift create mode 100644 Sources/AppUpdater/Support/Log.swift diff --git a/Package.swift b/Package.swift index fc84b47..769a7ce 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,7 @@ import PackageDescription let package = Package( name: "AppUpdater", + defaultLocalization: "en" platforms: [.macOS(.v11)], products: [ .library(name: "AppUpdater", targets: ["AppUpdater"]), diff --git a/README.md b/README.md index 6bc6a98..b3e56fd 100644 --- a/README.md +++ b/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. -## 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: @@ -33,3 +81,5 @@ let delegate = SelfUpdater( NSApplication.shared.delegate = delegate _ = 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). \ No newline at end of file diff --git a/Sources/AppUpdater/SelfUpdater.swift b/Sources/AppUpdater/API/SelfUpdater.swift similarity index 72% rename from Sources/AppUpdater/SelfUpdater.swift rename to Sources/AppUpdater/API/SelfUpdater.swift index e37d097..3f53aa4 100644 --- a/Sources/AppUpdater/SelfUpdater.swift +++ b/Sources/AppUpdater/API/SelfUpdater.swift @@ -41,17 +41,17 @@ open class SelfUpdater: NSObject, NSApplicationDelegate { } func installUpdate() async { - print("===========================================") - print("\(Executable.name), version \(Executable.fullVersion)") - print("Using AppUpdater by Nico Verbruggen") - print("===========================================") + Log.text("===========================================") + Log.text("\(Executable.name), version \(Executable.fullVersion)") + Log.text("Using AppUpdater by Nico Verbruggen") + Log.text("===========================================") - print("Configured for \(self.appName) bundles: \(self.bundleIdentifiers)") + Log.text("Configured for \(self.appName) bundles: \(self.bundleIdentifiers)") self.updaterPath = self.baseUpdaterPath .replacingOccurrences(of: "~", with: NSHomeDirectory()) - print("Updater directory set to: \(self.updaterPath)") + Log.text("Updater directory set to: \(self.updaterPath)") self.manifestPath = "\(updaterPath)/update.json" @@ -75,15 +75,15 @@ open class SelfUpdater: NSObject, NSApplicationDelegate { private func parseManifest() async -> ReleaseManifest? { // Read out the correct information from the manifest JSON - print("Checking manifest file at \(manifestPath)...") + Log.text("Checking manifest file at \(manifestPath)...") do { let manifestText = try String(contentsOfFile: manifestPath) manifest = try JSONDecoder().decode(ReleaseManifest.self, from: manifestText.data(using: .utf8)!) return manifest } catch { - print("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).") + Log.text("Parsing the manifest failed (or the manifest file doesn't exist)!") + await Alert.upgradeFailure(description: "The manifest file for a potential update was not found. Please try searching for updates again in \(appName).") } return nil @@ -102,16 +102,16 @@ open class SelfUpdater: NSObject, NSApplicationDelegate { // Ensure the zip exists if filename.isEmpty { - print("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.)") + Log.text("The update has not been downloaded. Sadly, that means that \(appName) cannot not updated!") + 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 - 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) // Compare the checksums - print(""" + Log.text(""" Comparing checksums... Expected SHA256: \(manifest.sha256) Actual SHA256: \(checksum) @@ -119,8 +119,8 @@ open class SelfUpdater: NSObject, NSApplicationDelegate { // Make sure the checksum matches before we do anything with the file if checksum != manifest.sha256 { - print("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.") + Log.text("The checksums failed to match. Cancelling!") + 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 @@ -137,7 +137,7 @@ open class SelfUpdater: NSObject, NSApplicationDelegate { // Make sure the updater directory exists var isDirectory: ObjCBool = true 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 @@ -147,15 +147,15 @@ open class SelfUpdater: NSObject, NSApplicationDelegate { let app = system("ls \"\(updaterPath)/extracted\" | grep .app") .trimmingCharacters(in: .whitespacesAndNewlines) - print("Finished extracting: \(updaterPath)/extracted/\(app)") + Log.text("Finished extracting: \(updaterPath)/extracted/\(app)") // Make sure the file was extracted 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 - print("Removing \(app) before replacing...") + Log.text("Removing \(app) before replacing...") system_quiet("rm -rf \"/Applications/\(app)\"") // Move the new app in place diff --git a/Sources/AppUpdater/API/UpdateCheck.swift b/Sources/AppUpdater/API/UpdateCheck.swift new file mode 100644 index 0000000..7783f18 --- /dev/null +++ b/Sources/AppUpdater/API/UpdateCheck.swift @@ -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!") + } + } +} diff --git a/Sources/AppUpdater/Support/Alert.swift b/Sources/AppUpdater/Support/Alert.swift index 122fe83..3513890 100644 --- a/Sources/AppUpdater/Support/Alert.swift +++ b/Sources/AppUpdater/Support/Alert.swift @@ -9,21 +9,55 @@ import Cocoa class Alert { public static var appName: String = "" - public static func show(description: String, shouldExit: Bool = true) async { - await withUnsafeContinuation { continuation in - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "\(Alert.appName) could not be updated." - alert.informativeText = description - alert.addButton(withTitle: "OK") - alert.alertStyle = .critical - alert.runModal() - if shouldExit { - exit(0) - } - continuation.resume() + // MARK: - Specific Cases + + @MainActor + public static func upgradeFailure(description: String, shouldExit: Bool = true) async { + await confirm( + title: "\(Alert.appName) could not be updated.", + description: description, + alertStyle: .critical, + callback: { + exit(0) } + ) + } + + // 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() + } } diff --git a/Sources/AppUpdater/Support/AppVersion.swift b/Sources/AppUpdater/Support/AppVersion.swift new file mode 100644 index 0000000..fc7a5f4 --- /dev/null +++ b/Sources/AppUpdater/Support/AppVersion.swift @@ -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: #"(?(\d+)[.](\d+)([.](\d+))?)(-(?[a-z]+)){0,1}((,|_)(?\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 + } + +} diff --git a/Sources/AppUpdater/Support/CaskFile.swift b/Sources/AppUpdater/Support/CaskFile.swift new file mode 100644 index 0000000..dde7295 --- /dev/null +++ b/Sources/AppUpdater/Support/CaskFile.swift @@ -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) + } +} diff --git a/Sources/AppUpdater/Support/Log.swift b/Sources/AppUpdater/Support/Log.swift new file mode 100644 index 0000000..e4ce567 --- /dev/null +++ b/Sources/AppUpdater/Support/Log.swift @@ -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) + } +}