hammerspoon/Hammerspoon/MJAppDelegate.m

385 lines
17 KiB
Objective-C

#import <Cocoa/Cocoa.h>
#import "MJAppDelegate.h"
#import "MJConsoleWindowController.h"
#import "MJPreferencesWindowController.h"
#import "MJDockIcon.h"
#import "MJMenuIcon.h"
#import "MJLua.h"
#import "MJVersionUtils.h"
#import "MJConfigUtils.h"
#import "MJFileUtils.h"
#import "MJAccessibilityUtils.h"
#import "HSLogger.h"
#import "variables.h"
#import "secrets.h"
@implementation MJAppDelegate
- (BOOL) applicationShouldHandleReopen:(NSApplication*)theApplication hasVisibleWindows:(BOOL)hasVisibleWindows {
callDockIconCallback();
if (HSOpenConsoleOnDockClickEnabled()) {
[[MJConsoleWindowController singleton] showWindow: nil];
};
return NO;
}
-(void)applicationWillFinishLaunching:(NSNotification *)aNotification
{
// Set up an early event manager handler so we can catch URLs used to launch us
NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
[appleEventManager setEventHandler:self
andSelector:@selector(handleGetURLEvent:withReplyEvent:)
forEventClass:kInternetEventClass andEventID:kAEGetURL];
self.startupEvent = nil;
self.startupFile = nil;
self.openFileDelegate = nil;
self.updateAvailable = nil;
}
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent
{
self.startupEvent = event;
}
#ifndef NO_INTENTS
- (id)application:(NSApplication *)application handlerForIntent:(INIntent *)intent API_AVAILABLE(macos(11.0)){
NSLog(@"handlerForIntent: Checking for HSExecuteLuaIntent");
if ([intent isKindOfClass:[HSExecuteLuaIntent class]]) {
NSLog(@"handlerForIntent: Found HSExecuteLuaIntent, dispatching to HSExecuteLuaIntentHandler");
return ([[HSExecuteLuaIntentHandler alloc] init]);
}
return nil;
}
#endif
- (BOOL)application:(NSApplication *)theApplication openFile:(NSString *)fileAndPath {
NSString *typeOfFile = [[NSWorkspace sharedWorkspace] typeOfFile:fileAndPath error:nil];
if ([typeOfFile isEqualToString:@"org.hammerspoon.hammerspoon.spoon"]) {
// This is a Spoon, so we will attempt to copy it to the Spoons directory
NSError *fileError;
BOOL success = NO;
BOOL upgrade = NO;
NSString *spoonPath = [MJConfigDirAbsolute() stringByAppendingPathComponent:@"Spoons"];
NSString *spoonName = [fileAndPath lastPathComponent];
NSString *dstSpoonFullPath = [spoonPath stringByAppendingPathComponent:spoonName];
if ([dstSpoonFullPath isEqualToString:fileAndPath]) {
NSLog(@"User double clicked on a Spoon in %@, skipping", MJConfigDirAbsolute());
return YES;
}
NSFileManager *fileManager = [NSFileManager defaultManager];
// Remove any pre-existing copy of the Spoon
if ([fileManager fileExistsAtPath:dstSpoonFullPath]) {
NSLog(@"Spoon already exists at %@, removing the old version", dstSpoonFullPath);
upgrade = YES;
success = [fileManager removeItemAtPath:dstSpoonFullPath error:&fileError];
if (!success) {
NSLog(@"Unable to remove existing Spoon (%@):%@", dstSpoonFullPath, fileError);
NSAlert *alert = [[NSAlert alloc] init];
[alert addButtonWithTitle:@"OK"];
[alert setMessageText:@"Error upgrading Spoon"];
[alert setInformativeText:[NSString stringWithFormat:@"%@\n\nSource: %@\nDest: %@", fileError.localizedDescription, fileAndPath, spoonPath]];
[alert setAlertStyle:NSAlertStyleCritical];
[alert runModal];
return YES;
}
}
success = [[NSFileManager defaultManager] moveItemAtPath:fileAndPath toPath:dstSpoonFullPath error:&fileError];
if (!success) {
NSLog(@"Unable to move %@ to %@: %@", fileAndPath, spoonPath, fileError);
NSAlert *alert = [[NSAlert alloc] init];
[alert addButtonWithTitle:@"OK"];
[alert setMessageText:@"Error installing Spoon"];
[alert setInformativeText:[NSString stringWithFormat:@"%@\n\nSource: %@\nDest: %@", fileError.localizedDescription, fileAndPath, spoonPath]];
[alert setAlertStyle:NSAlertStyleCritical];
[alert runModal];
} else {
NSUserNotification *notification = [[NSUserNotification alloc] init];
notification.title = [NSString stringWithFormat:@"Spoon %@", upgrade ? @"upgraded" : @"installed"];
notification.informativeText = [NSString stringWithFormat:@"%@ is now available%@", spoonName, upgrade ? @", reload your config" : @""];
notification.soundName = NSUserNotificationDefaultSoundName;
[[NSUserNotificationCenter defaultUserNotificationCenter] deliverNotification:notification];
}
return YES; // Note that we always return YES here because otherwise macOS tells the user that we can't open Spoons, which is ludicrous
}
NSString *fileExtension = [fileAndPath pathExtension];
NSDictionary *infoDict = [[NSBundle mainBundle] infoDictionary];
NSArray *supportedExtensions = [infoDict valueForKeyPath:@"CFBundleDocumentTypes.CFBundleTypeExtensions"];
NSArray *flatSupportedExtensions = [supportedExtensions valueForKeyPath:@"@unionOfArrays.self"];
// Files to be processed by hs.urlevent
if ([flatSupportedExtensions containsObject:fileExtension]) {
if (!self.openFileDelegate) {
self.startupFile = fileAndPath;
} else {
if ([self.openFileDelegate respondsToSelector:@selector(callbackWithURL:senderPID:)]) {
[self.openFileDelegate callbackWithURL:fileAndPath senderPID:-1];
}
}
} else {
// Trigger File Dropped to Dock Icon Callback
fileDroppedToDockIcon(fileAndPath);
}
return YES;
}
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
BOOL isTesting = NO;
// User is holding down Command (0x37) & Option (0x3A) keys:
if (CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState,0x3A) && CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState,0x37)) {
NSAlert *alert = [[NSAlert alloc] init];
[alert addButtonWithTitle:@"Continue"];
[alert addButtonWithTitle:@"Delete Preferences"];
[alert setMessageText:@"Do you want to delete the preferences?"];
[alert setInformativeText:@"Deleting the preferences will reset all Hammerspoon settings (including everything that uses hs.settings) to their defaults."];
[alert setAlertStyle:NSAlertStyleWarning];
if ([alert runModal] == NSAlertSecondButtonReturn) {
// Reset Preferences:
NSDictionary * allObjects;
allObjects = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
for(NSString *key in allObjects)
{
[[NSUserDefaults standardUserDefaults] removeObjectForKey: key];
}
[[NSUserDefaults standardUserDefaults] synchronize];
}
}
[[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(accessibilityChanged:) name:@"com.apple.accessibility.api" object:nil];
// Remove our early event manager handler so hs.urlevent can register for it later, if the user has it configured to
[[NSAppleEventManager sharedAppleEventManager] removeEventHandlerForEventClass:kInternetEventClass andEventID:kAEGetURL];
if(NSClassFromString(@"XCTest") != nil) {
// Hammerspoon Tests
NSLog(@"in testing mode!");
isTesting = YES;
NSBundle *mainBundle = [NSBundle mainBundle];
NSBundle *bundle = [NSBundle bundleWithPath:[NSString stringWithFormat:@"%@/Contents/Plugins/Hammerspoon Tests.xctest", mainBundle.bundlePath]];
NSString *lsUnitPath = [bundle pathForResource:@"lsunit" ofType:@"lua"];
const char *fsPath = [lsUnitPath fileSystemRepresentation];
if (!fsPath) {
NSLog(@"Unable to find lsunit.lua in Hammerspoon Tests.xctest. We're about to crash, sorry!");
abort();
} else {
NSLog(@"testing lsunit.lua");
}
MJConfigFile = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:fsPath length:strlen(fsPath)];
} else if ([[[NSProcessInfo processInfo] environment] objectForKey:@"XCTESTING"]) {
// Hammerspoon UI Tests
NSLog(@"in UI testing mode");
NSString *initPath = [[[NSFileManager defaultManager] currentDirectoryPath] stringByAppendingString:@"/Hammerspoon UI Tests-Runner.app/Contents/PlugIns/Hammerspoon UI Tests.xctest/Contents/Resources/init.lua"];
const char *fsPath = [initPath fileSystemRepresentation];
if (!fsPath) {
NSLog(@"Unable to find init.lua in Hammerspoon UI Tests. We're about to crash, sorry!");
abort();
} else {
NSLog(@"UI testing init.lua");
}
MJConfigFile = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:fsPath length:strlen(fsPath)];
[self showConsoleWindow:nil];
} else {
// No test environment detected, this is a live user run
NSString* userMJConfigFile = [[NSUserDefaults standardUserDefaults] stringForKey:@"MJConfigFile"];
if (userMJConfigFile) MJConfigFile = userMJConfigFile ;
// Ensure we have a Spoons directory
NSString *spoonsPath = [MJConfigDirAbsolute() stringByAppendingPathComponent:@"Spoons"];
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL spoonsPathIsDir;
BOOL spoonsPathExists = [fileManager fileExistsAtPath:spoonsPath isDirectory:&spoonsPathIsDir];
NSLog(@"Determined Spoons path will be: %@ (exists: %@, isDir: %@)", spoonsPath, spoonsPathExists ? @"YES" : @"NO", spoonsPathIsDir ? @"YES" : @"NO");
if (spoonsPathExists && !spoonsPathIsDir) {
NSLog(@"ERROR: %@ exists, but is a file", spoonsPath);
abort();
}
if (!spoonsPathExists) {
NSLog(@"Creating Spoons directory at: %@", spoonsPath);
[[NSFileManager defaultManager] createDirectoryAtPath:spoonsPath withIntermediateDirectories:YES attributes:nil error:nil];
}
}
// Become the handler for events from macOS Services
[NSApp setServicesProvider:self];
MJEnsureDirectoryExists(MJConfigDir());
[[NSFileManager defaultManager] changeCurrentDirectoryPath:MJConfigDir()];
[self registerDefaultDefaults];
// Enable Sentry, if we have an API URL available
#ifdef SENTRY_API_URL
if (HSUploadCrashData() && !isTesting) {
SentryEvent* (^sentryWillUploadCrashReport) (SentryEvent *event) = ^SentryEvent* (SentryEvent *event) {
if ([event.extra objectForKey:@"MjolnirModuleLoaded"]) {
dispatch_async(dispatch_get_main_queue(), ^{
[self showMjolnirMigrationNotification];
});
}
return event;
};
[SentrySDK startWithOptions:@{
@"dsn": @SENTRY_API_URL,
@"beforeSend": sentryWillUploadCrashReport,
@"release": [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]
}];
}
#endif
// Become the Sparkle delegate, if it's available
if (NSClassFromString(@"SUUpdater")) {
NSString *frameworkPath = [[[NSBundle mainBundle] privateFrameworksPath] stringByAppendingPathComponent:@"Sparkle.framework"];
if ([[NSBundle bundleWithPath:frameworkPath] load]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
id sharedUpdater = [NSClassFromString(@"SUUpdater") performSelector:@selector(sharedUpdater)];
NSMethodSignature * mySignature = [NSClassFromString(@"SUUpdater") instanceMethodSignatureForSelector:@selector(setDelegate:)];
NSInvocation * myInvocation = [NSInvocation invocationWithMethodSignature:mySignature];
[myInvocation setTarget:sharedUpdater];
// even though signature specifies this, we need to specify it in the invocation, since the signature is re-usable
// for any method which accepts the same signature list for the target.
[myInvocation setSelector:@selector(setDelegate:)];
[myInvocation setArgument:(void *)&self atIndex:2];
[myInvocation invoke];
#pragma clang diagnostic pop
}
}
MJMenuIconSetup(self.menuBarMenu);
MJDockIconSetup();
[[MJConsoleWindowController singleton] setup];
MJLuaCreate();
if (!MJAccessibilityIsEnabled())
[[MJPreferencesWindowController singleton] showWindow: nil];
}
// Dragging & Dropping of Text to Dock Item
-(void) processDockIconDraggedText:(NSPasteboard *)pboard userData:(NSString *)userData error:(NSString **)error {
NSString *pboardString = [pboard stringForType:NSPasteboardTypeString];
textDroppedToDockIcon(pboardString);
}
// Dragging & Dropping of File to Dock Item
-(void) processDockIconDraggedFile:(NSPasteboard *)pboard userData:(NSString *)userData error:(NSString **)error {
NSArray *filePaths = [pboard propertyListForType:NSFilenamesPboardType];
for (NSString *filePath in filePaths) {
fileDroppedToDockIcon(filePath);
}
}
- (void) accessibilityChanged:(NSNotification*)note {
HSNSLOG(@"accessibilityChanged: %@", MJAccessibilityIsEnabled() ? @"ENABLED" : @"DISABLED");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
callAccessibilityStateCallback();
});
}
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
MJLuaDestroy();
return NSTerminateNow;
}
- (void) registerDefaultDefaults {
[[NSUserDefaults standardUserDefaults]
registerDefaults: @{@"NSApplicationCrashOnExceptions": @YES,
MJShowDockIconKey: @NO,
MJShowMenuIconKey: @YES,
HSAutoLoadExtensions: @YES,
HSUploadCrashDataKey: @YES,
HSAppleScriptEnabledKey: @NO,
HSOpenConsoleOnDockClickKey: @YES,
HSPreferencesDarkModeKey: @NO,
HSConsoleDarkModeKey: @NO,
}];
}
- (IBAction) reloadConfig:(id)sender {
MJLuaReplace();
}
- (IBAction) showConsoleWindow:(id)sender {
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
[[MJConsoleWindowController singleton] showWindow: nil];
}
- (IBAction) showPreferencesWindow:(id)sender {
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
[[MJPreferencesWindowController singleton] showWindow: nil];
}
- (IBAction) showAboutPanel:(id)sender {
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
@try {
[[NSApplication sharedApplication] orderFrontStandardAboutPanel: nil];
} @catch (NSException *exception) {
[LuaSkin logError:@"Unable to open About dialog. This may mean your Hammerspoon installation is corrupt. Please re-install it!"];
}
}
- (IBAction) quitHammerspoon:(id)sender {
[[NSApplication sharedApplication] terminate:nil];
}
- (IBAction) openConfig:(id)sender {
NSString* path = MJConfigFileFullPath();
if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
[[NSFileManager defaultManager] createFileAtPath:path
contents:[NSData data]
attributes:nil];
}
NSWorkspace *workspace = [NSWorkspace sharedWorkspace];
if ([workspace openFile:path] == NO) {
// No app is associated with .lua files, so fall back on TextEdit
[workspace openFile:path withApplication:@"TextEdit" andDeactivate:YES];
}
}
- (void)showMjolnirMigrationNotification {
NSAlert *alert = [[NSAlert alloc] init];
[alert addButtonWithTitle:@"OK"];
[alert setMessageText:@"Hammerspoon crash detected"];
[alert setInformativeText:@"Your init.lua is loading Mjolnir modules and a previous launch crashed.\n\nHammerspoon ships with updated versions of many of the Mjolnir modules, with both new features and many bug fixes.\n\nPlease consult our API documentation and migrate your config."];
[alert setAlertStyle:NSAlertStyleCritical];
[alert runModal];
}
#pragma mark - Sparkle delegate methods
- (void)updater:(id)updater didFindValidUpdate:(id)update {
NSLog(@"Update found: %@ (Build: %@)", [update valueForKey:@"displayVersionString"], [update valueForKey:@"versionString"]);
self.updateAvailable = [update valueForKey:@"versionString"];
self.updateAvailableDisplayVersion = [update valueForKey:@"displayVersionString"];
}
- (void)updaterDidNotFindUpdate:(id)update {
self.updateAvailable = nil;
self.updateAvailableDisplayVersion = nil;
}
@end
int main(int argc, const char * argv[]) {
return NSApplicationMain(argc, argv);
}