commit d37a11fa61f2e7b780a1c3a13c40b5076de2ba52 Author: Nico Verbruggen Date: Sun May 26 22:20:37 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b3e453 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Nico Verbruggen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..fc84b47 --- /dev/null +++ b/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AppUpdater", + platforms: [.macOS(.v11)], + products: [ + .library(name: "AppUpdater", targets: ["AppUpdater"]), + ], + targets: [ + .target(name: "AppUpdater"), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9137f9 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# AppUpdater Package + +## What this does + +This is a package that helps you build a self-updater for a given macOS application. It is currently based on code for PHP Monitor. + +This package contains code that can be used for the self-updater app that you can ship with your app, and code that you can use in your main app. + +Your app must ship the self-updater as a separate sub-app, so that it can be launched independently from the main executable, which is terminated upon launching the sub-app. + +Here's how it works: + +- The updater checks if a newer manifest file is available. If there is, it is downloaded to the `UpdaterPath`. + +- If the user chooses to install the update, the main app is terminated once the self-updater app has 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 + +As a separate target (for a macOS app), you need to add the following file: + +```swift +import Cocoa +import AppUpdater + +let delegate = AppSelfUpdater( + appName: "My App", + bundleIdentifiers: ["com.example.my-app"], + baseUpdaterPath: "~/.config/com.example.my-app/updater" +) + +NSApplication.shared.delegate = delegate +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) +``` diff --git a/Sources/AppUpdater/AppSelfUpdater.swift b/Sources/AppUpdater/AppSelfUpdater.swift new file mode 100644 index 0000000..4073635 --- /dev/null +++ b/Sources/AppUpdater/AppSelfUpdater.swift @@ -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)" + } +} diff --git a/Sources/AppUpdater/Support/Alert.swift b/Sources/AppUpdater/Support/Alert.swift new file mode 100644 index 0000000..122fe83 --- /dev/null +++ b/Sources/AppUpdater/Support/Alert.swift @@ -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() + } + } + } +} + diff --git a/Sources/AppUpdater/Support/LaunchControl.swift b/Sources/AppUpdater/Support/LaunchControl.swift new file mode 100644 index 0000000..aef1b82 --- /dev/null +++ b/Sources/AppUpdater/Support/LaunchControl.swift @@ -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) + } + } + } +} + diff --git a/Sources/AppUpdater/Support/ReleaseManifest.swift b/Sources/AppUpdater/Support/ReleaseManifest.swift new file mode 100644 index 0000000..c61b8ce --- /dev/null +++ b/Sources/AppUpdater/Support/ReleaseManifest.swift @@ -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 +} diff --git a/Sources/AppUpdater/Support/Shell.swift b/Sources/AppUpdater/Support/Shell.swift new file mode 100644 index 0000000..c351e60 --- /dev/null +++ b/Sources/AppUpdater/Support/Shell.swift @@ -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 +}