mirror of
https://github.com/nicoverbruggen/NVAppUpdater.git
synced 2025-08-07 09:40:08 +02:00
Initial commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
@ -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
21
LICENSE
Normal 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
15
Package.swift
Normal 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
35
README.md
Normal 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)
|
||||||
|
```
|
173
Sources/AppUpdater/AppSelfUpdater.swift
Normal file
173
Sources/AppUpdater/AppSelfUpdater.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
29
Sources/AppUpdater/Support/Alert.swift
Normal file
29
Sources/AppUpdater/Support/Alert.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
44
Sources/AppUpdater/Support/LaunchControl.swift
Normal file
44
Sources/AppUpdater/Support/LaunchControl.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
9
Sources/AppUpdater/Support/ReleaseManifest.swift
Normal file
9
Sources/AppUpdater/Support/ReleaseManifest.swift
Normal 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
|
||||||
|
}
|
40
Sources/AppUpdater/Support/Shell.swift
Normal file
40
Sources/AppUpdater/Support/Shell.swift
Normal 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
|
||||||
|
}
|
Reference in New Issue
Block a user