Skip to content

Commit

Permalink
Formula status; Scheduled hourly brew update
Browse files Browse the repository at this point in the history
  • Loading branch information
mxcl committed Mar 5, 2019
1 parent 34c408e commit 7602aa1
Show file tree
Hide file tree
Showing 13 changed files with 2,242 additions and 1,720 deletions.
1 change: 1 addition & 0 deletions .cake/Batter.swift
Expand Up @@ -2,4 +2,5 @@
// Modules are sorted *topologically*.
@_exported import Dotfiles
@_exported import Item
@_exported import Brew
@_exported import Bakeware
3,478 changes: 1,791 additions & 1,687 deletions .cake/Cake.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .cake/Package.resolved
Expand Up @@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/mxcl/AppUpdater.git",
"state": {
"branch": null,
"revision": "ea7581f82be70c5e1b72181807d70812112db3dc",
"version": "1.0.2"
"revision": "ba6661149dc433ebe9620498063cbce8389de0ba",
"version": "1.0.3"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion Cakefile.swift
@@ -1,7 +1,7 @@
import Cakefile

dependencies = [
.cake(~>Version(1,0,0, prereleaseIdentifiers: ["debug"])),
.cake(~>1.0),
.github("mxcl/PromiseKit" ~> 6.7),
.github("Weebly/OrderedSet" ~> 3),
.github("mxcl/LegibleError" ~> 1),
Expand Down
Expand Up @@ -116,7 +116,7 @@ public class AppUpdater {
let url = URL(string: "https://api.github.com/repos/\(slug)/releases")!

active = firstly {
URLSession.shared.dataTask(.promise, with: url)
URLSession.shared.dataTask(.promise, with: url).validate()
}.map {
try JSONDecoder().decode([Release].self, from: $0.data)
}.compactMap { releases in
Expand Down
144 changes: 134 additions & 10 deletions Detritus/Main.storyboard

Large diffs are not rendered by default.

25 changes: 11 additions & 14 deletions Sources/AppDelegate.swift
Expand Up @@ -4,6 +4,7 @@ import Cake
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
let model = Dotfiles.Sync()
let brewModel = BrewModel()
let popover = NSPopover()
let statusItem = NSStatusBar.system.statusItem(withLength: 24)
var eventMonitor: Any?
Expand All @@ -26,6 +27,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let storyboard = NSStoryboard(name: .init("Main"), bundle: nil)
let identifier = NSStoryboard.SceneIdentifier("RootTabViewController")
popover.contentViewController = storyboard.instantiateController(withIdentifier: identifier) as? NSViewController

brewModel.promise.catch { [ weak self] in
if case Brew.E.noBrew = $0 {
self?.rootViewController?.hideTabs()
}
}
}

@objc func checkForUpdates() {
Expand Down Expand Up @@ -71,20 +78,6 @@ extension AppDelegate: Dotfiles.SyncDelegate {
}
}

extension AppDelegate: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return model.items.count
}

func tableView(_ tableView: NSTableView, objectValueFor column: NSTableColumn?, row: Int) -> Any? {
if column == dotfilesViewController?.filenameColumn {
return model.items[row].relativePath
} else {
return model.items[row].statusString
}
}
}

extension AppDelegate: NSTableViewDelegate {
func tableViewSelectionDidChange(_ notification: Notification) {
guard let tableView = notification.object as? NSTableView else {
Expand Down Expand Up @@ -123,3 +116,7 @@ private extension Bundle {
?? "App"
}
}

var NSAppDelegate: AppDelegate {
return NSApp.delegate as! AppDelegate
}
38 changes: 38 additions & 0 deletions Sources/BrewViewController.swift
@@ -0,0 +1,38 @@
import AppKit

class BrewViewController: NSViewController {
@IBOutlet var tableView: NSTableView!
@IBOutlet var formulaColumn: NSTableColumn!
@IBOutlet var versionColumn: NSTableColumn!

override func viewDidLoad() {
tableView.dataSource = self
}

override func viewWillAppear() {
// otherwise relative times are wrong
tableView.reloadData()
}
}

extension BrewViewController: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return NSAppDelegate.brewModel.listing.count
}

func tableView(_ tableView: NSTableView, objectValueFor column: NSTableColumn?, row: Int) -> Any? {
let item = NSAppDelegate.brewModel.listing[row]
switch column {
case formulaColumn:
return item.name
case versionColumn:
return item.version
default:
if let outdated = item.outdated {
return "\(outdated)"
} else {
return "\(item.mtime.ago)"
}
}
}
}
18 changes: 16 additions & 2 deletions Sources/DotfilesViewController.swift
Expand Up @@ -5,8 +5,8 @@ class DotfilesViewController: NSViewController {
@IBOutlet var filenameColumn: NSTableColumn!

override func viewDidLoad() {
tableView.delegate = NSApp.delegate as! AppDelegate
tableView.dataSource = NSApp.delegate as! AppDelegate
tableView.delegate = NSAppDelegate
tableView.dataSource = self

let menu = NSMenu()
menu.addItem(withTitle: "Remove From iCloud", action: #selector(AppDelegate.stopSyncing), keyEquivalent: "")
Expand All @@ -20,3 +20,17 @@ class DotfilesViewController: NSViewController {
tableView.delegate?.tableViewSelectionDidChange?(Notification(name: .CKAccountChanged, object: tableView, userInfo: nil))
}
}

extension DotfilesViewController: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return NSAppDelegate.model.items.count
}

func tableView(_ tableView: NSTableView, objectValueFor column: NSTableColumn?, row: Int) -> Any? {
if column == filenameColumn {
return NSAppDelegate.model.items[row].relativePath
} else {
return NSAppDelegate.model.items[row].statusString
}
}
}
159 changes: 159 additions & 0 deletions Sources/Model/Brew/BrewModel.swift
@@ -0,0 +1,159 @@
import PMKFoundation
import Foundation
import PromiseKit
import Bakeware
import Path

public enum E: Error {
case noBrew
}

//TODO other brew prefixes
//TODO check for too old Homebrew

public class BrewModel {

public struct Item {
public let name: String
public let version: String
public let outdated: String?
public let mtime: Date?
}

public var listing: [Item] {
return items.value ?? []
}

var outdatedCount: Int {
return listing.filter{ $0.outdated != nil }.count
}

var isAlerting: Bool {
return outdatedCount > 0
}

public var promise: Promise<Void> {
return items.asVoid()
}

public init() {
updater = NSBackgroundActivityScheduler(identifier: "dev.mxcl.Workbench.brew-update")
updater.repeats = true
updater.interval = 60 * 60
updater.schedule { [unowned self] completion in
guard !self.updater.shouldDefer else {
return completion(.deferred)
}
self.brewUpdate().then(self.reflect).log().finally {
completion(.finished)
}
}

cellarWatcher.observe = [Path.root.usr.local.opt]
cellarWatcher.delegate = self

items.catch { [weak self] in
if case E.noBrew = $0 {
self?.updater.invalidate()
}
}
}

deinit {
updater.invalidate()
}

private let cellarWatcher = FSWatcher()
private let updater: NSBackgroundActivityScheduler
private var items = get()

private func brewUpdate() -> Promise<Void> {
let proc = Process()
proc.launchPath = "/usr/local/bin/brew"
proc.arguments = ["update"]
return proc.launch(.promise).asVoid()
}

func openHomepageInBrowser(forIndex index: Int) -> Promise<Void> {
guard let item = listing[safe: index] else { return Promise(error: PMKError.badInput) }
let proc = Process()
proc.launchPath = "/usr/local/bin/brew"
proc.arguments = ["home", item.name]
return proc.launch(.promise).asVoid()
}
}

extension BrewModel: FSWatcherDelegate {
public func fsWatcher(diff: FSWatcher.Diff) {
reflect().log()
}

func reflect() -> Promise<Void> {
return firstly {
get()
}.done {
self.items = .value($0)
}
}
}

private func get() -> Promise<[BrewModel.Item]> {

let opt = Path.root.usr.local.opt
guard opt.isDirectory else {
return Promise(error: E.noBrew)
}

// calling `brew ls` is pretty slow, due to Ruby and brew being bloated nowadays
var current: Promise<[(String, String, Date?)]> {
return DispatchQueue.global().async(.promise) {
// `Set` because opt (often) has multiple entries for the same thing nowadays
var names = Set<String>()
return try opt.ls().compactMap {
try $0.path.readlink()
}.compactMap { dst in
let version = dst.basename()
let name = dst.parent.basename()
guard names.insert(name).inserted else { return nil }
return (name, version, dst.mtime)
}.sorted {
$0.0 < $1.0
}
}
}

struct Outdated: Decodable {
let name: String
let current_version: String
}

var outdated: Promise<[String: String]> {
let proc = Process()
proc.launchPath = "/usr/local/bin/brew"
proc.arguments = ["outdated", "--json=v1"]

return firstly {
proc.launch(.promise)
}.map {
$0.out.fileHandleForReading.readDataToEndOfFile()
}.map {
try JSONDecoder().decode([Outdated].self, from: $0)
}.mapValues {
($0.name, $0.current_version)
}.map {
return Dictionary(uniqueKeysWithValues: $0)
}
}

var items: Promise<[BrewModel.Item]> {
return firstly {
when(fulfilled: current, outdated)
}.map { current, outdated in
current.map { name, version, mtime in
BrewModel.Item(name: name, version: version, outdated: outdated[name], mtime: mtime)
}
}
}

return items
}
42 changes: 42 additions & 0 deletions Sources/Model/etc.swift
Expand Up @@ -34,6 +34,17 @@ public extension Date {
}
}

public extension Optional where Wrapped == Date {
var ago: String {
switch self {
case .none:
return "Never"
case .some(let date):
return date.ago
}
}
}

public extension Sequence {
@inlinable
func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
Expand All @@ -57,6 +68,13 @@ public extension Sequence {
}
}

public extension Collection {
subscript (safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}


import CloudKit

public var db: CKDatabase {
Expand All @@ -73,3 +91,27 @@ public struct StateMachineError: Error {
self.line = line
}
}


import LegibleError
import PromiseKit

public extension Promise {
@discardableResult
func log() -> PMKFinalizer {
return self.catch {
print("error:", $0.legibleDescription)
}
}
}

public extension PromiseKit.Result {
var value: T? {
switch self {
case .fulfilled(let tee):
return tee
case .rejected:
return nil
}
}
}

0 comments on commit 7602aa1

Please sign in to comment.