Add UpdateCheck and some supporting code

This commit is contained in:
2024-05-31 00:30:47 +02:00
parent 2e77fa0608
commit 9acf308ea6
8 changed files with 473 additions and 33 deletions

View File

@ -5,6 +5,7 @@ import PackageDescription
let package = Package(
name: "AppUpdater",
defaultLocalization: "en"
platforms: [.macOS(.v11)],
products: [
.library(name: "AppUpdater", targets: ["AppUpdater"]),

View File

@ -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).

View File

@ -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

View 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!")
}
}
}

View File

@ -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 {
// 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)
}
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()
}
}

View 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
}
}

View 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)
}
}

View 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)
}
}