// // SUUpdater.m // Sparkle // // Created by Andy Matuschak on 1/4/06. // Copyright 2006 Andy Matuschak. All rights reserved. // #import "SUUpdater.h" #import "SUAppcast.h" #import "SUAppcastItem.h" #import "SUUnarchiver.h" #import "SUUtilities.h" #import "SUUpdateAlert.h" #import "SUAutomaticUpdateAlert.h" #import "SUStatusController.h" #import "NSFileManager+Authentication.h" #import "NSFileManager+Verification.h" #import "NSApplication+AppCopies.h" #import #import #import #import #import @interface SUUpdater (Private) - (void)checkForUpdatesAndNotify:(BOOL)verbosity; - (void)showUpdateErrorAlertWithInfo:(NSString *)info; - (NSTimeInterval)storedCheckInterval; - (void)abandonUpdate; - (IBAction)installAndRestart:sender; - (NSString *)systemVersionString; @end @implementation SUUpdater - init { [super init]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidFinishLaunching:) name:@"NSApplicationDidFinishLaunchingNotification" object:NSApp]; // OS version (Apple recommends using SystemVersion.plist instead of Gestalt() here, don't ask me why). // This code *should* use NSSearchPathForDirectoriesInDomains(NSCoreServiceDirectory, NSSystemDomainMask, YES) // but that returns /Library/CoreServices for some reason NSString *versionPlistPath = @"/System/Library/CoreServices/SystemVersion.plist"; //gets a version string of the form X.Y.Z currentSystemVersion = [[[NSDictionary dictionaryWithContentsOfFile:versionPlistPath] objectForKey:@"ProductVersion"] retain]; return self; } - (void)scheduleCheckWithInterval:(NSTimeInterval)interval { if (checkTimer) { [checkTimer invalidate]; checkTimer = nil; } checkInterval = interval; if (interval > 0) checkTimer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(checkForUpdatesInBackground) userInfo:nil repeats:YES]; } - (void)scheduleCheckWithIntervalObject:(NSNumber *)interval { [self scheduleCheckWithInterval:[interval doubleValue]]; } - (void)applicationDidFinishLaunching:(NSNotification *)note { // If there's a scheduled interval, we see if it's been longer than that interval since the last // check. If so, we perform a startup check; if not, we don't. if ([self storedCheckInterval]) { NSTimeInterval interval = [self storedCheckInterval]; NSDate *lastCheck = [[NSUserDefaults standardUserDefaults] objectForKey:SULastCheckTimeKey]; if (!lastCheck) { lastCheck = [NSDate date]; } NSTimeInterval intervalSinceCheck = [[NSDate date] timeIntervalSinceDate:lastCheck]; if (intervalSinceCheck < interval) { // Hasn't been long enough; schedule a check for the future. [self performSelector:@selector(checkForUpdatesInBackground) withObject:nil afterDelay:intervalSinceCheck]; [self performSelector:@selector(scheduleCheckWithIntervalObject:) withObject:[NSNumber numberWithLong:interval] afterDelay:intervalSinceCheck]; } else { [self scheduleCheckWithInterval:interval]; [self checkForUpdatesInBackground]; } } else { // There's no scheduled check, so let's see if we're supposed to check on startup. NSNumber *shouldCheckAtStartup = [[NSUserDefaults standardUserDefaults] objectForKey:SUCheckAtStartupKey]; if (!shouldCheckAtStartup) // hasn't been set yet; ask the user { // Let's see if there's a key in Info.plist for a default, though. We'll let that override the dialog if it's there. NSNumber *infoStartupValue = SUInfoValueForKey(SUCheckAtStartupKey); if (infoStartupValue) { shouldCheckAtStartup = infoStartupValue; } else { shouldCheckAtStartup = [NSNumber numberWithBool:NSRunAlertPanel(SULocalizedString(@"Check for updates on startup?", nil), [NSString stringWithFormat:SULocalizedString(@"Would you like %@ to check for updates on startup? If not, you can initiate the check manually from the application menu.", nil), SUHostAppDisplayName()], SULocalizedString(@"Yes", nil), SULocalizedString(@"No", nil), nil) == NSAlertDefaultReturn]; } [[NSUserDefaults standardUserDefaults] setObject:shouldCheckAtStartup forKey:SUCheckAtStartupKey]; } if ([shouldCheckAtStartup boolValue]) [self checkForUpdatesInBackground]; } } - (void)dealloc { [updateItem release]; [updateAlert release]; [downloadPath release]; [statusController release]; [downloader release]; if (checkTimer) [checkTimer invalidate]; if (currentSystemVersion) [currentSystemVersion release]; [[NSNotificationCenter defaultCenter] removeObserver:self]; [super dealloc]; } - (void)checkForUpdatesInBackground { [self checkForUpdatesAndNotify:NO]; } - (IBAction)checkForUpdates:sender { [self checkForUpdatesAndNotify:YES]; // if we're coming from IB, then we want to be more verbose. } // If the verbosity flag is YES, Sparkle will say when it can't reach the server and when there's no new update. // This is generally useful for a menu item--when the check is explicitly invoked. - (void)checkForUpdatesAndNotify:(BOOL)verbosity { if (updateInProgress) { if (verbosity) { NSBeep(); if ([[statusController window] isVisible]) [statusController showWindow:self]; else if ([[updateAlert window] isVisible]) [updateAlert showWindow:self]; else [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An update is already in progress!", nil)]; } return; } verbose = verbosity; updateInProgress = YES; // A value in the user defaults overrides one in the Info.plist (so preferences panels can be created wherein users choose between beta / release feeds). NSString *appcastString = [[NSUserDefaults standardUserDefaults] objectForKey:SUFeedURLKey]; if (!appcastString) appcastString = SUInfoValueForKey(SUFeedURLKey); if (!appcastString) { [NSException raise:@"SUNoFeedURL" format:@"No feed URL is specified in the Info.plist or the user defaults!"]; } SUAppcast *appcast = [[SUAppcast alloc] init]; [appcast setDelegate:self]; [appcast fetchAppcastFromURL:[NSURL URLWithString:appcastString]]; } - (BOOL)automaticallyUpdates { if (![SUInfoValueForKey(SUAllowsAutomaticUpdatesKey) boolValue] && [SUInfoValueForKey(SUAllowsAutomaticUpdatesKey) boolValue]) { return NO; } if (![[NSUserDefaults standardUserDefaults] objectForKey:SUAutomaticallyUpdateKey]) { return NO; } // defaults to NO return [[[NSUserDefaults standardUserDefaults] objectForKey:SUAutomaticallyUpdateKey] boolValue]; } - (BOOL)isAutomaticallyUpdating { return [self automaticallyUpdates] && !verbose; } - (void)showUpdateErrorAlertWithInfo:(NSString *)info { if ([self isAutomaticallyUpdating]) { return; } NSRunAlertPanel(SULocalizedString(@"Update Error!", nil), info, NSLocalizedString(@"Cancel", nil), nil, nil); } - (NSTimeInterval)storedCheckInterval { // Returns the scheduled check interval stored in the user defaults / info.plist. User defaults override Info.plist. if ([[NSUserDefaults standardUserDefaults] objectForKey:SUScheduledCheckIntervalKey]) { long interval = [[[NSUserDefaults standardUserDefaults] objectForKey:SUScheduledCheckIntervalKey] longValue]; if (interval > 0) return interval; } if (SUInfoValueForKey(SUScheduledCheckIntervalKey)) return [SUInfoValueForKey(SUScheduledCheckIntervalKey) longValue]; return 0; } - (void)beginDownload { if (![self isAutomaticallyUpdating]) { statusController = [[SUStatusController alloc] init]; [statusController beginActionWithTitle:SULocalizedString(@"Downloading update...", nil) maxProgressValue:0 statusText:nil]; [statusController setButtonTitle:NSLocalizedString(@"Cancel", nil) target:self action:@selector(cancelDownload:) isDefault:NO]; [statusController showWindow:self]; } downloader = [[NSURLDownload alloc] initWithRequest:[NSURLRequest requestWithURL:[updateItem fileURL]] delegate:self]; } - (void)remindMeLater { // Clear out the skipped version so the dialog will actually come back if it was already skipped. [[NSUserDefaults standardUserDefaults] setObject:nil forKey:SUSkippedVersionKey]; if (checkInterval) [self scheduleCheckWithInterval:checkInterval]; else { // If the host hasn't provided a check interval, we'll use 30 minutes. [self scheduleCheckWithInterval:30 * 60]; } } - (void)updateAlert:(SUUpdateAlert *)alert finishedWithChoice:(SUUpdateAlertChoice)choice { [alert release]; switch (choice) { case SUInstallUpdateChoice: // Clear out the skipped version so the dialog will come back if the download fails. [[NSUserDefaults standardUserDefaults] setObject:nil forKey:SUSkippedVersionKey]; [self beginDownload]; break; case SURemindMeLaterChoice: updateInProgress = NO; [self remindMeLater]; break; case SUSkipThisVersionChoice: updateInProgress = NO; [[NSUserDefaults standardUserDefaults] setObject:[updateItem fileVersion] forKey:SUSkippedVersionKey]; break; } } - (void)showUpdatePanel { updateAlert = [[SUUpdateAlert alloc] initWithAppcastItem:updateItem]; [updateAlert setDelegate:self]; [updateAlert showWindow:self]; } - (void)appcastDidFailToLoad:(SUAppcast *)ac { [ac autorelease]; updateInProgress = NO; if (verbose) [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An error occurred in retrieving update information; are you connected to the internet? Please try again later.", nil)]; } // Override this to change the new version comparison logic! - (BOOL)newVersionAvailable { BOOL canRunOnCurrentSystem = (SUStandardVersionComparison([updateItem minimumSystemVersion], [self systemVersionString]) != NSOrderedAscending); return (canRunOnCurrentSystem && (SUStandardVersionComparison([updateItem fileVersion], SUHostAppVersion()) == NSOrderedAscending)); // Want straight-up string comparison like Sparkle 1.0b3 and earlier? Uncomment the line below and comment the one above. // return ![SUHostAppVersion() isEqualToString:[updateItem fileVersion]]; } - (NSString *)systemVersionString { return currentSystemVersion; } - (void)appcastDidFinishLoading:(SUAppcast *)ac { @try { if (!ac) { [NSException raise:@"SUAppcastException" format:@"Couldn't get a valid appcast from the server."]; } updateItem = [[ac newestItem] retain]; [ac autorelease]; // Record the time of the check for host app use and for interval checks on startup. [[NSUserDefaults standardUserDefaults] setObject:[NSDate date] forKey:SULastCheckTimeKey]; if (![updateItem fileVersion]) { [NSException raise:@"SUAppcastException" format:@"Can't extract a version string from the appcast feed. The filenames should look like YourApp_1.5.tgz, where 1.5 is the version number."]; } if (!verbose && [[[NSUserDefaults standardUserDefaults] objectForKey:SUSkippedVersionKey] isEqualToString:[updateItem fileVersion]]) { updateInProgress = NO; return; } if ([self newVersionAvailable]) { if (checkTimer) // There's a new version! Let's disable the automated checking timer unless the user cancels. { [checkTimer invalidate]; checkTimer = nil; } if ([self isAutomaticallyUpdating]) { [self beginDownload]; } else { [self showUpdatePanel]; } } else { if (verbose) // We only notify on no new version when we're being verbose. { NSRunAlertPanel(SULocalizedString(@"You're up to date!", nil), [NSString stringWithFormat:SULocalizedString(@"%@ %@ is currently the newest version available.", nil), SUHostAppDisplayName(), SUHostAppVersionString()], NSLocalizedString(@"OK", nil), nil, nil); } updateInProgress = NO; } } @catch (NSException *e) { NSLog([e reason]); updateInProgress = NO; if (verbose) [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An error occurred in retrieving update information. Please try again later.", nil)]; } } - (void)download:(NSURLDownload *)download didReceiveResponse:(NSURLResponse *)response { [statusController setMaxProgressValue:[response expectedContentLength]]; } - (void)download:(NSURLDownload *)download decideDestinationWithSuggestedFilename:(NSString *)name { // If name ends in .txt, the server probably has a stupid MIME configuration. We'll give // the developer the benefit of the doubt and chop that off. if ([[name pathExtension] isEqualToString:@"txt"]) name = [name stringByDeletingPathExtension]; // We create a temporary directory in /tmp and stick the file there. NSString *tempDir = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; BOOL success = [[NSFileManager defaultManager] createDirectoryAtPath:tempDir attributes:nil]; if (!success) { [NSException raise:@"SUFailTmpWrite" format:@"Couldn't create temporary directory in /tmp"]; [download cancel]; [download release]; } [downloadPath autorelease]; downloadPath = [[tempDir stringByAppendingPathComponent:name] retain]; [download setDestination:downloadPath allowOverwrite:YES]; } - (void)download:(NSURLDownload *)download didReceiveDataOfLength:(unsigned)length { [statusController setProgressValue:[statusController progressValue] + length]; [statusController setStatusText:[NSString stringWithFormat:SULocalizedString(@"%.0lfk of %.0lfk", nil), [statusController progressValue] / 1024.0, [statusController maxProgressValue] / 1024.0]]; } - (void)unarchiver:(SUUnarchiver *)ua extractedLength:(long)length { if ([self isAutomaticallyUpdating]) { return; } if ([statusController maxProgressValue] == 0) [statusController setMaxProgressValue:[[[[NSFileManager defaultManager] fileAttributesAtPath:downloadPath traverseLink:NO] objectForKey:NSFileSize] longValue]]; [statusController setProgressValue:[statusController progressValue] + length]; } - (void)unarchiverDidFinish:(SUUnarchiver *)ua { [ua autorelease]; if ([self isAutomaticallyUpdating]) { [self installAndRestart:self]; } else { [statusController beginActionWithTitle:SULocalizedString(@"Ready to install!", nil) maxProgressValue:1 statusText:nil]; [statusController setProgressValue:1]; // fill the bar [statusController setButtonTitle:SULocalizedString(@"Install and Relaunch", nil) target:self action:@selector(installAndRestart:) isDefault:YES]; [NSApp requestUserAttention:NSInformationalRequest]; } } - (void)unarchiverDidFail:(SUUnarchiver *)ua { [ua autorelease]; [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An error occurred while extracting the archive. Please try again later.", nil)]; [self abandonUpdate]; } - (void)extractUpdate { // Now we have to extract the downloaded archive. if (![self isAutomaticallyUpdating]) [statusController beginActionWithTitle:SULocalizedString(@"Extracting update...", nil) maxProgressValue:0 statusText:nil]; @try { // If the developer's provided a sparkle:md5Hash attribute on the enclosure, let's verify that. if ([updateItem MD5Sum] && ![[NSFileManager defaultManager] validatePath:downloadPath withMD5Hash:[updateItem MD5Sum]]) { [NSException raise:@"SUUnarchiveException" format:@"MD5 verification of the update archive failed."]; } // DSA verification, if activated by the developer if ([SUInfoValueForKey(SUExpectsDSASignatureKey) boolValue]) { NSString *dsaSignature = [updateItem DSASignature]; if (![[NSFileManager defaultManager] validatePath:downloadPath withEncodedDSASignature:dsaSignature]) { [NSException raise:@"SUUnarchiveException" format:@"DSA verification of the update archive failed."]; } } SUUnarchiver *unarchiver = [[SUUnarchiver alloc] init]; [unarchiver setDelegate:self]; [unarchiver unarchivePath:downloadPath]; // asynchronous extraction! } @catch(NSException *e) { NSLog([e reason]); [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An error occurred while extracting the archive. Please try again later.", nil)]; [self abandonUpdate]; } } - (void)downloadDidFinish:(NSURLDownload *)download { [download release]; downloader = nil; [self extractUpdate]; } - (void)abandonUpdate { [updateItem release]; [statusController close]; [statusController release]; updateInProgress = NO; } - (void)download:(NSURLDownload *)download didFailWithError:(NSError *)error { [self abandonUpdate]; NSLog(@"Download error: %@", [error localizedDescription]); [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An error occurred while trying to download the file. Please try again later.", nil)]; } - (IBAction)installAndRestart:sender { NSString *currentAppPath = [[NSBundle mainBundle] bundlePath]; NSString *newAppDownloadPath = [[downloadPath stringByDeletingLastPathComponent] stringByAppendingPathComponent:[SUInfoValueForKey(@"CFBundleName") stringByAppendingPathExtension:@"app"]]; @try { if (![self isAutomaticallyUpdating]) { [statusController beginActionWithTitle:SULocalizedString(@"Installing update...", nil) maxProgressValue:0 statusText:nil]; [statusController setButtonEnabled:NO]; // We have to wait for the UI to update. NSEvent *event; while((event = [NSApp nextEventMatchingMask:NSAnyEventMask untilDate:nil inMode:NSDefaultRunLoopMode dequeue:YES])) [NSApp sendEvent:event]; } // We assume that the archive will contain a file named {CFBundleName}.app // (where, obviously, CFBundleName comes from Info.plist) if (!SUInfoValueForKey(@"CFBundleName")) { [NSException raise:@"SUInstallException" format:@"This application has no CFBundleName! This key must be set to the application's name."]; } // Search subdirectories for the application NSString *file, *appName = [SUInfoValueForKey(@"CFBundleName") stringByAppendingPathExtension:@"app"]; NSDirectoryEnumerator *dirEnum = [[NSFileManager defaultManager] enumeratorAtPath:[downloadPath stringByDeletingLastPathComponent]]; while ((file = [dirEnum nextObject])) { // Some DMGs have symlinks into /Applications! That's no good! if ([file isEqualToString:@"/Applications"]) [dirEnum skipDescendents]; if ([[file lastPathComponent] isEqualToString:appName]) newAppDownloadPath = [[downloadPath stringByDeletingLastPathComponent] stringByAppendingPathComponent:file]; } if (!newAppDownloadPath || ![[NSFileManager defaultManager] fileExistsAtPath:newAppDownloadPath]) { [NSException raise:@"SUInstallException" format:@"The update archive didn't contain an application with the proper name: %@. Remember, the updated app's file name must be identical to {CFBundleName}.app", [SUInfoValueForKey(@"CFBundleName") stringByAppendingPathExtension:@"app"]]; } } @catch(NSException *e) { NSLog([e reason]); [self showUpdateErrorAlertWithInfo:SULocalizedString(@"An error occurred during installation. Please try again later.", nil)]; [self abandonUpdate]; } if ([self isAutomaticallyUpdating]) // Don't do authentication if we're automatically updating; that'd be surprising. { int tag = 0; BOOL result = [[NSWorkspace sharedWorkspace] performFileOperation:NSWorkspaceRecycleOperation source:[currentAppPath stringByDeletingLastPathComponent] destination:@"" files:[NSArray arrayWithObject:[currentAppPath lastPathComponent]] tag:&tag]; result &= [[NSFileManager defaultManager] movePath:newAppDownloadPath toPath:currentAppPath handler:nil]; if (!result) { [self abandonUpdate]; return; } } else // But if we're updating by the action of the user, do an authenticated move. { // Outside of the @try block because we want to be a little more informative on this error. if (![[NSFileManager defaultManager] movePathWithAuthentication:newAppDownloadPath toPath:currentAppPath]) { [self showUpdateErrorAlertWithInfo:[NSString stringWithFormat:SULocalizedString(@"%@ does not have permission to write to the application's directory! Are you running off a disk image? If not, ask your system administrator for help.", nil), SUHostAppDisplayName()]]; [self abandonUpdate]; return; } } // Prompt for permission to restart if we're automatically updating. if ([self isAutomaticallyUpdating]) { SUAutomaticUpdateAlert *alert = [[SUAutomaticUpdateAlert alloc] initWithAppcastItem:updateItem]; if ([NSApp runModalForWindow:[alert window]] == NSAlertAlternateReturn) { [alert release]; return; } } [[NSNotificationCenter defaultCenter] postNotificationName:SUUpdaterWillRestartNotification object:self]; // Thanks to Allan Odgaard for this restart code, which is much more clever than mine was. setenv("LAUNCH_PATH", [currentAppPath UTF8String], 1); setenv("TEMP_FOLDER", [[downloadPath stringByDeletingLastPathComponent] UTF8String], 1); // delete the temp stuff after it's all over system("/bin/bash -c '{ for (( i = 0; i < 3000 && $(echo $(/bin/ps -xp $PPID|/usr/bin/wc -l))-1; i++ )); do\n" " /bin/sleep .2;\n" " done\n" " if [[ $(/bin/ps -xp $PPID|/usr/bin/wc -l) -ne 2 ]]; then\n" " /usr/bin/open \"${LAUNCH_PATH}\"\n" " fi\n" " rm -rf \"${TEMP_FOLDER}\"\n" "} &>/dev/null &'"); [NSApp terminate:self]; } - (IBAction)cancelDownload:sender { if (downloader) { [downloader cancel]; [downloader release]; } [self abandonUpdate]; if (checkInterval) { [self scheduleCheckWithInterval:checkInterval]; } } @end