Kaynağa Gözat

Show paywall on launch and prevent resign-key auto-hide.

Present the premium paywall as an AppKit child window from AppDelegate
on cold launch so non-premium users see it on top of the launcher
immediately. Also skip the launcher's auto-hide behavior while a sheet
or child window is attached, so the paywall isn't torn down when it
takes key focus. Removes the now-redundant SwiftUI startup paywall
delay in LauncherRootView.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 ay önce
ebeveyn
işleme
a274b3a943
2 değiştirilmiş dosya ile 151 ekleme ve 16 silme
  1. 147 3
      google_apps/AppDelegate.swift
  2. 4 13
      google_apps/LauncherRootView.swift

+ 147 - 3
google_apps/AppDelegate.swift

@@ -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
     }

+ 4 - 13
google_apps/LauncherRootView.swift

@@ -23,7 +23,6 @@ struct LauncherRootView: View {
     @State private var showingPremiumScreen = false
     @State private var showingCreateAppSheet = false
     @State private var previouslyPremiumUnlocked = false
-    @State private var startupPaywallTask: Task<Void, Never>?
     @State private var ratingPromptTask: Task<Void, Never>?
     @State private var customApps: [LauncherApp] = []
     @State private var appOrder: [UUID] = []
@@ -226,16 +225,10 @@ struct LauncherRootView: View {
                 )
             }
 
-            startupPaywallTask?.cancel()
-            startupPaywallTask = Task {
-                try? await Task.sleep(nanoseconds: 2_000_000_000)
-                guard !Task.isCancelled else { return }
-                await premiumStore.refreshEntitlements()
-                guard !premiumStore.isPremiumUnlocked else { return }
-                guard !showingPremiumScreen else { return }
-                showingPremiumScreen = true
-            }
-
+            // Startup paywall is now presented by AppDelegate as an AppKit child
+            // window so that it can't be torn down by the launcher window's
+            // resign-key auto-hide behavior. We only need to keep the rating
+            // prompt scheduling here.
             ratingPromptTask?.cancel()
             ratingPromptTask = Task {
                 try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000)
@@ -246,8 +239,6 @@ struct LauncherRootView: View {
             }
         }
         .onDisappear {
-            startupPaywallTask?.cancel()
-            startupPaywallTask = nil
             ratingPromptTask?.cancel()
             ratingPromptTask = nil
         }