// ICeCoffEE - Internet Config Carbon/Cocoa Editor Extension // Nicholas Riley /* To do/think about: - TXNClick - MLTE has its own (lousy) support in Jaguar, seems improved in Panther, good enough to leave? */ #import "ICeCoffEE.h" #import #include #import "ICeCoffEESuper.h" #import "ICeCoffEESetServicesMenu.h" #import "ICeCoffEETrigger.h" iccfPrefRec ICCF_prefs; NSString *ICCF_ErrString(OSStatus err, NSString *context) { if (err == noErr || err == userCanceledErr) return nil; NSString *errNum = [NSString stringWithFormat: @"%ld", err]; NSString *errDesc = ICCF_LocalizedString(errNum); if (errDesc == NULL || errDesc == errNum) errDesc = [NSString stringWithFormat: ICCF_LocalizedString(@"An unknown error occurred in %@"), context]; return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err]; } CFStringRef ICCF_CopyErrString(OSStatus err, CFStringRef context) { if (err == noErr || err == userCanceledErr) return NULL; CFStringRef errNum = CFStringCreateWithFormat(NULL, NULL, CFSTR("%ld"), err); CFStringRef errDesc = ICCF_CopyLocalizedString(errNum); if (errDesc == NULL || errDesc == errNum) { CFStringRef errDescFormat = ICCF_CopyLocalizedString(CFSTR("An unknown error occurred in %@")); if (errDesc != NULL) CFRelease(errDesc); errDesc = CFStringCreateWithFormat(NULL, NULL, errDescFormat, context); } CFStringRef errStr = CFStringCreateWithFormat(NULL, NULL, CFSTR("%@ (%d)"), errDesc, (int)err); if (errNum != NULL) CFRelease(errNum); if (errDesc != NULL) CFRelease(errDesc); return errStr; } CFStringRef ICCF_CopyAppName() { ProcessSerialNumber psn = {0, kCurrentProcess}; CFStringRef appName = NULL; CopyProcessName(&psn, &appName); if (appName == NULL) return CFSTR("(unknown)"); return appName; } BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) { return ([e type] == NSLeftMouseDown && ([e modifierFlags] & NSCommandKeyMask) != 0 && [e clickCount] == 1); } iccfURLAction ICCF_KeyboardAction(NSEvent *e) { unsigned int modifierFlags = [e modifierFlags]; iccfURLAction action; action.presentMenu = (modifierFlags & NSAlternateKeyMask) != 0; action.launchInBackground = (modifierFlags & NSShiftKeyMask) != 0; return action; } void ICCF_CheckRange(NSRange range) { NSCAssert(range.length > 0, ICCF_LocalizedString(@"No URL is selected")); NSCAssert1(range.length <= ICCF_MAX_URL_LEN, ICCF_LocalizedString(@"The potential URL is longer than %lu characters"), ICCF_MAX_URL_LEN); } void ICCF_Delimiters(NSCharacterSet **leftPtr, NSCharacterSet **rightPtr) { static NSCharacterSet *urlLeftDelimiters = nil, *urlRightDelimiters = nil; if (urlLeftDelimiters == nil || urlRightDelimiters == nil) { NSMutableCharacterSet *set = [[NSCharacterSet whitespaceAndNewlineCharacterSet] mutableCopy]; NSMutableCharacterSet *tmpSet; [urlLeftDelimiters release]; [urlRightDelimiters release]; [set autorelease]; [set formUnionWithCharacterSet: [[NSCharacterSet characterSetWithRange: NSMakeRange(0x21, 0x5e)] invertedSet]]; // nonprintable and non-ASCII characters [set formUnionWithCharacterSet: [NSCharacterSet punctuationCharacterSet]]; [set removeCharactersInString: @";/?:@&=+$,-_.!~*'()%#"]; // RFC 2396 ¤2.2, 2.3, 2.4, plus % and # from "delims" set tmpSet = [[set mutableCopy] autorelease]; [tmpSet formUnionWithCharacterSet: [NSCharacterSet characterSetWithCharactersInString: @"><("]]; urlLeftDelimiters = [tmpSet copy]; // make immutable again - for efficiency tmpSet = [[set mutableCopy] autorelease]; [tmpSet formUnionWithCharacterSet: [NSCharacterSet characterSetWithCharactersInString: @"><)"]]; urlRightDelimiters = [tmpSet copy]; // make immutable again - for efficiency } *leftPtr = urlLeftDelimiters; *rightPtr = urlRightDelimiters; } static ICInstance ICCF_icInst = NULL; void ICCF_StartIC() { OSStatus err; if (ICCF_icInst != NULL) { ICLog(@"ICCF_StartIC: Internet Config is already running!"); ICCF_StopIC(); } err = ICStart(&ICCF_icInst, kICCFCreator); NSCAssert1(err == noErr, ICCF_LocalizedString(@"Unable to start Internet Config (error %d)"), err); } void ICCF_StopIC() { if (ICCF_icInst == NULL) { ICLog(@"ICCF_StopIC: Internet Config is not running!"); } else { ICStop(ICCF_icInst); ICCF_icInst = NULL; } } ICInstance ICCF_GetInst() { NSCAssert(ICCF_icInst != NULL, @"Internal error: Called ICCF_GetInst without ICCF_StartIC"); return ICCF_icInst; } ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) { Handle h = NewHandle(0); OSStatus err; if (h == NULL) return NULL; // parse the URL providing a bogus protocol, to get rid of escaped forms err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h); if (err != noErr) return NULL; // scan through the parsed URL looking for characters not found in email addresses Size hSize = GetHandleSize(h); if (hSize == 0) return NULL; const char *urlParsed = *h; long i = 0; Boolean sawAt = false; if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') { // this is an IC-inserted protocol; skip over it i = 2; *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/'); } else *needsSlashes = false; for ( ; i < hSize ; i++) { char c = urlParsed[i]; if (c == '@') { sawAt = true; } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || (c == '+' || c == '-' || c == '_' || c == '!' || c == '.'))) { DisposeHandle(h); return "\phttp"; } } DisposeHandle(h); if (sawAt) { *needsSlashes = false; return "\pmailto"; } return "\phttp"; } static const char *kICSlashes = "//"; void ICCF_AddSlashes(Handle h, ConstStringPtr hint) { Size sizeBefore = GetHandleSize(h); unsigned char hintLength = StrLength(hint); char *copy = (char *)malloc(sizeBefore); memcpy(copy, *h, sizeBefore); ICLog(@"ICCF_AddSlashes before: |%s|\n", *h); ReallocateHandle(h, sizeBefore + 2); // if *h begins with ':', then copy the slashes after it if (sizeBefore > hintLength + 1 && strncmp((const char *)&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') { memcpy(*h, copy, hintLength + 1); memcpy(*h + hintLength + 1, kICSlashes, 2); memcpy(*h + hintLength + 3, ©[hintLength + 1], sizeBefore - hintLength - 1); } else { memcpy(*h, kICSlashes, 2); memcpy(*h + 2, copy, sizeBefore); } free(copy); ICLog(@"ICCF_AddSlashes after: |%s|\n", *h); } // input/output 'range' is the range of source document which contains 'string' void ICCF_ParseURL(NSString *string, NSRange *range) { OSStatus err; Handle h; long selStart = 0, selEnd = range->length; // local offsets within 'string' char *urlData = NULL; NSCAssert(selEnd == [string length], @"Internal error: URL string is wrong length"); NS_DURING if ([[NSCharacterSet characterSetWithCharactersInString: @";,."] characterIsMember: [string characterAtIndex: selEnd - 1]]) { selEnd--; } NSCharacterSet *alphanumericCharacterSet = [NSCharacterSet alphanumericCharacterSet]; while (![alphanumericCharacterSet characterIsMember: [string characterAtIndex: selStart]]) { selStart++; NSCAssert(selStart < selEnd, @"No URL is selected"); } string = [string substringWithRange: NSMakeRange(selStart, selEnd - selStart)]; ICLog(@"Parsing URL |%@|", string); NSCAssert([string canBeConvertedToEncoding: NSASCIIStringEncoding], @"No URL is selected"); urlData = (char *)malloc( (range->length + 1) * sizeof(char)); NSCAssert(urlData != NULL, @"Internal error: can't allocate memory for URL string"); // XXX getCString: is deprecated in 10.4, but this is safe and shouldn't assert because we've already verified the string can be converted to ASCII, which should be a subset of any possible system encoding. The replacement (getCString:maxLength:encoding:) is not available until 10.4, so we leave this until we dump Internet Config and gain IDN friendliness. [string getCString: urlData]; h = NewHandle(0); NSCAssert(h != NULL, @"Internal error: can't allocate URL handle"); err = ICParseURL(ICCF_GetInst(), "\pmailto", urlData, range->length, &selStart, &selEnd, h); DisposeHandle(h); ICCF_OSErrCAssert(err, @"ICParseURL"); range->length = selEnd - selStart; range->location += selStart; NS_HANDLER free(urlData); [localException raise]; NS_ENDHANDLER free(urlData); } BOOL ICCF_LaunchURL(NSString *string, iccfURLAction action) { OSStatus err = noErr; long selStart, selEnd; NSMutableString *urlString = [[NSMutableString alloc] init]; NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet]; NSScanner *scanner = [[NSScanner alloc] initWithString: string]; NSString *fragmentString; while ([scanner scanUpToCharactersFromSet: whitespace intoString: &fragmentString]) { [urlString appendString: fragmentString]; } unsigned len = [urlString length]; Handle h = NULL; NS_DURING h = NewHandle(len); if (h == NULL) ICCF_OSErrCAssert(MemError(), @"NewHandle"); if (CFStringGetBytes((CFStringRef)urlString, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, (UInt8 *)*h, len, NULL) != len) ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes"); selStart = 0; selEnd = len; Boolean needsSlashes; ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes); NSCAssert(hint != NULL, @"Internal error: can't get protocol hint for URL"); if (needsSlashes) { ICCF_AddSlashes(h, hint); len = selEnd = GetHandleSize(h); } err = ICCF_DoURLAction(ICCF_GetInst(), hint, *h, selStart, selEnd, action); ICCF_OSErrCAssert(err, @"ICCF_DoURLAction"); NS_HANDLER DisposeHandle(h); [urlString release]; [localException raise]; NS_ENDHANDLER DisposeHandle(h); [urlString release]; return (err == noErr); } // XXX not sure what to do if there's already a selection; BBEdit and MLTE extend it, Tex-Edit Plus doesn't. // RFC-ordained max URL length, just to avoid passing IC/LS multi-megabyte documents #if ICCF_DEBUG const long ICCF_MAX_URL_LEN = 60; // XXX change later #else const long ICCF_MAX_URL_LEN = 1024; #endif Boolean ICCF_enabled = true; BOOL ICCF_HandleException(NSException *e) { if ([e reason] == nil || [[e reason] length] == 0) return NO; if (ICCF_prefs.errorSoundEnabled) NSBeep(); if (!ICCF_prefs.errorDialogEnabled) return YES; int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e); if (result != NSAlertDefaultReturn) { result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil, [(NSString *)ICCF_CopyAppName() autorelease]); if (result == NSAlertDefaultReturn) ICCF_enabled = NO; } return YES; } void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) { NSCharacterSet *urlLeftDelimiters = nil, *urlRightDelimiters = nil; NSRange range = [self selectedRange], delimiterRange; NSColor *insertionPointColor = [self insertionPointColor]; NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1) unsigned extraLen; int i; NS_DURING NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked")); NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection")); ICCF_StartIC(); NSCAssert([s length] != 0, ICCF_LocalizedString(@"No text was found")); if (range.location == [s length]) range.location--; // work around bug in selectionRangeForProposedRange (r. 2845418) range = [self selectionRangeForProposedRange: range granularity: NSSelectByWord]; // However, NSSelectByWord does not capture even the approximate boundaries of a URL // (text to a space/line ending character); it'll stop at a period in the middle of a hostname. // So, we expand it as follows: ICCF_CheckRange(range); ICCF_Delimiters(&urlLeftDelimiters, &urlRightDelimiters); // XXX instead of 0, make this stop at the max URL length to prevent protracted searches // add 1 to range to trap delimiters that are on the edge of the selection (i.e., <...) delimiterRange = [s rangeOfCharacterFromSet: urlLeftDelimiters options: NSLiteralSearch | NSBackwardsSearch range: NSMakeRange(0, range.location + (range.location != [s length]))]; if (delimiterRange.location == NSNotFound) { // extend to beginning of string range.length += range.location; range.location = 0; } else { NSCAssert(delimiterRange.length == 1, @"Internal error: delimiter matched range is not of length 1"); range.length += range.location - delimiterRange.location - 1; range.location = delimiterRange.location + 1; } ICCF_CheckRange(range); // XXX instead of length of string, make this stop at the max URL length to prevent protracted searches // add 1 to range to trap delimiters that are on the edge of the selection (i.e., ...>) extraLen = [s length] - range.location - range.length; delimiterRange = [s rangeOfCharacterFromSet: urlRightDelimiters options: NSLiteralSearch range: NSMakeRange(range.location + range.length - (range.length != 0), extraLen + (range.length != 0))]; if (delimiterRange.location == NSNotFound) { // extend to end of string range.length += extraLen; } else { NSCAssert(delimiterRange.length == 1, @"Internal error: delimiter matched range is not of length 1"); range.length += delimiterRange.location - range.location - range.length; } ICCF_CheckRange(range); ICCF_ParseURL([s substringWithRange: range], &range); [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO]; [self display]; if (ICCF_LaunchURL([s substringWithRange: range], ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) { for (i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) { NSRange emptyRange = {range.location, 0}; [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES]; [self display]; usleep(kICBlinkDelayUsecs); [self setInsertionPointColor: [self backgroundColor]]; [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES]; [self display]; usleep(kICBlinkDelayUsecs); } } NS_HANDLER ICCF_HandleException(localException); NS_ENDHANDLER ICCF_StopIC(); [self setInsertionPointColor: insertionPointColor]; } NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item"; NSMenuItem *ICCF_ServicesMenuItem() { NSMenuItem *servicesItem; NSString *servicesTitle = nil; NSMenu *servicesMenu = [NSApp servicesMenu]; if (servicesMenu != nil) { servicesTitle = [servicesMenu title]; if (servicesTitle == nil) { ICLog(@"Can't get service menu title"); servicesTitle = @"Services"; } } else { servicesTitle = [[NSBundle bundleWithIdentifier: @"com.apple.AppKit"] localizedStringForKey: @"Services" value: nil table: @"ServicesMenu"]; if (servicesTitle == nil) { ICLog(@"Can't get localized text for 'Services' in AppKit.framework"); servicesTitle = @"Services"; } } servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle]; servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""]; ICCF_SetServicesMenu(servicesMenu); [servicesItem setSubmenu: servicesMenu]; [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM]; [servicesMenu release]; return [servicesItem autorelease]; } static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8; // returns YES if menu contains useful items, NO otherwise BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions) { [menu update]; // doesn't propagate to submenus, so we need to do this first NSEnumerator *enumerator = [[menu itemArray] objectEnumerator]; NSMenuItem *menuItem; NSMenu *submenu; NSDictionary *itemOptions = nil; BOOL shouldKeepItem = NO, shouldKeepMenu = NO; while ( (menuItem = [enumerator nextObject]) != nil) { if (serviceOptions != nil) itemOptions = [serviceOptions objectForKey: [menuItem title]]; if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) { shouldKeepItem = NO; } else if ( (submenu = [menuItem submenu]) != nil) { shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu]); if (shouldKeepItem && [submenu numberOfItems] == 1) { // consolidate NSMenuItem *serviceItem = [[submenu itemAtIndex: 0] retain]; [serviceItem setTitle: [NSString stringWithFormat: @"%@ %@ %@", [menuItem title], [NSString stringWithCharacters: &UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE length: 1], [serviceItem title]]]; int serviceIndex = [menu indexOfItem: menuItem]; [submenu removeItemAtIndex: 0]; // can't have item in two menus [menu removeItemAtIndex: serviceIndex]; [menu insertItem: serviceItem atIndex: serviceIndex]; [serviceItem release]; } } else { [menuItem setKeyEquivalent: @""]; shouldKeepItem = [menuItem isEnabled]; } if (shouldKeepItem) { shouldKeepMenu = YES; } else { [menu removeItem: menuItem]; } } return shouldKeepMenu; } NSMenuItem *ICCF_ContextualServicesMenuItem() { NSMenuItem *servicesItem = ICCF_ServicesMenuItem(); if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions)) return servicesItem; else return nil; } void ICCF_AddRemoveServicesMenu() { // needed because: // (a) we get called before the runloop has properly started and will crash if we don't delay on app startup // (b) the APE message handler calls us from another thread and nothing happens if we try to add a menu on it [ICeCoffEE performSelectorOnMainThread: @selector(IC_addRemoveServicesMenu) withObject: nil waitUntilDone: NO]; } NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) { if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) { int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM]; // always regenerate: make sure menu reflects context if (servicesItemIndex != -1) { [contextMenu removeItemAtIndex: servicesItemIndex]; [contextMenu removeItemAtIndex: servicesItemIndex - 1]; } if (ICCF_prefs.servicesInContextualMenu) { NSMenuItem *contextualServicesItem = ICCF_ContextualServicesMenuItem(); if (contextualServicesItem != nil) { [contextMenu addItem: [NSMenuItem separatorItem]]; [contextMenu addItem: contextualServicesItem]; } } } return contextMenu; } static NSEvent *ICCF_MouseDownEventWithModifierFlags(NSEvent *e, BOOL inheritModifierFlags) { return [NSEvent mouseEventWithType: NSLeftMouseDown location: [e locationInWindow] modifierFlags: (inheritModifierFlags ? [e modifierFlags] : 0) timestamp: [e timestamp] windowNumber: [e windowNumber] context: [e context] eventNumber: [e eventNumber] clickCount: 1 pressure: 0]; } @interface NSTextView (IC_NSSharing) // only in Mac OS X 10.4 and later - (NSArray *)selectedRanges; @end @implementation ICeCoffEE + (void)IC_addRemoveServicesMenu; { NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu]; static NSMenuItem *servicesItem = nil; if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) { servicesItem = [ICCF_ServicesMenuItem() retain]; int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]]; if (insertLoc == -1) insertLoc = [mainMenu numberOfItems]; [mainMenu insertItem: servicesItem atIndex: insertLoc]; } else if (servicesItem != nil && !ICCF_prefs.servicesInMenuBar) { [mainMenu removeItem: servicesItem]; [servicesItem release]; servicesItem = nil; } if (floor(NSAppKitVersionNumber) <= NSAppKitVersionNumber10_3) { [[NSApp servicesMenu] update]; // enable keyboard equivalents in Mac OS X 10.3 } } // XXX localization? - (NSMenu *)menuForEvent:(NSEvent *)e; { NSMenu *myMenu = [super menuForEvent: e]; return ICCF_MenuForEvent(self, myMenu, e); } - (void)mouseDown:(NSEvent *)e; { #if ICCF_DEBUG static BOOL down = NO; if (down) { ICLog(@"recursive invocation!"); return; } down = YES; ICLog(@"ICeCoffEE down: %@", e); #endif if (ICCF_sharedTrigger != nil) { ICLog(@"%@ cancelling", ICCF_sharedTrigger); [ICCF_sharedTrigger cancel]; } if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(e)) { BOOL inheritModifierFlags; if ([self respondsToSelector: @selector(selectedRanges)]) { // Command-multiple-click or -drag for discontiguous selection, Mac OS X 10.4 or later inheritModifierFlags = YES; } else { // don't want to trigger selection extension or anything else; pass through as a plain click // (on Mac OS X 10.3, command does not modify behavior) inheritModifierFlags = NO; } [super mouseDown: ICCF_MouseDownEventWithModifierFlags(e, inheritModifierFlags)]; // we don't actually get a mouseUp event, just wait for mouseDown to return NSEvent *upEvent = [[self window] currentEvent]; NSPoint downPt = [e locationInWindow]; NSPoint upPt = [upEvent locationInWindow]; ICLog(@"next: %@", upEvent); NSAssert([upEvent type] == NSLeftMouseUp, @"NSTextView mouseDown: did not return with current event as mouse up!"); if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) { if (inheritModifierFlags) { // Mac OS X 10.4 and later: make sure we don't have a command-double-click [ICeCoffEETrigger setTriggerForEvent: e onTarget: self]; // gets stored in ICCF_sharedTrigger; the reason for this weird calling pattern is that we don't want to add methods to NSTextView, and we don't want to add a method call on every mouseDown ICLog(@"%@ set", ICCF_sharedTrigger); } else { // Mac OS X 10.3 ICCF_LaunchURLFromTextView(self, e); } } } else { [super mouseDown: e]; } #if ICCF_DEBUG down = NO; #endif } @end