// C o p y r i g h t © 2 0 1 5 A b h i s h e k B a n t h i a
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 ( ) {
// S e t u p A p p S t o r e C o u n t r y
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 "
}
// S e t u p A p p V e r s i o n
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 "
// B u n d l e I d e n t i f i e r
self . applicationBundleID = Bundle . main . bundleIdentifier ? ? " com.abhishek.Clocker "
// d e f a u l t s e t t i n g s
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 ( ) {
// R e m i n d e r t a k e s p r i o r i t y o v e r c h e c k p e r i o d
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 " )
}
// p e r f o r m t h e c h e c k
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 " )
}
// g e t v e r s i o n d e t a i l s
let releaseNotes = firstResult [ " releaseNotes " ]
latestVersion = firstResult [ " version " ] as ? String
if let version = latestVersion , osVersionSupported {
versions = [ version : ( releaseNotes as ? String ) ? ? " " ]
}
// G e t a p p I D
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: S e t d o w n l o a d e r r o r
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 {
// r e t u r n [ d i c t i o n a r y . a l l K e y s s o r t e d A r r a y U s i n g S e l e c t o r : @ s e l e c t o r ( c o m p a r e V e r s i o n : ) ] . l a s t O b j e c t ;
// TODO: F i x t h i s s o r t i n g
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 {
// R i g h t m o s t b u t t o n
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 ) {
// G e t B u t t o n I n d i c e
}
@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 {
// C h e c k i f i g n o r e d
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 {
// C l e a r R e m i n d e r
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
}
}
}