// ICeCoffEE - Internet Config Carbon/Cocoa Editor Extension // Nicholas Riley #import "ICeCoffEE.h" #import #include #import "ICeCoffEESuper.h" #import "ICeCoffEEServices.h" #import "ICeCoffEETextViewTrigger.h" #import "ICeCoffEEParser.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; } // RFC-ordained max URL length, just to avoid passing IC/LS 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 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); } 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); } 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. Boolean ICCF_enabled = true; BOOL ICCF_HandleException(NSException *e, NSEvent *event) { if ([e reason] == nil || [[e reason] length] == 0) return NO; if (ICCF_prefs.errorSoundEnabled) NSBeep(); if (!ICCF_prefs.errorDialogEnabled) return YES; [[NSApplication sharedApplication] activateIgnoringOtherApps: YES]; [[event window] makeKeyAndOrderFront: nil]; 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) { NSColor *insertionPointColor = [self insertionPointColor]; NS_DURING NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1) unsigned length = [s length]; NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection")); NSCAssert(length != 0, ICCF_LocalizedString(@"No text was found")); ICCF_StartIC(); NSRange range = [self selectedRange]; NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked")); if (range.length == 0) { if (range.location == length) range.location--; range.length = 1; range = ICCF_URLEnclosingRange(s, range); [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO]; } if (ICCF_LaunchURL([s substringWithRange: range], ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) { for (unsigned 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, triggeringEvent); 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 static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) { [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, *itemInfo = nil; BOOL shouldKeepItem = NO, shouldKeepMenu = NO; while ( (menuItem = [enumerator nextObject]) != nil) { if (serviceOptions != nil) itemOptions = [serviceOptions objectForKey: [menuItem title]]; if (serviceInfo != nil) itemInfo = [serviceInfo objectForKey: [menuItem title]]; if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) { shouldKeepItem = NO; } else if ( (submenu = [menuItem submenu]) != nil) { // XXX don't rely on nil-sending working shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo 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]; menuItem = serviceItem; } } else { [menuItem setKeyEquivalent: @""]; shouldKeepItem = [menuItem isEnabled]; } if (!shouldKeepItem) { [menu removeItem: menuItem]; continue; } shouldKeepMenu = YES; if (itemInfo == nil) continue; NSString *bundlePath = (NSString *)[itemInfo objectForKey: (NSString *)kICServiceBundlePath]; if (bundlePath == NULL) continue; IconRef serviceIcon = ICCF_CopyIconRefForPath(bundlePath); if (serviceIcon == NULL) continue; [menuItem _setIconRef: serviceIcon]; ReleaseIconRef(serviceIcon); } return shouldKeepMenu; } NSMenuItem *ICCF_ContextualServicesMenuItem() { NSMenuItem *servicesItem = ICCF_ServicesMenuItem(); NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo)) 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]; } @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 *)downEvent; { #if ICCF_DEBUG static BOOL down = NO; if (down) { ICLog(@"recursive invocation!"); return; } down = YES; ICLog(@"ICeCoffEE down: %@", downEvent); #endif [ICeCoffEETrigger cancel]; if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(downEvent)) { [super mouseDown: ICCF_MouseDownEventWithModifierFlags(downEvent, YES)]; // we don't actually get a mouseUp event, just wait for mouseDown to return NSEvent *upEvent = [[self window] currentEvent]; NSPoint downPt = [downEvent 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) { // make sure we don't have a Command-double-click [ICeCoffEETextViewTrigger setTriggerForEvent: downEvent 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 } } else { [super mouseDown: downEvent]; } #if ICCF_DEBUG down = NO; #endif } @end