mirror of
https://github.com/exelban/stats.git
synced 2026-05-07 20:02:34 +00:00
feat: improved updater mechanism for better handling of the mount point, and added new app version signature
This commit is contained in:
+14
-7
@@ -1256,14 +1256,21 @@ public func iconFromSymbol(name: String, scale: NSImage.SymbolScale) -> NSImage
|
||||
}
|
||||
|
||||
public func showAlert(_ message: String, _ information: String? = nil, _ style: NSAlert.Style = .informational) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = message
|
||||
if let information = information {
|
||||
alert.informativeText = information
|
||||
let show = {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = message
|
||||
if let information = information {
|
||||
alert.informativeText = information
|
||||
}
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.alertStyle = style
|
||||
alert.runModal()
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
show()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: show)
|
||||
}
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.alertStyle = style
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
var isDarkMode: Bool {
|
||||
|
||||
+139
-24
@@ -11,6 +11,7 @@
|
||||
|
||||
import Cocoa
|
||||
import SystemConfiguration
|
||||
import Security
|
||||
|
||||
public struct version_s {
|
||||
public let current: String
|
||||
@@ -166,12 +167,13 @@ public class Updater {
|
||||
}
|
||||
|
||||
public func install(path: String, completion: @escaping (_ error: String?) -> Void) {
|
||||
let pwd = Bundle.main.bundleURL.absoluteString
|
||||
.replacingOccurrences(of: "file://", with: "")
|
||||
.replacingOccurrences(of: "Stats.app", with: "")
|
||||
.replacingOccurrences(of: "//", with: "/")
|
||||
let dmg = path.replacingOccurrences(of: "file://", with: "")
|
||||
let pwd = Bundle.main.bundleURL.deletingLastPathComponent().path
|
||||
|
||||
guard FileManager.default.fileExists(atPath: dmg) else {
|
||||
completion("DMG not found at \(dmg)")
|
||||
return
|
||||
}
|
||||
if !FileManager.default.isWritableFile(atPath: pwd) {
|
||||
completion("has no write permission on \(pwd)")
|
||||
return
|
||||
@@ -185,38 +187,151 @@ public class Updater {
|
||||
|
||||
print("Started new version installation...")
|
||||
|
||||
_ = syncShell("mkdir /tmp/Stats") // make sure that directory exist
|
||||
let res = syncShell("/usr/bin/hdiutil attach \(path) -mountpoint /tmp/Stats -noverify -nobrowse -noautoopen 2>&1") // mount the dmg
|
||||
|
||||
print("DMG is mounted")
|
||||
|
||||
if res.contains("is busy") { // dmg can be busy, if yes, unmount it and mount again
|
||||
print("DMG is busy, remounting")
|
||||
|
||||
_ = syncShell("/usr/bin/hdiutil detach $TMPDIR/Stats")
|
||||
_ = syncShell("/usr/bin/hdiutil attach \(path) -mountpoint /tmp/Stats -noverify -nobrowse -noautoopen")
|
||||
} else if res.contains("attach failed") { // Attach can fail due to edge cases like MDM restrictions.
|
||||
let errorMessage = res.replacingOccurrences(of: "hdiutil: attach failed - ", with: "")
|
||||
completion("Could not mount DMG (attach failed) - \(errorMessage)")
|
||||
_ = syncShell("rm \(dmg)")
|
||||
let mountPoint: String
|
||||
do {
|
||||
mountPoint = try self.makeUniqueMountPoint()
|
||||
} catch {
|
||||
completion("failed to create mount point: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
_ = syncShell("cp -rf /tmp/Stats/Stats.app/Contents/Resources/Scripts/updater.sh $TMPDIR/updater.sh") // copy updater script to tmp folder
|
||||
var attach = self.runProcess("/usr/bin/hdiutil", [
|
||||
"attach", dmg, "-mountpoint", mountPoint, "-nobrowse", "-noautoopen", "-readonly"
|
||||
])
|
||||
if attach.exit != 0, (attach.error + attach.output).contains("is busy") {
|
||||
print("DMG is busy, remounting")
|
||||
_ = self.runProcess("/usr/bin/hdiutil", ["detach", mountPoint, "-force"])
|
||||
attach = self.runProcess("/usr/bin/hdiutil", [
|
||||
"attach", dmg, "-mountpoint", mountPoint, "-nobrowse", "-noautoopen", "-readonly"
|
||||
])
|
||||
}
|
||||
if attach.exit != 0 {
|
||||
let msg = (attach.error + attach.output).replacingOccurrences(of: "hdiutil: attach failed - ", with: "")
|
||||
completion("Could not mount DMG (attach failed) - \(msg)")
|
||||
try? FileManager.default.removeItem(atPath: dmg)
|
||||
try? FileManager.default.removeItem(atPath: mountPoint)
|
||||
return
|
||||
}
|
||||
|
||||
print("Script is copied to $TMPDIR/updater.sh")
|
||||
print("DMG is mounted at \(mountPoint)")
|
||||
|
||||
asyncShell("sh $TMPDIR/updater.sh --app \(pwd) --dmg \(dmg) >/dev/null &") // run updater script in in background
|
||||
let mountedApp = (mountPoint as NSString).appendingPathComponent("Stats.app")
|
||||
if let err = self.validateAppSignature(at: mountedApp) {
|
||||
_ = self.runProcess("/usr/bin/hdiutil", ["detach", mountPoint, "-force"])
|
||||
try? FileManager.default.removeItem(atPath: mountPoint)
|
||||
try? FileManager.default.removeItem(atPath: dmg)
|
||||
completion("DMG signature validation failed: \(err)")
|
||||
return
|
||||
}
|
||||
|
||||
print("DMG signature validated")
|
||||
|
||||
let scriptSrc = (mountedApp as NSString).appendingPathComponent("Contents/Resources/Scripts/updater.sh")
|
||||
let scriptDst = (NSTemporaryDirectory() as NSString).appendingPathComponent("stats-updater-\(UUID().uuidString).sh")
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: scriptDst) {
|
||||
try FileManager.default.removeItem(atPath: scriptDst)
|
||||
}
|
||||
try FileManager.default.copyItem(atPath: scriptSrc, toPath: scriptDst)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptDst)
|
||||
} catch {
|
||||
_ = self.runProcess("/usr/bin/hdiutil", ["detach", mountPoint, "-force"])
|
||||
completion("failed to stage updater script: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
print("Script staged at \(scriptDst)")
|
||||
|
||||
let task = Process()
|
||||
task.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||
task.arguments = [scriptDst, "--app", pwd, "--dmg", dmg, "--mount", mountPoint]
|
||||
do {
|
||||
try task.run()
|
||||
} catch {
|
||||
completion("failed to launch updater: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
print("Run updater.sh with app: \(pwd) and dmg: \(dmg)")
|
||||
|
||||
defer {
|
||||
self.lastInstallTS = Int(Date().timeIntervalSince1970)
|
||||
}
|
||||
self.lastInstallTS = Int(Date().timeIntervalSince1970)
|
||||
|
||||
exit(0)
|
||||
}
|
||||
|
||||
private func makeUniqueMountPoint() throws -> String {
|
||||
let template = (NSTemporaryDirectory() as NSString).appendingPathComponent("Stats-update-XXXXXX")
|
||||
var bytes = Array(template.utf8).map { Int8($0) } + [Int8(0)]
|
||||
guard let dir = mkdtemp(&bytes) else {
|
||||
throw NSError(domain: "Updater", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))])
|
||||
}
|
||||
return String(cString: dir)
|
||||
}
|
||||
|
||||
private func validateAppSignature(at path: String) -> String? {
|
||||
var staticCode: SecStaticCode?
|
||||
let url = URL(fileURLWithPath: path) as CFURL
|
||||
var status = SecStaticCodeCreateWithPath(url, [], &staticCode)
|
||||
guard status == errSecSuccess, let code = staticCode else {
|
||||
return "SecStaticCodeCreateWithPath failed (\(status))"
|
||||
}
|
||||
let flags = SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode)
|
||||
status = SecStaticCodeCheckValidity(code, flags, nil)
|
||||
guard status == errSecSuccess else {
|
||||
return "SecStaticCodeCheckValidity failed (\(status))"
|
||||
}
|
||||
|
||||
var selfCode: SecCode?
|
||||
guard SecCodeCopySelf([], &selfCode) == errSecSuccess, let selfCode else {
|
||||
return "SecCodeCopySelf failed"
|
||||
}
|
||||
var selfStatic: SecStaticCode?
|
||||
guard SecCodeCopyStaticCode(selfCode, [], &selfStatic) == errSecSuccess, let selfStatic else {
|
||||
return "SecCodeCopyStaticCode failed"
|
||||
}
|
||||
guard let selfTeam = self.teamID(for: selfStatic) else {
|
||||
return "could not read current team ID"
|
||||
}
|
||||
guard let dmgTeam = self.teamID(for: code) else {
|
||||
return "could not read DMG team ID"
|
||||
}
|
||||
if selfTeam != dmgTeam {
|
||||
return "team ID mismatch: \(selfTeam) vs \(dmgTeam)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func teamID(for code: SecStaticCode) -> String? {
|
||||
var info: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess,
|
||||
let dict = info as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return dict[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
}
|
||||
|
||||
private func runProcess(_ launch: String, _ args: [String]) -> (output: String, error: String, exit: Int32) {
|
||||
let task = Process()
|
||||
task.executableURL = URL(fileURLWithPath: launch)
|
||||
task.arguments = args
|
||||
let out = Pipe(), err = Pipe()
|
||||
task.standardOutput = out
|
||||
task.standardError = err
|
||||
do {
|
||||
try task.run()
|
||||
} catch {
|
||||
return ("", "runProcess: \(error.localizedDescription)", -1)
|
||||
}
|
||||
let outData = out.fileHandleForReading.readDataToEndOfFile()
|
||||
let errData = err.fileHandleForReading.readDataToEndOfFile()
|
||||
task.waitUntilExit()
|
||||
return (
|
||||
String(data: outData, encoding: .utf8) ?? "",
|
||||
String(data: errData, encoding: .utf8) ?? "",
|
||||
task.terminationStatus
|
||||
)
|
||||
}
|
||||
|
||||
private func copyFile(from: URL, to: URL, completionHandler: @escaping (_ path: String, _ error: Error?) -> Void) {
|
||||
var toPath = to
|
||||
let fileName = (URL(fileURLWithPath: to.absoluteString)).lastPathComponent
|
||||
|
||||
+47
-24
@@ -37,18 +37,37 @@ extension AppDelegate {
|
||||
if let mountIndex = args.firstIndex(of: "--mount-path") {
|
||||
if args.indices.contains(mountIndex+1) {
|
||||
let mountPath = args[mountIndex+1]
|
||||
asyncShell("/usr/bin/hdiutil detach \(mountPath)")
|
||||
asyncShell("/bin/rm -rf \(mountPath)")
|
||||
|
||||
debug("DMG was unmounted and mountPath deleted")
|
||||
let tmp = NSTemporaryDirectory()
|
||||
let stdTmp = (tmp as NSString).standardizingPath
|
||||
let stdMount = (mountPath as NSString).standardizingPath
|
||||
let inTmp = stdMount.hasPrefix(stdTmp) || stdMount.hasPrefix("/private/tmp/") || stdMount.hasPrefix("/tmp/")
|
||||
if inTmp, !stdMount.contains("..") {
|
||||
let detach = Process()
|
||||
detach.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil")
|
||||
detach.arguments = ["detach", mountPath, "-force"]
|
||||
try? detach.run()
|
||||
detach.waitUntilExit()
|
||||
try? FileManager.default.removeItem(atPath: mountPath)
|
||||
|
||||
debug("DMG was unmounted and mountPath deleted")
|
||||
} else {
|
||||
debug("rejected --mount-path outside tmp: \(mountPath)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let dmgIndex = args.firstIndex(of: "--dmg-path") {
|
||||
if args.indices.contains(dmgIndex+1) {
|
||||
asyncShell("/bin/rm -rf \(args[dmgIndex+1])")
|
||||
|
||||
debug("DMG was deleted")
|
||||
let dmgPath = args[dmgIndex+1]
|
||||
let stdDmg = (dmgPath as NSString).standardizingPath
|
||||
let downloads = (try? FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: false).path) ?? ""
|
||||
let inDownloads = downloads.isEmpty || stdDmg.hasPrefix(downloads)
|
||||
if stdDmg.hasSuffix(".dmg"), !stdDmg.contains(".."), inDownloads {
|
||||
try? FileManager.default.removeItem(atPath: dmgPath)
|
||||
debug("DMG was deleted")
|
||||
} else {
|
||||
debug("rejected --dmg-path: \(dmgPath)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,23 +199,27 @@ extension AppDelegate {
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional:
|
||||
self.showUpdateNotification(version: version)
|
||||
case .denied:
|
||||
self.showUpdateWindow(version: version)
|
||||
case .notDetermined:
|
||||
center.requestAuthorization(options: [.sound, .alert, .badge], completionHandler: { (_, error) in
|
||||
if error == nil {
|
||||
NSApplication.shared.registerForRemoteNotifications()
|
||||
self.showUpdateNotification(version: version)
|
||||
} else {
|
||||
self.showUpdateWindow(version: version)
|
||||
}
|
||||
})
|
||||
@unknown default:
|
||||
self.showUpdateWindow(version: version)
|
||||
error_msg("unknown notification setting")
|
||||
DispatchQueue.main.async {
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional:
|
||||
self.showUpdateNotification(version: version)
|
||||
case .denied:
|
||||
self.showUpdateWindow(version: version)
|
||||
case .notDetermined:
|
||||
center.requestAuthorization(options: [.sound, .alert, .badge], completionHandler: { (_, error) in
|
||||
DispatchQueue.main.async {
|
||||
if error == nil {
|
||||
NSApplication.shared.registerForRemoteNotifications()
|
||||
self.showUpdateNotification(version: version)
|
||||
} else {
|
||||
self.showUpdateWindow(version: version)
|
||||
}
|
||||
}
|
||||
})
|
||||
@unknown default:
|
||||
self.showUpdateWindow(version: version)
|
||||
error_msg("unknown notification setting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user