diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index 7b011abc..7ba9666f 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -104,9 +104,7 @@ 9AE29AE2249A50640071B02D /* ModuleKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9AABEADD243FB13500668CB0 /* ModuleKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 9AE29AE5249A50640071B02D /* StatsKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0C82DA24460F7200FAE3D4 /* StatsKit.framework */; }; 9AE29AE6249A50640071B02D /* StatsKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0C82DA24460F7200FAE3D4 /* StatsKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 9AE29AEE249A50960071B02D /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9AE29AEC249A50960071B02D /* Info.plist */; }; 9AE29AF3249A51D70071B02D /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE29AF1249A50CD0071B02D /* main.swift */; }; - 9AE29AF5249A52870071B02D /* config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9AE29AF4249A52870071B02D /* config.plist */; }; 9AE29AF6249A52B00071B02D /* config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9AE29AF4249A52870071B02D /* config.plist */; }; 9AE29AFB249A53DC0071B02D /* readers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE29AF9249A53780071B02D /* readers.swift */; }; 9AE29AFC249A53DC0071B02D /* values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE29AF7249A53420071B02D /* values.swift */; }; @@ -1187,9 +1185,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9AE29AEE249A50960071B02D /* Info.plist in Resources */, 9A6CFC0122A1C9F5001E782D /* Assets.xcassets in Resources */, - 9AE29AF5249A52870071B02D /* config.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Stats/AppDelegate.swift b/Stats/AppDelegate.swift index d71ce692..39195861 100755 --- a/Stats/AppDelegate.swift +++ b/Stats/AppDelegate.swift @@ -25,10 +25,9 @@ var modules: [Module] = [Battery(&store), Network(&store), Sensors(&store, &smc) var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Stats") class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDelegate { - private let settingsWindow: SettingsWindow = SettingsWindow() - private let updateWindow: UpdateWindow = UpdateWindow() + internal let settingsWindow: SettingsWindow = SettingsWindow() + internal let updateNotification = NSUserNotification() - private let updateNotification = NSUserNotification() private let updateActivity = NSBackgroundActivityScheduler(identifier: "eu.exelban.Stats.updateCheck") func applicationDidFinishLaunching(_ aNotification: Notification) { @@ -37,7 +36,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele self.parseArguments() NSUserNotificationCenter.default.removeAllDeliveredNotifications() - NotificationCenter.default.addObserver(self, selector: #selector(checkForNewVersion), name: .checkForUpdates, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateCron), name: .changeCronInterval, object: nil) modules.forEach{ $0.mount() } @@ -54,7 +52,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele if let uri = notification.userInfo?["url"] as? String { os_log(.debug, log: log, "Downloading new version of app...") if let url = URL(string: uri) { - updater.download(url) + updater.download(url, doneHandler: { path in + updater.install(path: path) + }) } } @@ -76,44 +76,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele return true } - @objc internal func checkForNewVersion(_ window: Bool = false) { - updater.check() { result, error in - if error != nil { - os_log(.error, log: log, "error updater.check(): %s", "\(error!.localizedDescription)") - return - } - - guard error == nil, let version: version = result else { - os_log(.error, log: log, "download error(): %s", "\(error!.localizedDescription)") - return - } - - DispatchQueue.main.async(execute: { - if window { - os_log(.debug, log: log, "open update window: %s", "\(version.latest)") - self.updateWindow.open(version) - return - } - - if version.newest { - os_log(.debug, log: log, "show update window because new version of app found: %s", "\(version.latest)") - - self.updateNotification.identifier = "new-version-\(version.latest)" - self.updateNotification.title = "New version available" - self.updateNotification.subtitle = "Click to install the new version of Stats" - self.updateNotification.soundName = NSUserNotificationDefaultSoundName - - self.updateNotification.hasActionButton = true - self.updateNotification.actionButtonTitle = "Install" - self.updateNotification.userInfo = ["url": version.url] - - NSUserNotificationCenter.default.delegate = self - NSUserNotificationCenter.default.deliver(self.updateNotification) - } - }) - } - } - @objc private func updateCron() { self.updateActivity.invalidate() self.updateActivity.repeats = true @@ -132,7 +94,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele } self.updateActivity.schedule { (completion: @escaping NSBackgroundActivityScheduler.CompletionHandler) in - self.checkForNewVersion(false) + self.checkForNewVersion() completion(NSBackgroundActivityScheduler.Result.finished) } } diff --git a/Stats/Views/AppSettings.swift b/Stats/Views/AppSettings.swift index 5622a6b7..fe827086 100644 --- a/Stats/Views/AppSettings.swift +++ b/Stats/Views/AppSettings.swift @@ -11,6 +11,7 @@ import Cocoa import StatsKit +import os.log class ApplicationSettings: NSView { private let width: CGFloat = 540 @@ -23,6 +24,9 @@ class ApplicationSettings: NSView { } } + private var updateButton: NSButton? = nil + private let updateWindow: UpdateWindow = UpdateWindow() + init() { super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) self.wantsLayer = true @@ -36,6 +40,16 @@ class ApplicationSettings: NSView { fatalError("init(coder:) has not been implemented") } + public override func viewDidMoveToWindow() { + if let button = self.updateButton, let version = updater.latest { + if version.newest { + button.title = "Update application" + } else { + button.title = "Check for updates" + } + } + } + private func addSettings() { let view: NSView = NSView(frame: NSRect(x: 0, y: 1, width: self.width-1, height: self.height - self.deviceInfoHeight)) let rowHeight: CGFloat = 40 @@ -262,7 +276,8 @@ class ApplicationSettings: NSView { button.title = "Check for updates" button.bezelStyle = .rounded button.target = self - button.action = #selector(checkNewVersion) + button.action = #selector(updateAction) + self.updateButton = button rightPanel.addSubview(iconView) rightPanel.addSubview(infoView) @@ -274,8 +289,23 @@ class ApplicationSettings: NSView { self.addSubview(view) } - @objc func checkNewVersion(_ sender: NSObject) { - NotificationCenter.default.post(name: .checkForUpdates, object: nil, userInfo: nil) + @objc func updateAction(_ sender: NSObject) { + updater.check() { result, error in + if error != nil { + os_log(.error, log: log, "error updater.check(): %s", "\(error!.localizedDescription)") + return + } + + guard error == nil, let version: version_s = result else { + os_log(.error, log: log, "download error(): %s", "\(error!.localizedDescription)") + return + } + + DispatchQueue.main.async(execute: { + self.updateWindow.open(version) + return + }) + } } @objc private func toggleUpdateInterval(_ sender: NSMenuItem) { diff --git a/Stats/Views/Update.swift b/Stats/Views/Update.swift index 3c90fa7b..7973784a 100644 --- a/Stats/Views/Update.swift +++ b/Stats/Views/Update.swift @@ -20,7 +20,12 @@ class UpdateWindow: NSWindow, NSWindowDelegate { let w = NSScreen.main!.frame.width let h = NSScreen.main!.frame.height super.init( - contentRect: NSMakeRect(w - self.viewController.view.frame.width, h - self.viewController.view.frame.height, self.viewController.view.frame.width, self.viewController.view.frame.height), + contentRect: NSMakeRect( + w - self.viewController.view.frame.width, + h - self.viewController.view.frame.height, + self.viewController.view.frame.width, + self.viewController.view.frame.height + ), styleMask: [.closable, .titled], backing: .buffered, defer: true @@ -39,7 +44,7 @@ class UpdateWindow: NSWindow, NSWindowDelegate { windowController.loadWindow() } - public func open(_ v: version) { + public func open(_ v: version_s) { if !self.isVisible { self.setIsVisible(true) self.makeKeyAndOrderFront(nil) @@ -61,72 +66,80 @@ private class UpdateViewController: NSViewController { fatalError("init(coder:) has not been implemented") } - public func open(_ v: version) { - self.update.setVersions(v) + public func open(_ v: version_s) { + self.update.clear() + + if v.newest { + self.update.newVersion(v) + return + } + self.update.noUpdates() } } private class UpdateView: NSView { - private let progressBar: NSProgressIndicator = NSProgressIndicator() - private var version: version? = nil - private var informationView: NSView? = nil - private var noNew: NSView? = nil - private var currentVersion: NSTextField? = nil - private var latestVersion: NSTextField? = nil + private var version: version_s? = nil + private var path: String = "" override init(frame: NSRect) { super.init(frame: CGRect(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)) self.wantsLayer = true - - self.addProgressBar() - self.addInformation() - self.addNoNew() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func addProgressBar() { - self.progressBar.isDisplayedWhenStopped = false - self.progressBar.frame = NSRect(x: (self.frame.width - 22)/2, y: (self.frame.height - 22)/2, width: 22, height: 22) - self.progressBar.style = .spinning - - self.addSubview(self.progressBar) - } - - private func addInformation() { + public func newVersion(_ version: version_s) { + self.version = version let view: NSView = NSView(frame: NSRect(x: 10, y: 10, width: self.frame.width - 20, height: self.frame.height - 20)) - let title: NSTextField = TextView(frame: NSRect(x: 0, y: view.frame.height - 18, width: view.frame.width, height: 18)) - title.font = NSFont.systemFont(ofSize: 14, weight: .bold) + let title: NSTextField = TextView(frame: NSRect(x: 0, y: view.frame.height - 20, width: view.frame.width, height: 18)) + title.font = NSFont.systemFont(ofSize: 14, weight: .semibold) title.alignment = .center title.stringValue = "New version available" - let currentVersion: NSTextField = TextView(frame: NSRect(x: 0, y: title.frame.origin.y - 40, width: view.frame.width, height: 16)) - currentVersion.stringValue = "Current version: 0.0.0" + let currentVersionString = "Current version: \(version.current)" + let currentVersionWidth = currentVersionString.widthOfString(usingFont: .systemFont(ofSize: 13, weight: .light)) + let currentVersion: NSTextField = TextView(frame: NSRect( + x: (view.frame.width-currentVersionWidth)/2, + y: title.frame.origin.y - 40, + width: currentVersionWidth, + height: 16 + )) + currentVersion.stringValue = currentVersionString - let latestVersion: NSTextField = TextView(frame: NSRect(x: 0, y: currentVersion.frame.origin.y - 22, width: view.frame.width, height: 16)) - latestVersion.stringValue = "Latest version: 0.0.0" + let latestVersionString = "Latest version: \(version.latest)" + let latestVersionWidth = latestVersionString.widthOfString(usingFont: .systemFont(ofSize: 13, weight: .light)) + let latestVersion: NSTextField = TextView(frame: NSRect( + x: (view.frame.width-currentVersionWidth)/2, + y: currentVersion.frame.origin.y - 22, + width: latestVersionWidth, + height: 16 + )) + latestVersion.stringValue = latestVersionString - let button: NSButton = NSButton(frame: NSRect(x: 0, y: 0, width: view.frame.width, height: 26)) - button.title = "Download" - button.bezelStyle = .rounded - button.action = #selector(self.download) - button.target = self + let closeButton: NSButton = NSButton(frame: NSRect(x: 0, y: 0, width: view.frame.width/2, height: 26)) + closeButton.title = "Close" + closeButton.bezelStyle = .rounded + closeButton.action = #selector(self.close) + closeButton.target = self + + let downloadButton: NSButton = NSButton(frame: NSRect(x: view.frame.width/2, y: 0, width: view.frame.width/2, height: 26)) + downloadButton.title = "Download" + downloadButton.bezelStyle = .rounded + downloadButton.action = #selector(self.download) + downloadButton.target = self view.addSubview(title) view.addSubview(currentVersion) view.addSubview(latestVersion) - view.addSubview(button) - view.isHidden = true + view.addSubview(closeButton) + view.addSubview(downloadButton) self.addSubview(view) - self.informationView = view - self.currentVersion = currentVersion - self.latestVersion = latestVersion } - private func addNoNew() { + public func noUpdates() { let view: NSView = NSView(frame: NSRect(x: 10, y: 10, width: self.frame.width - 20, height: self.frame.height - 20)) let title: NSTextField = TextView(frame: NSRect(x: 0, y: ((view.frame.height - 18)/2)+20, width: view.frame.width, height: 18)) @@ -143,37 +156,77 @@ private class UpdateView: NSView { view.addSubview(button) view.addSubview(title) self.addSubview(view) - self.noNew = view } - public func setVersions(_ v: version) { - self.progressBar.stopAnimation(self) - self.noNew?.isHidden = true - self.informationView?.isHidden = true - - if v.newest { - self.informationView?.isHidden = false - self.version = v - - currentVersion?.stringValue = "Current version: \(v.current)" - latestVersion?.stringValue = "Latest version: \(v.latest)" - return - } - - self.noNew?.isHidden = false + public func clear() { + self.subviews.forEach{ $0.removeFromSuperview() } } - @objc func close(_ sender: Any) { - self.window?.setIsVisible(false) - } - - @objc func download(_ sender: Any) { + @objc private func download(_ sender: Any) { guard let urlString = self.version?.url, let url = URL(string: urlString) else { return } - os_log(.debug, log: log, "start downloading new version of app from: %s", "\(url.absoluteString)") - updater.download(url) - self.progressBar.startAnimation(self) - self.informationView?.isHidden = true + + self.clear() + + let view: NSView = NSView(frame: NSRect(x: 10, y: 10, width: self.frame.width - 20, height: self.frame.height - 20)) + + let title: NSTextField = TextView(frame: NSRect(x: 0, y: view.frame.height - 28, width: view.frame.width, height: 18)) + title.font = NSFont.systemFont(ofSize: 14, weight: .semibold) + title.alignment = .center + title.stringValue = "Downloading..." + + let progressBar: NSProgressIndicator = NSProgressIndicator() + progressBar.frame = NSRect(x: 20, y: 64, width: view.frame.width - 40, height: 22) + progressBar.minValue = 0 + progressBar.maxValue = 1 + progressBar.isIndeterminate = false + + let state: NSTextField = TextView(frame: NSRect(x: 0, y: 48, width: view.frame.width, height: 18)) + state.font = NSFont.systemFont(ofSize: 12, weight: .light) + state.alignment = .center + state.textColor = .secondaryLabelColor + state.stringValue = "0%" + + let closeButton: NSButton = NSButton(frame: NSRect(x: 0, y: 0, width: view.frame.width, height: 26)) + closeButton.title = "Close" + closeButton.bezelStyle = .rounded + closeButton.action = #selector(self.close) + closeButton.target = self + + let installButton: NSButton = NSButton(frame: NSRect(x: view.frame.width/2, y: 0, width: view.frame.width/2, height: 26)) + installButton.title = "Install" + installButton.bezelStyle = .rounded + installButton.action = #selector(self.install) + installButton.target = self + installButton.isHidden = true + + updater.download(url, progressHandler: { progress in + DispatchQueue.main.async { + progressBar.doubleValue = progress.fractionCompleted + state.stringValue = "\(Int(progress.fractionCompleted*100))%" + } + }, doneHandler: { path in + self.path = path + DispatchQueue.main.async { + closeButton.setFrameSize(NSSize(width: view.frame.width/2, height: closeButton.frame.height)) + installButton.isHidden = false + } + }) + + view.addSubview(title) + view.addSubview(progressBar) + view.addSubview(state) + view.addSubview(closeButton) + view.addSubview(installButton) + self.addSubview(view) + } + + @objc private func close(_ sender: Any) { + self.window?.close() + } + + @objc private func install(_ sender: Any) { + updater.install(path: self.path) } } diff --git a/Stats/helpers.swift b/Stats/helpers.swift index 2bf63d4f..10bec10f 100644 --- a/Stats/helpers.swift +++ b/Stats/helpers.swift @@ -89,7 +89,39 @@ extension AppDelegate { } if updateIntervals(rawValue: store.string(key: "update-interval", defaultValue: updateIntervals.atStart.rawValue)) != .never { - self.checkForNewVersion(false) + self.checkForNewVersion() + } + } + + internal func checkForNewVersion() { + updater.check() { result, error in + if error != nil { + os_log(.error, log: log, "error updater.check(): %s", "\(error!.localizedDescription)") + return + } + + guard error == nil, let version: version_s = result else { + os_log(.error, log: log, "download error(): %s", "\(error!.localizedDescription)") + return + } + + DispatchQueue.main.async(execute: { + if version.newest { + os_log(.debug, log: log, "show update window because new version of app found: %s", "\(version.latest)") + + self.updateNotification.identifier = "new-version-\(version.latest)" + self.updateNotification.title = "New version available" + self.updateNotification.subtitle = "Click to install the new version of Stats" + self.updateNotification.soundName = NSUserNotificationDefaultSoundName + + self.updateNotification.hasActionButton = true + self.updateNotification.actionButtonTitle = "Install" + self.updateNotification.userInfo = ["url": version.url] + + NSUserNotificationCenter.default.delegate = self + NSUserNotificationCenter.default.deliver(self.updateNotification) + } + }) } } } diff --git a/StatsKit/updater.swift b/StatsKit/updater.swift index 61d9ce8b..b6cc05f7 100644 --- a/StatsKit/updater.swift +++ b/StatsKit/updater.swift @@ -12,11 +12,18 @@ import Cocoa import SystemConfiguration -public struct version { +public struct version_s { public let current: String public let latest: String public let newest: Bool public let url: String + + public init(current: String, latest: String, newest: Bool, url: String) { + self.current = current + self.latest = latest + self.newest = newest + self.url = url + } } public struct Version { @@ -32,6 +39,9 @@ public class macAppUpdater { private let appName: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String private let currentVersion: String = "v\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String)" + public var latest: version_s? = nil + private var observation: NSKeyValueObservation? + private var url: String { return "https://api.github.com/repos/\(user)/\(repo)/releases/latest" } @@ -41,7 +51,11 @@ public class macAppUpdater { self.repo = repo } - public func check(completionHandler: @escaping (_ result: version?, _ error: Error?) -> Void) { + deinit { + observation?.invalidate() + } + + public func check(completionHandler: @escaping (_ result: version_s?, _ error: Error?) -> Void) { if !isConnectedToNetwork() { completionHandler(nil, "No internet connection") return @@ -62,7 +76,8 @@ public class macAppUpdater { let lastVersion: String = result![0] let newVersion: Bool = IsNewestVersion(currentVersion: self.currentVersion, latestVersion: lastVersion) - completionHandler(version(current: self.currentVersion, latest: lastVersion, newest: newVersion, url: downloadURL), nil) + self.latest = version_s(current: self.currentVersion, latest: lastVersion, newest: newVersion, url: downloadURL) + completionHandler(self.latest, nil) } } @@ -93,13 +108,8 @@ public class macAppUpdater { task.resume() } - public func download(_ url: URL) { - let downloadTask = URLSession.shared.downloadTask(with: url) { - urlOrNil, responseOrNil, errorOrNil in - // check for and handle errors: - // * errorOrNil should be nil - // * responseOrNil should be an HTTPURLResponse with statusCode in 200..<299 - + public func download(_ url: URL, progressHandler: @escaping (_ progress: Progress) -> Void = {_ in }, doneHandler: @escaping (_ path: String) -> Void = {_ in }) { + let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in guard let fileURL = urlOrNil else { return } do { let downloadsURL = try FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: false) @@ -111,27 +121,47 @@ public class macAppUpdater { return } - _ = syncShell("mkdir /tmp/Stats") // make sure that directory exist - let res = syncShell("/usr/bin/hdiutil attach \(path) -mountpoint /tmp/Stats -noverify -nobrowse -noautoopen") // mount the dmg - - if res.contains("is busy") { // dmg can be busy, if yes, unmount it and mount again - _ = syncShell("/usr/bin/hdiutil detach $TMPDIR/Stats") - _ = syncShell("/usr/bin/hdiutil attach \(path) -mountpoint /tmp/Stats -noverify -nobrowse -noautoopen") - } - - _ = syncShell("cp /tmp/Stats/Stats.app/Contents/Resources/Scripts/updater.sh $TMPDIR/updater.sh") // copy updater script to tmp folder - - let pwd = Bundle.main.bundleURL.absoluteString.replacingOccurrences(of: "file://", with: "").replacingOccurrences(of: "Stats.app/", with: "") - _ = asyncShell("sh $TMPDIR/updater.sh --app \(pwd) --dmg \(path) >/dev/null &") // run updater script in in background - exit(0) + doneHandler(path) } } catch { print ("file error: \(error)") } } + + self.observation = downloadTask.progress.observe(\.fractionCompleted) { progress, _ in + progressHandler(progress) + } + downloadTask.resume() } + public func install(path: String) { + 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") // 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") + } + + _ = syncShell("cp -rf /tmp/Stats/Stats.app/Contents/Resources/Scripts/updater.sh $TMPDIR/updater.sh") // copy updater script to tmp folder + + print("Script is copied to $TMPDIR/updater.sh") + + let pwd = Bundle.main.bundleURL.absoluteString.replacingOccurrences(of: "file://", with: "").replacingOccurrences(of: "Stats.app/", with: "") + _ = asyncShell("sh $TMPDIR/updater.sh --app \(pwd) --dmg \(path) >/dev/null &") // run updater script in in background + + print("Run updater.sh with app: \(pwd) and dmg: \(path)") + + exit(0) + } + private func copyFile(from: URL, to: URL, completionHandler: @escaping (_ path: String, _ error: Error?) -> Void) { var toPath = to let fileName = (URL(fileURLWithPath: to.absoluteString)).lastPathComponent