Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Sources/ZoomItMacCore/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum SelfTestRunner {
try testTypingAnnotations()
try testAnnotationRenderingTouchesPixels()
try testSettingsRoundTrip()
try testFirstLaunchFlag()
try testDemoTypeSettingsRoundTrip()
try testDemoTypeScriptCleaningAndTokens()
try testDemoTypeScriptDecoding()
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions Sources/ZoomItMacCore/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
19 changes: 14 additions & 5 deletions Sources/ZoomItMacCore/Settings/SettingsWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
Expand Down