AppDelegate.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. //
  2. // AppDelegate.swift
  3. // google_apps
  4. //
  5. // Created by Dev Mac 1 on 27/03/2026.
  6. //
  7. import Cocoa
  8. import Combine
  9. import SwiftUI
  10. @main
  11. class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
  12. private var statusItem: NSStatusItem?
  13. private weak var launcherWindowReference: NSWindow?
  14. private var launcherWindowResignObserver: NSObjectProtocol?
  15. private var refocusLauncherObserver: NSObjectProtocol?
  16. private var appearanceChangedObserver: NSObjectProtocol?
  17. private var effectiveAppearanceObservation: NSKeyValueObservation?
  18. private var startupPaywallWindow: NSWindow?
  19. private var hasPresentedStartupPaywall = false
  20. private var premiumUnlockObservation: NSKeyValueObservation?
  21. private var premiumStateMonitorTask: Task<Void, Never>?
  22. private var launcherWindow: NSWindow? {
  23. if let launcherWindowReference, launcherWindowReference.contentViewController is ViewController {
  24. return launcherWindowReference
  25. }
  26. let resolved =
  27. NSApp.windows.first(where: { $0.contentViewController is ViewController }) ??
  28. NSApp.orderedWindows.first(where: { $0.contentViewController is ViewController }) ??
  29. NSApp.windows.first ??
  30. NSApp.orderedWindows.first
  31. launcherWindowReference = resolved
  32. return resolved
  33. }
  34. func applicationDidFinishLaunching(_ aNotification: Notification) {
  35. applyBundledAppIcon()
  36. configureMainWindowIfNeeded(centerOnLaunch: true)
  37. observeRefocusLauncherRequests()
  38. setupStatusBarItem()
  39. StatusBarAppIconsController.shared.start()
  40. presentLauncherAndPaywallOnLaunch()
  41. }
  42. private func observeRefocusLauncherRequests() {
  43. if let refocusLauncherObserver {
  44. NotificationCenter.default.removeObserver(refocusLauncherObserver)
  45. self.refocusLauncherObserver = nil
  46. }
  47. refocusLauncherObserver = NotificationCenter.default.addObserver(
  48. forName: .refocusLauncherWindowRequested,
  49. object: nil,
  50. queue: .main
  51. ) { [weak self] _ in
  52. guard let self else { return }
  53. // Defer a tick so window-close/miniaturize animations complete cleanly.
  54. DispatchQueue.main.async {
  55. self.showMainWindow()
  56. }
  57. }
  58. }
  59. private func presentLauncherAndPaywallOnLaunch() {
  60. // Bring the launcher up-front on cold launch, then show the paywall on top
  61. // for non-premium users. The paywall is presented as an AppKit child window
  62. // (instead of a SwiftUI .sheet) to avoid the resign-key observer below
  63. // tearing the window stack down the moment the sheet takes key focus.
  64. showMainWindow()
  65. Task { @MainActor in
  66. await PremiumStore.shared.refreshEntitlements()
  67. guard !PremiumStore.shared.isPremiumUnlocked else { return }
  68. self.presentStartupPaywallIfNeeded()
  69. }
  70. }
  71. @MainActor
  72. private func presentStartupPaywallIfNeeded() {
  73. guard !hasPresentedStartupPaywall else { return }
  74. guard !PremiumStore.shared.isPremiumUnlocked else { return }
  75. guard let parent = launcherWindow else { return }
  76. presentPremiumPaywall(over: parent, storeAsStartupPaywall: true)
  77. hasPresentedStartupPaywall = true
  78. observePremiumUnlockToAutoDismissPaywall()
  79. }
  80. /// Presents the premium paywall as a non-draggable, non-resizable child window that exactly covers `parent`.
  81. @MainActor
  82. func presentPremiumPaywall(over parent: NSWindow? = nil, storeAsStartupPaywall: Bool = false) {
  83. if storeAsStartupPaywall {
  84. // Startup paywall is only for non-premium users.
  85. guard !PremiumStore.shared.isPremiumUnlocked else { return }
  86. } else {
  87. // Allow showing the paywall for the yearly->lifetime upgrade flow.
  88. if PremiumStore.shared.isPremiumUnlocked && PremiumStore.shared.activePremiumProductID != .yearly {
  89. return
  90. }
  91. }
  92. guard let parentWindow = parent ?? launcherWindow else { return }
  93. if let existing = startupPaywallWindow, existing.isVisible {
  94. // Ensure it stays pinned to the parent’s current frame.
  95. existing.setFrame(PremiumPaywallWindowSizing.paywallFrame(forParentWindow: parentWindow), display: false)
  96. existing.makeKeyAndOrderFront(nil)
  97. NSApp.activate(ignoringOtherApps: true)
  98. return
  99. }
  100. let paywallFrame = PremiumPaywallWindowSizing.paywallFrame(forParentWindow: parentWindow)
  101. let paywallWindow = NSWindow(
  102. contentRect: paywallFrame,
  103. styleMask: [.borderless],
  104. backing: .buffered,
  105. defer: false
  106. )
  107. paywallWindow.isReleasedWhenClosed = false
  108. paywallWindow.delegate = self
  109. paywallWindow.isMovable = false
  110. paywallWindow.isMovableByWindowBackground = false
  111. paywallWindow.hasShadow = true
  112. paywallWindow.isOpaque = false
  113. paywallWindow.backgroundColor = .clear
  114. paywallWindow.level = .floating
  115. paywallWindow.minSize = paywallFrame.size
  116. paywallWindow.maxSize = paywallFrame.size
  117. let hosting = NSHostingController(
  118. rootView: PremiumFeaturesView(onDismissFromAppKitHost: { [weak self] in
  119. self?.closeStartupPaywall()
  120. })
  121. .environmentObject(PremiumStore.shared)
  122. )
  123. hosting.view.frame = NSRect(origin: .zero, size: paywallFrame.size)
  124. hosting.view.autoresizingMask = [.width, .height]
  125. paywallWindow.contentViewController = hosting
  126. if let layer = paywallWindow.contentView?.layer {
  127. paywallWindow.contentView?.wantsLayer = true
  128. layer.cornerRadius = 16
  129. if #available(macOS 11.0, *) {
  130. layer.cornerCurve = .continuous
  131. }
  132. layer.masksToBounds = true
  133. }
  134. paywallWindow.setFrame(paywallFrame, display: false)
  135. if storeAsStartupPaywall {
  136. startupPaywallWindow = paywallWindow
  137. } else {
  138. startupPaywallWindow = paywallWindow
  139. }
  140. parentWindow.addChildWindow(paywallWindow, ordered: .above)
  141. paywallWindow.makeKeyAndOrderFront(nil)
  142. NSApp.activate(ignoringOtherApps: true)
  143. }
  144. @MainActor
  145. private func closeStartupPaywall() {
  146. guard let window = startupPaywallWindow else { return }
  147. window.parent?.removeChildWindow(window)
  148. window.orderOut(nil)
  149. startupPaywallWindow = nil
  150. premiumStateMonitorTask?.cancel()
  151. premiumStateMonitorTask = nil
  152. }
  153. private func observePremiumUnlockToAutoDismissPaywall() {
  154. premiumStateMonitorTask?.cancel()
  155. premiumStateMonitorTask = Task { @MainActor [weak self] in
  156. for await unlocked in PremiumStore.shared.$isPremiumUnlocked.values {
  157. guard let self else { return }
  158. if unlocked {
  159. self.closeStartupPaywall()
  160. return
  161. }
  162. }
  163. }
  164. }
  165. private func applyBundledAppIcon() {
  166. if let assetAppIcon = NSImage(named: "AppIcon") {
  167. NSApp.applicationIconImage = assetAppIcon
  168. return
  169. }
  170. if let icnsURL = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"),
  171. let icnsIcon = NSImage(contentsOf: icnsURL) {
  172. NSApp.applicationIconImage = icnsIcon
  173. }
  174. }
  175. private func setupStatusBarItem() {
  176. let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
  177. statusItem = item
  178. guard let button = item.button else { return }
  179. updateStatusBarIconForCurrentAppearance()
  180. observeAppearanceChanges()
  181. button.imageScaling = .scaleProportionallyDown
  182. button.imagePosition = .imageOnly
  183. button.action = #selector(statusBarButtonClicked(_:))
  184. button.target = self
  185. button.sendAction(on: [.leftMouseUp, .rightMouseUp])
  186. }
  187. private func updateStatusBarIconForCurrentAppearance() {
  188. guard let button = statusItem?.button else { return }
  189. // Use a template image so macOS tints it appropriately (white on dark menu bar,
  190. // black on light menu bar), matching other status bar icons.
  191. if let menuBarIcon = NSImage(named: "menu_bar_dark_icon") ?? NSImage(named: "menu_bar_icon") {
  192. menuBarIcon.isTemplate = true
  193. button.image = menuBarIcon
  194. button.image?.isTemplate = true
  195. return
  196. }
  197. button.image = NSImage(systemSymbolName: "square.grid.2x2.fill", accessibilityDescription: "My Apps")
  198. button.image?.isTemplate = true
  199. }
  200. private func observeAppearanceChanges() {
  201. if let appearanceChangedObserver {
  202. DistributedNotificationCenter.default().removeObserver(appearanceChangedObserver)
  203. self.appearanceChangedObserver = nil
  204. }
  205. effectiveAppearanceObservation?.invalidate()
  206. effectiveAppearanceObservation = nil
  207. appearanceChangedObserver = DistributedNotificationCenter.default().addObserver(
  208. forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
  209. object: nil,
  210. queue: .main
  211. ) { [weak self] _ in
  212. self?.updateStatusBarIconForCurrentAppearance()
  213. }
  214. effectiveAppearanceObservation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
  215. self?.updateStatusBarIconForCurrentAppearance()
  216. }
  217. }
  218. @objc
  219. private func statusBarButtonClicked(_ sender: Any?) {
  220. guard let event = NSApp.currentEvent else {
  221. toggleMainWindowFromStatusBar()
  222. return
  223. }
  224. if event.type == .rightMouseUp {
  225. presentStatusBarShortcutsMenu()
  226. } else {
  227. toggleMainWindowFromStatusBar()
  228. }
  229. }
  230. private func presentStatusBarShortcutsMenu() {
  231. guard let button = statusItem?.button else { return }
  232. let menu = NSMenu()
  233. let openItem = NSMenuItem(
  234. title: "Open My Apps",
  235. action: #selector(openMainWindowFromStatusBar),
  236. keyEquivalent: ""
  237. )
  238. openItem.target = self
  239. menu.addItem(openItem)
  240. menu.addItem(.separator())
  241. let quitItem = NSMenuItem(
  242. title: "Quit",
  243. action: #selector(terminateApp),
  244. keyEquivalent: "q"
  245. )
  246. quitItem.target = self
  247. menu.addItem(quitItem)
  248. let location = NSPoint(x: 0, y: button.bounds.height)
  249. menu.popUp(positioning: nil, at: location, in: button)
  250. }
  251. @objc
  252. private func terminateApp() {
  253. NSApp.terminate(nil)
  254. }
  255. @objc
  256. private func openMainWindowFromStatusBar() {
  257. showMainWindow()
  258. }
  259. @objc
  260. private func toggleMainWindowFromStatusBar() {
  261. configureMainWindowIfNeeded()
  262. guard let window = launcherWindow else { return }
  263. if window.isVisible && window.isKeyWindow {
  264. hideMainWindow()
  265. return
  266. }
  267. showMainWindow()
  268. }
  269. private func hideMainWindow() {
  270. guard let window = launcherWindow else { return }
  271. // Don't auto-hide while a modal-style child window (e.g. the paywall) is
  272. // attached; otherwise the launcher and its child get torn down together
  273. // the moment the child takes key focus.
  274. if windowHasAttachedChild(window) {
  275. return
  276. }
  277. if window.isMiniaturized {
  278. window.deminiaturize(nil)
  279. }
  280. window.orderOut(nil)
  281. }
  282. private func windowHasAttachedChild(_ window: NSWindow) -> Bool {
  283. if window.attachedSheet != nil { return true }
  284. if let children = window.childWindows, !children.isEmpty { return true }
  285. return false
  286. }
  287. private func showMainWindow() {
  288. configureMainWindowIfNeeded(centerOnLaunch: false)
  289. NSApp.setActivationPolicy(.regular)
  290. NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
  291. NSApp.activate(ignoringOtherApps: true)
  292. guard let window = launcherWindow else { return }
  293. if window.isMiniaturized {
  294. window.deminiaturize(nil)
  295. }
  296. window.orderFrontRegardless()
  297. window.makeKeyAndOrderFront(nil)
  298. // Re-assert key/front on next runloop; status-bar activations can race app focus handoff.
  299. DispatchQueue.main.async {
  300. window.orderFrontRegardless()
  301. window.makeKeyAndOrderFront(nil)
  302. }
  303. }
  304. private func configureMainWindowIfNeeded(centerOnLaunch: Bool = false) {
  305. guard let window = launcherWindow else { return }
  306. observeLauncherWindowResign(window)
  307. window.title = "My Apps"
  308. window.styleMask.insert(.titled)
  309. window.isOpaque = false
  310. window.backgroundColor = .clear
  311. applyRoundedCorners(to: window)
  312. window.titlebarAppearsTransparent = true
  313. window.titleVisibility = .hidden
  314. window.styleMask.insert(.fullSizeContentView)
  315. window.isMovableByWindowBackground = true
  316. window.minSize = NSSize(width: 760, height: 520)
  317. window.standardWindowButton(.closeButton)?.isHidden = true
  318. window.standardWindowButton(.miniaturizeButton)?.isHidden = true
  319. window.standardWindowButton(.zoomButton)?.isHidden = true
  320. let targetContentSize = NSSize(width: 980, height: 700)
  321. if window.contentRect(forFrameRect: window.frame).size.width < targetContentSize.width ||
  322. window.contentRect(forFrameRect: window.frame).size.height < targetContentSize.height {
  323. let currentCenter = NSPoint(x: window.frame.midX, y: window.frame.midY)
  324. var targetFrame = window.frameRect(forContentRect: NSRect(origin: .zero, size: targetContentSize))
  325. targetFrame.origin = NSPoint(
  326. x: currentCenter.x - (targetFrame.width / 2),
  327. y: currentCenter.y - (targetFrame.height / 2)
  328. )
  329. window.setFrame(targetFrame, display: true)
  330. }
  331. if centerOnLaunch {
  332. centerWindowOnCurrentOrPrimaryScreen(window)
  333. }
  334. }
  335. private func centerWindowOnCurrentOrPrimaryScreen(_ window: NSWindow) {
  336. // Prefer the screen the window is currently on; fall back to the primary screen.
  337. let screen = window.screen ?? NSScreen.main
  338. guard let visibleFrame = screen?.visibleFrame else { return }
  339. let frameSize = window.frame.size
  340. let unclampedX = visibleFrame.midX - (frameSize.width / 2)
  341. let unclampedY = visibleFrame.midY - (frameSize.height / 2)
  342. // Clamp so we don't move the window completely off-screen on small displays.
  343. let clampedX = min(max(unclampedX, visibleFrame.minX), visibleFrame.maxX - frameSize.width)
  344. let clampedY = min(max(unclampedY, visibleFrame.minY), visibleFrame.maxY - frameSize.height)
  345. window.setFrame(NSRect(origin: NSPoint(x: clampedX, y: clampedY), size: frameSize), display: false)
  346. }
  347. private func observeLauncherWindowResign(_ window: NSWindow) {
  348. if let observer = launcherWindowResignObserver {
  349. NotificationCenter.default.removeObserver(observer)
  350. launcherWindowResignObserver = nil
  351. }
  352. launcherWindowResignObserver = NotificationCenter.default.addObserver(
  353. forName: NSWindow.didResignKeyNotification,
  354. object: window,
  355. queue: .main
  356. ) { [weak self] _ in
  357. guard let self else { return }
  358. // Skip auto-hide while a child window (e.g. paywall) is attached;
  359. // the parent loses key when the child becomes key, but the user is
  360. // still interacting with our app's window stack.
  361. if let win = self.launcherWindow, self.windowHasAttachedChild(win) {
  362. return
  363. }
  364. self.hideMainWindow()
  365. }
  366. }
  367. private func applyRoundedCorners(to window: NSWindow) {
  368. let cornerRadius: CGFloat = 16
  369. window.hasShadow = true
  370. window.contentView?.wantsLayer = true
  371. window.contentView?.layer?.cornerRadius = cornerRadius
  372. window.contentView?.layer?.masksToBounds = true
  373. }
  374. func applicationWillTerminate(_ aNotification: Notification) {
  375. if let observer = launcherWindowResignObserver {
  376. NotificationCenter.default.removeObserver(observer)
  377. launcherWindowResignObserver = nil
  378. }
  379. if let observer = refocusLauncherObserver {
  380. NotificationCenter.default.removeObserver(observer)
  381. refocusLauncherObserver = nil
  382. }
  383. if let observer = appearanceChangedObserver {
  384. DistributedNotificationCenter.default().removeObserver(observer)
  385. appearanceChangedObserver = nil
  386. }
  387. effectiveAppearanceObservation?.invalidate()
  388. effectiveAppearanceObservation = nil
  389. premiumStateMonitorTask?.cancel()
  390. premiumStateMonitorTask = nil
  391. premiumUnlockObservation?.invalidate()
  392. premiumUnlockObservation = nil
  393. }
  394. func applicationDidResignActive(_ notification: Notification) {
  395. // Same guard as the resign-key observer: don't hide while the user is
  396. // looking at the paywall (or any other attached child window).
  397. if let win = launcherWindow, windowHasAttachedChild(win) {
  398. return
  399. }
  400. hideMainWindow()
  401. }
  402. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
  403. showMainWindow()
  404. return true
  405. }
  406. func windowWillClose(_ notification: Notification) {
  407. guard let closing = notification.object as? NSWindow else { return }
  408. if closing === startupPaywallWindow {
  409. closing.parent?.removeChildWindow(closing)
  410. startupPaywallWindow = nil
  411. premiumStateMonitorTask?.cancel()
  412. premiumStateMonitorTask = nil
  413. }
  414. }
  415. func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
  416. return true
  417. }
  418. }