-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathhalfFullApp.swift
More file actions
322 lines (280 loc) · 13.8 KB
/
Copy pathhalfFullApp.swift
File metadata and controls
322 lines (280 loc) · 13.8 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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
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(launchCommand: PlainClipCommand.parse(CommandLine.arguments))
app.delegate = delegate
app.run()
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var statusBar: StatusBarController!
private var mainWindowController: MainWindowController?
private let launchCommand: PlainClipCommand?
init(launchCommand: PlainClipCommand? = nil) {
self.launchCommand = launchCommand
super.init()
}
// 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) {
// Single-instance gate — MUST run before registerHotKey().
//
// halfFull is often present both as /Applications/halfFull.app and as a
// local build/ under the repo. Both share bundle id me.taresky.halffull
// and both RegisterEventHotKey the same binding. Carbon delivers the
// hotkey to every registrant, so one trusted instance converts while an
// untrusted twin pops the Accessibility sheet on every press — exactly
// the "works, but keeps asking for permission" symptom.
if launchCommand == nil && Self.handoffToExistingInstanceIfAny() {
NSApp.terminate(nil)
return
}
// CLI work is deliberately performed by this short-lived process, even
// when the resident app is running. Distributed notifications cannot
// authenticate their sender, so they must never become a route to use
// halfFull's trusted PostEvent permission for `-v` keyboard injection.
// This path also avoids all window, status-item, and hotkey startup cost.
if let launchCommand {
NSApp.setActivationPolicy(.accessory)
DispatchQueue.main.async {
PlainClipController.shared.trigger(
optionsOverride: launchCommand.options,
pasteAfterCleaning: launchCommand.pasteAfterCleaning
) {
NSApp.terminate(nil)
}
}
return
}
observeActivateRequests()
// 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) }
)
registerHotKeys()
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.refreshTrustState() == .staleGrant {
AccessibilityHelper.shared.ensureTrustedPrompt()
AccessibilityHelper.shared.openAccessibilitySettings()
}
}
}
// MARK: - Single instance
/// Posted by a second launch that is about to exit, so the surviving
/// instance can surface its settings window (Dock re-open doesn't fire
/// when the second process is a different on-disk path).
private static let activateNotification =
Notification.Name("me.taresky.halffull.activateExisting")
/// If another process with our bundle id is already running, activate it
/// and return `true` so the caller can terminate this one.
private static func handoffToExistingInstanceIfAny() -> Bool {
let bid = Bundle.main.bundleIdentifier ?? "me.taresky.halffull"
let others = NSRunningApplication.runningApplications(withBundleIdentifier: bid)
.filter { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier
&& !$0.isTerminated }
guard let other = others.first else { return false }
DistributedNotificationCenter.default().postNotificationName(
activateNotification,
object: bid,
userInfo: nil,
deliverImmediately: true
)
other.activate(options: [.activateIgnoringOtherApps])
return true
}
private func observeActivateRequests() {
let bid = Bundle.main.bundleIdentifier ?? "me.taresky.halffull"
DistributedNotificationCenter.default().addObserver(
forName: Self.activateNotification,
object: bid,
queue: .main
) { [weak self] _ in
self?.showMainWindow()
}
}
// 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 {
// Login-item launches are detected from the launching Apple Event
// (keyAELaunchedAsLogInItem) — authoritative, so a manual open right
// after boot still shows the window. The uptime heuristic only breaks
// ties when no event is available. See LaunchReason.
let isLoginLaunch = LaunchReason.isLoginItemLaunch(
event: NSAppleEventManager.shared().currentAppleEvent,
systemUptime: ProcessInfo.processInfo.systemUptime,
isLoginItemRegistered: SMAppService.mainApp.status == .enabled)
return !isLoginLaunch
}
/// 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 registerHotKeys() {
registerHotKey(for: .focusedText)
registerHotKey(for: .clipboard)
}
private func registerHotKey(for mode: TargetMode) {
let prefs = PreferencesStore.shared
switch mode {
case .focusedText:
HotKeyManager.shared.register(prefs.hotKey(for: mode), for: mode) {
ConversionController.shared.trigger()
}
case .clipboard:
HotKeyManager.shared.register(prefs.hotKey(for: mode), for: mode) {
PlainClipController.shared.trigger()
}
}
}
private func observeHotKeyChanges() {
NotificationCenter.default.addObserver(forName: PreferencesStore.hotKeyChangedNotification,
object: nil, queue: .main) { [weak self] note in
if let mode = note.object as? TargetMode {
self?.registerHotKey(for: mode)
} else {
self?.registerHotKeys()
}
}
}
// 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)
}
}
}