SKFileUpdateChecker.m 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. //
  2. // SKFileUpdateChecker.m
  3. // Skim
  4. //
  5. // Created by Christiaan Hofman on 12/23/10.
  6. /*
  7. This software is Copyright (c) 2010-2018
  8. Christiaan Hofman. All rights reserved.
  9. Redistribution and use in source and binary forms, with or without
  10. modification, are permitted provided that the following conditions
  11. are met:
  12. - Redistributions of source code must retain the above copyright
  13. notice, this list of conditions and the following disclaimer.
  14. - Redistributions in binary form must reproduce the above copyright
  15. notice, this list of conditions and the following disclaimer in
  16. the documentation and/or other materials provided with the
  17. distribution.
  18. - Neither the name of Christiaan Hofman nor the names of any
  19. contributors may be used to endorse or promote products derived
  20. from this software without specific prior written permission.
  21. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
  22. "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
  23. LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
  24. A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
  25. OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
  26. SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
  27. LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  28. DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  29. THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  30. (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  31. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  32. */
  33. #import "SKFileUpdateChecker.h"
  34. //#import "NSDocument_SKExtensions.h"
  35. //#import "NSData_SKExtensions.h"
  36. //#import <SkimNotes/SkimNotes.h>
  37. //#import "NSUserDefaultsController_SKExtensions.h"
  38. //#import "NSString_SKExtensions.h"
  39. //#import "NSError_SKExtensions.h"
  40. #import <PDF_Reader_Pro-Swift.h>
  41. #define PATH_KEY @"path"
  42. static char SKFileUpdateCheckerObservationContext;
  43. static BOOL isURLOnHFSVolume(NSURL *fileURL);
  44. static BOOL canUpdateFromURL(NSURL *fileURL);
  45. @interface SKFileUpdateChecker (SKPrivate)
  46. - (void)fileUpdated;
  47. - (void)noteFileUpdated;
  48. - (void)noteFileMoved;
  49. - (void)noteFileRemoved;
  50. @end
  51. @implementation SKFileUpdateChecker
  52. @dynamic enabled, fileChangedOnDisk, isUpdatingFile;
  53. - (id)initForDocument:(NSDocument *)aDocument {
  54. self = [super init];
  55. if (self) {
  56. document = aDocument;
  57. // hidden pref to always auto update without first asking the user
  58. memset(&fucFlags, 0, sizeof(fucFlags));
  59. fucFlags.autoUpdate = [[NSUserDefaults standardUserDefaults] boolForKey:@"SKAutoReloadFileUpdate"];
  60. // [[NSUserDefaultsController sharedUserDefaultsController] addObserver:self forKey:@"SKAutoCheckFileUpdate" context:&SKFileUpdateCheckerObservationContext];
  61. [[NSUserDefaultsController sharedUserDefaultsController]addObserver:self forKeyPath:@"SKAutoCheckFileUpdate" options:0 context:&SKFileUpdateCheckerObservationContext];
  62. [document addObserver:self forKeyPath:@"fileURL" options:0 context:&SKFileUpdateCheckerObservationContext];
  63. }
  64. return self;
  65. }
  66. //self.addObserver(anObserver, forKeyPath: "values.\(key)", options: .init(rawValue: 0), context: context as! UnsafeMutableRawPointer)
  67. - (void)dealloc {
  68. @try { [[NSUserDefaultsController sharedUserDefaultsController] removeObserver:self forKey:@"SKAutoCheckFileUpdate"]; }
  69. @catch (id) {}
  70. document = nil;
  71. [self stop];
  72. }
  73. - (void)terminate {
  74. [self stop];
  75. @try { [document removeObserver:self forKeyPath:@"fileURL"]; }
  76. @catch (id) {}
  77. document = nil;
  78. }
  79. - (void)stop {
  80. // remove file monitor and invalidate timer; maybe we've changed filesystems
  81. if (source) {
  82. dispatch_source_cancel(source);
  83. source = nil;
  84. }
  85. if (fileUpdateTimer) {
  86. [fileUpdateTimer invalidate];
  87. fileUpdateTimer = nil;
  88. }
  89. fucFlags.fileWasMoved = NO;
  90. }
  91. - (void)checkForFileModification:(NSTimer *)timer {
  92. NSDate *currentFileModifiedDate = [[[NSFileManager defaultManager] attributesOfItemAtPath:[[[document fileURL] URLByResolvingSymlinksInPath] path] error:NULL] fileModificationDate];
  93. if (nil == lastModifiedDate) {
  94. lastModifiedDate = [currentFileModifiedDate copy];
  95. } else if ([lastModifiedDate compare:currentFileModifiedDate] == NSOrderedAscending) {
  96. // Always reset mod date to prevent repeating messages; note that the kqueue also notifies only once
  97. lastModifiedDate = [currentFileModifiedDate copy];
  98. [self noteFileUpdated];
  99. }
  100. }
  101. - (void)checkForFileReplacement:(NSTimer *)timer {
  102. if ([[[document fileURL] URLByResolvingSymlinksInPath] checkResourceIsReachableAndReturnError:NULL]) {
  103. // the deleted file was replaced at the old path, restart the file updating for the replacement file and note the update
  104. [self reset];
  105. [self noteFileUpdated];
  106. }
  107. }
  108. - (void)startTimerWithSelector:(SEL)aSelector {
  109. fileUpdateTimer = [[NSTimer alloc] initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:0.1] interval:2.0 target:self selector:aSelector userInfo:nil repeats:YES];
  110. [[NSRunLoop currentRunLoop] addTimer:fileUpdateTimer forMode:NSDefaultRunLoopMode];
  111. }
  112. - (void)reset {
  113. [self stop];
  114. NSURL *fileURL = [[document fileURL] URLByResolvingSymlinksInPath];
  115. if (fileURL) {
  116. if (fucFlags.enabled && [[NSUserDefaults standardUserDefaults] boolForKey:@"SKAutoCheckFileUpdate"]) {
  117. // AFP, NFS, SMB etc. don't support kqueues, so we have to manually poll and compare mod dates
  118. if (isURLOnHFSVolume(fileURL)) {
  119. int fd = open([[fileURL path] fileSystemRepresentation], O_EVTONLY);
  120. if (fd >= 0) {
  121. dispatch_queue_t queue = dispatch_get_main_queue();
  122. source = dispatch_source_create(DISPATCH_SOURCE_TYPE_VNODE, fd, DISPATCH_VNODE_DELETE | DISPATCH_VNODE_RENAME | DISPATCH_VNODE_WRITE, queue);
  123. if (source) {
  124. dispatch_source_set_event_handler(source, ^{
  125. unsigned long flags = dispatch_source_get_data(self->source);
  126. if ((flags & DISPATCH_VNODE_DELETE))
  127. [self noteFileRemoved];
  128. else if ((flags & DISPATCH_VNODE_RENAME))
  129. [self noteFileMoved];
  130. else if ((flags & DISPATCH_VNODE_WRITE))
  131. [self noteFileUpdated];
  132. });
  133. dispatch_source_set_cancel_handler(source, ^{ close(fd); });
  134. dispatch_resume(source);
  135. } else {
  136. close(fd);
  137. }
  138. }
  139. } else if (nil == fileUpdateTimer) {
  140. // Use a fairly long delay since this is likely a network volume.
  141. [self startTimerWithSelector:@selector(checkForFileModification:)];
  142. }
  143. }
  144. }
  145. }
  146. - (void)fileUpdateAlertDidEnd:(NSAlert *)alert returnCode:(NSInteger)returnCode contextInfo:(void *)contextInfo {
  147. if (returnCode == NSAlertSecondButtonReturn) {
  148. // if we don't reload now, we should not do it automatically next
  149. fucFlags.autoUpdate = NO;
  150. } else {
  151. // should we reset autoUpdate to YES on NSAlertFirstButtonReturn when SKAutoReloadFileUpdateKey is set?
  152. if (returnCode == NSAlertThirdButtonReturn)
  153. fucFlags.autoUpdate = YES;
  154. [[alert window] orderOut:nil];
  155. NSError *error = nil;
  156. BOOL didRevert = [document revertToContentsOfURL:[document fileURL] ofType:[document fileType] error:&error];
  157. if (didRevert == NO && error != nil && [self isUserCancelledError:error] == NO)
  158. [document presentError:error modalForWindow:[document windowForSheet] delegate:nil didPresentSelector:NULL contextInfo:NULL];
  159. if (didRevert == NO && fucFlags.fileWasUpdated)
  160. [self performSelector:@selector(fileUpdated) withObject:nil afterDelay:0.0];
  161. }
  162. fucFlags.isUpdatingFile = NO;
  163. fucFlags.fileWasUpdated = NO;
  164. }
  165. - (BOOL)isUserCancelledError: (NSError *)error {
  166. return [[error domain] isEqualToString:NSCocoaErrorDomain] && [error code] == NSUserCancelledError;
  167. }
  168. - (void)handleWindowDidEndSheetNotification:(NSNotification *)notification {
  169. // This is only called to delay a file update handling
  170. [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowDidEndSheetNotification object:[notification object]];
  171. // Make sure we finish the sheet event first. E.g. the documentEdited status may need to be updated.
  172. [self performSelector:@selector(fileUpdated) withObject:nil afterDelay:0.0];
  173. }
  174. - (void)fileUpdated {
  175. NSURL *fileURL = [[document fileURL] URLByResolvingSymlinksInPath];
  176. // should never happen
  177. if (fucFlags.isUpdatingFile)
  178. NSLog(@"*** already busy updating file %@", [fileURL path]);
  179. if (fucFlags.enabled &&
  180. [[NSUserDefaults standardUserDefaults] boolForKey:@"SKAutoCheckFileUpdate"] &&
  181. [fileURL checkResourceIsReachableAndReturnError:NULL]) {
  182. fucFlags.fileChangedOnDisk = YES;
  183. fucFlags.isUpdatingFile = YES;
  184. fucFlags.fileWasUpdated = NO;
  185. NSWindow *docWindow = [document windowForSheet];
  186. // check for attached sheet, since reloading the document while an alert is up looks a bit strange
  187. if ([docWindow attachedSheet]) {
  188. [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleWindowDidEndSheetNotification:)
  189. name:NSWindowDidEndSheetNotification object:docWindow];
  190. } else if (canUpdateFromURL(fileURL)) {
  191. BOOL documentHasEdits = [document isDocumentEdited]/* || [[document notes] count] > 0*/;
  192. if (fucFlags.autoUpdate && documentHasEdits == NO) {
  193. // tried queuing this with a delayed perform/cancel previous, but revert takes long enough that the cancel was never used
  194. [self fileUpdateAlertDidEnd:nil returnCode:NSAlertFirstButtonReturn contextInfo:NULL];
  195. } else {
  196. NSString *message;
  197. if (documentHasEdits)
  198. 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");
  199. else if (fucFlags.autoUpdate)
  200. message = NSLocalizedString(@"The PDF file has changed on disk. Do you want to reload this document now?", @"Informative text in alert dialog");
  201. else
  202. 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");
  203. NSAlert *alert = [[NSAlert alloc] init];
  204. [alert setMessageText:NSLocalizedString(@"File Updated", @"Message in alert dialog")];
  205. [alert setInformativeText:message];
  206. [alert addButtonWithTitle:NSLocalizedString(@"Yes", @"Button title")];
  207. [alert addButtonWithTitle:NSLocalizedString(@"No", @"Button title")];
  208. if (fucFlags.autoUpdate == NO)
  209. [alert addButtonWithTitle:NSLocalizedString(@"Auto", @"Button title")];
  210. // [alert beginSheetModalForWindow:docWindow
  211. // modalDelegate:self
  212. // didEndSelector:@selector(fileUpdateAlertDidEnd:returnCode:contextInfo:)
  213. // contextInfo:NULL];
  214. [alert beginSheetModalForWindow:docWindow completionHandler:^(NSModalResponse returnCode) {
  215. [self fileUpdateAlertDidEnd:nil returnCode:NSAlertFirstButtonReturn contextInfo:NULL];
  216. }];
  217. }
  218. } else {
  219. fucFlags.isUpdatingFile = NO;
  220. fucFlags.fileWasUpdated = NO;
  221. }
  222. } else {
  223. fucFlags.isUpdatingFile = NO;
  224. fucFlags.fileWasUpdated = NO;
  225. }
  226. }
  227. - (void)noteFileUpdated {
  228. if (fucFlags.fileWasMoved == NO) {
  229. if (fucFlags.isUpdatingFile)
  230. fucFlags.fileWasUpdated = YES;
  231. else
  232. [self fileUpdated];
  233. }
  234. }
  235. - (void)noteFileMoved {
  236. // If the file is moved, NSDocument will notice and will call setFileURL, where we start watching again
  237. // unless the file is deleted before NSDocument notices, in which case we can treat this as just deleting the file
  238. // but as long as neither happens we will ignore updates, as we cannot know which file NSDocument will think it has
  239. fucFlags.fileChangedOnDisk = YES;
  240. fucFlags.fileWasMoved = YES;
  241. }
  242. - (void)noteFileRemoved {
  243. [self stop];
  244. fucFlags.fileChangedOnDisk = YES;
  245. // poll the (old) path to see whether the deleted file will be replaced
  246. [self startTimerWithSelector:@selector(checkForFileReplacement:)];
  247. }
  248. - (void)setEnabled:(BOOL)flag {
  249. if (fucFlags.enabled != flag) {
  250. fucFlags.enabled = flag;
  251. [self reset];
  252. }
  253. }
  254. - (BOOL)isEnabled {
  255. return fucFlags.enabled;
  256. }
  257. - (BOOL)fileChangedOnDisk {
  258. return fucFlags.fileChangedOnDisk;
  259. }
  260. - (BOOL)isUpdatingFile {
  261. return fucFlags.isUpdatingFile;
  262. }
  263. - (void)didUpdateFromURL:(NSURL *)fileURL {
  264. fucFlags.fileChangedOnDisk = NO;
  265. lastModifiedDate = [[[NSFileManager defaultManager] attributesOfItemAtPath:[[fileURL URLByResolvingSymlinksInPath] path] error:NULL] fileModificationDate];
  266. }
  267. - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
  268. if (context == &SKFileUpdateCheckerObservationContext)
  269. [self reset];
  270. else
  271. [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
  272. }
  273. @end
  274. static BOOL isURLOnHFSVolume(NSURL *fileURL) {
  275. BOOL isHFSVolume = NO;
  276. FSRef fileRef;
  277. if (CFURLGetFSRef((CFURLRef)fileURL, &fileRef)) {
  278. OSStatus err;
  279. FSCatalogInfo fileInfo;
  280. err = FSGetCatalogInfo(&fileRef, kFSCatInfoVolume, &fileInfo, NULL, NULL, NULL);
  281. FSVolumeInfo volInfo;
  282. if (noErr == err) {
  283. err = FSGetVolumeInfo(fileInfo.volume, 0, NULL, kFSVolInfoFSInfo, &volInfo, NULL, NULL);
  284. if (noErr == err)
  285. // HFS and HFS+ are documented to have zero for filesystemID; AFP at least is non-zero
  286. isHFSVolume = (0 == volInfo.filesystemID);
  287. }
  288. }
  289. return isHFSVolume;
  290. }
  291. static BOOL canUpdateFromURL(NSURL *fileURL) {
  292. NSString *extension = [fileURL pathExtension];
  293. BOOL isDVI = NO;
  294. if (extension) {
  295. NSWorkspace *ws = [NSWorkspace sharedWorkspace];
  296. NSString *theUTI = [ws typeOfFile:[[fileURL URLByStandardizingPath] path] error:NULL];
  297. if ([extension isCaseInsensitiveEqual:@"pdfd"] || [ws type:theUTI conformsToType:@"net.sourceforge.skim-app.pdfd"]) {
  298. // fileURL = [[NSFileManager defaultManager] bundledFileURLWithExtension:@"pdf" inPDFBundleAtURL:fileURL error:NULL];
  299. if ([fileURL.pathExtension hasSuffix:@"pdf"]) {
  300. return [[NSFileManager defaultManager] fileExistsAtPath:fileURL.path];
  301. }else{
  302. NSString *pathS = [fileURL.path stringByAppendingPathExtension:@"pdf"];
  303. return [[NSFileManager defaultManager] fileExistsAtPath:pathS];
  304. }
  305. } else if ([extension isCaseInsensitiveEqual:@"dvi"] || [extension isCaseInsensitiveEqual:@"xdv"]) {
  306. isDVI = YES;
  307. }
  308. }
  309. NSFileHandle *fh = [NSFileHandle fileHandleForReadingFromURL:fileURL error:NULL];
  310. // read the last 1024 bytes of the file (or entire file); Adobe's spec says they allow %%EOF anywhere in that range
  311. unsigned long long fileEnd = [fh seekToEndOfFile];
  312. unsigned long long startPos = fileEnd < 1024 ? 0 : fileEnd - 1024;
  313. [fh seekToFileOffset:startPos];
  314. NSData *trailerData = [fh readDataToEndOfFile];
  315. NSRange range = NSMakeRange(0, [trailerData length]);
  316. NSData *pattern = [NSData dataWithBytes:"%%EOF" length:5];
  317. NSDataSearchOptions options = NSDataSearchBackwards;
  318. if (isDVI) {
  319. const char bytes[4] = {0xDF, 0xDF, 0xDF, 0xDF};
  320. pattern = [NSData dataWithBytes:bytes length:4];
  321. options |= NSDataSearchAnchored;
  322. }
  323. return NSNotFound != [trailerData rangeOfData:pattern options:options range:range].location;
  324. }