|
|
@@ -6,12 +6,18 @@
|
|
|
//
|
|
|
|
|
|
import Cocoa
|
|
|
+import Combine
|
|
|
+import SwiftUI
|
|
|
|
|
|
@main
|
|
|
-class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
+class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
private var statusItem: NSStatusItem?
|
|
|
private weak var launcherWindowReference: NSWindow?
|
|
|
private var launcherWindowResignObserver: NSObjectProtocol?
|
|
|
+ private var startupPaywallWindow: NSWindow?
|
|
|
+ private var hasPresentedStartupPaywall = false
|
|
|
+ private var premiumUnlockObservation: NSKeyValueObservation?
|
|
|
+ private var premiumStateMonitorTask: Task<Void, Never>?
|
|
|
|
|
|
private var launcherWindow: NSWindow? {
|
|
|
if let launcherWindowReference, launcherWindowReference.contentViewController is ViewController {
|
|
|
@@ -31,6 +37,107 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
configureMainWindowIfNeeded()
|
|
|
setupStatusBarItem()
|
|
|
StatusBarAppIconsController.shared.start()
|
|
|
+ presentLauncherAndPaywallOnLaunch()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func presentLauncherAndPaywallOnLaunch() {
|
|
|
+ // Bring the launcher up-front on cold launch, then show the paywall on top
|
|
|
+ // for non-premium users. The paywall is presented as an AppKit child window
|
|
|
+ // (instead of a SwiftUI .sheet) to avoid the resign-key observer below
|
|
|
+ // tearing the window stack down the moment the sheet takes key focus.
|
|
|
+ showMainWindow()
|
|
|
+
|
|
|
+ Task { @MainActor in
|
|
|
+ await PremiumStore.shared.refreshEntitlements()
|
|
|
+ guard !PremiumStore.shared.isPremiumUnlocked else { return }
|
|
|
+ self.presentStartupPaywallIfNeeded()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func presentStartupPaywallIfNeeded() {
|
|
|
+ guard !hasPresentedStartupPaywall else { return }
|
|
|
+ guard !PremiumStore.shared.isPremiumUnlocked else { return }
|
|
|
+ guard let parent = launcherWindow else { return }
|
|
|
+ if let existing = startupPaywallWindow, existing.isVisible {
|
|
|
+ existing.makeKeyAndOrderFront(nil)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let paywallSize = NSSize(width: 540, height: 720)
|
|
|
+ let paywallWindow = NSWindow(
|
|
|
+ contentRect: NSRect(origin: .zero, size: paywallSize),
|
|
|
+ styleMask: [.borderless, .resizable],
|
|
|
+ backing: .buffered,
|
|
|
+ defer: false
|
|
|
+ )
|
|
|
+ paywallWindow.isReleasedWhenClosed = false
|
|
|
+ paywallWindow.delegate = self
|
|
|
+ paywallWindow.isMovableByWindowBackground = true
|
|
|
+ paywallWindow.hasShadow = true
|
|
|
+ paywallWindow.isOpaque = false
|
|
|
+ paywallWindow.backgroundColor = .clear
|
|
|
+ paywallWindow.level = .floating
|
|
|
+
|
|
|
+ let hosting = NSHostingController(
|
|
|
+ rootView: PremiumFeaturesView(onDismissFromAppKitHost: { [weak self] in
|
|
|
+ self?.closeStartupPaywall()
|
|
|
+ })
|
|
|
+ .environmentObject(PremiumStore.shared)
|
|
|
+ )
|
|
|
+ hosting.view.frame = NSRect(origin: .zero, size: paywallSize)
|
|
|
+ paywallWindow.contentViewController = hosting
|
|
|
+
|
|
|
+ if let layer = paywallWindow.contentView?.layer {
|
|
|
+ paywallWindow.contentView?.wantsLayer = true
|
|
|
+ layer.cornerRadius = 16
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
+ layer.cornerCurve = .continuous
|
|
|
+ }
|
|
|
+ layer.masksToBounds = true
|
|
|
+ }
|
|
|
+
|
|
|
+ let parentFrame = parent.frame
|
|
|
+ paywallWindow.setFrame(
|
|
|
+ NSRect(
|
|
|
+ x: parentFrame.midX - paywallSize.width / 2,
|
|
|
+ y: parentFrame.midY - paywallSize.height / 2,
|
|
|
+ width: paywallSize.width,
|
|
|
+ height: paywallSize.height
|
|
|
+ ),
|
|
|
+ display: false
|
|
|
+ )
|
|
|
+
|
|
|
+ startupPaywallWindow = paywallWindow
|
|
|
+ hasPresentedStartupPaywall = true
|
|
|
+ parent.addChildWindow(paywallWindow, ordered: .above)
|
|
|
+ paywallWindow.makeKeyAndOrderFront(nil)
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+
|
|
|
+ observePremiumUnlockToAutoDismissPaywall()
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func closeStartupPaywall() {
|
|
|
+ guard let window = startupPaywallWindow else { return }
|
|
|
+ window.parent?.removeChildWindow(window)
|
|
|
+ window.orderOut(nil)
|
|
|
+ startupPaywallWindow = nil
|
|
|
+ premiumStateMonitorTask?.cancel()
|
|
|
+ premiumStateMonitorTask = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func observePremiumUnlockToAutoDismissPaywall() {
|
|
|
+ premiumStateMonitorTask?.cancel()
|
|
|
+ premiumStateMonitorTask = Task { @MainActor [weak self] in
|
|
|
+ for await unlocked in PremiumStore.shared.$isPremiumUnlocked.values {
|
|
|
+ guard let self else { return }
|
|
|
+ if unlocked {
|
|
|
+ self.closeStartupPaywall()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func applyBundledAppIcon() {
|
|
|
@@ -126,12 +233,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
|
|
|
private func hideMainWindow() {
|
|
|
guard let window = launcherWindow else { return }
|
|
|
+ // Don't auto-hide while a modal-style child window (e.g. the paywall) is
|
|
|
+ // attached; otherwise the launcher and its child get torn down together
|
|
|
+ // the moment the child takes key focus.
|
|
|
+ if windowHasAttachedChild(window) {
|
|
|
+ return
|
|
|
+ }
|
|
|
if window.isMiniaturized {
|
|
|
window.deminiaturize(nil)
|
|
|
}
|
|
|
window.orderOut(nil)
|
|
|
}
|
|
|
|
|
|
+ private func windowHasAttachedChild(_ window: NSWindow) -> Bool {
|
|
|
+ if window.attachedSheet != nil { return true }
|
|
|
+ if let children = window.childWindows, !children.isEmpty { return true }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
private func showMainWindow() {
|
|
|
configureMainWindowIfNeeded()
|
|
|
NSApp.setActivationPolicy(.regular)
|
|
|
@@ -191,7 +310,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
object: window,
|
|
|
queue: .main
|
|
|
) { [weak self] _ in
|
|
|
- self?.hideMainWindow()
|
|
|
+ guard let self else { return }
|
|
|
+ // Skip auto-hide while a child window (e.g. paywall) is attached;
|
|
|
+ // the parent loses key when the child becomes key, but the user is
|
|
|
+ // still interacting with our app's window stack.
|
|
|
+ if let win = self.launcherWindow, self.windowHasAttachedChild(win) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ self.hideMainWindow()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -208,10 +334,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
NotificationCenter.default.removeObserver(observer)
|
|
|
launcherWindowResignObserver = nil
|
|
|
}
|
|
|
- // Insert code here to tear down your application
|
|
|
+ premiumStateMonitorTask?.cancel()
|
|
|
+ premiumStateMonitorTask = nil
|
|
|
+ premiumUnlockObservation?.invalidate()
|
|
|
+ premiumUnlockObservation = nil
|
|
|
}
|
|
|
|
|
|
func applicationDidResignActive(_ notification: Notification) {
|
|
|
+ // Same guard as the resign-key observer: don't hide while the user is
|
|
|
+ // looking at the paywall (or any other attached child window).
|
|
|
+ if let win = launcherWindow, windowHasAttachedChild(win) {
|
|
|
+ return
|
|
|
+ }
|
|
|
hideMainWindow()
|
|
|
}
|
|
|
|
|
|
@@ -220,6 +354,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
+ func windowWillClose(_ notification: Notification) {
|
|
|
+ guard let closing = notification.object as? NSWindow else { return }
|
|
|
+ if closing === startupPaywallWindow {
|
|
|
+ closing.parent?.removeChildWindow(closing)
|
|
|
+ startupPaywallWindow = nil
|
|
|
+ premiumStateMonitorTask?.cancel()
|
|
|
+ premiumStateMonitorTask = nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
|
|
return true
|
|
|
}
|