From 61256bc38c6811ffd3740a93e88bf95c191ca24c Mon Sep 17 00:00:00 2001 From: Jessica Deen Date: Wed, 15 Jul 2026 23:15:56 -0700 Subject: [PATCH 1/2] Fix menu-bar manager conflict, first-launch Settings, and hotkeys-while-Settings-open Three related menu-bar/Settings fixes: - Remove the status item autosaveName so AppKit no longer persists and re-asserts an absolute menu-bar position, which fought third-party menu-bar managers like Bartender (icon would not stay in a chosen section). Fixes #27. - Open Settings on a fresh install so users have a clear entry point instead of a silent menu-bar-only launch. Upgrading users are detected as returning via an existing-preference migration sentinel and do not get an unexpected pop-up. Fixes #21. - Scope the global-hotkey suspension to active shortcut recording only, instead of the entire time the Settings dialog is open, so shortcuts like the Zoom toggle keep working while Settings is open. Fixes #22. Adds a self-test covering the first-launch flag, its persistence, and the upgrade migration sentinel. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f9580d4-0a5a-45c6-8d9b-0ad776f7d8f9 --- Sources/ZoomItMacCore/App/AppDelegate.swift | 16 +++++++++- .../SelfTest/SelfTestRunner.swift | 32 +++++++++++++++++++ .../Settings/SettingsStore.swift | 27 ++++++++++++++++ .../Settings/SettingsWindowController.swift | 19 ++++++++--- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/Sources/ZoomItMacCore/App/AppDelegate.swift b/Sources/ZoomItMacCore/App/AppDelegate.swift index 0bcc24c..695e114 100644 --- a/Sources/ZoomItMacCore/App/AppDelegate.swift +++ b/Sources/ZoomItMacCore/App/AppDelegate.swift @@ -76,6 +76,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { self?.updateRecordingIndicator(recording) } hotkeyService.start() + + // On a fresh install, open Settings so the user has a clear entry point + // instead of a silent menu-bar-only launch (issue #21). Upgrading users + // are detected as returning by hasCompletedFirstLaunch, so they don't get + // an unexpected pop-up. Persist the flag either way so later launches no + // longer depend on the legacy migration sentinel. + if !settingsStore.hasCompletedFirstLaunch { + appController?.showSettings() + } + settingsStore.markFirstLaunchCompleted() } public func applicationWillTerminate(_ notification: Notification) { @@ -103,7 +113,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private func makeStatusItem(controller: AppController) -> NSStatusItem { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) - item.autosaveName = NSStatusItem.AutosaveName("com.sysinternals.ZoomIt.statusItem") + // Intentionally no autosaveName: persisting an absolute menu-bar slot makes + // AppKit re-assert that position on every relaunch/relayout, which fights + // menu-bar managers like Bartender and Ice (the icon snaps back out of their + // "shown" section). Letting the system place the item keeps ZoomIt compatible + // with those tools; users can still Command-drag it to reorder. // Use the Windows ZoomIt icon (document with a magnifying glass) rendered // as a black template image so it tints to match the menu bar, following // the macOS convention for menu-bar icons. diff --git a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift index 693525e..ed31650 100644 --- a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift +++ b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift @@ -24,6 +24,7 @@ public enum SelfTestRunner { try testTypingAnnotations() try testAnnotationRenderingTouchesPixels() try testSettingsRoundTrip() + try testFirstLaunchFlag() try testDemoTypeSettingsRoundTrip() try testDemoTypeScriptCleaningAndTokens() try testDemoTypeScriptDecoding() @@ -270,6 +271,37 @@ public enum SelfTestRunner { defaults.removePersistentDomain(forName: suiteName) } + private static func testFirstLaunchFlag() throws { + let suiteName = "ZoomItMacSelfTest.FirstLaunch.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw SelfTestError.failure("Could not create test UserDefaults suite") + } + let store = UserDefaultsSettingsStore(defaults: defaults) + + // A fresh store reports first launch so the app opens Settings (issue #21). + try expect(!store.hasCompletedFirstLaunch, "Expected fresh store to report first launch not completed") + + store.markFirstLaunchCompleted() + try expect(store.hasCompletedFirstLaunch, "Expected first launch to be marked completed") + + // The flag must persist so subsequent launches do not reopen Settings. + let reloaded = UserDefaultsSettingsStore(defaults: defaults) + try expect(reloaded.hasCompletedFirstLaunch, "Expected first-launch completion to persist across store instances") + + defaults.removePersistentDomain(forName: suiteName) + + // Migration: a user upgrading from a build without the flag but with prior + // ZoomIt preferences must be treated as returning, not a fresh install. + let legacySuite = "ZoomItMacSelfTest.FirstLaunchLegacy.\(UUID().uuidString)" + guard let legacyDefaults = UserDefaults(suiteName: legacySuite) else { + throw SelfTestError.failure("Could not create legacy test UserDefaults suite") + } + legacyDefaults.set(11281, forKey: "NSStatusItem Preferred Position com.sysinternals.ZoomIt.statusItem") + let legacyStore = UserDefaultsSettingsStore(defaults: legacyDefaults) + try expect(legacyStore.hasCompletedFirstLaunch, "Expected prior status-item position default to count as a completed first launch") + legacyDefaults.removePersistentDomain(forName: legacySuite) + } + private static func testDemoTypeSettingsRoundTrip() throws { let suiteName = "ZoomItMacSelfTest.DemoType.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { diff --git a/Sources/ZoomItMacCore/Settings/SettingsStore.swift b/Sources/ZoomItMacCore/Settings/SettingsStore.swift index fd759bc..c8a0bcb 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsStore.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsStore.swift @@ -238,6 +238,7 @@ final class UserDefaultsSettingsStore: SettingsStore { static let copySnipToClipboardOnSave = "copySnipToClipboardOnSave" static let saveSnipToDirectory = "saveSnipToDirectory" static let snipSaveDirectory = "snipSaveDirectory" + static let hasCompletedFirstLaunch = "hasCompletedFirstLaunch" } private let defaults: UserDefaults @@ -250,6 +251,32 @@ final class UserDefaultsSettingsStore: SettingsStore { defaults.object(forKey: Key.launchAtLogin) != nil } + /// Default key AppKit used to persist the status-item slot in builds that set + /// an `autosaveName`. It reliably indicates the app has run before, so it + /// doubles as a first-launch migration sentinel for upgrading users even + /// though current builds no longer set it. + private static let legacyStatusItemPositionKey = + "NSStatusItem Preferred Position com.sysinternals.ZoomIt.statusItem" + + /// True once the app has run at least once. Used to show the Settings dialog + /// on a fresh install so the user has a clear entry point instead of a silent + /// menu-bar-only launch. Upgrading users who predate this flag are detected + /// via any pre-existing ZoomIt preference so they don't get an unexpected + /// Settings pop-up on their first run of a new build. + var hasCompletedFirstLaunch: Bool { + if defaults.bool(forKey: Key.hasCompletedFirstLaunch) { return true } + return hasAnyExistingPreference + } + + private var hasAnyExistingPreference: Bool { + defaults.object(forKey: Self.legacyStatusItemPositionKey) != nil || + defaults.object(forKey: Key.launchAtLogin) != nil + } + + func markFirstLaunchCompleted() { + defaults.set(true, forKey: Key.hasCompletedFirstLaunch) + } + func load() -> AppSettings { var settings = AppSettings.defaults diff --git a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift index 2aecb70..c781051 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift @@ -134,18 +134,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { demoTypeHotKeyButton?.title = demoTypeHotKeyDisplayString() panoramaHotKeyButton?.title = panoramaHotKeyDisplayString() launchAtLoginCheckbox?.state = settings.launchAtLogin ? .on : .off - // Suspend the global hotkeys while the dialog is open so the user can - // record a new shortcut without it firing; they resume on close. - onSuspendHotkeys() NSApp.activate(ignoringOtherApps: true) window.center() window.makeKeyAndOrderFront(nil) } func windowWillClose(_ notification: Notification) { - // Cancel any in-progress recording and re-enable the global hotkeys. + // Cancel any in-progress recording (which resumes the global hotkeys if + // they were suspended for capture). finishRecording() - onResumeHotkeys() } // MARK: - Window construction @@ -491,6 +488,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { return } recordingTarget = target + // Suspend the global hotkeys only while actively capturing a shortcut so + // the pressed keys are recorded instead of triggering their action. They + // resume as soon as recording finishes, so shortcuts like the Zoom toggle + // keep working while the Settings dialog is merely open (issue #22). + onSuspendHotkeys() sender.title = "Type shortcut… (Esc cancels)" hotKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in guard let self else { return event } @@ -666,11 +668,18 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { } private func finishRecording() { + // Resume the global hotkeys only if a capture was actually in progress, + // keeping the suspend/resume balanced no matter how recording ends + // (successful capture, Esc, toggling the button, or window close). + let wasRecording = recordingTarget != nil if let hotKeyMonitor { NSEvent.removeMonitor(hotKeyMonitor) } hotKeyMonitor = nil recordingTarget = nil + if wasRecording { + onResumeHotkeys() + } hotKeyButton?.title = zoomHotKeyDisplayString() drawHotKeyButton?.title = drawHotKeyDisplayString() liveHotKeyButton?.title = liveHotKeyDisplayString() From 66a06a751de6ab05a1280033faa51a39231c039d Mon Sep 17 00:00:00 2001 From: MarioHewardt Date: Thu, 16 Jul 2026 14:58:37 -0700 Subject: [PATCH 2/2] Trigger CodeQL