-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhalfFullApp.swift
More file actions
230 lines (198 loc) · 10.1 KB
/
Copy pathhalfFullApp.swift
File metadata and controls
230 lines (198 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import Cocoa
import SwiftUI
import ServiceManagement
/// Pure-AppKit entry point. We deliberately do NOT use SwiftUI's `App` protocol —
/// any SwiftUI `Settings { ... }` or `WindowGroup { ... }` scene becomes part of
/// the app's restorable state, and macOS will helpfully re-show whichever
/// SwiftUI windows were open the last time the app quit. That's how we ended up
/// with a stray empty "halfFull Settings" window appearing on launch.
///
/// Pure NSApplicationDelegate + NSWindow lifecycle gives us full control.
@main
final class HalfFullMain {
static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var statusBar: StatusBarController!
private var mainWindowController: MainWindowController?
// We explicitly switched to .regular to show a window — used to suppress the
// launch-to-convert path on subsequent activations (kept for compatibility
// even though the legacy launch-to-convert flow is gone in v3+; harmless).
private var showingMainWindow = false
// MARK: - NSApplicationDelegate
func applicationDidFinishLaunching(_ notification: Notification) {
// Intentionally NOT requesting notification authorization here —
// a fresh install shouldn't pop any system dialogs except the
// Accessibility one (which the user must grant for the hotkey to
// work at all). Notification permission is requested lazily, only
// when the user toggles "Show notifications" ON.
_ = NotificationPresenter.shared
// Build the system menu bar. Pure-AppKit @main with no XIB means
// we get NO menu bar by default — so ⌘Q, ⌘H, ⌘W, the standard
// Edit-menu items (Cut/Copy/Paste/Select All), and ⌘, wouldn't fire.
// Build it programmatically and assign to NSApp.mainMenu.
installMainMenu()
statusBar = StatusBarController(
showMainWindow: { [weak self] in self?.showMainWindow() },
openAbout: { [weak self] in self?.showMainWindow(selectingAboutTab: true) }
)
registerHotKey()
observeHotKeyChanges()
if shouldOpenMainWindowOnLaunch() {
showMainWindow()
} else {
NSApp.setActivationPolicy(.accessory)
}
// Stale-grant auto-recovery on launch:
// • If currently trusted → set the sticky bit (so future updates can
// recognize the "was granted, now stale" state). This runs even
// when the user only uses the menu bar and never opens the window.
// • If sticky bit is set AND not currently trusted → typical
// ad-hoc-update breakage. Auto-fire the system prompt + open
// System Settings so the user lands exactly where they need to be.
// Deferred to next runloop turn so AppKit is fully up; the AX prompt
// needs a parent app context to render in-context.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if AccessibilityHelper.shared.isTrusted {
PreferencesStore.shared.hasGrantedAXBefore = true
} else if PreferencesStore.shared.hasGrantedAXBefore {
AccessibilityHelper.shared.ensureTrustedPrompt()
AccessibilityHelper.shared.openAccessibilitySettings()
}
}
}
// MARK: - Menu bar
private func installMainMenu() {
let appName = Bundle.main.appName
let mainMenu = NSMenu()
// Application menu — title is hidden by macOS (it shows the running
// app's bundle name automatically), so we leave the NSMenuItem
// title blank and only fill the submenu.
let appMenuItem = NSMenuItem()
let appMenu = NSMenu()
appMenu.addItem(item("About \(appName)", target: self,
action: #selector(menuShowMainWindow), key: ""))
appMenu.addItem(.separator())
appMenu.addItem(item("Settings…", target: self,
action: #selector(menuShowMainWindow), key: ","))
appMenu.addItem(.separator())
appMenu.addItem(item("Hide \(appName)", target: nil,
action: #selector(NSApplication.hide(_:)), key: "h"))
let hideOthers = item("Hide Others", target: nil,
action: #selector(NSApplication.hideOtherApplications(_:)), key: "h")
hideOthers.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(hideOthers)
appMenu.addItem(item("Show All", target: nil,
action: #selector(NSApplication.unhideAllApplications(_:)), key: ""))
appMenu.addItem(.separator())
appMenu.addItem(item("Quit \(appName)", target: nil,
action: #selector(NSApplication.terminate(_:)), key: "q"))
appMenuItem.submenu = appMenu
mainMenu.addItem(appMenuItem)
// Edit — standard items wired via responder-chain selectors so the
// active text field in the Settings window picks them up.
let editMenuItem = NSMenuItem()
let editMenu = NSMenu(title: "Edit")
editMenu.addItem(item("Undo", target: nil, action: Selector(("undo:")), key: "z"))
let redo = item("Redo", target: nil, action: Selector(("redo:")), key: "z")
redo.keyEquivalentModifierMask = [.command, .shift]
editMenu.addItem(redo)
editMenu.addItem(.separator())
editMenu.addItem(item("Cut", target: nil, action: #selector(NSText.cut(_:)), key: "x"))
editMenu.addItem(item("Copy", target: nil, action: #selector(NSText.copy(_:)), key: "c"))
editMenu.addItem(item("Paste", target: nil, action: #selector(NSText.paste(_:)), key: "v"))
editMenu.addItem(item("Select All", target: nil, action: #selector(NSText.selectAll(_:)), key: "a"))
editMenuItem.submenu = editMenu
mainMenu.addItem(editMenuItem)
// Window — Minimize / Zoom / Bring All to Front.
let windowMenuItem = NSMenuItem()
let windowMenu = NSMenu(title: "Window")
windowMenu.addItem(item("Minimize", target: nil,
action: #selector(NSWindow.performMiniaturize(_:)), key: "m"))
windowMenu.addItem(item("Zoom", target: nil,
action: #selector(NSWindow.performZoom(_:)), key: ""))
windowMenu.addItem(item("Close", target: nil,
action: #selector(NSWindow.performClose(_:)), key: "w"))
windowMenu.addItem(.separator())
windowMenu.addItem(item("Bring All to Front", target: nil,
action: #selector(NSApplication.arrangeInFront(_:)), key: ""))
windowMenuItem.submenu = windowMenu
mainMenu.addItem(windowMenuItem)
NSApp.windowsMenu = windowMenu
NSApp.mainMenu = mainMenu
}
/// Small helper to keep the menu construction readable.
private func item(_ title: String, target: AnyObject?, action: Selector?, key: String) -> NSMenuItem {
let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
mi.target = target
return mi
}
@objc private func menuShowMainWindow() {
showMainWindow()
}
/// Disable secure state restoration: nothing in this app benefits from
/// macOS auto-reopening windows on next launch, and the SwiftUI Settings
/// scene's restoration was the source of the v3.1 empty-window bug.
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return false
}
private func shouldOpenMainWindowOnLaunch() -> Bool {
// Within the first 90 s of system uptime AND registered as a Login Item ⇒
// treat as a "user just logged in" launch and stay silent in the menu bar.
// Anything else (Finder/Spotlight/manual launch) → show the window.
let uptime = ProcessInfo.processInfo.systemUptime
let isLoginItemRegistered = SMAppService.mainApp.status == .enabled
let looksLikeLoginLaunch = uptime < 90 && isLoginItemRegistered
return !looksLikeLoginLaunch
}
/// Dock-click / Cmd+Tab when no window is visible — reopen the main window.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
if !hasVisibleWindows { showMainWindow() }
return true
}
// MARK: - Hotkey
private func registerHotKey() {
let prefs = PreferencesStore.shared
HotKeyManager.shared.register(keyCode: prefs.hotKeyKeyCode,
carbonModifiers: prefs.hotKeyCarbonModifiers) {
ConversionController.shared.trigger()
}
}
private func observeHotKeyChanges() {
NotificationCenter.default.addObserver(forName: PreferencesStore.hotKeyChangedNotification,
object: nil, queue: .main) { [weak self] _ in
self?.registerHotKey()
}
}
// MARK: - Main window
func showMainWindow(selectingAboutTab: Bool = false) {
if mainWindowController == nil {
mainWindowController = MainWindowController()
mainWindowController?.window?.delegate = self
}
NSApp.setActivationPolicy(.regular)
showingMainWindow = true
if #available(macOS 14.0, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
mainWindowController?.window?.makeKeyAndOrderFront(nil)
_ = selectingAboutTab // v3.1 collapsed tabs; About is always visible at the bottom.
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
guard let closingWindow = notification.object as? NSWindow,
closingWindow === mainWindowController?.window else { return }
showingMainWindow = false
// Defer the policy switch so AppKit finishes the close animation first.
DispatchQueue.main.async {
NSApp.setActivationPolicy(.accessory)
}
}
}