// ICeCoffEE - Internet Config Cocoa Editor Extension // Nicholas Riley /* To do/think about: - Carbon contextual menu plugin which presents Services (yah!) for both files and text - if it's not a URL, try using TextExtras' open list - TXNClick - MLTE has its own support in Jaguar and later, but it's lousy Done: - TEClick - TextEdit - flash on success (like BBEdit) - display dialog on failure (decode OSStatus) - adjust URL blinking - app exclusion list - make a pref pane (see AquaShade config) - _LSCopyApplicationURLsForItemURL - list apps - Menu on command-option-click: add bookmark, open with other helper, pass to configurable service, ...? */ #import "ICeCoffEE.h" #import #include #import "ICeCoffEESuper.h" #import "ICeCoffEEActionMenu.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; } BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) { unsigned int modifierFlags = [e modifierFlags]; return ([e type] == NSLeftMouseDown && (modifierFlags == NSCommandKeyMask || modifierFlags == (NSCommandKeyMask | NSAlternateKeyMask)) && [e clickCount] == 1); } BOOL ICCF_OptionKeyIsDown() { unsigned int modifierFlags = [[NSApp currentEvent] modifierFlags]; return (modifierFlags & NSAlternateKeyMask) != 0; } 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 punctuationCharacterSet]]; [set removeCharactersInString: @";/?:@&=+$,-_.!~*'()%#"]; // RFC 2396 ¤2.2, 2.3, 2.4, plus # 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(&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); } void ICCF_ParseURL(NSString *string, NSRange *range) { OSStatus err; Handle h; long selStart, selEnd; char *urlData = NULL; NSCAssert(range->length == [string length], @"Internal error: URL string is wrong length"); NS_DURING if ([[NSCharacterSet characterSetWithCharactersInString: @";,."] characterIsMember: [string characterAtIndex: range->length - 1]]) { range->length--; } string = [string substringToIndex: range->length]; ICLog(@"Parsing URL |%@|", string); urlData = (char *)malloc( (range->length + 1) * sizeof(char)); NSCAssert(urlData != NULL, @"Internal error: canŐt allocate memory for URL string"); selStart = 0; selEnd = range->length; [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); } void ICCF_LaunchURL(NSString *string, BOOL chooseApp) { OSStatus err; long selStart, selEnd; unsigned len = [string length]; Handle h = NULL; NS_DURING h = NewHandle(len); if (h == NULL) ICCF_OSErrCAssert(MemError(), @"NewHandle"); if (CFStringGetBytes((CFStringRef)string, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, *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); } if (chooseApp) { err = ICCF_DoURLActionMenu(ICCF_GetInst(), hint, *h, selStart, selEnd); ICCF_OSErrCAssert(err, @"ICCF_DoURLActionMenu"); } else { err = ICLaunchURL(ICCF_GetInst(), hint, *h, len, &selStart, &selEnd); ICCF_OSErrCAssert(err, @"ICLaunchURL"); } NS_HANDLER DisposeHandle(h); [localException raise]; NS_ENDHANDLER DisposeHandle(h); } // 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 multi-megabyte documents #if ICCF_DEBUG const long ICCF_MAX_URL_LEN = 1024; // 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); if (result == NSAlertDefaultReturn) ICCF_enabled = NO; } return YES; } void ICCF_LaunchURLFromTextView(NSTextView *self) { 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]; ICCF_LaunchURL([s substringWithRange: range], ICCF_OptionKeyIsDown()); if (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; NSMenu *servicesMenu; // XXX better to just use [[NSApp servicesMenu] title]? That grabs the title from the existing Services submenu. NSString *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:@""]; [[NSApplication sharedApplication] setServicesMenu: servicesMenu]; [servicesItem setSubmenu: servicesMenu]; [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM]; [servicesMenu release]; return servicesItem; } 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(NSTextView *self, NSMenu *contextMenu, NSEvent *e) { if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) { int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM]; if (servicesItemIndex == -1 && ICCF_prefs.servicesInContextualMenu) { [contextMenu addItem: [NSMenuItem separatorItem]]; [contextMenu addItem: ICCF_ServicesMenuItem()]; } else if (servicesItemIndex != -1 && !ICCF_prefs.servicesInContextualMenu) { [contextMenu removeItemAtIndex: servicesItemIndex]; [contextMenu removeItemAtIndex: servicesItemIndex - 1]; } } return contextMenu; } @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; } } // 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 // we don't actually get a mouseUp event, just wait for mouseDown to return [super mouseDown: e]; if (!ICCF_enabled || !ICCF_prefs.commandClickEnabled) { #if ICCF_DEBUG down = NO; #endif return; } // don't want command-shift-click, etc. to trigger if (ICCF_EventIsCommandMouseDown(e)) { 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) { ICCF_LaunchURLFromTextView(self); } } #if ICCF_DEBUG down = NO; #endif } @end