mirror of
https://github.com/nicoverbruggen/NVAppUpdater.git
synced 2025-08-07 09:40:08 +02:00
Add UpdateCheck and some supporting code
This commit is contained in:
@ -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
|
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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
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