From 32d9c4dc501503beb957110c80a7097e6c52445d Mon Sep 17 00:00:00 2001 From: Joe Flateau Date: Fri, 17 Apr 2026 12:13:17 -0400 Subject: [PATCH 1/2] fix: terminate duplicate instances on launch macOS Launch Services normally routes GetURL events to an already-running Finicky instance, but that routing can fail (stale LS registration after app moves/updates, different bundle paths, SSO agents launching from another context) and silently spawn a second process that proceeds to create its own status bar icon. Without a guard, duplicates accumulate in the menu bar over time. Enumerate running applications with our bundle identifier at launch and terminate any other instances so we're the single surviving process. Verified locally: launching the patched build with 16 stale instances already running reduced the count to 1 (the new process), with NSLog entries confirming each termination. Fixes #515 --- apps/finicky/src/main.m | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 261191cf..427c725e 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -28,6 +28,8 @@ - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)sho // Use bool for openWindow and related logic - (void)applicationDidFinishLaunching:(NSNotification *)notification { + [self terminateOtherInstances]; + bool openWindow = self.forceOpenWindow; if (!openWindow) { // Even if we aren't forcing the window to open, we still want to open it if didn't receive a URL @@ -44,6 +46,33 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { QueueWindowDisplay(openWindow); } +// Ensure only one Finicky process is running. macOS's Launch Services normally +// routes GetURL events to an already-running instance, but that routing can fail +// (stale LS registration after app moves/updates, different bundle paths, SSO +// agents launching from another context) and silently spawn a second process +// that proceeds to create its own status bar icon. Without a guard, duplicates +// accumulate in the menu bar over time. Terminate any other instances we find +// with the same bundle identifier so we're the single surviving process. +- (void)terminateOtherInstances { + NSString *selfBundleID = [[NSBundle mainBundle] bundleIdentifier]; + if (!selfBundleID) { + return; + } + + NSArray *instances = [NSRunningApplication runningApplicationsWithBundleIdentifier:selfBundleID]; + pid_t myPID = [[NSRunningApplication currentApplication] processIdentifier]; + + for (NSRunningApplication *app in instances) { + if ([app processIdentifier] == myPID) continue; + if ([app isTerminated]) continue; + + NSLog(@"Terminating duplicate Finicky instance (pid %d)", [app processIdentifier]); + if (![app terminate]) { + [app forceTerminate]; + } + } +} + - (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)flag { if (!flag) { // If there are no visible windows, we should open a new one From f905de48bf0fc22789842b4fe564bff37a77036e Mon Sep 17 00:00:00 2001 From: Joe Flateau Date: Fri, 17 Apr 2026 12:29:14 -0400 Subject: [PATCH 2/2] fix: bounded wait on duplicate termination [NSRunningApplication terminate] returning YES only confirms the request was dispatched; the target may still be running when the call returns. If a duplicate hung or ignored SIGTERM we'd reach createStatusItem alongside it, defeating the single-instance guarantee. Fire terminate() on all duplicates in parallel, then share a single 1s deadline across the poll (so N hung duplicates don't each cost 1s), then forceTerminate any holdouts. Addresses review feedback on #516. --- apps/finicky/src/main.m | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 427c725e..ad00c07a 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -62,14 +62,42 @@ - (void)terminateOtherInstances { NSArray *instances = [NSRunningApplication runningApplicationsWithBundleIdentifier:selfBundleID]; pid_t myPID = [[NSRunningApplication currentApplication] processIdentifier]; + NSMutableArray *duplicates = [NSMutableArray array]; for (NSRunningApplication *app in instances) { if ([app processIdentifier] == myPID) continue; if ([app isTerminated]) continue; + [duplicates addObject:app]; + } + if (duplicates.count == 0) { + return; + } + + // -[NSRunningApplication terminate] returning YES only means the request was + // sent, not that the target has exited. Fire all requests in parallel, then + // share a single deadline across the poll so 16 hung duplicates don't cost + // 16x the wait budget. + for (NSRunningApplication *app in duplicates) { NSLog(@"Terminating duplicate Finicky instance (pid %d)", [app processIdentifier]); - if (![app terminate]) { - [app forceTerminate]; + [app terminate]; + } + + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:1.0]; + while ([deadline timeIntervalSinceNow] > 0) { + BOOL anyAlive = NO; + for (NSRunningApplication *app in duplicates) { + if (![app isTerminated]) { anyAlive = YES; break; } } + if (!anyAlive) return; + + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + + for (NSRunningApplication *app in duplicates) { + if ([app isTerminated]) continue; + NSLog(@"Duplicate Finicky instance (pid %d) did not exit within 1s; force-terminating", [app processIdentifier]); + [app forceTerminate]; } }