-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Ehlen, David
committed
Mar 7, 2019
1 parent
d01c45b
commit 669df39
Showing
581 changed files
with
51,040 additions
and
1 deletion.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,256 @@ | ||
import class AppKit.NSBackgroundActivityScheduler | ||
import var AppKit.NSApp | ||
import PMKFoundation | ||
import Foundation | ||
import PromiseKit | ||
|
||
public class AppUpdater { | ||
var active = Promise() | ||
#if !DEBUG | ||
let activity: NSBackgroundActivityScheduler | ||
#endif | ||
let owner: String | ||
let repo: String | ||
|
||
var slug: String { | ||
return "\(owner)/\(repo)" | ||
} | ||
|
||
public init(owner: String, repo: String) { | ||
self.owner = owner | ||
self.repo = repo | ||
#if DEBUG | ||
check().cauterize() | ||
#else | ||
activity = NSBackgroundActivityScheduler(identifier: "dev.mxcl.AppUpdater") | ||
activity.repeats = true | ||
activity.interval = 24 * 60 * 60 | ||
activity.schedule { [unowned self] completion in | ||
guard !self.activity.shouldDefer, self.active.isResolved else { | ||
return completion(.deferred) | ||
} | ||
self.check().cauterize().finally { | ||
completion(.finished) | ||
} | ||
} | ||
#endif | ||
} | ||
|
||
#if !DEBUG | ||
deinit { | ||
activity.invalidate() | ||
} | ||
#endif | ||
|
||
private enum Error: Swift.Error { | ||
case bundleExecutableURL | ||
case codeSigningIdentity | ||
case invalidDownloadedBundle | ||
} | ||
|
||
public func check() -> Promise<Void> { | ||
guard active.isResolved else { | ||
return active | ||
} | ||
guard Bundle.main.executableURL != nil else { | ||
return Promise(error: Error.bundleExecutableURL) | ||
} | ||
let currentVersion = Bundle.main.version | ||
|
||
func validate(codeSigning b1: Bundle, _ b2: Bundle) -> Promise<Void> { | ||
return firstly { | ||
when(fulfilled: b1.codeSigningIdentity, b2.codeSigningIdentity) | ||
}.done { | ||
guard $0 == $1 else { throw Error.codeSigningIdentity } | ||
} | ||
} | ||
|
||
func update(with asset: Release.Asset) throws -> Promise<Void> { | ||
#if DEBUG | ||
print("notice: AppUpdater dry-run:", asset) | ||
return Promise() | ||
#else | ||
let tmpdir = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: Bundle.main.bundleURL, create: true) | ||
|
||
return firstly { | ||
URLSession.shared.downloadTask(.promise, with: asset.browser_download_url, to: tmpdir.appendingPathComponent("download")) | ||
}.then { dst, _ in | ||
unzip(dst, contentType: asset.content_type) | ||
}.compactMap { downloadedAppBundle in | ||
Bundle(url: downloadedAppBundle) | ||
}.then { downloadedAppBundle in | ||
validate(codeSigning: .main, downloadedAppBundle).map{ downloadedAppBundle } | ||
}.done { downloadedAppBundle in | ||
|
||
// UNIX is cool. Delete ourselves, move new one in then restart. | ||
|
||
let installedAppBundle = Bundle.main | ||
guard let exe = downloadedAppBundle.executable, exe.exists else { | ||
throw Error.invalidDownloadedBundle | ||
} | ||
let finalExecutable = installedAppBundle.path/exe.relative(to: downloadedAppBundle.path) | ||
|
||
try installedAppBundle.path.delete() | ||
try downloadedAppBundle.path.move(to: installedAppBundle.path) | ||
try FileManager.default.removeItem(at: tmpdir) | ||
|
||
let proc = Process() | ||
if #available(OSX 10.13, *) { | ||
proc.executableURL = finalExecutable.url | ||
} else { | ||
proc.launchPath = finalExecutable.string | ||
} | ||
proc.launch() | ||
|
||
// seems to work, though for sure, seems asking a lot for it to be reliable! | ||
//TODO be reliable! Probably get an external applescript to ask us this one to quit then exec the new one | ||
NSApp.terminate(self) | ||
}.ensure { | ||
_ = try? FileManager.default.removeItem(at: tmpdir) | ||
} | ||
#endif | ||
} | ||
|
||
let url = URL(string: "https://api.github.com/repos/\(slug)/releases")! | ||
|
||
active = firstly { | ||
URLSession.shared.dataTask(.promise, with: url).validate() | ||
}.map { | ||
try JSONDecoder().decode([Release].self, from: $0.data) | ||
}.compactMap { releases in | ||
try releases.findViableUpdate(appVersion: currentVersion, repo: self.repo) | ||
}.then { asset in | ||
try update(with: asset) | ||
} | ||
|
||
return active | ||
} | ||
} | ||
|
||
private struct Release: Decodable { | ||
let tag_name: Version | ||
let prerelease: Bool | ||
struct Asset: Decodable { | ||
let name: String | ||
let browser_download_url: URL | ||
let content_type: ContentType | ||
} | ||
let assets: [Asset] | ||
|
||
func viableAsset(forRepo repo: String) -> Asset? { | ||
return assets.first(where: { (asset) -> Bool in | ||
let prefix = "\(repo.lowercased())-\(tag_name)" | ||
let name = (asset.name as NSString).deletingPathExtension.lowercased() | ||
|
||
switch (name, asset.content_type) { | ||
case ("\(prefix).tar", .tar): | ||
return true | ||
case (prefix, _): | ||
return true | ||
default: | ||
return false | ||
} | ||
}) | ||
} | ||
} | ||
|
||
private enum ContentType: Decodable { | ||
init(from decoder: Decoder) throws { | ||
switch try decoder.singleValueContainer().decode(String.self) { | ||
case "application/x-bzip2", "application/x-xz", "application/x-gzip": | ||
self = .tar | ||
case "application/zip": | ||
self = .zip | ||
default: | ||
throw PMKError.badInput | ||
} | ||
} | ||
|
||
case zip | ||
case tar | ||
} | ||
|
||
extension Release: Comparable { | ||
static func < (lhs: Release, rhs: Release) -> Bool { | ||
return lhs.tag_name < rhs.tag_name | ||
} | ||
|
||
static func == (lhs: Release, rhs: Release) -> Bool { | ||
return lhs.tag_name == rhs.tag_name | ||
} | ||
} | ||
|
||
private extension Array where Element == Release { | ||
func findViableUpdate(appVersion: Version, repo: String) throws -> Release.Asset? { | ||
let properReleases = filter{ !$0.prerelease } | ||
guard let latestRelease = properReleases.sorted().last else { return nil } | ||
guard appVersion < latestRelease.tag_name else { throw PMKError.cancelled } | ||
return latestRelease.viableAsset(forRepo: repo) | ||
} | ||
} | ||
|
||
private func unzip(_ url: URL, contentType: ContentType) -> Promise<URL> { | ||
|
||
let proc = Process() | ||
if #available(OSX 10.13, *) { | ||
proc.currentDirectoryURL = url.deletingLastPathComponent() | ||
} else { | ||
proc.currentDirectoryPath = url.deletingLastPathComponent().path | ||
} | ||
|
||
switch contentType { | ||
case .tar: | ||
proc.launchPath = "/usr/bin/tar" | ||
proc.arguments = ["xf", url.path] | ||
case .zip: | ||
proc.launchPath = "/usr/bin/unzip" | ||
proc.arguments = [url.path] | ||
} | ||
|
||
func findApp() throws -> URL? { | ||
let cnts = try FileManager.default.contentsOfDirectory(at: url.deletingLastPathComponent(), includingPropertiesForKeys: [.isDirectoryKey], options: .skipsSubdirectoryDescendants) | ||
for url in cnts { | ||
guard url.pathExtension == "app" else { continue } | ||
guard let foo = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, foo else { continue } | ||
return url | ||
} | ||
return nil | ||
} | ||
|
||
return firstly { | ||
proc.launch(.promise) | ||
}.compactMap { _ in | ||
try findApp() | ||
} | ||
} | ||
|
||
private extension Bundle { | ||
var isCodeSigned: Guarantee<Bool> { | ||
let proc = Process() | ||
proc.launchPath = "/usr/bin/codesign" | ||
proc.arguments = ["-dv", bundlePath] | ||
return proc.launch(.promise).map { _ in | ||
true | ||
}.recover { _ in | ||
.value(false) | ||
} | ||
} | ||
|
||
var codeSigningIdentity: Promise<String> { | ||
let proc = Process() | ||
proc.launchPath = "/usr/bin/codesign" | ||
proc.arguments = ["-dvvv", bundlePath] | ||
|
||
return firstly { | ||
proc.launch(.promise) | ||
}.compactMap { | ||
String(data: $0.err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) | ||
}.map { | ||
$0.split(separator: "\n") | ||
}.filterValues { | ||
$0.hasPrefix("Authority=") | ||
}.firstValue.map { line in | ||
String(line.dropFirst(10)) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import Foundation | ||
|
||
/// Extensions on Foundation’s `Bundle` so you get `Path` rather than `String` or `URL`. | ||
public extension Bundle { | ||
/// Returns the path for requested resource in this bundle. | ||
func path(forResource: String, ofType: String?) -> Path? { | ||
let f: (String?, String?) -> String? = path(forResource:ofType:) | ||
let str = f(forResource, ofType) | ||
return str.flatMap(Path.init) | ||
} | ||
|
||
/** | ||
Returns the path for the shared-frameworks directory in this bundle. | ||
- Note: This is typically `ShareFrameworks` | ||
*/ | ||
var sharedFrameworks: Path { | ||
return sharedFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath | ||
} | ||
|
||
/** | ||
Returns the path for the private-frameworks directory in this bundle. | ||
- Note: This is typically `Frameworks` | ||
*/ | ||
var privateFrameworks: Path { | ||
return privateFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath | ||
} | ||
|
||
/// Returns the path for the resources directory in this bundle. | ||
var resources: Path { | ||
return resourcePath.flatMap(Path.init) ?? defaultResourcesPath | ||
} | ||
|
||
/// Returns the path for this bundle. | ||
var path: Path { | ||
return Path(string: bundlePath) | ||
} | ||
|
||
/// Returns the executable for this bundle, if there is one, not all bundles have one hence `Optional`. | ||
var executable: Path? { | ||
return executablePath.flatMap(Path.init) | ||
} | ||
} | ||
|
||
/// Extensions on `String` that work with `Path` rather than `String` or `URL` | ||
public extension String { | ||
/// Initializes this `String` with the contents of the provided path. | ||
@inlinable | ||
init(contentsOf path: Path) throws { | ||
try self.init(contentsOfFile: path.string) | ||
} | ||
|
||
/// - Returns: `to` to allow chaining | ||
@inlinable | ||
@discardableResult | ||
func write(to: Path, atomically: Bool = false, encoding: String.Encoding = .utf8) throws -> Path { | ||
try write(toFile: to.string, atomically: atomically, encoding: encoding) | ||
return to | ||
} | ||
} | ||
|
||
/// Extensions on `Data` that work with `Path` rather than `String` or `URL` | ||
public extension Data { | ||
/// Initializes this `Data` with the contents of the provided path. | ||
@inlinable | ||
init(contentsOf path: Path) throws { | ||
try self.init(contentsOf: path.url) | ||
} | ||
|
||
/// - Returns: `to` to allow chaining | ||
@inlinable | ||
@discardableResult | ||
func write(to: Path, atomically: Bool = false) throws -> Path { | ||
let opts: NSData.WritingOptions | ||
if atomically { | ||
#if !os(Linux) | ||
opts = .atomicWrite | ||
#else | ||
opts = .atomic | ||
#endif | ||
} else { | ||
opts = [] | ||
} | ||
try write(to: to.url, options: opts) | ||
return to | ||
} | ||
} | ||
|
||
/// Extensions on `FileHandle` that work with `Path` rather than `String` or `URL` | ||
public extension FileHandle { | ||
/// Initializes this `FileHandle` for reading at the location of the provided path. | ||
@inlinable | ||
convenience init(forReadingAt path: Path) throws { | ||
try self.init(forReadingFrom: path.url) | ||
} | ||
|
||
/// Initializes this `FileHandle` for writing at the location of the provided path. | ||
@inlinable | ||
convenience init(forWritingAt path: Path) throws { | ||
try self.init(forWritingTo: path.url) | ||
} | ||
|
||
/// Initializes this `FileHandle` for reading and writing at the location of the provided path. | ||
@inlinable | ||
convenience init(forUpdatingAt path: Path) throws { | ||
try self.init(forUpdating: path.url) | ||
} | ||
} | ||
|
||
internal extension Bundle { | ||
var defaultSharedFrameworksPath: Path { | ||
#if os(macOS) | ||
return path.join("Contents/Frameworks") | ||
#elseif os(Linux) | ||
return path.join("lib") | ||
#else | ||
return path.join("Frameworks") | ||
#endif | ||
} | ||
|
||
var defaultResourcesPath: Path { | ||
#if os(macOS) | ||
return path.join("Contents/Resources") | ||
#elseif os(Linux) | ||
return path.join("share") | ||
#else | ||
return path | ||
#endif | ||
} | ||
} |
Oops, something went wrong.