Refactored GrowlMail to have a separate singleton notifier object, in order to make the mail-bundle class less dependent on being a singleton. (Assumptions are bad, especially when you're working with private/undocumented APIs.)
5 // Created by Peter Hosey on 2009-05-10.
6 // Copyright 2009 Peter Hosey. All rights reserved.
9 #import "GrowlMailNotifier.h"
11 #import "Message+GrowlMail.h"
12 #import <objc/objc-runtime.h>
14 #import "MessageFrameworkHeaders.h"
16 #define AUTO_THRESHOLD 10
18 #define MAX_NOTIFICATION_THREADS 5
20 static int activeNotificationThreads = 0;
22 static int messageCopies = 0;
24 static GrowlMailNotifier *sharedNotifier = nil;
26 static BOOL notifierEnabled = YES;
28 @implementation GrowlMailNotifier
30 #pragma mark Panic buttons
32 //The purpose of this method is to shut down GrowlMail completely: we should not be notified of any messages, nor notify the user of any messages, after this message is called.
33 - (void) shutDownGrowlMail {
34 [[NSNotificationCenter defaultCenter] removeObserver:self];
35 [GrowlApplicationBridge setGrowlDelegate:nil];
37 //Prevent ourselves from re-enabling later.
41 //This is a suicide pill. GrowlMail sends itself this message any time it detects a change in Mail's implementation, such as a missing method or an object of the wrong class.
42 - (void) shutDownGrowlMailAndWarn:(NSString *)specificWarning {
43 NSLog(NSLocalizedString(@"WARNING: Mail is not behaving in the way that GrowlMail expects. This is probably because GrowlMail is incompatible with the version of Mail you're using. GrowlMail will now turn itself off. Please check the Growl website for a new version. If you're a programmer and want to debug this error, run gdb, load Mail, set a breakpoint on %s, and run.", /*comment*/ nil), __PRETTY_FUNCTION__);
45 NSLog(@"Furthermore, the caller provided a more specific message: %@", specificWarning);
47 [self shutDownGrowlMail];
50 #pragma mark The circle of life
52 + (id) sharedNotifier {
53 if (!sharedNotifier) {
54 //-init and -dealloc will each assign to sharedNotifier.
55 [[[GrowlMailNotifier alloc] init] autorelease];
57 return sharedNotifier;
63 return [sharedNotifier retain];
66 //No shared notifier yet; someone is trying to create one. If we previously disabled ourselves, abort this attempt.
67 if (!notifierEnabled) {
72 if((self = [super init])) {
73 NSNumber *automatic = [NSNumber numberWithInt:GrowlMailSummaryModeAutomatic];
74 NSDictionary *defaultsDictionary = [NSDictionary dictionaryWithObjectsAndKeys:
75 @"(%account) %sender", @"GMTitleFormat",
76 @"%subject\n%body", @"GMDescriptionFormat",
77 automatic, @"GMSummaryMode",
78 [NSNumber numberWithBool:YES], @"GMEnableGrowlMailBundle",
79 [NSNumber numberWithBool:NO], @"GMInboxOnly",
81 [[NSUserDefaults standardUserDefaults] registerDefaults:defaultsDictionary];
83 [GrowlApplicationBridge setGrowlDelegate:self];
85 [[NSNotificationCenter defaultCenter] addObserver:self
86 selector:@selector(messageStoreDidAddMessages:)
87 name:@"MessageStoreMessagesAdded_inMainThread_"
89 [[NSNotificationCenter defaultCenter] addObserver:self
90 selector:@selector(monitoredActivityStarted:)
91 name:@"MonitoredActivityStarted_inMainThread_"
93 [[NSNotificationCenter defaultCenter] addObserver:self
94 selector:@selector(monitoredActivityEnded:)
95 name:@"MonitoredActivityEnded_inMainThread_"
98 #ifdef GROWL_MAIL_DEBUG
100 [[NSNotificationCenter defaultCenter] addObserver:self
101 selector:@selector(showAllNotifications:)
102 name:nil object:nil];
105 sharedNotifier = self;
111 [self shutDownGrowlMail];
112 [[NSNotificationCenter defaultCenter] removeObserver:self];
113 sharedNotifier = nil;
118 #pragma mark GrowlApplicationBridge delegate methods
120 - (NSString *) applicationNameForGrowl {
124 - (NSImage *) applicationIconForGrowl {
125 return [NSImage imageNamed:@"NSApplicationIcon"];
128 - (void) growlNotificationWasClicked:(NSString *)clickContext {
129 if ([clickContext length]) {
130 //Make sure we have all the methods we need.
131 if (!class_getClassMethod([Library class], @selector(messageWithMessageID:)))
132 [self shutDownGrowlMailAndWarn:@"Library does not respond to +messageWithMessageID:"];
133 if (!class_getInstanceMethod([SingleMessageViewer class], @selector(initForViewingMessage:showAllHeaders:viewingState:fromDefaults:)))
134 [self shutDownGrowlMailAndWarn:@"SingleMessageViewer does not respond to -initForViewingMessage:showAllHeaders:viewingState:fromDefaults:"];
135 if (!class_getInstanceMethod([SingleMessageViewer class], @selector(showAndMakeKey:)))
136 [self shutDownGrowlMailAndWarn:@"SingleMessageViewer does not respond to -showAndMakeKey:"];
138 Message *message = [Library messageWithMessageID:clickContext];
139 MessageViewingState *viewingState = [[MessageViewingState alloc] init];
140 SingleMessageViewer *messageViewer = [[SingleMessageViewer alloc] initForViewingMessage:message showAllHeaders:NO viewingState:viewingState fromDefaults:NO];
141 [viewingState release];
142 [messageViewer showAndMakeKey:YES];
143 [messageViewer release];
144 [Library markMessageAsViewed:message];
146 [NSApp activateIgnoringOtherApps:YES];
149 - (NSDictionary *) registrationDictionaryForGrowl {
150 // Register our ticket with Growl
151 NSArray *allowedNotifications = [NSArray arrayWithObjects:
152 NEW_MAIL_NOTIFICATION,
153 NEW_JUNK_MAIL_NOTIFICATION,
154 NEW_NOTE_NOTIFICATION,
156 NSDictionary *humanReadableNames = [NSDictionary dictionaryWithObjectsAndKeys:
157 NSLocalizedStringFromTableInBundle(@"New mail", nil, GMGetGrowlMailBundle(), ""), NEW_MAIL_NOTIFICATION,
158 NSLocalizedStringFromTableInBundle(@"New junk mail", nil, GMGetGrowlMailBundle(), ""), NEW_JUNK_MAIL_NOTIFICATION,
159 NSLocalizedStringFromTableInBundle(@"New note", nil, GMGetGrowlMailBundle(), ""), NEW_NOTE_NOTIFICATION,
161 NSArray *defaultNotifications = [NSArray arrayWithObject:NEW_MAIL_NOTIFICATION];
163 NSDictionary *ticket = [NSDictionary dictionaryWithObjectsAndKeys:
164 allowedNotifications, GROWL_NOTIFICATIONS_ALL,
165 defaultNotifications, GROWL_NOTIFICATIONS_DEFAULT,
166 humanReadableNames, GROWL_NOTIFICATIONS_HUMAN_READABLE_NAMES,
168 #ifdef GROWL_MAIL_DEBUG
169 NSLog(@"%s: Returning Growl dictionary %@", __PRETTY_FUNCTION__, ticket);
175 #pragma mark Mail notification handlers
177 + (void)showNotificationForMessage:(Message *)message
179 if (activeNotificationThreads < MAX_NOTIFICATION_THREADS) {
180 activeNotificationThreads++;
184 * If we want the message body, it may not be immediately available.
185 * It can be retrieved without blocking if it's available, which we initially try.
186 * However, if we really, really want it, we may have to request it in a blocking fashion:
187 * for example, if the user doesn't read the message and doesn't have Mail set to download it automatically,
188 * we'll never get it without blocking.
190 * Blocking the main thread is, of course, out of the question.
192 * We're making some assumptions about Mail's internals, but the fact that notifications are posted on auxiliary threads
193 * and then again with a _inMainThread_ suffix on the main thread indicates that threads are being used for mail access elsewhere.
195 [NSThread detachNewThreadSelector:@selector(GMShowNotificationPart1)
199 [self performSelector:@selector(showNotificationForMessage:)
205 - (void)didFinishNotificationForMessage:(Message *)message
207 #pragma unused(message)
208 activeNotificationThreads--;
211 - (void)messageStoreDidAddMessages:(NSNotification *)notification {
212 if (![self isEnabled]) return;
214 #ifdef GROWL_MAIL_DEBUG
215 NSLog(@"%s called", __PRETTY_FUNCTION__);
219 #ifdef GROWL_MAIL_DEBUG
220 NSLog(@"Ignoring because %i message copies are in process", messageCopies);
225 Library *store = [notification object];
227 [self shutDownGrowlMailAndWarn:[NSString stringWithFormat:@"'%@' notification has no object", [notification name]]];
229 if ([store isKindOfClass:[LibraryStore class]]) {
230 //As of Tiger, this is normal; this notification is posted a couple times (perhaps once per inbox) with a LibraryStore object.
231 //This is not the notification we're looking for; we don't need to see its papers. We will move along now.
234 //We don't actually use the store. We only retrieve it and examine it at all because we know we don't want the one with a LibraryStore as its object.
235 //The rest of the handler should be able to work just fine without proving anything else about the store, since it doesn't use the store.
237 NSDictionary *userInfo = [notification userInfo];
238 if (!userInfo) [self shutDownGrowlMailAndWarn:@"Notification had no userInfo"];
240 NSArray *mailboxes = [userInfo objectForKey:@"mailboxes"];
241 #ifdef GROWL_MAIL_DEBUG
242 NSLog(@"%s: Adding messages to mailboxes %@", __PRETTY_FUNCTION__, mailboxes);
245 //As of Tiger, it's normal for about half of these notifications to not have any mailboxes. We simply ignore the notification in this case.
246 if (!(mailboxes && [mailboxes count])) return;
248 //Ignore a notification if we're ignoring all of the mailboxes involved.
249 Class MailAccount_class = [MailAccount class];
250 if (!class_getClassMethod(MailAccount_class, @selector(draftMailboxUids)))
251 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +draftMailboxUids"];
252 if (!class_getClassMethod(MailAccount_class, @selector(outboxMailboxUids)))
253 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +outboxMailboxUids"];
254 if (!class_getClassMethod(MailAccount_class, @selector(sentMessagesMailboxUids)))
255 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +sentMessagesMailboxUids"];
256 if (!class_getClassMethod(MailAccount_class, @selector(trashMailboxUids)))
257 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +trashMailboxUids"];
258 //We need this method to support the Inbox Only preference.
259 if (!class_getClassMethod(MailAccount_class, @selector(inboxMailboxUids)))
260 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +inboxMailboxUids"];
262 //Ignore messages being written.
263 NSMutableSet *mailboxesToIgnore = [NSMutableSet setWithArray:[MailAccount draftMailboxUids]];
264 //Ignore messages being sent.
265 [mailboxesToIgnore unionSet:[NSSet setWithArray:[MailAccount outboxMailboxUids]]];
266 [mailboxesToIgnore unionSet:[NSSet setWithArray:[MailAccount sentMessagesMailboxUids]]];
267 //Ignore messages being deleted.
268 [mailboxesToIgnore unionSet:[NSSet setWithArray:[MailAccount trashMailboxUids]]];
270 NSSet *mailboxesSet = [NSSet setWithArray:mailboxes];
271 NSMutableSet *mailboxesNotIgnored = [[mailboxesSet mutableCopy] autorelease];
272 [mailboxesNotIgnored minusSet:mailboxesToIgnore];
273 if ([mailboxesNotIgnored count] == 0U)
276 NSArray *messages = [userInfo objectForKey:@"messages"];
277 if (!messages) [self shutDownGrowlMailAndWarn:@"Notification's userInfo has no messages"];
279 #ifdef GROWL_MAIL_DEBUG
280 NSLog(@"%s: Mail added messages [1] to mailboxes [2].\n[1]: %@\n[2]: %@", __PRETTY_FUNCTION__, messages, mailboxes);
283 unsigned count = [messages count];
285 int summaryMode = [self summaryMode];
286 if (summaryMode == GrowlMailSummaryModeAutomatic) {
287 if (count >= AUTO_THRESHOLD)
288 summaryMode = GrowlMailSummaryModeAlways;
290 summaryMode = GrowlMailSummaryModeDisabled;
293 #ifdef GROWL_MAIL_DEBUG
294 NSLog(@"Got %i new messages. Summary mode was %i and is now %i", count, [self summaryMode], summaryMode);
297 Class Message_class = [Message class];
299 switch (summaryMode) {
301 case GrowlMailSummaryModeDisabled: {
302 NSEnumerator *messagesEnum = [messages objectEnumerator];
304 while ((message = [messagesEnum nextObject])) {
305 MailboxUid *mailbox = [message mailbox];
306 //If this mailbox is not an inbox, and we only care about inboxes, then skip this message.
307 if ([self inboxOnly] && ![[MailAccount inboxMailboxUids] containsObject:mailbox])
310 MailAccount *account = [mailbox account];
311 if (![self isAccountEnabled:account])
314 if (![message isKindOfClass:Message_class])
315 [self shutDownGrowlMailAndWarn:[NSString stringWithFormat:@"Message in notification was not a Message; it is %@", message]];
317 if (![message respondsToSelector:@selector(isRead)] || ![message isRead]) {
318 /* Don't display read messages */
319 [[self class] showNotificationForMessage:message];
324 case GrowlMailSummaryModeAlways: {
325 if (!class_getClassMethod([MailAccount class], @selector(mailAccounts)))
326 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +mailAccounts"];
327 if (!class_getInstanceMethod(Message_class, @selector(mailbox)))
328 [self shutDownGrowlMailAndWarn:@"Message does not respond to -mailbox"];
330 NSArray *accounts = [MailAccount mailAccounts];
331 unsigned accountsCount = [accounts count];
332 NSCountedSet *accountSummary = [NSCountedSet setWithCapacity:accountsCount];
333 NSCountedSet *accountJunkSummary = [NSCountedSet setWithCapacity:accountsCount];
334 NSEnumerator *messagesEnum = [messages objectEnumerator];
335 NSArray *junkMailboxUids = [MailAccount junkMailboxUids];
337 while ((message = [messagesEnum nextObject])) {
338 MailboxUid *mailbox = [message mailbox];
339 //If this mailbox is not an inbox, and we only care about inboxes, then skip this message.
340 if ([self inboxOnly] && ![[MailAccount inboxMailboxUids] containsObject:mailbox])
343 MailAccount *account = [mailbox account];
344 if (![self isAccountEnabled:account])
347 if (([message isJunk]) || [junkMailboxUids containsObject:[message mailbox]])
348 [accountJunkSummary addObject:account];
350 [accountSummary addObject:account];
352 NSString *title = NSLocalizedStringFromTableInBundle(@"New mail", NULL, GMGetGrowlMailBundle(), "");
353 NSString *titleJunk = NSLocalizedStringFromTableInBundle(@"New junk mail", NULL, GMGetGrowlMailBundle(), "");
354 NSString *description;
356 MailAccount *account;
358 NSEnumerator *accountSummaryEnum = [accountSummary objectEnumerator];
359 while ((account = [accountSummaryEnum nextObject])) {
360 if (![self isAccountEnabled:account])
363 unsigned summaryCount = [accountSummary countForObject:account];
365 if (summaryCount == 1) {
366 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n 1 new mail", NULL, GMGetGrowlMailBundle(), "%@ is an account name"), [account displayName]];
368 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n %u new mails", NULL, GMGetGrowlMailBundle(), "%@ is an account name; %u becomes a number"), [account displayName], summaryCount];
370 [GrowlApplicationBridge notifyWithTitle:title
371 description:description
372 notificationName:NEW_MAIL_NOTIFICATION
376 clickContext:@""]; // non-nil click context
380 NSEnumerator *accountJunkSummaryEnum = [accountJunkSummary objectEnumerator];
381 while ((account = [accountJunkSummaryEnum nextObject])) {
382 if (![self isAccountEnabled:account])
385 unsigned summaryCount = [accountJunkSummary countForObject:account];
387 if (summaryCount == 1) {
388 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n 1 new mail", NULL, GMGetGrowlMailBundle(), "%@ is an account name"), [account displayName]];
390 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n %u new mails", NULL, GMGetGrowlMailBundle(), "%@ is an account name; %u becomes a number"), [account displayName], summaryCount];
392 [GrowlApplicationBridge notifyWithTitle:titleJunk
393 description:description
394 notificationName:NEW_JUNK_MAIL_NOTIFICATION
398 clickContext:@""]; // non-nil click context
406 - (void)showAllNotifications:(NSNotification *)notification
408 if (([[notification name] rangeOfString:@"NSWindow"].location == NSNotFound) &&
409 ([[notification name] rangeOfString:@"NSMouse"].location == NSNotFound) &&
410 ([[notification name] rangeOfString:@"_NSThread"].location == NSNotFound)) {
411 NSLog(@"%@", notification);
415 - (void)monitoredActivityStarted:(NSNotification *)notification
417 if ([[[notification object] description] isEqualToString:@"Copying messages"]) {
419 #ifdef GROWL_MAIL_DEBUG
420 NSLog(@"Copying a message: messageCopies is now %i", messageCopies);
422 if (messageCopies <= 0)
423 [self shutDownGrowlMailAndWarn:@"Number of message-copying operations overflowed. How on earth did you accomplish starting more than 2 billion copying operations at a time?!"];
427 - (void)monitoredActivityEnded:(NSNotification *)notification
429 if ([[[notification object] description] isEqualToString:@"Copying messages"]) {
430 if (messageCopies <= 0)
431 [self shutDownGrowlMailAndWarn:@"Number of message-copying operations went below 0. It is not possible to have a negative number of copying operations!"];
433 #ifdef GROWL_MAIL_DEBUG
434 NSLog(@"Finished copying a message: messageCopies is now %i", messageCopies);
439 #pragma mark Preferences
441 - (BOOL) isAccountEnabled:(MailAccount *)account {
442 BOOL isEnabled = YES;
443 NSDictionary *accountSettings = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMAccounts"];
444 if (accountSettings) {
445 NSNumber *value = [accountSettings objectForKey:[account path]];
447 isEnabled = [value boolValue];
452 - (void) setAccount:(MailAccount *)account enabled:(BOOL)yesOrNo {
453 NSDictionary *accountSettings = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMAccounts"];
454 NSMutableDictionary *newSettings = [[accountSettings mutableCopy] autorelease];
456 newSettings = [NSMutableDictionary dictionaryWithCapacity:1U];
457 [newSettings setObject:[NSNumber numberWithBool:yesOrNo] forKey:[account path]];
458 [[NSUserDefaults standardUserDefaults] setObject:newSettings forKey:@"GMAccounts"];
461 #pragma mark Accessors
464 NSNumber *enabledNum = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMEnableGrowlMailBundle"];
465 return enabledNum ? [enabledNum boolValue] : YES;
467 - (GrowlMailSummaryMode) summaryMode {
468 NSNumber *summaryModeNum = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMSummaryMode"];
469 return summaryModeNum ? [summaryModeNum intValue] : GrowlMailSummaryModeAutomatic;
472 NSNumber *inboxOnlyNum = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMInboxOnly"];
473 return inboxOnlyNum ? [inboxOnlyNum boolValue] : YES;
476 - (NSString *) titleFormat {
477 NSString *titleFormat = [[NSUserDefaults standardUserDefaults] stringForKey:@"GMTitleFormat"];
478 return titleFormat ? titleFormat : @"(%account) %sender";
480 - (NSString *) descriptionFormat {
481 NSString *descriptionFormat = [[NSUserDefaults standardUserDefaults] stringForKey:@"GMDescriptionFormat"];
482 return descriptionFormat ? descriptionFormat : @"%subject\n%body";