// 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 - Carbon version - app exclusion list - make a pref pane (see AquaShade config) - if it's not a URL, try using TextExtras' open list - John Hayes' suggestions - ICeCoffEE 2 functionality (bookmark helper app for cmd-option-click) - adjust URL blinking Carbon support ideas: TEClick - TextEdit TXNClick - MLTE ? ATSUI WASTE has its own support Done: - flash on success (like BBEdit) - display dialog on failure (decode OSStatus) */ #import "ICeCoffEE.h" #import #include #import "ICeCoffEESuper.h" @implementation ICeCoffEE typedef struct { OSStatus status; NSString * const desc; } errRec, errList[]; static errList ERRS = { // Internet Config errors { icPrefNotFoundErr, @"No helper application is defined for the selected URLŐs scheme (e.g. http:)" }, { icNoURLErr, @"The selection is not a URL" }, { icInternalErr, @"Internet Config experienced an internal error" }, // Misc. errors { paramErr, @"The selection is not a complete URL" }, { 0, NULL } }; NSString *ICCF_ErrString(OSStatus err, NSString *context) { errRec *rec; NSString *errDesc = [NSString stringWithFormat: @"An unknown error occurred in %@", context]; if (err == noErr) return nil; for (rec = &(ERRS[0]) ; rec->status != 0 ; rec++) if (rec->status == err) { errDesc = rec->desc; break; } return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err]; } BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) { return ([e type] == NSLeftMouseDown && [e modifierFlags] == NSCommandKeyMask && [e clickCount] == 1); } void ICCF_CheckRange(NSRange range) { NSCAssert(range.length > 0, @"No URL is selected"); NSCAssert1(range.length <= ICCF_MAX_URL_LEN, @"The potential URL is longer than %ld 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, 'ICCF'); NSCAssert1(err == noErr, @"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; } 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); ICCF_OSErrCAssert(err, @"ICParseURL"); DisposeHandle(h); range->length = range->length - (range->length - selEnd) + selStart; range->location += selStart; NS_HANDLER free(urlData); [localException raise]; NS_ENDHANDLER free(urlData); } void ICCF_LaunchURL(NSString *string) { OSStatus err; long selStart, selEnd; unsigned len = [string length]; char *urlData = NULL; NS_DURING urlData = (char *)malloc( (len + 1) * sizeof(char)); NSCAssert(urlData != NULL, @"Internal error: can't allocate memory for URL string"); [string getCString: urlData]; selStart = 0; selEnd = len; err = ICLaunchURL(ICCF_GetInst(), "\pmailto", urlData, len, &selStart, &selEnd); ICCF_OSErrCAssert(err, @"ICLaunchURL"); NS_HANDLER free(urlData); [localException raise]; NS_ENDHANDLER free(urlData); } // XXX not sure what to do if there's already a selection; BBEdit extends 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 BOOL ICCF_enabled = YES; void ICCF_HandleException(NSException *e) { int result = NSRunAlertPanel(@"Open Internet Location", @"The selected Internet location could not be opened.\n\n%@.", @"OK", nil, @"Disable ICeCoffEEÉ", e); if (result != NSAlertDefaultReturn) { result = NSRunAlertPanel(@"Disable ICeCoffEE", @"If you believe ICeCoffEE is interfering with the normal functioning of this application, you can turn it off in this application until the application has quit.\n\nIf this is the first time you are experiencing this problem, please email icecoffee@sabi.net with the details of the conflict.\n\nTo remove ICeCoffEE permanently, drag its icon to the Trash or use the ICeCoffEE Installer.", @"Disable", @"DonŐt Disable", nil); if (result == NSAlertDefaultReturn) ICCF_enabled = NO; } } 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, @"There is no insertion point or selection in the text field you clicked"); NSCAssert(s != nil, @"Sorry, ICeCoffEE is unable to locate the insertion point or selection"); ICCF_StartIC(); NSCAssert([s length] != 0, @"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); for (i = 0 ; i < 3 ; i++) { NSRange emptyRange = {range.location, 0}; [self setInsertionPointColor: [self backgroundColor]]; [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES]; [self display]; usleep(60000); [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES]; [self display]; usleep(60000); } [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO]; [self display]; ICCF_LaunchURL([s substringWithRange: range]); 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_AddServicesMenu() { [ICeCoffEE performSelector: @selector(IC_addServicesMenu) withObject: nil afterDelay: 0.0]; } 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) { [contextMenu addItem: [NSMenuItem separatorItem]]; [contextMenu addItem: ICCF_ServicesMenuItem()]; } } return contextMenu; } + (NSString *)IC_version; { // XXX get from bundle if possible: centralize return [NSString stringWithCString: ICCF_VERSION]; } + (void)IC_addServicesMenu; { NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu]; int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]]; if (insertLoc == -1) insertLoc = [mainMenu numberOfItems]; [mainMenu insertItem: ICCF_ServicesMenuItem() atIndex: insertLoc]; } // 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); NSLog(@"super is %@", self); #endif // we don't actually get a mouseUp event, just wait for mouseDown to return [super mouseDown: e]; if (!ICCF_enabled) return; // don't want command-option-click, 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) <= 4 && abs(downPt.y - upPt.y) <= 4) { ICCF_LaunchURLFromTextView(self); } } #if ICCF_DEBUG down = NO; #endif } @end