// // SKFileUpdateChecker.m // Skim // // Created by Christiaan Hofman on 12/23/10. /* This software is Copyright (c) 2010-2018 Christiaan Hofman. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - Neither the name of Christiaan Hofman nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #import "SKFileUpdateChecker.h" //#import "NSDocument_SKExtensions.h" //#import "NSData_SKExtensions.h" //#import //#import "NSUserDefaultsController_SKExtensions.h" //#import "NSString_SKExtensions.h" //#import "NSError_SKExtensions.h" #import #define PATH_KEY @"path" static char SKFileUpdateCheckerObservationContext; static BOOL isURLOnHFSVolume(NSURL *fileURL); static BOOL canUpdateFromURL(NSURL *fileURL); @interface SKFileUpdateChecker (SKPrivate) - (void)fileUpdated; - (void)noteFileUpdated; - (void)noteFileMoved; - (void)noteFileRemoved; @end @implementation SKFileUpdateChecker @dynamic enabled, fileChangedOnDisk, isUpdatingFile; - (id)initForDocument:(NSDocument *)aDocument { self = [super init]; if (self) { document = aDocument; // hidden pref to always auto update without first asking the user memset(&fucFlags, 0, sizeof(fucFlags)); fucFlags.autoUpdate = [[NSUserDefaults standardUserDefaults] boolForKey:@"SKAutoReloadFileUpdate"]; // [[NSUserDefaultsController sharedUserDefaultsController] addObserver:self forKey:@"SKAutoCheckFileUpdate" context:&SKFileUpdateCheckerObservationContext]; [[NSUserDefaultsController sharedUserDefaultsController]addObserver:self forKeyPath:@"SKAutoCheckFileUpdate" options:0 context:&SKFileUpdateCheckerObservationContext]; [document addObserver:self forKeyPath:@"fileURL" options:0 context:&SKFileUpdateCheckerObservationContext]; } return self; } //self.addObserver(anObserver, forKeyPath: "values.\(key)", options: .init(rawValue: 0), context: context as! UnsafeMutableRawPointer) - (void)dealloc { @try { [[NSUserDefaultsController sharedUserDefaultsController] removeObserver:self forKey:@"SKAutoCheckFileUpdate"]; } @catch (id) {} document = nil; [self stop]; } - (void)terminate { [self stop]; @try { [document removeObserver:self forKeyPath:@"fileURL"]; } @catch (id) {} document = nil; } - (void)stop { // remove file monitor and invalidate timer; maybe we've changed filesystems if (source) { dispatch_source_cancel(source); source = nil; } if (fileUpdateTimer) { [fileUpdateTimer invalidate]; fileUpdateTimer = nil; } fucFlags.fileWasMoved = NO; } - (void)checkForFileModification:(NSTimer *)timer { NSDate *currentFileModifiedDate = [[[NSFileManager defaultManager] attributesOfItemAtPath:[[[document fileURL] URLByResolvingSymlinksInPath] path] error:NULL] fileModificationDate]; if (nil == lastModifiedDate) { lastModifiedDate = [currentFileModifiedDate copy]; } else if ([lastModifiedDate compare:currentFileModifiedDate] == NSOrderedAscending) { // Always reset mod date to prevent repeating messages; note that the kqueue also notifies only once lastModifiedDate = [currentFileModifiedDate copy]; [self noteFileUpdated]; } } - (void)checkForFileReplacement:(NSTimer *)timer { if ([[[document fileURL] URLByResolvingSymlinksInPath] checkResourceIsReachableAndReturnError:NULL]) { // the deleted file was replaced at the old path, restart the file updating for the replacement file and note the update [self reset]; [self noteFileUpdated]; } } - (void)startTimerWithSelector:(SEL)aSelector { fileUpdateTimer = [[NSTimer alloc] initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:0.1] interval:2.0 target:self selector:aSelector userInfo:nil repeats:YES]; [[NSRunLoop currentRunLoop] addTimer:fileUpdateTimer forMode:NSDefaultRunLoopMode]; } - (void)reset { [self stop]; NSURL *fileURL = [[document fileURL] URLByResolvingSymlinksInPath]; if (fileURL) { if (fucFlags.enabled && [[NSUserDefaults standardUserDefaults] boolForKey:@"SKAutoCheckFileUpdate"]) { // AFP, NFS, SMB etc. don't support kqueues, so we have to manually poll and compare mod dates if (isURLOnHFSVolume(fileURL)) { int fd = open([[fileURL path] fileSystemRepresentation], O_EVTONLY); if (fd >= 0) { dispatch_queue_t queue = dispatch_get_main_queue(); source = dispatch_source_create(DISPATCH_SOURCE_TYPE_VNODE, fd, DISPATCH_VNODE_DELETE | DISPATCH_VNODE_RENAME | DISPATCH_VNODE_WRITE, queue); if (source) { dispatch_source_set_event_handler(source, ^{ unsigned long flags = dispatch_source_get_data(self->source); if ((flags & DISPATCH_VNODE_DELETE)) [self noteFileRemoved]; else if ((flags & DISPATCH_VNODE_RENAME)) [self noteFileMoved]; else if ((flags & DISPATCH_VNODE_WRITE)) [self noteFileUpdated]; }); dispatch_source_set_cancel_handler(source, ^{ close(fd); }); dispatch_resume(source); } else { close(fd); } } } else if (nil == fileUpdateTimer) { // Use a fairly long delay since this is likely a network volume. [self startTimerWithSelector:@selector(checkForFileModification:)]; } } } } - (void)fileUpdateAlertDidEnd:(NSAlert *)alert returnCode:(NSInteger)returnCode contextInfo:(void *)contextInfo { if (returnCode == NSAlertSecondButtonReturn) { // if we don't reload now, we should not do it automatically next fucFlags.autoUpdate = NO; } else { // should we reset autoUpdate to YES on NSAlertFirstButtonReturn when SKAutoReloadFileUpdateKey is set? if (returnCode == NSAlertThirdButtonReturn) fucFlags.autoUpdate = YES; [[alert window] orderOut:nil]; NSError *error = nil; BOOL didRevert = [document revertToContentsOfURL:[document fileURL] ofType:[document fileType] error:&error]; if (didRevert == NO && error != nil && [self isUserCancelledError:error] == NO) [document presentError:error modalForWindow:[document windowForSheet] delegate:nil didPresentSelector:NULL contextInfo:NULL]; if (didRevert == NO && fucFlags.fileWasUpdated) [self performSelector:@selector(fileUpdated) withObject:nil afterDelay:0.0]; } fucFlags.isUpdatingFile = NO; fucFlags.fileWasUpdated = NO; } - (BOOL)isUserCancelledError: (NSError *)error { return [[error domain] isEqualToString:NSCocoaErrorDomain] && [error code] == NSUserCancelledError; } - (void)handleWindowDidEndSheetNotification:(NSNotification *)notification { // This is only called to delay a file update handling [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowDidEndSheetNotification object:[notification object]]; // Make sure we finish the sheet event first. E.g. the documentEdited status may need to be updated. [self performSelector:@selector(fileUpdated) withObject:nil afterDelay:0.0]; } - (void)fileUpdated { NSURL *fileURL = [[document fileURL] URLByResolvingSymlinksInPath]; // should never happen if (fucFlags.isUpdatingFile) NSLog(@"*** already busy updating file %@", [fileURL path]); if (fucFlags.enabled && [[NSUserDefaults standardUserDefaults] boolForKey:@"SKAutoCheckFileUpdate"] && [fileURL checkResourceIsReachableAndReturnError:NULL]) { fucFlags.fileChangedOnDisk = YES; fucFlags.isUpdatingFile = YES; fucFlags.fileWasUpdated = NO; NSWindow *docWindow = [document windowForSheet]; // check for attached sheet, since reloading the document while an alert is up looks a bit strange if ([docWindow attachedSheet]) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleWindowDidEndSheetNotification:) name:NSWindowDidEndSheetNotification object:docWindow]; } else if (canUpdateFromURL(fileURL)) { BOOL documentHasEdits = [document isDocumentEdited]/* || [[document notes] count] > 0*/; if (fucFlags.autoUpdate && documentHasEdits == NO) { // tried queuing this with a delayed perform/cancel previous, but revert takes long enough that the cancel was never used [self fileUpdateAlertDidEnd:nil returnCode:NSAlertFirstButtonReturn contextInfo:NULL]; } else { NSString *message; if (documentHasEdits) message = NSLocalizedString(@"The PDF file has changed on disk. If you reload, your changes will be lost. Do you want to reload this document now?", @"Informative text in alert dialog"); else if (fucFlags.autoUpdate) message = NSLocalizedString(@"The PDF file has changed on disk. Do you want to reload this document now?", @"Informative text in alert dialog"); else message = NSLocalizedString(@"The PDF file has changed on disk. Do you want to reload this document now? Choosing Auto will reload this file automatically for future changes.", @"Informative text in alert dialog"); NSAlert *alert = [[NSAlert alloc] init]; [alert setMessageText:NSLocalizedString(@"File Updated", @"Message in alert dialog")]; [alert setInformativeText:message]; [alert addButtonWithTitle:NSLocalizedString(@"Yes", @"Button title")]; [alert addButtonWithTitle:NSLocalizedString(@"No", @"Button title")]; if (fucFlags.autoUpdate == NO) [alert addButtonWithTitle:NSLocalizedString(@"Auto", @"Button title")]; // [alert beginSheetModalForWindow:docWindow // modalDelegate:self // didEndSelector:@selector(fileUpdateAlertDidEnd:returnCode:contextInfo:) // contextInfo:NULL]; [alert beginSheetModalForWindow:docWindow completionHandler:^(NSModalResponse returnCode) { [self fileUpdateAlertDidEnd:nil returnCode:NSAlertFirstButtonReturn contextInfo:NULL]; }]; } } else { fucFlags.isUpdatingFile = NO; fucFlags.fileWasUpdated = NO; } } else { fucFlags.isUpdatingFile = NO; fucFlags.fileWasUpdated = NO; } } - (void)noteFileUpdated { if (fucFlags.fileWasMoved == NO) { if (fucFlags.isUpdatingFile) fucFlags.fileWasUpdated = YES; else [self fileUpdated]; } } - (void)noteFileMoved { // If the file is moved, NSDocument will notice and will call setFileURL, where we start watching again // unless the file is deleted before NSDocument notices, in which case we can treat this as just deleting the file // but as long as neither happens we will ignore updates, as we cannot know which file NSDocument will think it has fucFlags.fileChangedOnDisk = YES; fucFlags.fileWasMoved = YES; } - (void)noteFileRemoved { [self stop]; fucFlags.fileChangedOnDisk = YES; // poll the (old) path to see whether the deleted file will be replaced [self startTimerWithSelector:@selector(checkForFileReplacement:)]; } - (void)setEnabled:(BOOL)flag { if (fucFlags.enabled != flag) { fucFlags.enabled = flag; [self reset]; } } - (BOOL)isEnabled { return fucFlags.enabled; } - (BOOL)fileChangedOnDisk { return fucFlags.fileChangedOnDisk; } - (BOOL)isUpdatingFile { return fucFlags.isUpdatingFile; } - (void)didUpdateFromURL:(NSURL *)fileURL { fucFlags.fileChangedOnDisk = NO; lastModifiedDate = [[[NSFileManager defaultManager] attributesOfItemAtPath:[[fileURL URLByResolvingSymlinksInPath] path] error:NULL] fileModificationDate]; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (context == &SKFileUpdateCheckerObservationContext) [self reset]; else [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; } @end static BOOL isURLOnHFSVolume(NSURL *fileURL) { BOOL isHFSVolume = NO; FSRef fileRef; if (CFURLGetFSRef((CFURLRef)fileURL, &fileRef)) { OSStatus err; FSCatalogInfo fileInfo; err = FSGetCatalogInfo(&fileRef, kFSCatInfoVolume, &fileInfo, NULL, NULL, NULL); FSVolumeInfo volInfo; if (noErr == err) { err = FSGetVolumeInfo(fileInfo.volume, 0, NULL, kFSVolInfoFSInfo, &volInfo, NULL, NULL); if (noErr == err) // HFS and HFS+ are documented to have zero for filesystemID; AFP at least is non-zero isHFSVolume = (0 == volInfo.filesystemID); } } return isHFSVolume; } static BOOL canUpdateFromURL(NSURL *fileURL) { NSString *extension = [fileURL pathExtension]; BOOL isDVI = NO; if (extension) { NSWorkspace *ws = [NSWorkspace sharedWorkspace]; NSString *theUTI = [ws typeOfFile:[[fileURL URLByStandardizingPath] path] error:NULL]; if ([extension isCaseInsensitiveEqual:@"pdfd"] || [ws type:theUTI conformsToType:@"net.sourceforge.skim-app.pdfd"]) { // fileURL = [[NSFileManager defaultManager] bundledFileURLWithExtension:@"pdf" inPDFBundleAtURL:fileURL error:NULL]; if ([fileURL.pathExtension hasSuffix:@"pdf"]) { return [[NSFileManager defaultManager] fileExistsAtPath:fileURL.path]; }else{ NSString *pathS = [fileURL.path stringByAppendingPathExtension:@"pdf"]; return [[NSFileManager defaultManager] fileExistsAtPath:pathS]; } } else if ([extension isCaseInsensitiveEqual:@"dvi"] || [extension isCaseInsensitiveEqual:@"xdv"]) { isDVI = YES; } } NSFileHandle *fh = [NSFileHandle fileHandleForReadingFromURL:fileURL error:NULL]; // read the last 1024 bytes of the file (or entire file); Adobe's spec says they allow %%EOF anywhere in that range unsigned long long fileEnd = [fh seekToEndOfFile]; unsigned long long startPos = fileEnd < 1024 ? 0 : fileEnd - 1024; [fh seekToFileOffset:startPos]; NSData *trailerData = [fh readDataToEndOfFile]; NSRange range = NSMakeRange(0, [trailerData length]); NSData *pattern = [NSData dataWithBytes:"%%EOF" length:5]; NSDataSearchOptions options = NSDataSearchBackwards; if (isDVI) { const char bytes[4] = {0xDF, 0xDF, 0xDF, 0xDF}; pattern = [NSData dataWithBytes:bytes length:4]; options |= NSDataSearchAnchored; } return NSNotFound != [trailerData rangeOfData:pattern options:options range:range].location; }