source: trunk/ICeCoffEE/ICeCoffEE/ICeCoffEE.m @ 88

Last change on this file since 88 was 88, checked in by Nicholas Riley, 19 years ago

ICeCoffEE 1.3.1d1

File size: 18.7 KB
Line 
1// ICeCoffEE - Internet Config Cocoa Editor Extension
2// Nicholas Riley <mailto:icecoffee@sabi.net>
3
4/* To do/think about:
5
6- TXNClick - MLTE has its own support in Jaguar and later, but it's lousy
7
8Done:
9
10- TEClick - TextEdit
11- flash on success (like BBEdit)
12- display dialog on failure (decode OSStatus)
13- adjust URL blinking
14- app exclusion list - make a pref pane (see AquaShade config)
15- _LSCopyApplicationURLsForItemURL - list apps
16- Menu on command-option-click: add bookmark, open with other helper, pass to configurable service, ...?
17
18*/
19
20#import "ICeCoffEE.h"
21#import <Carbon/Carbon.h>
22#include <unistd.h>
23#import "ICeCoffEESuper.h"
24#import "ICeCoffEEActionMenu.h"
25
26iccfPrefRec ICCF_prefs;
27
28NSString *ICCF_ErrString(OSStatus err, NSString *context) {   
29    if (err == noErr || err == userCanceledErr) return nil;
30
31    NSString *errNum = [NSString stringWithFormat: @"%ld", err];
32    NSString *errDesc = ICCF_LocalizedString(errNum);
33
34    if (errDesc == NULL || errDesc == errNum)
35        errDesc = [NSString stringWithFormat: ICCF_LocalizedString(@"An unknown error occurred in %@"), context];
36
37    return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err];
38}
39
40CFStringRef ICCF_CopyErrString(OSStatus err, CFStringRef context) {
41    if (err == noErr || err == userCanceledErr) return NULL;
42
43    CFStringRef errNum = CFStringCreateWithFormat(NULL, NULL, CFSTR("%ld"), err);
44    CFStringRef errDesc = ICCF_CopyLocalizedString(errNum);
45
46    if (errDesc == NULL || errDesc == errNum) {
47        CFStringRef errDescFormat = ICCF_CopyLocalizedString(CFSTR("An unknown error occurred in %@"));
48        if (errDesc != NULL) CFRelease(errDesc);
49        errDesc = CFStringCreateWithFormat(NULL, NULL, errDescFormat, context);
50    }
51
52    CFStringRef errStr = CFStringCreateWithFormat(NULL, NULL, CFSTR("%@ (%d)"), errDesc, (int)err);
53
54    if (errNum != NULL) CFRelease(errNum);
55    if (errDesc != NULL) CFRelease(errDesc);
56    return errStr;
57}
58
59BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) {
60    unsigned int modifierFlags = [e modifierFlags];
61    return ([e type] == NSLeftMouseDown &&
62            (modifierFlags == NSCommandKeyMask || modifierFlags == (NSCommandKeyMask | NSAlternateKeyMask))
63            && [e clickCount] == 1);
64}
65
66BOOL ICCF_OptionKeyIsDown() {
67    unsigned int modifierFlags = [[NSApp currentEvent] modifierFlags];
68    return (modifierFlags & NSAlternateKeyMask) != 0;
69}
70
71void ICCF_CheckRange(NSRange range) {
72    NSCAssert(range.length > 0, ICCF_LocalizedString(@"No URL is selected"));
73    NSCAssert1(range.length <= ICCF_MAX_URL_LEN, ICCF_LocalizedString(@"The potential URL is longer than %lu characters"), ICCF_MAX_URL_LEN);
74}
75
76void ICCF_Delimiters(NSCharacterSet **leftPtr, NSCharacterSet **rightPtr) {
77    static NSCharacterSet *urlLeftDelimiters = nil, *urlRightDelimiters = nil;
78
79    if (urlLeftDelimiters == nil || urlRightDelimiters == nil) {
80        NSMutableCharacterSet *set = [[NSCharacterSet whitespaceAndNewlineCharacterSet] mutableCopy];
81        NSMutableCharacterSet *tmpSet;
82        [urlLeftDelimiters release];
83        [urlRightDelimiters release];
84
85        [set autorelease];
86        [set formUnionWithCharacterSet: [NSCharacterSet punctuationCharacterSet]];
87        [set removeCharactersInString: @";/?:@&=+$,-_.!~*'()%#"]; // RFC 2396 ¤2.2, 2.3, 2.4, plus #
88
89        tmpSet = [[set mutableCopy] autorelease];
90        [tmpSet formUnionWithCharacterSet: [NSCharacterSet characterSetWithCharactersInString: @"><("]];
91        urlLeftDelimiters = [tmpSet copy]; // make immutable again - for efficiency
92
93        tmpSet = [[set mutableCopy] autorelease];
94        [tmpSet formUnionWithCharacterSet: [NSCharacterSet characterSetWithCharactersInString: @"><)"]];
95        urlRightDelimiters = [tmpSet copy]; // make immutable again - for efficiency
96    }
97
98    *leftPtr = urlLeftDelimiters; *rightPtr = urlRightDelimiters;
99}
100
101static ICInstance ICCF_icInst = NULL;
102
103void ICCF_StartIC() {
104    OSStatus err;
105   
106    if (ICCF_icInst != NULL) {
107        ICLog(@"ICCF_StartIC: Internet Config is already running!");
108        ICCF_StopIC();
109    }
110    err = ICStart(&ICCF_icInst, kICCFCreator);
111    NSCAssert1(err == noErr, ICCF_LocalizedString(@"Unable to start Internet Config (error %d)"), err);
112}
113
114void ICCF_StopIC() {
115    if (ICCF_icInst == NULL) {
116        ICLog(@"ICCF_StopIC: Internet Config is not running!");
117    } else {
118        ICStop(ICCF_icInst);
119        ICCF_icInst = NULL;
120    }
121}
122
123ICInstance ICCF_GetInst() {
124    NSCAssert(ICCF_icInst != NULL, @"Internal error: Called ICCF_GetInst without ICCF_StartIC");
125    return ICCF_icInst;
126}
127
128ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) {
129    Handle h = NewHandle(0);
130    OSStatus err;
131
132    if (h == NULL) return NULL;
133
134    // parse the URL providing a bogus protocol, to get rid of escaped forms
135    err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h);
136    if (err != noErr) return NULL;
137
138    // scan through the parsed URL looking for characters not found in email addresses
139    Size hSize = GetHandleSize(h);
140    if (hSize == 0) return NULL;
141
142    const char *urlParsed = *h;
143    long i = 0;
144    Boolean sawAt = false;
145    if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') {
146        // this is an IC-inserted protocol; skip over it
147        i = 2;
148        *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/');
149    } else *needsSlashes = false;
150    for ( ; i < hSize ; i++) {
151        char c = urlParsed[i];
152        if (c == '@') {
153            sawAt = true;
154        } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
155                     (c == '+' || c == '-' || c == '_' || c == '!' || c == '.'))) {
156            DisposeHandle(h);
157            return "\phttp";
158        }
159    }
160    DisposeHandle(h);
161    if (sawAt) {
162        *needsSlashes = false;
163        return "\pmailto";
164    }
165    return "\phttp";
166}
167
168static const char *kICSlashes = "//";
169
170void ICCF_AddSlashes(Handle h, ConstStringPtr hint) {
171    Size sizeBefore = GetHandleSize(h);
172    unsigned char hintLength = StrLength(hint);
173    char *copy = (char *)malloc(sizeBefore);
174    memcpy(copy, *h, sizeBefore);
175    ICLog(@"ICCF_AddSlashes before: |%s|\n", *h);
176    ReallocateHandle(h, sizeBefore + 2);
177
178    // if *h begins with '<hint>:', then copy the slashes after it
179    if (sizeBefore > hintLength + 1 && strncmp(&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') {
180        memcpy(*h, copy, hintLength + 1);
181        memcpy(*h + hintLength + 1, kICSlashes, 2);
182        memcpy(*h + hintLength + 3, &copy[hintLength + 1], sizeBefore - hintLength - 1);
183    } else {
184        memcpy(*h, kICSlashes, 2);
185        memcpy(*h + 2, copy, sizeBefore);
186    }
187       
188    free(copy);
189    ICLog(@"ICCF_AddSlashes after: |%s|\n", *h);
190}
191
192void ICCF_ParseURL(NSString *string, NSRange *range) {
193    OSStatus err;
194    Handle h;
195    long selStart, selEnd;
196    char *urlData = NULL;
197
198    NSCAssert(range->length == [string length], @"Internal error: URL string is wrong length");
199   
200    NS_DURING
201        if ([[NSCharacterSet characterSetWithCharactersInString: @";,."] characterIsMember:
202            [string characterAtIndex: range->length - 1]]) {
203            range->length--;
204        }
205
206        string = [string substringToIndex: range->length];
207
208        ICLog(@"Parsing URL |%@|", string);
209
210        urlData = (char *)malloc( (range->length + 1) * sizeof(char));
211        NSCAssert(urlData != NULL, @"Internal error: canÕt allocate memory for URL string");
212
213        selStart = 0; selEnd = range->length;
214
215        [string getCString: urlData];
216
217        h = NewHandle(0);
218        NSCAssert(h != NULL, @"Internal error: canÕt allocate URL handle");
219
220        err = ICParseURL(ICCF_GetInst(), "\pmailto", urlData, range->length, &selStart, &selEnd, h);
221        DisposeHandle(h);
222
223        ICCF_OSErrCAssert(err, @"ICParseURL");
224   
225        range->length = selEnd - selStart;
226        range->location += selStart;
227    NS_HANDLER
228        free(urlData);
229        [localException raise];
230    NS_ENDHANDLER
231   
232    free(urlData);
233}
234
235void ICCF_LaunchURL(NSString *string, BOOL chooseApp) {
236    OSStatus err;
237    long selStart, selEnd;
238    unsigned len = [string length];
239
240    Handle h = NULL;
241   
242    NS_DURING
243        h = NewHandle(len);
244        if (h == NULL)
245            ICCF_OSErrCAssert(MemError(), @"NewHandle");
246
247        if (CFStringGetBytes((CFStringRef)string, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, *h, len, NULL) != len)
248            ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes");
249
250        selStart = 0; selEnd = len;
251
252        Boolean needsSlashes;
253        ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes);
254        NSCAssert(hint != NULL, @"Internal error: canÕt get protocol hint for URL");
255
256        if (needsSlashes) {
257            ICCF_AddSlashes(h, hint);
258            len = selEnd = GetHandleSize(h);
259        }
260
261        if (chooseApp) {
262            err = ICCF_DoURLActionMenu(ICCF_GetInst(), hint, *h, selStart, selEnd);
263            ICCF_OSErrCAssert(err, @"ICCF_DoURLActionMenu");
264        } else {
265            err = ICLaunchURL(ICCF_GetInst(), hint, *h, len, &selStart, &selEnd);
266            ICCF_OSErrCAssert(err, @"ICLaunchURL");
267        }
268       
269    NS_HANDLER
270        DisposeHandle(h);
271        [localException raise];
272    NS_ENDHANDLER
273
274    DisposeHandle(h);   
275}
276
277// XXX not sure what to do if there's already a selection; BBEdit and MLTE extend it, Tex-Edit Plus doesn't.
278// RFC-ordained max URL length, just to avoid passing IC multi-megabyte documents
279#if ICCF_DEBUG
280const long ICCF_MAX_URL_LEN = 1024; // XXX change later
281#else
282const long ICCF_MAX_URL_LEN = 1024;
283#endif
284
285Boolean ICCF_enabled = true;
286
287BOOL ICCF_HandleException(NSException *e) {
288    if ([e reason] == nil || [[e reason] length] == 0)
289        return NO;
290   
291    if (ICCF_prefs.errorSoundEnabled) NSBeep();
292    if (!ICCF_prefs.errorDialogEnabled) return YES;
293   
294    int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
295    if (result != NSAlertDefaultReturn) {
296        result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil);
297        if (result == NSAlertDefaultReturn)
298            ICCF_enabled = NO;
299    }
300    return YES;
301}
302
303void ICCF_LaunchURLFromTextView(NSTextView *self) {
304    NSCharacterSet *urlLeftDelimiters = nil, *urlRightDelimiters = nil;
305    NSRange range = [self selectedRange], delimiterRange;
306    NSColor *insertionPointColor = [self insertionPointColor];
307    NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
308    unsigned extraLen;
309    int i;
310
311    NS_DURING
312
313        NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked"));
314        NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
315
316        ICCF_StartIC();
317
318        NSCAssert([s length] != 0, ICCF_LocalizedString(@"No text was found"));
319
320        if (range.location == [s length]) range.location--; // work around bug in selectionRangeForProposedRange (r. 2845418)
321
322        range = [self selectionRangeForProposedRange: range granularity: NSSelectByWord];
323
324        // However, NSSelectByWord does not capture even the approximate boundaries of a URL
325        // (text to a space/line ending character); it'll stop at a period in the middle of a hostname.
326        // So, we expand it as follows:
327
328        ICCF_CheckRange(range);
329
330        ICCF_Delimiters(&urlLeftDelimiters, &urlRightDelimiters);
331
332        // XXX instead of 0, make this stop at the max URL length to prevent protracted searches
333        // add 1 to range to trap delimiters that are on the edge of the selection (i.e., <...)
334        delimiterRange = [s rangeOfCharacterFromSet: urlLeftDelimiters
335                                            options: NSLiteralSearch | NSBackwardsSearch
336                                              range: NSMakeRange(0, range.location + (range.location != [s length]))];
337        if (delimiterRange.location == NSNotFound) {
338            // extend to beginning of string
339            range.length += range.location;
340            range.location = 0;
341        } else {
342            NSCAssert(delimiterRange.length == 1, @"Internal error: delimiter matched range is not of length 1");
343            range.length += range.location - delimiterRange.location - 1;
344            range.location = delimiterRange.location + 1;
345        }
346
347        ICCF_CheckRange(range);
348
349        // XXX instead of length of string, make this stop at the max URL length to prevent protracted searches
350        // add 1 to range to trap delimiters that are on the edge of the selection (i.e., ...>)
351        extraLen = [s length] - range.location - range.length;
352        delimiterRange = [s rangeOfCharacterFromSet: urlRightDelimiters
353                                            options: NSLiteralSearch
354                                              range: NSMakeRange(range.location + range.length - (range.length != 0),
355                                                                 extraLen + (range.length != 0))];
356        if (delimiterRange.location == NSNotFound) {
357            // extend to end of string
358            range.length += extraLen;
359        } else {
360            NSCAssert(delimiterRange.length == 1, @"Internal error: delimiter matched range is not of length 1");
361            range.length += delimiterRange.location - range.location - range.length;
362        }
363
364        ICCF_CheckRange(range);
365
366        ICCF_ParseURL([s substringWithRange: range], &range);
367
368        [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
369        [self display];
370
371        ICCF_LaunchURL([s substringWithRange: range], ICCF_OptionKeyIsDown());
372
373        if (ICCF_prefs.textBlinkEnabled) {
374            for (i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
375                NSRange emptyRange = {range.location, 0};
376                [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
377                [self display];
378                usleep(kICBlinkDelayUsecs);
379                [self setInsertionPointColor: [self backgroundColor]];
380                [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
381                [self display];
382                usleep(kICBlinkDelayUsecs);
383            }
384        }
385
386    NS_HANDLER
387        ICCF_HandleException(localException);
388    NS_ENDHANDLER
389
390    ICCF_StopIC();
391    [self setInsertionPointColor: insertionPointColor];
392}
393
394NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item";
395
396NSMenuItem *ICCF_ServicesMenuItem() {
397    NSMenuItem *servicesItem;
398    NSMenu *servicesMenu;
399    // XXX better to just use [[NSApp servicesMenu] title]?  That grabs the title from the existing Services submenu.
400    NSString *servicesTitle = [[NSBundle bundleWithIdentifier: @"com.apple.AppKit"] localizedStringForKey: @"Services" value: nil table: @"ServicesMenu"];
401    if (servicesTitle == nil) {
402        ICLog(@"CanÕt get localized text for ÒServicesÓ in AppKit.framework");
403        servicesTitle = @"Services";
404    }
405    servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
406    servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
407    [[NSApplication sharedApplication] setServicesMenu: servicesMenu];
408    [servicesItem setSubmenu: servicesMenu];
409    [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
410    [servicesMenu release];
411    return servicesItem;
412}
413
414void ICCF_AddRemoveServicesMenu() {
415    // needed because:
416    // (a) we get called before the runloop has properly started and will crash if we don't delay on app startup
417    // (b) the APE message handler calls us from another thread and nothing happens if we try to add a menu on it
418    [ICeCoffEE performSelectorOnMainThread: @selector(IC_addRemoveServicesMenu) withObject: nil waitUntilDone: NO];
419}
420
421NSMenu *ICCF_MenuForEvent(NSTextView *self, NSMenu *contextMenu, NSEvent *e) {   
422    if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
423        int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
424        if (servicesItemIndex == -1 && ICCF_prefs.servicesInContextualMenu) {
425            [contextMenu addItem: [NSMenuItem separatorItem]];
426            [contextMenu addItem: ICCF_ServicesMenuItem()];
427        } else if (servicesItemIndex != -1 && !ICCF_prefs.servicesInContextualMenu) {
428            [contextMenu removeItemAtIndex: servicesItemIndex];
429            [contextMenu removeItemAtIndex: servicesItemIndex - 1];
430        }
431    }
432    return contextMenu;
433}
434
435@implementation ICeCoffEE
436
437+ (void)IC_addRemoveServicesMenu;
438{
439    NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
440    static NSMenuItem *servicesItem = nil;
441   
442    if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
443        servicesItem = [ICCF_ServicesMenuItem() retain];
444
445        int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
446        if (insertLoc == -1)
447            insertLoc = [mainMenu numberOfItems];
448
449        [mainMenu insertItem: servicesItem atIndex: insertLoc];
450    } else if (servicesItem != nil && !ICCF_prefs.servicesInMenuBar) {
451        [mainMenu removeItem: servicesItem];
452        [servicesItem release];
453        servicesItem = nil;
454    }
455}
456
457// XXX localization?
458- (NSMenu *)menuForEvent:(NSEvent *)e;
459{
460    NSMenu *myMenu = [super menuForEvent: e];
461    return ICCF_MenuForEvent(self, myMenu, e);
462}
463
464- (void)mouseDown:(NSEvent *)e;
465{
466#if ICCF_DEBUG
467    static BOOL down = NO;
468    if (down) {
469        ICLog(@"recursive invocation!");
470        return;
471    }
472    down = YES;
473    ICLog(@"ICeCoffEE down: %@", e);
474#endif
475    // we don't actually get a mouseUp event, just wait for mouseDown to return
476    [super mouseDown: e];
477    if (!ICCF_enabled || !ICCF_prefs.commandClickEnabled) {
478#if ICCF_DEBUG
479        down = NO;
480#endif
481        return;
482    }
483    // don't want command-shift-click, etc. to trigger
484    if (ICCF_EventIsCommandMouseDown(e)) {
485        NSEvent *upEvent = [[self window] currentEvent];
486        NSPoint downPt = [e locationInWindow];
487        NSPoint upPt = [upEvent locationInWindow];
488        ICLog(@"next: %@", upEvent);
489        NSAssert([upEvent type] == NSLeftMouseUp, @"NSTextView mouseDown: did not return with current event as mouse up!");
490        if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
491            ICCF_LaunchURLFromTextView(self);
492        }
493    }
494#if ICCF_DEBUG
495    down = NO;
496#endif
497}
498
499@end
Note: See TracBrowser for help on using the repository browser.