You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
520 lines
20 KiB
520 lines
20 KiB
// Copyright © 2015 Abhishek Banthia |
|
|
|
import Cocoa |
|
import CoreLoggerKit |
|
|
|
class VersionUpdateHandler: NSObject { |
|
enum VersionUpdateHandlerPriority { |
|
case defaultPri |
|
case low |
|
case medium |
|
case high |
|
} |
|
|
|
static let kSecondsInDay: Double = 86400.0 |
|
static let kMacAppStoreRefreshDelay: Double = 5.0 |
|
static let kMacRequestTimeout: Double = 60.0 |
|
static let kVersionCheckLastVersionKey = "VersionCheckLastVersionKey" |
|
static let kVersionIgnoreVersionKey = "VersionCheckIgnoreVersionKey" |
|
static let kMacAppStoreIDKey = "VersionCheckAppStoreIDKey" |
|
static let kVersionLastCheckedKey = "VersionLastCheckedKey" |
|
static let kVersionLastRemindedKey = "VersionLastRemindedKey" |
|
|
|
static let sharedInstance = VersionUpdateHandler() |
|
|
|
private var appStoreCountry: String! |
|
private var applicationVersion: String! |
|
private var applicationBundleID: String = Bundle.main.bundleIdentifier ?? "N/A" |
|
private var updatePriority = VersionUpdateHandlerPriority.defaultPri |
|
public var useAllAvailableLanguages: Bool = true |
|
private var onlyPromptIfMainWindowIsAvailable: Bool = true |
|
private var checkAtLaunch: Bool = true |
|
private var checkPeriod: Double = 0.0 |
|
private var remindPeriod: Double = 1.0 |
|
private var verboseLogging: Bool = true |
|
private var updateURL: URL! |
|
private var checkingForNewVersion: Bool = false |
|
private var remoteVersionsDict: [String: Any] = [:] |
|
private var downloadError: Error? |
|
private var dataTask: URLSessionDataTask? = .none |
|
|
|
private var showOnFirstLaunch: Bool = false |
|
public var previewMode: Bool = false |
|
private var versionDetails: String? |
|
|
|
override init() { |
|
// Setup App Store Country |
|
appStoreCountry = Locale.current.regionCode |
|
if appStoreCountry == "150" { |
|
appStoreCountry = "eu" |
|
} else if appStoreCountry.replacingOccurrences(of: "[A-Za-z]{2}", with: "", options: .regularExpression, range: appStoreCountry.startIndex ..< appStoreCountry.endIndex).isEmpty == false { |
|
appStoreCountry = "us" |
|
} |
|
|
|
// Setup App Version |
|
var appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String |
|
if appVersion == nil { |
|
appVersion = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String |
|
} |
|
|
|
applicationVersion = appVersion ?? "N/A" |
|
|
|
// Bundle Identifier |
|
self.applicationBundleID = Bundle.main.bundleIdentifier ?? "com.abhishek.Clocker" |
|
|
|
//default settings |
|
self.updatePriority = .defaultPri; |
|
self.useAllAvailableLanguages = true; |
|
self.onlyPromptIfMainWindowIsAvailable = true; |
|
self.checkAtLaunch = true; |
|
self.checkPeriod = 0.0; |
|
self.remindPeriod = 1.0; |
|
self.verboseLogging = true; |
|
|
|
super.init() |
|
|
|
applicationLaunched() |
|
} |
|
|
|
private func inThisVersionTitle() -> String { |
|
return "New in this version" |
|
} |
|
|
|
private func updateAvailableTitle() -> String { |
|
return "New version available" |
|
} |
|
|
|
private func versionLabelFormat() -> String { |
|
return "Version %@" |
|
} |
|
|
|
private func okayButtonLabel() -> String { |
|
return "OK" |
|
} |
|
|
|
private func ignoreButtonLabel() -> String { |
|
return "Ignore" |
|
} |
|
|
|
private func downloadButtonLabel() -> String { |
|
return "Download" |
|
} |
|
|
|
private func remindButtonLabel() -> String { |
|
return "Remind Me Later" |
|
} |
|
|
|
private func updatedURL() -> URL { |
|
if updateURL.absoluteString.isEmpty == false { |
|
return updateURL |
|
} |
|
|
|
guard let appStoreId = appStoreID() else { |
|
Logger.info("No App Store ID was found for Clocker") |
|
return URL(string: "")! |
|
} |
|
|
|
return URL(string: "macappstore://itunes.apple.com/app/id\(appStoreId)")! |
|
} |
|
|
|
private func appStoreID() -> Int? { |
|
return UserDefaults.standard.integer(forKey: VersionUpdateHandler.kMacAppStoreIDKey) |
|
} |
|
|
|
@objc func setAppStoreID(_ appStoreID: Int) { |
|
UserDefaults.standard.set(appStoreID, forKey: VersionUpdateHandler.kMacAppStoreIDKey) |
|
} |
|
|
|
@objc private func setLastChecked(_ date: Date) { |
|
UserDefaults.standard.set(date, forKey: VersionUpdateHandler.kVersionLastCheckedKey) |
|
} |
|
|
|
private func lastChecked() -> Date? { |
|
return UserDefaults.standard.object(forKey: VersionUpdateHandler.kVersionLastCheckedKey) as? Date |
|
} |
|
|
|
private func setLastReminded(_ date: Date?) { |
|
UserDefaults.standard.set(date, forKey: VersionUpdateHandler.kVersionLastRemindedKey) |
|
} |
|
|
|
private func lastReminded() -> Date? { |
|
return UserDefaults.standard.object(forKey: VersionUpdateHandler.kVersionLastRemindedKey) as? Date |
|
} |
|
|
|
private func ignoredVersion() -> String? { |
|
return UserDefaults.standard.object(forKey: VersionUpdateHandler.kVersionIgnoreVersionKey) as? String |
|
} |
|
|
|
private func setIgnoredVersion(_ version: String) { |
|
UserDefaults.standard.set(version, forKey: VersionUpdateHandler.kVersionIgnoreVersionKey) |
|
} |
|
|
|
private func setViewedVersionDetails(_ viewed: Bool) { |
|
UserDefaults.standard.set(viewed ? applicationVersion : nil, forKey: VersionUpdateHandler.kVersionCheckLastVersionKey) |
|
} |
|
|
|
private func viewedVersionDetails() -> Bool { |
|
let lastVersionKey = UserDefaults.standard.object(forKey: VersionUpdateHandler.kVersionCheckLastVersionKey) as? String ?? "" |
|
return lastVersionKey == applicationVersion |
|
} |
|
|
|
private func lastVersion() -> String { |
|
return UserDefaults.standard.object(forKey: VersionUpdateHandler.kVersionCheckLastVersionKey) as? String ?? "" |
|
} |
|
|
|
private func setLastVersion(_ version: String) { |
|
UserDefaults.standard.set(version, forKey: VersionUpdateHandler.kVersionCheckLastVersionKey) |
|
} |
|
|
|
private func localVersionsDict() -> [String: Any] { |
|
return [String: Any]() |
|
} |
|
|
|
private func versionDetails(_ version: String, _ dict: [String: Any]) -> String? { |
|
if let versionData = dict[version] as? String { |
|
return versionData |
|
} else if let versionDataArray = dict[version] as? NSArray { |
|
return versionDataArray.componentsJoined(by: "\n") |
|
} |
|
return nil |
|
} |
|
|
|
private func versionDetails(since lastVersion: String, in dict: [String: Any]) -> String? { |
|
var lastVersionCopy = lastVersion |
|
if previewMode { |
|
lastVersionCopy = "0" |
|
} |
|
var newVersionFound = false |
|
var details = "" |
|
|
|
let versions = dict.keys.sorted() |
|
|
|
for version in versions { |
|
if version.compareVersion(lastVersionCopy) == .orderedDescending { |
|
newVersionFound = true |
|
} |
|
details.append(versionDetails(version, dict) ?? "") |
|
details.append("\n") |
|
} |
|
|
|
if newVersionFound { |
|
return details.trimmingCharacters(in: CharacterSet.newlines) |
|
} |
|
return nil |
|
} |
|
|
|
private func shouldCheckForNewVersion() -> Bool { |
|
if (!self.previewMode) { |
|
if let lastRemindedDate = lastReminded() { |
|
// Reminder takes priority over check period |
|
if Date().timeIntervalSince(lastRemindedDate) < Double(remindPeriod * Self.kSecondsInDay) { |
|
if verboseLogging { |
|
Logger.info("iVersion did not check for a new version because the user last asked to be reminded less than \(self.remindPeriod) days ago") |
|
} |
|
return false |
|
} |
|
} else if let lastCheckedDate = lastChecked(), Date().timeIntervalSince(lastCheckedDate) < Double(self.checkPeriod * Self.kSecondsInDay) { |
|
if (self.verboseLogging) { |
|
Logger.info("iVersion did not check for a new version because the last check was less than \(self.checkPeriod) days ago") |
|
} |
|
return false |
|
} |
|
} else if (self.verboseLogging) { |
|
Logger.info("iVersion debug mode is enabled - make sure you disable this for release") |
|
} |
|
// perform the check |
|
return true |
|
} |
|
|
|
private func checkForNewVersionInBackground() { |
|
var newerVersionAvailable = false |
|
var osVersionSupported = false |
|
var latestVersion: String? = nil |
|
var versions: [String:String]? = nil |
|
|
|
var itunesServiceURL = "http://itunes.apple.com/\(self.appStoreCountry ?? "us")/lookup" |
|
if let appStoreID = appStoreID(), appStoreID != 0 { |
|
Logger.info("--- App Store ID is \(appStoreID)") |
|
itunesServiceURL = itunesServiceURL.appendingFormat("?id=%@", appStoreID) |
|
} else { |
|
itunesServiceURL = itunesServiceURL.appendingFormat("?bundleId=%@", self.applicationBundleID) |
|
} |
|
|
|
if (verboseLogging) { |
|
Logger.info("iVersion is checking \(itunesServiceURL) for a new app version...") |
|
} |
|
|
|
|
|
|
|
dataTask = NetworkManager.task(with: itunesServiceURL) { [weak self] response, error in |
|
guard let self, let data = response else {return } |
|
|
|
if (error != nil || response == nil) { |
|
Logger.info("Response is nil or error is non-nil") |
|
} |
|
|
|
let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) |
|
|
|
if let unwrapped = json as? [String: Any], |
|
let results = unwrapped["results"] as? Array<Any>, |
|
let firstResult = results.first as? [String: Any], |
|
let bundleID = firstResult["bundleId"] as? String { |
|
|
|
if (bundleID == self.applicationBundleID) { |
|
guard let minimumSupportedOSVersion = firstResult["minimumOsVersion"] as? String else { return } |
|
let version = ProcessInfo.processInfo.operatingSystemVersion |
|
let systemVersion = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" |
|
osVersionSupported = systemVersion.compareVersion(minimumSupportedOSVersion) != ComparisonResult.orderedAscending |
|
if (!osVersionSupported) { |
|
Logger.info("Current OS version is not supported") |
|
} |
|
// get version details |
|
let releaseNotes = firstResult["releaseNotes"] |
|
latestVersion = firstResult["version"] as? String |
|
|
|
if let version = latestVersion, osVersionSupported { |
|
versions = [version : (releaseNotes as? String) ?? ""] |
|
} |
|
|
|
// Get app ID |
|
if (appStoreID() == nil) { |
|
let appStoreIDString = firstResult["trackId"] |
|
performSelector(onMainThread: #selector(setAppStoreID(_:)), |
|
with: appStoreIDString, |
|
waitUntilDone: true) |
|
if (verboseLogging) { |
|
Logger.info("iVersion found the app on iTunes. The App Store ID is \(appStoreIDString ?? "")") |
|
} |
|
} |
|
|
|
newerVersionAvailable = latestVersion?.compareVersion(self.applicationVersion) == .orderedDescending |
|
if (verboseLogging) { |
|
if (newerVersionAvailable) { |
|
Logger.info("iVersion found a new version \(latestVersion ?? "N/A") of the app on iTunes. Current version is \(self.applicationVersion ?? "nil")") |
|
} else { |
|
Logger.info("iVersion did not find a new version of the app on iTunes. Current version is \(self.applicationVersion ?? "nil") and the latest version is \(latestVersion ?? "nil")") |
|
} |
|
} |
|
} else { |
|
if (verboseLogging) { |
|
Logger.info("iVersion found that the application bundle ID \(self.applicationBundleID) does not match the bundle ID of the app found on iTunes \(bundleID) with the specified App Store ID \(self.appStoreID() ?? 0)") |
|
} |
|
} |
|
} else { |
|
Logger.info("Server returned an error while fetching version info") |
|
} |
|
|
|
//TODO: Set download error |
|
|
|
performSelector(onMainThread: #selector(setRemoteVersionsDict(_:)), with: versions, waitUntilDone: true) |
|
performSelector(onMainThread: #selector(setLastChecked(_:)), with: Date(), waitUntilDone: true) |
|
performSelector(onMainThread: #selector(Self.downloadVersionsData), with: nil, waitUntilDone: true) |
|
} |
|
|
|
dataTask?.resume() |
|
} |
|
|
|
@objc private func setRemoteVersionsDict(_ dict: [String: Any]?) { |
|
if let unwrappedDict = dict { |
|
remoteVersionsDict = unwrappedDict |
|
} |
|
} |
|
|
|
private func checkForNewVersion() { |
|
if (!self.checkingForNewVersion) { |
|
self.checkingForNewVersion = true |
|
DispatchQueue.global(qos: .userInitiated).async { |
|
self.checkForNewVersionInBackground() |
|
} |
|
} |
|
} |
|
|
|
private func applicationLaunched() { |
|
if checkAtLaunch { |
|
checkIfNewVersion() |
|
if (shouldCheckForNewVersion()) { |
|
checkForNewVersion() |
|
} |
|
} else if verboseLogging { |
|
Logger.info("iVersion will not check for updatess because checkAtLaunch option is disabled") |
|
} |
|
} |
|
|
|
private func versionDetailsString() -> String { |
|
if versionDetails == nil { |
|
if viewedVersionDetails() { |
|
versionDetails = versionDetails(applicationVersion, localVersionsDict()) |
|
} |
|
} else { |
|
versionDetails = versionDetails(since: lastVersion(), in: localVersionsDict()) |
|
} |
|
|
|
return versionDetails! |
|
} |
|
|
|
private func mostRecentVersionInDict(_ dict: [String: Any]) -> String { |
|
// return [dictionary.allKeys sortedArrayUsingSelector:@selector(compareVersion:)].lastObject; |
|
// TODO: Fix this sorting |
|
return dict.keys.sorted().last ?? "" |
|
} |
|
|
|
private func showAlertWithTitle(_ title: String, _ details: String, _ defaultButton: String, _ ignoreButton: String, _ remindButton: String) -> NSAlert { |
|
let floatMax = CGFloat.greatestFiniteMagnitude |
|
|
|
let alert = NSAlert() |
|
alert.messageText = title |
|
alert.informativeText = inThisVersionTitle() |
|
alert.addButton(withTitle: defaultButton) |
|
|
|
let scrollView = NSScrollView(frame: NSRect(x: 0.0, |
|
y: 0.0, |
|
width: 380.0, |
|
height: 15.0)) |
|
let contentSize = scrollView.contentSize |
|
scrollView.borderType = .bezelBorder |
|
scrollView.hasVerticalScroller = true |
|
scrollView.hasHorizontalScroller = false |
|
scrollView.autoresizingMask = [.width, .height] |
|
|
|
let textView = NSTextView(frame: NSRect(x: 0.0, |
|
y: 0.0, |
|
width: contentSize.width, |
|
height: contentSize.height)) |
|
textView.minSize = NSSize(width: 0.0, height: contentSize.height) |
|
textView.maxSize = NSSize(width: floatMax, height: floatMax) |
|
textView.isVerticallyResizable = true |
|
textView.isHorizontallyResizable = false |
|
textView.isEditable = false |
|
textView.autoresizingMask = .width |
|
textView.textContainer?.containerSize = NSSize(width: contentSize.width, height: floatMax) |
|
textView.textContainer?.widthTracksTextView = true |
|
textView.string = details |
|
scrollView.documentView = textView |
|
textView.sizeToFit() |
|
|
|
let height = min(200.0, scrollView.documentView?.frame.size.height ?? 200.0) + 3.0 |
|
scrollView.frame = NSRect(x: 0.0, y: 0.0, width: scrollView.frame.size.width, height: height) |
|
alert.accessoryView = scrollView |
|
|
|
if ignoreButton.isEmpty == false { |
|
alert.addButton(withTitle: ignoreButton) |
|
} |
|
|
|
if remindButton.isEmpty == false { |
|
alert.addButton(withTitle: remindButton) |
|
|
|
let modalResponse = alert.runModal() |
|
if modalResponse == .alertFirstButtonReturn { |
|
// Right most button |
|
didDismissAlert(alert, 0) |
|
} else if modalResponse == .alertSecondButtonReturn { |
|
didDismissAlert(alert, 1) |
|
} else { |
|
didDismissAlert(alert, 2) |
|
} |
|
} |
|
|
|
return alert |
|
} |
|
|
|
private func showIgnoreButton() -> Bool { |
|
return false |
|
} |
|
|
|
private func showRemindButtton() -> Bool { |
|
return false |
|
} |
|
|
|
private func didDismissAlert(_: NSAlert, _: Int) { |
|
// Get Button Indice |
|
} |
|
|
|
@objc private func downloadVersionsData() { |
|
if onlyPromptIfMainWindowIsAvailable { |
|
guard NSApplication.shared.mainWindow != nil else { |
|
return |
|
} |
|
|
|
_ = Repeater(interval: .seconds(0.5), mode: .infinite) { _ in |
|
OperationQueue.main.addOperation { [weak self] in |
|
guard let self = self else { |
|
return |
|
} |
|
self.downloadVersionsData() |
|
} |
|
} |
|
} |
|
|
|
if checkingForNewVersion { |
|
checkingForNewVersion = false |
|
|
|
if remoteVersionsDict.isEmpty { |
|
if downloadError != nil { |
|
Logger.info("Update Check Failed because of \(downloadError!.localizedDescription)") |
|
} else { |
|
Logger.info("Version Update Check because an unknown error occurred") |
|
} |
|
} |
|
return |
|
} |
|
|
|
let details = versionDetails(since: applicationVersion, in: remoteVersionsDict) |
|
let mostRecentVersion = mostRecentVersionInDict(remoteVersionsDict) |
|
|
|
if details != nil { |
|
// Check if ignored |
|
let showDetails = ignoredVersion() == mostRecentVersion || previewMode |
|
|
|
if showDetails {} |
|
} |
|
} |
|
|
|
private func checkIfNewVersion() { |
|
if onlyPromptIfMainWindowIsAvailable { |
|
guard NSApplication.shared.mainWindow != nil else { |
|
return |
|
} |
|
|
|
_ = Repeater(interval: .seconds(5), mode: .infinite) { _ in |
|
OperationQueue.main.addOperation { [weak self] in |
|
guard let self = self else { |
|
return |
|
} |
|
self.checkIfNewVersion() |
|
} |
|
} |
|
} |
|
|
|
let lastVersionString = lastVersion() |
|
if lastVersionString.isEmpty == false || showOnFirstLaunch || previewMode { |
|
if applicationVersion.compareVersion(lastVersionString) == ComparisonResult.orderedDescending || previewMode { |
|
// Clear Reminder |
|
setLastReminded(nil) |
|
} |
|
} |
|
} |
|
} |
|
|
|
extension String { |
|
func compareVersion(_ version: String) -> ComparisonResult { |
|
return compare(version, |
|
options: CompareOptions.numeric, |
|
range: nil, |
|
locale: nil) |
|
} |
|
|
|
func compareVersionDescending(_ version: String) -> ComparisonResult { |
|
let comparsionResult = (0 - compareVersion(version).rawValue) |
|
switch comparsionResult { |
|
case -1: |
|
return ComparisonResult.orderedAscending |
|
case 0: |
|
return ComparisonResult.orderedSame |
|
case 1: |
|
return ComparisonResult.orderedDescending |
|
default: |
|
assertionFailure("Invalid Comparison Result") |
|
return .orderedSame |
|
} |
|
} |
|
}
|
|
|