From 28c8c66c6e104c0061c1e1d81ac9c18f1b737ec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 15:42:48 +0000 Subject: [PATCH 01/10] fix(window): defer window auto-open to avoid flash on cold URL launch When Finicky is cold-launched to handle a URL, the GetURL/openFile Apple Event may be delivered after applicationDidFinishLaunching: runs. Deciding to auto-open the config window synchronously raced that event, briefly flashing the window before the target browser opened. Defer the auto-open decision by a short, named interval so a pending URL event has a chance to set receivedURL first. An explicit --window request still shows the window immediately. --- apps/finicky/src/main.m | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index ad2aa53..1e781cb 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -7,6 +7,13 @@ #import "window/window.h" // For ShowWindow() +// On a cold launch, the GetURL/openFile Apple Event that Finicky was started to +// handle is not guaranteed to have been delivered by the time +// applicationDidFinishLaunching: runs. We defer the auto-open-window decision by +// this much so a pending URL event has a chance to set receivedURL first, +// otherwise the config window briefly flashes before the browser launches. +static const double kWindowAutoOpenDelaySeconds = 0.2; + // Extend BrowseAppDelegate to hold a status item and declare menu action @interface BrowseAppDelegate () @property (nonatomic, strong) NSStatusItem *statusItem; @@ -30,20 +37,29 @@ - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)sho - (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 - openWindow = !self.receivedURL; + if (self.forceOpenWindow) { + // The window was explicitly requested (e.g. via --window), so there's no + // need to wait on a possible incoming URL — show it right away. + if (self.showMenuItem) { + [self createStatusItem]; + } + QueueWindowDisplay(true); + return; } - // Only show menu item if the option is enabled, and we either didn't receive a URL or we are keeping - // the application running. We don't want to show the icon if Finicky is just receiving a url to open - // and is expected to exit after - if (self.showMenuItem && (self.keepRunning || !self.receivedURL)) { - [self createStatusItem]; - } + // Defer the auto-open decision so a URL event delivered slightly after launch + // suppresses the window instead of racing it (see kWindowAutoOpenDelaySeconds). + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kWindowAutoOpenDelaySeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + // Only show menu item if the option is enabled, and we either didn't receive a URL or we are keeping + // the application running. We don't want to show the icon if Finicky is just receiving a url to open + // and is expected to exit after + if (self.showMenuItem && (self.keepRunning || !self.receivedURL)) { + [self createStatusItem]; + } - QueueWindowDisplay(openWindow); + // Open the window only if we didn't end up receiving a URL to handle. + QueueWindowDisplay(!self.receivedURL); + }); } // Ensure only one Finicky process is running. macOS's Launch Services normally From 1748e7647f4f6f440aa486a1049c9b46c982cdbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 15:46:40 +0000 Subject: [PATCH 02/10] feat(config): add suppressWindow option to never auto-open the window Adds a new boolean config option, suppressWindow (default false), that prevents the config window from opening automatically on launch when no URL is being handled. The menu-bar "Show Window" item and the --window flag still open it on demand, and error windows are unaffected, so the user is never locked out. Plumbed through the config schema, the JS option reader, the JSON rules options (so it works from the UI too), the Objective-C app delegate, and the finicky-ui options panel. Also bumps the bundle version label. --- apps/finicky/assets/Info.plist | 4 ++-- apps/finicky/src/config/vm.go | 4 ++++ apps/finicky/src/main.go | 7 +++++-- apps/finicky/src/main.h | 5 +++-- apps/finicky/src/main.m | 12 +++++++----- apps/finicky/src/rules/rules.go | 4 ++++ packages/config-api/src/configSchema.ts | 6 +++++- packages/finicky-ui/src/pages/StartPage.svelte | 14 ++++++++++++-- packages/finicky-ui/src/types.ts | 1 + 9 files changed, 43 insertions(+), 14 deletions(-) diff --git a/apps/finicky/assets/Info.plist b/apps/finicky/assets/Info.plist index 64dd522..854e181 100644 --- a/apps/finicky/assets/Info.plist +++ b/apps/finicky/assets/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 4.4.0-alpha + 4.4.0-alpha+suppress-window CFBundleVersion - 4.4.0-alpha + 4.4.0-alpha+suppress-window CFBundleIconFile finicky.icns LSUIElement diff --git a/apps/finicky/src/config/vm.go b/apps/finicky/src/config/vm.go index 3383dd8..a6daab7 100644 --- a/apps/finicky/src/config/vm.go +++ b/apps/finicky/src/config/vm.go @@ -19,6 +19,7 @@ type VM struct { type ConfigOptions struct { KeepRunning bool HideIcon bool + SuppressWindow bool LogRequests bool CheckForUpdates bool } @@ -156,6 +157,7 @@ func (vm *VM) GetAllConfigOptions() ConfigOptions { defaults := ConfigOptions{ KeepRunning: true, HideIcon: false, + SuppressWindow: false, LogRequests: false, CheckForUpdates: true, } @@ -165,6 +167,7 @@ func (vm *VM) GetAllConfigOptions() ConfigOptions { script := `({ keepRunning: finickyConfigAPI.getOption('keepRunning', finalConfig, true), hideIcon: finickyConfigAPI.getOption('hideIcon', finalConfig, false), + suppressWindow: finickyConfigAPI.getOption('suppressWindow', finalConfig, false), logRequests: finickyConfigAPI.getOption('logRequests', finalConfig, false), checkForUpdates: finickyConfigAPI.getOption('checkForUpdates', finalConfig, true) })` @@ -177,6 +180,7 @@ func (vm *VM) GetAllConfigOptions() ConfigOptions { return ConfigOptions{ KeepRunning: obj.Get("keepRunning").ToBoolean(), HideIcon: obj.Get("hideIcon").ToBoolean(), + SuppressWindow: obj.Get("suppressWindow").ToBoolean(), LogRequests: obj.Get("logRequests").ToBoolean(), CheckForUpdates: obj.Get("checkForUpdates").ToBoolean(), } diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 6297c9c..b8953b5 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -249,10 +249,13 @@ func main() { }() shouldHideIcon := false + suppressWindow := false if vm != nil { - shouldHideIcon = vm.GetAllConfigOptions().HideIcon + opts := vm.GetAllConfigOptions() + shouldHideIcon = opts.HideIcon + suppressWindow = opts.SuppressWindow } - C.RunApp(C.bool(forceWindowOpen), C.bool(!shouldHideIcon), C.bool(shouldKeepRunning)) + C.RunApp(C.bool(forceWindowOpen), C.bool(!shouldHideIcon), C.bool(shouldKeepRunning), C.bool(suppressWindow)) } func handleRuntimeError(err error) { diff --git a/apps/finicky/src/main.h b/apps/finicky/src/main.h index e8029c4..092826a 100644 --- a/apps/finicky/src/main.h +++ b/apps/finicky/src/main.h @@ -20,12 +20,13 @@ extern char* GetCurrentConfigPath(); @property (nonatomic) bool receivedURL; @property (nonatomic) bool keepRunning; @property (nonatomic) bool showMenuItem; - - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning; + @property (nonatomic) bool suppressWindow; + - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning suppressWindow:(bool)suppressWindow; - (void)handleGetURLEvent:(NSAppleEventDescriptor *) event withReplyEvent:(NSAppleEventDescriptor *)replyEvent; - (bool)application:(NSApplication *)sender openFile:(NSString *)filename; @end #endif -void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning); +void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning, bool suppressWindow); #endif /* MAIN_H */ diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 1e781cb..9412390 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -22,12 +22,13 @@ - (void)showWindowAction:(id)sender; @implementation BrowseAppDelegate -- (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning { +- (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning suppressWindow:(bool)suppressWindow { self = [super init]; if (self) { _forceOpenWindow = forceOpenWindow; _showMenuItem = showMenuItem; _keepRunning = keepRunning; + _suppressWindow = suppressWindow; _receivedURL = false; } return self; @@ -57,8 +58,9 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { [self createStatusItem]; } - // Open the window only if we didn't end up receiving a URL to handle. - QueueWindowDisplay(!self.receivedURL); + // Open the window only if we didn't end up receiving a URL to handle, + // unless the user opted out of the automatic window entirely. + QueueWindowDisplay(!self.receivedURL && !self.suppressWindow); }); } @@ -275,12 +277,12 @@ - (void)application:(NSApplication *)application didFailToContinueUserActivityWi @end -void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning) { +void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning, bool suppressWindow) { @autoreleasepool { // Initialize on the main thread directly, not async [NSApplication sharedApplication]; - BrowseAppDelegate *app = [[BrowseAppDelegate alloc] initWithForceOpenWindow:forceOpenWindow initShow:showStatusItem keepRunning:keepRunning]; + BrowseAppDelegate *app = [[BrowseAppDelegate alloc] initWithForceOpenWindow:forceOpenWindow initShow:showStatusItem keepRunning:keepRunning suppressWindow:suppressWindow]; [NSApp setDelegate:app]; [NSApp finishLaunching]; diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go index 30909ec..647fd00 100644 --- a/apps/finicky/src/rules/rules.go +++ b/apps/finicky/src/rules/rules.go @@ -55,6 +55,7 @@ func (r Rule) MarshalJSON() ([]byte, error) { type Options struct { KeepRunning *bool `json:"keepRunning,omitempty"` HideIcon *bool `json:"hideIcon,omitempty"` + SuppressWindow *bool `json:"suppressWindow,omitempty"` LogRequests *bool `json:"logRequests,omitempty"` CheckForUpdates *bool `json:"checkForUpdates,omitempty"` } @@ -212,6 +213,9 @@ func ToJSConfigScript(rf RulesFile, namespace string) (string, error) { if rf.Options.HideIcon != nil { opts["hideIcon"] = *rf.Options.HideIcon } + if rf.Options.SuppressWindow != nil { + opts["suppressWindow"] = *rf.Options.SuppressWindow + } if rf.Options.LogRequests != nil { opts["logRequests"] = *rf.Options.LogRequests } diff --git a/packages/config-api/src/configSchema.ts b/packages/config-api/src/configSchema.ts index cea27ae..08d056c 100644 --- a/packages/config-api/src/configSchema.ts +++ b/packages/config-api/src/configSchema.ts @@ -135,7 +135,11 @@ const ConfigOptionsSchema = z logRequests: z.boolean().optional().describe("Log to file on disk"), checkForUpdates: z.boolean().optional().describe("Check for updates"), keepRunning: z.boolean().optional().describe("Keep the app running"), - hideIcon: z.boolean().optional().describe("Hide the app icon") + hideIcon: z.boolean().optional().describe("Hide the app icon"), + suppressWindow: z + .boolean() + .optional() + .describe("Don't open the window automatically on launch") }) .identifier("ConfigOptions"); diff --git a/packages/finicky-ui/src/pages/StartPage.svelte b/packages/finicky-ui/src/pages/StartPage.svelte index 3c237c5..497bb4a 100644 --- a/packages/finicky-ui/src/pages/StartPage.svelte +++ b/packages/finicky-ui/src/pages/StartPage.svelte @@ -30,6 +30,7 @@ // Local editable state, seeded from rules.json overrides then JS config then defaults let keepRunning = rulesFile.options?.keepRunning ?? config.options?.keepRunning ?? true; let hideIcon = rulesFile.options?.hideIcon ?? config.options?.hideIcon ?? false; + let suppressWindow = rulesFile.options?.suppressWindow ?? config.options?.suppressWindow ?? false; let logRequests = rulesFile.options?.logRequests ?? config.options?.logRequests ?? false; let checkForUpdates = rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true; @@ -45,6 +46,7 @@ $: { keepRunning = rulesFile.options?.keepRunning ?? config.options?.keepRunning ?? true; hideIcon = rulesFile.options?.hideIcon ?? config.options?.hideIcon ?? false; + suppressWindow = rulesFile.options?.suppressWindow ?? config.options?.suppressWindow ?? false; logRequests = rulesFile.options?.logRequests ?? config.options?.logRequests ?? false; checkForUpdates = rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true; defaultBrowser = isJSConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser || SAFARI); @@ -64,7 +66,7 @@ ...rulesFile, defaultBrowser, defaultProfile, - options: { keepRunning, hideIcon, logRequests, checkForUpdates }, + options: { keepRunning, hideIcon, suppressWindow, logRequests, checkForUpdates }, }, }); } @@ -79,7 +81,7 @@ ...rulesFile, defaultBrowser, defaultProfile, - options: { keepRunning, hideIcon, logRequests, checkForUpdates }, + options: { keepRunning, hideIcon, suppressWindow, logRequests, checkForUpdates }, }, }); }, SAVE_DEBOUNCE); @@ -164,6 +166,14 @@ onLockedClick={onLockedClick} onchange={scheduleSave} /> + Date: Fri, 5 Jun 2026 15:56:19 +0000 Subject: [PATCH 03/10] fix(ui): include suppressWindow in config options sent to the UI The suppressWindow value was omitted from the config message sent to the webview, so the new "Don't open window" toggle always rendered off even when the option was enabled in the config. Send it like the other options. --- apps/finicky/src/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index b8953b5..7c2706a 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -489,6 +489,7 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error "options": map[string]interface{}{ "keepRunning": opts.KeepRunning, "hideIcon": opts.HideIcon, + "suppressWindow": opts.SuppressWindow, "logRequests": opts.LogRequests, "checkForUpdates": opts.CheckForUpdates, }, From 17df6158095a4cda783fe09f1554a228cd5d0133 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:01:54 +0000 Subject: [PATCH 04/10] fix(window): keep Finicky in background when routing URLs from other apps When the config window was already open in the background and a URL was opened from another app, delivering the GetURL Apple Event activated Finicky and pulled the open window to the foreground, flashing it before the target browser opened. When Finicky wasn't the active app, hide it again on the next runloop turn so it stays out of the way. --- apps/finicky/src/main.m | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 9412390..5b663a0 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -251,6 +251,17 @@ - (void)handleGetURLEvent:(NSAppleEventDescriptor *)event // If Finicky isn't frontmost, we take that to mean that the browser should, by default, be opened in the background HandleURL((char*)url, (char*)name, (char*)bundleId, (char*)path, windowTitle, !finickyIsInFront); free(windowTitle); + + // When routing a URL while we weren't the active app, delivering the Apple + // Event can pull Finicky — and any open config window — to the foreground, + // briefly flashing it in front of the browser we're about to open. Order the + // window back and hand activation back so it stays out of the way. Deferred to + // the next runloop turn so it runs after any system-initiated activation. + if (!finickyIsInFront) { + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp hide:nil]; + }); + } } - (bool)application:(NSApplication *)application willContinueUserActivityWithType:(NSString *)userActivityType { From 685eed97dbaa3c5ef2e65a8ff3e6a84a36b8f4d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:04:19 +0000 Subject: [PATCH 05/10] fix(build): remove stale .app bundles before building A second local build failed with "Directory not empty" because `mv Finicky-arm64.app Finicky.app` nests the new bundle inside the existing Finicky.app directory instead of replacing it. Clean the previous build's .app bundles before building. --- scripts/build.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/build.sh b/scripts/build.sh index cc67f33..7c75c8a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -62,6 +62,13 @@ build_arch() { -o ../build/${APP_NAME}/Contents/MacOS/Finicky } +# Remove stale .app bundles from previous builds. Without this, a second local +# build fails because `mv Finicky-arm64.app Finicky.app` nests the new bundle +# inside the existing Finicky.app directory instead of replacing it. +rm -rf apps/finicky/build/Finicky.app \ + apps/finicky/build/Finicky-arm64.app \ + apps/finicky/build/Finicky-amd64.app + if [ "${BUILD_UNIVERSAL:-0}" = "1" ]; then build_arch arm64 build_arch amd64 From ee56201df824b4d7e949272707efaade626bf853 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:15:16 +0000 Subject: [PATCH 06/10] refactor(window): rename suppressWindow to hideWindowOnStart and drop launch deferral Rename the config option to hideWindowOnStart, which describes precisely what it does: the window is only ever auto-opened on launch (routing a URL never opens it), so the option simply controls the launch window. Updated across the schema, option reader, JSON rules, Objective-C delegate, and the UI (now labelled "Hide window on start"). Also remove the cold-launch auto-open deferral. The window auto-opens only on launch, so the synchronous check is sufficient; the timed deferral guarded a launch-time race that isn't worth the added complexity. The background-hide on URL routing is unaffected. --- apps/finicky/src/config/vm.go | 40 ++++++++-------- apps/finicky/src/main.go | 16 +++---- apps/finicky/src/main.h | 6 +-- apps/finicky/src/main.m | 48 +++++++------------ apps/finicky/src/rules/rules.go | 14 +++--- packages/config-api/src/configSchema.ts | 4 +- .../finicky-ui/src/pages/StartPage.svelte | 14 +++--- packages/finicky-ui/src/types.ts | 2 +- 8 files changed, 64 insertions(+), 80 deletions(-) diff --git a/apps/finicky/src/config/vm.go b/apps/finicky/src/config/vm.go index a6daab7..e30c67c 100644 --- a/apps/finicky/src/config/vm.go +++ b/apps/finicky/src/config/vm.go @@ -17,11 +17,11 @@ type VM struct { // ConfigOptions holds the values of all runtime config options. type ConfigOptions struct { - KeepRunning bool - HideIcon bool - SuppressWindow bool - LogRequests bool - CheckForUpdates bool + KeepRunning bool + HideIcon bool + HideWindowOnStart bool + LogRequests bool + CheckForUpdates bool } // ConfigState represents the current state of the configuration @@ -155,21 +155,21 @@ func (vm *VM) SetIsJSConfig(v bool) { // Safe to call on a nil VM — returns defaults in that case. func (vm *VM) GetAllConfigOptions() ConfigOptions { defaults := ConfigOptions{ - KeepRunning: true, - HideIcon: false, - SuppressWindow: false, - LogRequests: false, - CheckForUpdates: true, + KeepRunning: true, + HideIcon: false, + HideWindowOnStart: false, + LogRequests: false, + CheckForUpdates: true, } if vm == nil || vm.runtime == nil { return defaults } script := `({ - keepRunning: finickyConfigAPI.getOption('keepRunning', finalConfig, true), - hideIcon: finickyConfigAPI.getOption('hideIcon', finalConfig, false), - suppressWindow: finickyConfigAPI.getOption('suppressWindow', finalConfig, false), - logRequests: finickyConfigAPI.getOption('logRequests', finalConfig, false), - checkForUpdates: finickyConfigAPI.getOption('checkForUpdates', finalConfig, true) + keepRunning: finickyConfigAPI.getOption('keepRunning', finalConfig, true), + hideIcon: finickyConfigAPI.getOption('hideIcon', finalConfig, false), + hideWindowOnStart: finickyConfigAPI.getOption('hideWindowOnStart', finalConfig, false), + logRequests: finickyConfigAPI.getOption('logRequests', finalConfig, false), + checkForUpdates: finickyConfigAPI.getOption('checkForUpdates', finalConfig, true) })` val, err := vm.runtime.RunString(script) if err != nil { @@ -178,11 +178,11 @@ func (vm *VM) GetAllConfigOptions() ConfigOptions { } obj := val.ToObject(vm.runtime) return ConfigOptions{ - KeepRunning: obj.Get("keepRunning").ToBoolean(), - HideIcon: obj.Get("hideIcon").ToBoolean(), - SuppressWindow: obj.Get("suppressWindow").ToBoolean(), - LogRequests: obj.Get("logRequests").ToBoolean(), - CheckForUpdates: obj.Get("checkForUpdates").ToBoolean(), + KeepRunning: obj.Get("keepRunning").ToBoolean(), + HideIcon: obj.Get("hideIcon").ToBoolean(), + HideWindowOnStart: obj.Get("hideWindowOnStart").ToBoolean(), + LogRequests: obj.Get("logRequests").ToBoolean(), + CheckForUpdates: obj.Get("checkForUpdates").ToBoolean(), } } diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 7c2706a..00aa623 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -249,13 +249,13 @@ func main() { }() shouldHideIcon := false - suppressWindow := false + hideWindowOnStart := false if vm != nil { opts := vm.GetAllConfigOptions() shouldHideIcon = opts.HideIcon - suppressWindow = opts.SuppressWindow + hideWindowOnStart = opts.HideWindowOnStart } - C.RunApp(C.bool(forceWindowOpen), C.bool(!shouldHideIcon), C.bool(shouldKeepRunning), C.bool(suppressWindow)) + C.RunApp(C.bool(forceWindowOpen), C.bool(!shouldHideIcon), C.bool(shouldKeepRunning), C.bool(hideWindowOnStart)) } func handleRuntimeError(err error) { @@ -487,11 +487,11 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error "configPath": util.ShortenPath(configInfo.ConfigPath), "isJSConfig": newVM.IsJSConfig(), "options": map[string]interface{}{ - "keepRunning": opts.KeepRunning, - "hideIcon": opts.HideIcon, - "suppressWindow": opts.SuppressWindow, - "logRequests": opts.LogRequests, - "checkForUpdates": opts.CheckForUpdates, + "keepRunning": opts.KeepRunning, + "hideIcon": opts.HideIcon, + "hideWindowOnStart": opts.HideWindowOnStart, + "logRequests": opts.LogRequests, + "checkForUpdates": opts.CheckForUpdates, }, }) diff --git a/apps/finicky/src/main.h b/apps/finicky/src/main.h index 092826a..ab07486 100644 --- a/apps/finicky/src/main.h +++ b/apps/finicky/src/main.h @@ -20,13 +20,13 @@ extern char* GetCurrentConfigPath(); @property (nonatomic) bool receivedURL; @property (nonatomic) bool keepRunning; @property (nonatomic) bool showMenuItem; - @property (nonatomic) bool suppressWindow; - - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning suppressWindow:(bool)suppressWindow; + @property (nonatomic) bool hideWindowOnStart; + - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning hideWindowOnStart:(bool)hideWindowOnStart; - (void)handleGetURLEvent:(NSAppleEventDescriptor *) event withReplyEvent:(NSAppleEventDescriptor *)replyEvent; - (bool)application:(NSApplication *)sender openFile:(NSString *)filename; @end #endif -void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning, bool suppressWindow); +void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning, bool hideWindowOnStart); #endif /* MAIN_H */ diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 5b663a0..2f102e7 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -7,13 +7,6 @@ #import "window/window.h" // For ShowWindow() -// On a cold launch, the GetURL/openFile Apple Event that Finicky was started to -// handle is not guaranteed to have been delivered by the time -// applicationDidFinishLaunching: runs. We defer the auto-open-window decision by -// this much so a pending URL event has a chance to set receivedURL first, -// otherwise the config window briefly flashes before the browser launches. -static const double kWindowAutoOpenDelaySeconds = 0.2; - // Extend BrowseAppDelegate to hold a status item and declare menu action @interface BrowseAppDelegate () @property (nonatomic, strong) NSStatusItem *statusItem; @@ -22,13 +15,13 @@ - (void)showWindowAction:(id)sender; @implementation BrowseAppDelegate -- (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning suppressWindow:(bool)suppressWindow { +- (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning hideWindowOnStart:(bool)hideWindowOnStart { self = [super init]; if (self) { _forceOpenWindow = forceOpenWindow; _showMenuItem = showMenuItem; _keepRunning = keepRunning; - _suppressWindow = suppressWindow; + _hideWindowOnStart = hideWindowOnStart; _receivedURL = false; } return self; @@ -38,30 +31,21 @@ - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)sho - (void)applicationDidFinishLaunching:(NSNotification *)notification { [self terminateOtherInstances]; - if (self.forceOpenWindow) { - // The window was explicitly requested (e.g. via --window), so there's no - // need to wait on a possible incoming URL — show it right away. - if (self.showMenuItem) { - [self createStatusItem]; - } - QueueWindowDisplay(true); - return; + bool openWindow = self.forceOpenWindow; + if (!openWindow) { + // Open the window on launch unless we received a URL to handle, or the + // user opted out of the automatic launch window via hideWindowOnStart. + openWindow = !self.receivedURL && !self.hideWindowOnStart; } - // Defer the auto-open decision so a URL event delivered slightly after launch - // suppresses the window instead of racing it (see kWindowAutoOpenDelaySeconds). - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kWindowAutoOpenDelaySeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - // Only show menu item if the option is enabled, and we either didn't receive a URL or we are keeping - // the application running. We don't want to show the icon if Finicky is just receiving a url to open - // and is expected to exit after - if (self.showMenuItem && (self.keepRunning || !self.receivedURL)) { - [self createStatusItem]; - } + // Only show menu item if the option is enabled, and we either didn't receive a URL or we are keeping + // the application running. We don't want to show the icon if Finicky is just receiving a url to open + // and is expected to exit after + if (self.showMenuItem && (self.keepRunning || !self.receivedURL)) { + [self createStatusItem]; + } - // Open the window only if we didn't end up receiving a URL to handle, - // unless the user opted out of the automatic window entirely. - QueueWindowDisplay(!self.receivedURL && !self.suppressWindow); - }); + QueueWindowDisplay(openWindow); } // Ensure only one Finicky process is running. macOS's Launch Services normally @@ -288,12 +272,12 @@ - (void)application:(NSApplication *)application didFailToContinueUserActivityWi @end -void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning, bool suppressWindow) { +void RunApp(bool forceOpenWindow, bool showStatusItem, bool keepRunning, bool hideWindowOnStart) { @autoreleasepool { // Initialize on the main thread directly, not async [NSApplication sharedApplication]; - BrowseAppDelegate *app = [[BrowseAppDelegate alloc] initWithForceOpenWindow:forceOpenWindow initShow:showStatusItem keepRunning:keepRunning suppressWindow:suppressWindow]; + BrowseAppDelegate *app = [[BrowseAppDelegate alloc] initWithForceOpenWindow:forceOpenWindow initShow:showStatusItem keepRunning:keepRunning hideWindowOnStart:hideWindowOnStart]; [NSApp setDelegate:app]; [NSApp finishLaunching]; diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go index 647fd00..46a03a2 100644 --- a/apps/finicky/src/rules/rules.go +++ b/apps/finicky/src/rules/rules.go @@ -53,11 +53,11 @@ func (r Rule) MarshalJSON() ([]byte, error) { } type Options struct { - KeepRunning *bool `json:"keepRunning,omitempty"` - HideIcon *bool `json:"hideIcon,omitempty"` - SuppressWindow *bool `json:"suppressWindow,omitempty"` - LogRequests *bool `json:"logRequests,omitempty"` - CheckForUpdates *bool `json:"checkForUpdates,omitempty"` + KeepRunning *bool `json:"keepRunning,omitempty"` + HideIcon *bool `json:"hideIcon,omitempty"` + HideWindowOnStart *bool `json:"hideWindowOnStart,omitempty"` + LogRequests *bool `json:"logRequests,omitempty"` + CheckForUpdates *bool `json:"checkForUpdates,omitempty"` } type RulesFile struct { @@ -213,8 +213,8 @@ func ToJSConfigScript(rf RulesFile, namespace string) (string, error) { if rf.Options.HideIcon != nil { opts["hideIcon"] = *rf.Options.HideIcon } - if rf.Options.SuppressWindow != nil { - opts["suppressWindow"] = *rf.Options.SuppressWindow + if rf.Options.HideWindowOnStart != nil { + opts["hideWindowOnStart"] = *rf.Options.HideWindowOnStart } if rf.Options.LogRequests != nil { opts["logRequests"] = *rf.Options.LogRequests diff --git a/packages/config-api/src/configSchema.ts b/packages/config-api/src/configSchema.ts index 08d056c..af7062e 100644 --- a/packages/config-api/src/configSchema.ts +++ b/packages/config-api/src/configSchema.ts @@ -136,10 +136,10 @@ const ConfigOptionsSchema = z checkForUpdates: z.boolean().optional().describe("Check for updates"), keepRunning: z.boolean().optional().describe("Keep the app running"), hideIcon: z.boolean().optional().describe("Hide the app icon"), - suppressWindow: z + hideWindowOnStart: z .boolean() .optional() - .describe("Don't open the window automatically on launch") + .describe("Don't open the window when Finicky starts") }) .identifier("ConfigOptions"); diff --git a/packages/finicky-ui/src/pages/StartPage.svelte b/packages/finicky-ui/src/pages/StartPage.svelte index 497bb4a..92a56f3 100644 --- a/packages/finicky-ui/src/pages/StartPage.svelte +++ b/packages/finicky-ui/src/pages/StartPage.svelte @@ -30,7 +30,7 @@ // Local editable state, seeded from rules.json overrides then JS config then defaults let keepRunning = rulesFile.options?.keepRunning ?? config.options?.keepRunning ?? true; let hideIcon = rulesFile.options?.hideIcon ?? config.options?.hideIcon ?? false; - let suppressWindow = rulesFile.options?.suppressWindow ?? config.options?.suppressWindow ?? false; + let hideWindowOnStart = rulesFile.options?.hideWindowOnStart ?? config.options?.hideWindowOnStart ?? false; let logRequests = rulesFile.options?.logRequests ?? config.options?.logRequests ?? false; let checkForUpdates = rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true; @@ -46,7 +46,7 @@ $: { keepRunning = rulesFile.options?.keepRunning ?? config.options?.keepRunning ?? true; hideIcon = rulesFile.options?.hideIcon ?? config.options?.hideIcon ?? false; - suppressWindow = rulesFile.options?.suppressWindow ?? config.options?.suppressWindow ?? false; + hideWindowOnStart = rulesFile.options?.hideWindowOnStart ?? config.options?.hideWindowOnStart ?? false; logRequests = rulesFile.options?.logRequests ?? config.options?.logRequests ?? false; checkForUpdates = rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true; defaultBrowser = isJSConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser || SAFARI); @@ -66,7 +66,7 @@ ...rulesFile, defaultBrowser, defaultProfile, - options: { keepRunning, hideIcon, suppressWindow, logRequests, checkForUpdates }, + options: { keepRunning, hideIcon, hideWindowOnStart, logRequests, checkForUpdates }, }, }); } @@ -81,7 +81,7 @@ ...rulesFile, defaultBrowser, defaultProfile, - options: { keepRunning, hideIcon, suppressWindow, logRequests, checkForUpdates }, + options: { keepRunning, hideIcon, hideWindowOnStart, logRequests, checkForUpdates }, }, }); }, SAVE_DEBOUNCE); @@ -167,9 +167,9 @@ onchange={scheduleSave} /> Date: Fri, 5 Jun 2026 16:27:05 +0000 Subject: [PATCH 07/10] revert: restore original bundle version in Info.plist The version label was bumped only to distinguish local fork test builds; revert it so this PR doesn't change versioning, which is the maintainer's call. This also removes the non-Apple-compliant +suppress-window suffix from CFBundleShortVersionString/CFBundleVersion. --- apps/finicky/assets/Info.plist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/finicky/assets/Info.plist b/apps/finicky/assets/Info.plist index 854e181..64dd522 100644 --- a/apps/finicky/assets/Info.plist +++ b/apps/finicky/assets/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 4.4.0-alpha+suppress-window + 4.4.0-alpha CFBundleVersion - 4.4.0-alpha+suppress-window + 4.4.0-alpha CFBundleIconFile finicky.icns LSUIElement From c6ff5e7c4271f6848bc59c02e801c7ab60a017d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:28:34 +0000 Subject: [PATCH 08/10] fix(startup): build VM from rules files that contain only options setupVM only constructed a VM when a rules file had a defaultBrowser or rules, so a rules file with only an options block (e.g. hideWindowOnStart) left the VM nil and the options fell back to defaults at startup. Build the VM when options are present too, matching the SaveRulesHandler path. --- apps/finicky/src/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 00aa623..7689117 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -449,7 +449,7 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error slog.Warn("Failed to load rules file", "error", rulesErr) } else { resolver.SetCachedRules(rf) - if rf.DefaultBrowser != "" || len(rf.Rules) > 0 { + if rf.DefaultBrowser != "" || len(rf.Rules) > 0 || rf.Options != nil { script, scriptErr := rules.ToJSConfigScript(rf, namespace) if scriptErr != nil { return nil, fmt.Errorf("failed to generate config from rules: %v", scriptErr) From a6f8ce16edd52a2b63da09a56c5ddcc9b18aef5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:30:11 +0000 Subject: [PATCH 09/10] test(rules): cover options-only ToJSConfigScript output Guards the startup fix: a rules file with only an options block must still serialize its options (e.g. hideWindowOnStart) into the generated config script, falling back to the default browser. --- apps/finicky/src/rules/rules_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/finicky/src/rules/rules_test.go b/apps/finicky/src/rules/rules_test.go index 0b81fc5..5070298 100644 --- a/apps/finicky/src/rules/rules_test.go +++ b/apps/finicky/src/rules/rules_test.go @@ -124,6 +124,24 @@ func TestToJSConfigScript_NamespaceIsUsed(t *testing.T) { } } +// A rules file with only an options block (no defaultBrowser or rules) must +// still emit those options so flags like hideWindowOnStart take effect at +// startup, falling back to the default browser. +func TestToJSConfigScript_OptionsOnly(t *testing.T) { + hide := true + rf := RulesFile{Options: &Options{HideWindowOnStart: &hide}} + script, err := ToJSConfigScript(rf, "finickyConfig") + if err != nil { + t.Fatal(err) + } + if !contains(script, `"hideWindowOnStart":true`) { + t.Errorf("expected hideWindowOnStart option in script, got: %s", script) + } + if !contains(script, "com.apple.Safari") { + t.Errorf("expected fallback default browser in script, got: %s", script) + } +} + // ---- Load / Save round-trip ---- func TestLoadSave_RoundTrip(t *testing.T) { From c6768732690b80b6b131f97eb42755cc0200352d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:13:10 +0000 Subject: [PATCH 10/10] revert(window): drop NSApp hide on URL routing This unconditional [NSApp hide:nil] when routing a URL from another app was out of scope for the hideWindowOnStart feature: it changed behavior for all users regardless of the option and could hide a window the user deliberately left open. With hideWindowOnStart the window no longer auto-opens, so there's nothing to flash; the manual-open edge case, if it matters, belongs in its own focused change. --- apps/finicky/src/main.m | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 2f102e7..5838c30 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -235,17 +235,6 @@ - (void)handleGetURLEvent:(NSAppleEventDescriptor *)event // If Finicky isn't frontmost, we take that to mean that the browser should, by default, be opened in the background HandleURL((char*)url, (char*)name, (char*)bundleId, (char*)path, windowTitle, !finickyIsInFront); free(windowTitle); - - // When routing a URL while we weren't the active app, delivering the Apple - // Event can pull Finicky — and any open config window — to the foreground, - // briefly flashing it in front of the browser we're about to open. Order the - // window back and hand activation back so it stays out of the way. Deferred to - // the next runloop turn so it runs after any system-initiated activation. - if (!finickyIsInFront) { - dispatch_async(dispatch_get_main_queue(), ^{ - [NSApp hide:nil]; - }); - } } - (bool)application:(NSApplication *)application willContinueUserActivityWithType:(NSString *)userActivityType {