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

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

21
LICENSE Normal file
View File

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

15
Package.swift Normal file
View File

@ -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"),
]
)

35
README.md Normal file
View File

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

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
}