|
|
@@ -245,6 +245,13 @@ final class ViewController: NSViewController {
|
|
|
private let typography = Typography()
|
|
|
private let launchContentSize = NSSize(width: 920, height: 690)
|
|
|
private let launchMinContentSize = NSSize(width: 760, height: 600)
|
|
|
+ private let launchSplashMinimumVisibleDuration: TimeInterval = 2
|
|
|
+ private let launchSplashTimeout: TimeInterval = 12
|
|
|
+ private var launchSplashView: LaunchSplashView?
|
|
|
+ private var launchSplashTimeoutWorkItem: DispatchWorkItem?
|
|
|
+ private var launchSplashMinimumDelayWorkItem: DispatchWorkItem?
|
|
|
+ private var launchSplashShownAt: Date?
|
|
|
+ private var hasDismissedLaunchSplash = false
|
|
|
|
|
|
private var mainContentHost: NSView?
|
|
|
/// Pin constraints for the current page inside `mainContentHost`; deactivated before each swap so relayout never stacks duplicates.
|
|
|
@@ -431,6 +438,7 @@ final class ViewController: NSViewController {
|
|
|
observeAppLifecycleForUsageTrackingIfNeeded()
|
|
|
setupRootView()
|
|
|
buildMainLayout()
|
|
|
+ showLaunchSplashIfNeeded()
|
|
|
startStoreKit()
|
|
|
}
|
|
|
|
|
|
@@ -438,6 +446,7 @@ final class ViewController: NSViewController {
|
|
|
super.viewDidAppear()
|
|
|
hasViewAppearedOnce = true
|
|
|
presentLaunchPaywallIfNeeded()
|
|
|
+ dismissLaunchSplashIfReady()
|
|
|
applyWindowTitle(for: selectedSidebarPage)
|
|
|
guard let window = view.window else { return }
|
|
|
configureMainWindowChrome(window)
|
|
|
@@ -473,6 +482,8 @@ final class ViewController: NSViewController {
|
|
|
deinit {
|
|
|
premiumUpgradeRatingPromptWorkItem?.cancel()
|
|
|
endUsageTrackingSession()
|
|
|
+ launchSplashTimeoutWorkItem?.cancel()
|
|
|
+ launchSplashMinimumDelayWorkItem?.cancel()
|
|
|
if hasObservedAppLifecycleForUsage {
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
}
|
|
|
@@ -492,6 +503,97 @@ private extension ViewController {
|
|
|
view.layer?.backgroundColor = palette.pageBackground.cgColor
|
|
|
}
|
|
|
|
|
|
+ private func makeLaunchSplashTheme() -> LaunchSplashView.Theme {
|
|
|
+ let borderAlpha: CGFloat = darkModeEnabled ? 0.9 : 0.45
|
|
|
+ let cardBackground = darkModeEnabled ? palette.sectionCard : palette.tabBarBackground
|
|
|
+ let cardBorder = darkModeEnabled ? palette.inputBorder : palette.separator
|
|
|
+ return LaunchSplashView.Theme(
|
|
|
+ background: palette.pageBackground,
|
|
|
+ cardBackground: cardBackground,
|
|
|
+ cardBorder: cardBorder.withAlphaComponent(borderAlpha),
|
|
|
+ titleText: palette.textPrimary,
|
|
|
+ subtitleText: palette.textSecondary,
|
|
|
+ accent: palette.primaryBlue
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func showLaunchSplashIfNeeded() {
|
|
|
+ guard launchSplashView == nil else { return }
|
|
|
+ let splash = LaunchSplashView(frame: .zero)
|
|
|
+ splash.alphaValue = 1
|
|
|
+ let displayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)?
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let fallbackName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
|
+ let resolvedName = (displayName?.isEmpty == false ? displayName : fallbackName) ?? "Assistant for Google Meet"
|
|
|
+ splash.configure(appName: resolvedName, appIcon: NSApp.applicationIconImage, theme: makeLaunchSplashTheme())
|
|
|
+
|
|
|
+ view.addSubview(splash)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
|
+ splash.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
|
+ splash.topAnchor.constraint(equalTo: view.topAnchor),
|
|
|
+ splash.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
|
+ ])
|
|
|
+ view.layoutSubtreeIfNeeded()
|
|
|
+ launchSplashView = splash
|
|
|
+ launchSplashShownAt = Date()
|
|
|
+
|
|
|
+ launchSplashTimeoutWorkItem?.cancel()
|
|
|
+ let timeoutWorkItem = DispatchWorkItem { [weak self] in
|
|
|
+ self?.dismissLaunchSplash(force: true)
|
|
|
+ }
|
|
|
+ launchSplashTimeoutWorkItem = timeoutWorkItem
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + launchSplashTimeout, execute: timeoutWorkItem)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func dismissLaunchSplashIfReady() {
|
|
|
+ guard hasCompletedInitialStoreKitSync else { return }
|
|
|
+ guard !hasDismissedLaunchSplash else { return }
|
|
|
+ if let shownAt = launchSplashShownAt {
|
|
|
+ let elapsed = Date().timeIntervalSince(shownAt)
|
|
|
+ let remaining = launchSplashMinimumVisibleDuration - elapsed
|
|
|
+ if remaining > 0 {
|
|
|
+ launchSplashMinimumDelayWorkItem?.cancel()
|
|
|
+ let minDelayWorkItem = DispatchWorkItem { [weak self] in
|
|
|
+ self?.dismissLaunchSplash(force: false)
|
|
|
+ }
|
|
|
+ launchSplashMinimumDelayWorkItem = minDelayWorkItem
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: minDelayWorkItem)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ dismissLaunchSplash(force: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func dismissLaunchSplash(force: Bool) {
|
|
|
+ guard !hasDismissedLaunchSplash else { return }
|
|
|
+ guard let splash = launchSplashView else { return }
|
|
|
+ if !force && !hasCompletedInitialStoreKitSync { return }
|
|
|
+
|
|
|
+ hasDismissedLaunchSplash = true
|
|
|
+ launchSplashTimeoutWorkItem?.cancel()
|
|
|
+ launchSplashTimeoutWorkItem = nil
|
|
|
+ launchSplashMinimumDelayWorkItem?.cancel()
|
|
|
+ launchSplashMinimumDelayWorkItem = nil
|
|
|
+
|
|
|
+ let removeSplash: () -> Void = { [weak self] in
|
|
|
+ splash.removeFromSuperview()
|
|
|
+ self?.launchSplashView = nil
|
|
|
+ self?.launchSplashShownAt = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
|
|
|
+ removeSplash()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ NSAnimationContext.runAnimationGroup({ context in
|
|
|
+ context.duration = 0.24
|
|
|
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
|
+ splash.animator().alphaValue = 0
|
|
|
+ }, completionHandler: removeSplash)
|
|
|
+ }
|
|
|
+
|
|
|
func systemPrefersDarkMode() -> Bool {
|
|
|
// Use the system-wide appearance setting (not app/window effective appearance).
|
|
|
// When the key is missing, macOS is in Light mode.
|
|
|
@@ -861,6 +963,7 @@ private extension ViewController {
|
|
|
NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
|
|
|
view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
|
|
|
palette = Palette(isDarkMode: enabled)
|
|
|
+ launchSplashView?.apply(theme: makeLaunchSplashTheme())
|
|
|
reloadTheme()
|
|
|
}
|
|
|
|
|
|
@@ -1282,6 +1385,7 @@ private extension ViewController {
|
|
|
self.hasCompletedInitialStoreKitSync = true
|
|
|
self.refreshPaywallStoreUI()
|
|
|
self.presentLaunchPaywallIfNeeded()
|
|
|
+ self.dismissLaunchSplashIfReady()
|
|
|
}
|
|
|
}
|
|
|
|