Skip to content

Commit

Permalink
#26 add AppUpdater
Browse files Browse the repository at this point in the history
  • Loading branch information
Ehlen, David committed Mar 7, 2019
1 parent d01c45b commit 669df39
Show file tree
Hide file tree
Showing 581 changed files with 51,040 additions and 1 deletion.
96 changes: 96 additions & 0 deletions Capture.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Capture/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let storyboard = NSStoryboard(name: "Preferences", bundle: nil)
return storyboard.instantiateInitialController() as? NSWindowController
}()

let updater = AppUpdater(owner: "dehlen", repo: "Capture")

func applicationDidFinishLaunching(_ aNotification: Notification) {
os_log(.info, log: .app, "Application did finish launching")
Expand Down
256 changes: 256 additions & 0 deletions Capture/AppUpdater/AppUpdater.swift
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))
}
}
}
129 changes: 129 additions & 0 deletions Capture/AppUpdater/Path/Extensions.swift
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
}
}
Loading

0 comments on commit 669df39

Please sign in to comment.