Initial commit

This commit is contained in:
2024-05-26 22:20:37 +02:00
commit d37a11fa61
10 changed files with 382 additions and 0 deletions

View File

@ -0,0 +1,173 @@
//
// Created by Nico Verbruggen on 26/05/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import Cocoa
open class AppSelfUpdater: NSObject, NSApplicationDelegate {
// MARK: - Requires Configuration
public init(appName: String, bundleIdentifiers: [String], baseUpdaterPath: String) {
self.appName = appName
self.bundleIdentifiers = bundleIdentifiers
self.baseUpdaterPath = baseUpdaterPath
}
// MARK: - Regular Updater Flow
// Set by the user
private var appName: String
private var bundleIdentifiers: [String]
private var baseUpdaterPath: String
// Determined during the flow of the updater
private var updaterPath: String = ""
private var manifestPath: String = ""
private var manifest: ReleaseManifest! = nil
public func applicationDidFinishLaunching(_ aNotification: Notification) {
Alert.appName = self.appName
Task { await self.installUpdate() }
}
public func applicationWillTerminate(_ aNotification: Notification) {
exit(1)
}
public func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return false
}
func installUpdate() async {
print("APP SELF-UPDATER by Nico Verbruggen")
print("Configured for \(self.appName)\n\(self.bundleIdentifiers)")
print("===========================================")
self.updaterPath = self.baseUpdaterPath
.replacingOccurrences(of: "~", with: NSHomeDirectory())
print("Updater directory set to: \(self.updaterPath)")
self.manifestPath = "\(updaterPath)/update.json"
// Fetch the manifest on the local filesystem
let manifest = await parseManifest()!
// Download the latest file
let zipPath = await download(manifest)
// Terminate all instances of app first
await LaunchControl.terminateApplications(bundleIds: self.bundleIdentifiers)
// Install the app based on the zip
let appPath = await extractAndInstall(zipPath: zipPath)
// Restart app, this will also close the updater
_ = await LaunchControl.startApplication(at: appPath)
exit(1)
}
private func parseManifest() async -> ReleaseManifest? {
// Read out the correct information from the manifest JSON
print("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).")
}
return nil
}
private func download(_ manifest: ReleaseManifest) async -> String {
// Remove all zips
system_quiet("rm -rf \(updaterPath)/*.zip")
// Download the file (and follow redirects + no output on failure)
system_quiet("cd \"\(updaterPath)\" && curl \(manifest.url) -fLO --max-time 20")
// Identify the downloaded file
let filename = system("cd \"\(updaterPath)\" && ls | grep .zip")
.trimmingCharacters(in: .whitespacesAndNewlines)
// 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.)")
}
// Calculate the checksum for the downloaded file
let checksum = system("openssl dgst -sha256 \"\(updaterPath)/\(filename)\" | awk '{print $NF}'")
.trimmingCharacters(in: .whitespacesAndNewlines)
// Compare the checksums
print("""
Comparing checksums...
Expected SHA256: \(manifest.sha256)
Actual SHA256: \(checksum)
""")
// 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.")
}
// Return the path to the zip
return "\(updaterPath)/\(filename)"
}
private func extractAndInstall(zipPath: String) async -> String {
// Remove the directory that will contain the extracted update
system_quiet("rm -rf \"\(updaterPath)/extracted\"")
// Recreate the directory where we will unzip the .app file
system_quiet("mkdir -p \"\(updaterPath)/extracted\"")
// 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.")
}
// Unzip the file
system_quiet("unzip \"\(zipPath)\" -d \"\(updaterPath)/extracted\"")
// Find the .app file
let app = system("ls \"\(updaterPath)/extracted\" | grep .app")
.trimmingCharacters(in: .whitespacesAndNewlines)
print("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.")
}
// Remove the original app
print("Removing \(app) before replacing...")
system_quiet("rm -rf \"/Applications/\(app)\"")
// Move the new app in place
system_quiet("mv \"\(updaterPath)/extracted/\(app)\" \"/Applications/\(app)\"")
// Remove the zip
system_quiet("rm \"\(zipPath)\"")
// Remove the manifest
system_quiet("rm \"\(manifestPath)\"")
// Write a file that is only written when we upgraded successfully
system_quiet("touch \"\(updaterPath)/upgrade.success\"")
// Return the new location of the app
return "/Applications/\(app)"
}
}

View File

@ -0,0 +1,29 @@
//
// Created by Nico Verbruggen on 26/05/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import Foundation
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()
}
}
}
}

View File

@ -0,0 +1,44 @@
//
// Created by Nico Verbruggen on 26/05/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import Foundation
import Cocoa
class LaunchControl {
public static func smartRestart(priority: [String]) async {
for appPath in priority {
if FileManager.default.fileExists(atPath: appPath) {
let app = await LaunchControl.startApplication(at: appPath)
if app != nil {
return
}
}
}
}
public static func terminateApplications(bundleIds: [String]) async {
let runningApplications = NSWorkspace.shared.runningApplications
// Terminate all instances found
for id in bundleIds {
if let phpmon = runningApplications.first(where: {
(application) in return application.bundleIdentifier == id
}) {
phpmon.terminate()
}
}
}
public static func startApplication(at path: String) async -> NSRunningApplication? {
await withCheckedContinuation { continuation in
let url = NSURL(fileURLWithPath: path, isDirectory: true) as URL
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: url, configuration: configuration) { phpmon, error in
continuation.resume(returning: phpmon)
}
}
}
}

View File

@ -0,0 +1,9 @@
//
// Created by Nico Verbruggen on 26/05/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
struct ReleaseManifest: Codable {
let url: String
let sha256: String
}

View File

@ -0,0 +1,40 @@
//
// Created by Nico Verbruggen on 26/05/2024.
// Copyright © 2024 Nico Verbruggen. All rights reserved.
//
import Foundation
/**
Run a simple blocking Shell command on the user's own system.
*/
func system(_ command: String) -> String {
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", command]
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output: String = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String
return output
}
/**
Same as the `system` command, but does not return the output.
*/
func system_quiet(_ command: String) {
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", command]
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
_ = pipe.fileHandleForReading.readDataToEndOfFile()
return
}