Procházet zdrojové kódy

Add animated splash screen with light/dark theme.

Shows a themed splash UI for 3 seconds on launch, then switches to the main ViewController.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 před 2 měsíci
rodič
revize
54507a324a
2 změnil soubory, kde provedl 338 přidání a 8 odebrání
  1. 4 4
      zoom_app.xcodeproj/project.pbxproj
  2. 334 4
      zoom_app/AppDelegate.swift

+ 4 - 4
zoom_app.xcodeproj/project.pbxproj

@@ -256,11 +256,11 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
+				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
-				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
-				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
@@ -292,11 +292,11 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
+				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
-				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
-				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",

+ 334 - 4
zoom_app/AppDelegate.swift

@@ -13,6 +13,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
     private let loginStateKey = "zoom_app.isLoggedIn"
     private var statusItem: NSStatusItem?
     private var statusMenu: NSMenu?
+    private var mainWindowController: NSWindowController?
+    private weak var mainWindow: NSWindow?
+    private var mainOriginalContentViewController: NSViewController?
 
     private enum MenuItemTag: Int {
         case openHome = 10
@@ -35,15 +38,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
 
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
+        // Ensure we are a regular app so windows can appear reliably.
+        // (Some configurations behave like an accessory/menubar app otherwise.)
+        NSApp.setActivationPolicy(.regular)
+
         let darkEnabled = systemPrefersDarkMode()
         UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
         NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
         configureStatusItem()
 
-        // Force a consistent launch size (avoid state restoration overriding it).
-        DispatchQueue.main.async { [weak self] in
-            self?.applyDefaultWindowSizeIfNeeded()
-        }
+        showSplashThenMainWindow()
     }
 
     func applicationWillTerminate(_ aNotification: Notification) {
@@ -75,6 +79,70 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         return style?.lowercased() == "dark"
     }
 
+    private func showSplashThenMainWindow() {
+        let (window, originalVC) = openMainWindowIfNeededAndReturnWindow()
+        guard let window else { return }
+
+        let splashVC = SplashViewController()
+        mainOriginalContentViewController = originalVC
+        mainWindow = window
+
+        // Show main window immediately with splash content.
+        window.contentViewController = splashVC
+        window.isRestorable = false
+        window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
+        window.center()
+        NSApp.activate(ignoringOtherApps: true)
+        window.makeKeyAndOrderFront(nil)
+
+        Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
+            guard let self else { return }
+            self.restoreMainContentAfterSplash()
+        }
+    }
+
+    @discardableResult
+    private func openMainWindowIfNeededAndReturnWindow() -> (NSWindow?, NSViewController?) {
+        if let existing = windowHostingViewController() {
+            existing.isRestorable = false
+            existing.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
+            existing.center()
+            existing.makeKeyAndOrderFront(nil)
+            return (existing, existing.contentViewController)
+        }
+
+        let storyboard = NSStoryboard(name: "Main", bundle: nil)
+        if let controller = storyboard.instantiateInitialController() as? NSWindowController {
+            mainWindowController = controller
+            controller.showWindow(nil)
+            if let window = controller.window {
+                window.isRestorable = false
+                window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
+                window.center()
+                window.makeKeyAndOrderFront(nil)
+                return (window, window.contentViewController)
+            }
+        }
+        return (nil, nil)
+    }
+
+    private func restoreMainContentAfterSplash() {
+        guard let window = mainWindow else { return }
+
+        // If we captured the original VC (from storyboard), restore it.
+        if let original = mainOriginalContentViewController {
+            window.contentViewController = original
+        }
+
+        window.isRestorable = false
+        window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
+        window.center()
+        window.makeKeyAndOrderFront(nil)
+
+        mainOriginalContentViewController = nil
+        mainWindow = nil
+    }
+
     private func configureStatusItem() {
         let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
         if let button = item.button {
@@ -198,3 +266,265 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
 
 }
 
+private final class SplashViewController: NSViewController {
+    private struct Theme {
+        let isDark: Bool
+        let backgroundTop: NSColor
+        let backgroundBottom: NSColor
+        let cardFill: NSColor
+        let titleColor: NSColor
+        let subtitleColor: NSColor
+        let iconBadgeFill: NSColor
+        let iconBadgeStroke: NSColor
+    }
+
+    private final class GradientBackgroundView: NSView {
+        let gradientLayer = CAGradientLayer()
+
+        override init(frame frameRect: NSRect) {
+            super.init(frame: frameRect)
+            wantsLayer = true
+            layer = CALayer()
+            gradientLayer.startPoint = CGPoint(x: 0.1, y: 1.0)
+            gradientLayer.endPoint = CGPoint(x: 0.9, y: 0.0)
+            gradientLayer.locations = [0.0, 1.0]
+            layer?.addSublayer(gradientLayer)
+        }
+
+        @available(*, unavailable)
+        required init?(coder: NSCoder) { nil }
+
+        override func layout() {
+            super.layout()
+            gradientLayer.frame = bounds
+        }
+    }
+
+    private var theme: Theme = SplashViewController.makeTheme(isDark: true)
+    private var dotsTimer: Timer?
+    private var dotsStep: Int = 0
+
+    private weak var rootBackground: GradientBackgroundView?
+    private weak var cardEffectView: NSVisualEffectView?
+    private weak var iconBadgeView: NSView?
+    private weak var iconView: NSImageView?
+    private weak var titleLabel: NSTextField?
+    private weak var subtitleLabel: NSTextField?
+    private weak var contentStack: NSStackView?
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        applyTheme()
+    }
+
+    override func viewDidAppear() {
+        super.viewDidAppear()
+        startAnimations()
+        startDots()
+    }
+
+    override func viewWillDisappear() {
+        super.viewWillDisappear()
+        dotsTimer?.invalidate()
+        dotsTimer = nil
+    }
+
+    override func loadView() {
+        let background = GradientBackgroundView()
+
+        let card = NSVisualEffectView()
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.blendingMode = .withinWindow
+        card.state = .active
+        card.wantsLayer = true
+        card.layer?.cornerRadius = 22
+        card.layer?.masksToBounds = true
+
+        // Subtle border similar to the app's card styling.
+        let border = CALayer()
+        border.borderWidth = 1
+        border.cornerRadius = 22
+        border.masksToBounds = true
+        card.layer?.addSublayer(border)
+        border.frame = card.bounds
+        border.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+
+        let stack = NSStackView()
+        stack.orientation = .vertical
+        stack.alignment = .centerX
+        stack.spacing = 12
+        stack.translatesAutoresizingMaskIntoConstraints = false
+
+        let iconBadge = NSView()
+        iconBadge.translatesAutoresizingMaskIntoConstraints = false
+        iconBadge.wantsLayer = true
+        iconBadge.layer?.cornerRadius = 22
+        iconBadge.layer?.masksToBounds = true
+
+        let iconBadgeBorder = CALayer()
+        iconBadgeBorder.borderWidth = 1
+        iconBadgeBorder.cornerRadius = 22
+        iconBadgeBorder.masksToBounds = true
+        iconBadge.layer?.addSublayer(iconBadgeBorder)
+        iconBadgeBorder.frame = iconBadge.bounds
+        iconBadgeBorder.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+
+        let iconView = NSImageView()
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        iconView.imageScaling = .scaleProportionallyUpOrDown
+        iconView.image = NSApp.applicationIconImage
+        iconView.wantsLayer = true
+
+        let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Zoom App"
+        let title = NSTextField(labelWithString: appName)
+        title.font = .systemFont(ofSize: 30, weight: .semibold)
+
+        let subtitle = NSTextField(labelWithString: "Starting up…")
+        subtitle.font = .systemFont(ofSize: 14, weight: .regular)
+        subtitle.alignment = .center
+
+        iconBadge.addSubview(iconView)
+        NSLayoutConstraint.activate([
+            iconView.centerXAnchor.constraint(equalTo: iconBadge.centerXAnchor),
+            iconView.centerYAnchor.constraint(equalTo: iconBadge.centerYAnchor),
+            iconView.widthAnchor.constraint(equalToConstant: 70),
+            iconView.heightAnchor.constraint(equalTo: iconView.widthAnchor)
+        ])
+
+        stack.addArrangedSubview(iconBadge)
+        stack.addArrangedSubview(title)
+        stack.addArrangedSubview(subtitle)
+
+        card.addSubview(stack)
+        background.addSubview(card)
+
+        NSLayoutConstraint.activate([
+            iconBadge.widthAnchor.constraint(equalToConstant: 120),
+            iconBadge.heightAnchor.constraint(equalToConstant: 120),
+
+            card.centerXAnchor.constraint(equalTo: background.centerXAnchor),
+            card.centerYAnchor.constraint(equalTo: background.centerYAnchor),
+            card.widthAnchor.constraint(greaterThanOrEqualToConstant: 360),
+            card.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 28),
+            card.trailingAnchor.constraint(lessThanOrEqualTo: background.trailingAnchor, constant: -28),
+            card.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
+
+            stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
+            stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
+            stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 30),
+            stack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -30)
+        ])
+
+        // Initial state for animation.
+        stack.alphaValue = 0
+        stack.setFrameOrigin(NSPoint(x: stack.frame.origin.x, y: stack.frame.origin.y - 8))
+
+        self.rootBackground = background
+        self.cardEffectView = card
+        self.iconBadgeView = iconBadge
+        self.iconView = iconView
+        self.titleLabel = title
+        self.subtitleLabel = subtitle
+        self.contentStack = stack
+
+        view = background
+    }
+
+    private func startAnimations() {
+        guard let stack = contentStack, let iconView else { return }
+
+        // Entrance animation: fade + slight rise + gentle icon scale.
+        iconView.layer?.transform = CATransform3DMakeScale(0.92, 0.92, 1)
+        NSAnimationContext.runAnimationGroup { context in
+            context.duration = 0.55
+            context.timingFunction = CAMediaTimingFunction(name: .easeOut)
+            stack.animator().alphaValue = 1
+        }
+
+        let scale = CABasicAnimation(keyPath: "transform")
+        scale.fromValue = CATransform3DMakeScale(0.92, 0.92, 1)
+        scale.toValue = CATransform3DIdentity
+        scale.duration = 0.55
+        scale.timingFunction = CAMediaTimingFunction(name: .easeOut)
+        iconView.layer?.add(scale, forKey: "splash.scaleIn")
+        iconView.layer?.transform = CATransform3DIdentity
+
+        // Subtle breathing pulse after entrance.
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak self] in
+            guard let self, let iconLayer = self.iconView?.layer else { return }
+            let pulse = CABasicAnimation(keyPath: "transform")
+            pulse.fromValue = CATransform3DIdentity
+            pulse.toValue = CATransform3DMakeScale(1.03, 1.03, 1)
+            pulse.duration = 0.9
+            pulse.autoreverses = true
+            pulse.repeatCount = .infinity
+            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            iconLayer.add(pulse, forKey: "splash.pulse")
+        }
+    }
+
+    private func startDots() {
+        dotsTimer?.invalidate()
+        dotsStep = 0
+        dotsTimer = Timer.scheduledTimer(withTimeInterval: 0.45, repeats: true) { [weak self] _ in
+            guard let self, let subtitleLabel else { return }
+            self.dotsStep = (self.dotsStep + 1) % 4
+            let dots = String(repeating: ".", count: self.dotsStep)
+            subtitleLabel.stringValue = "Starting up" + dots
+        }
+    }
+
+    private func applyTheme() {
+        let isDark = view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+        theme = Self.makeTheme(isDark: isDark)
+
+        rootBackground?.gradientLayer.colors = [theme.backgroundTop.cgColor, theme.backgroundBottom.cgColor]
+
+        // Card styling.
+        if let card = cardEffectView {
+            card.material = theme.isDark ? .hudWindow : .sidebar
+            card.layer?.backgroundColor = theme.cardFill.cgColor
+            if let border = card.layer?.sublayers?.first(where: { $0.borderWidth > 0 }) {
+                border.borderColor = (theme.isDark ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.10)).cgColor
+            }
+        }
+
+        // Icon badge styling.
+        if let badge = iconBadgeView {
+            badge.layer?.backgroundColor = theme.iconBadgeFill.cgColor
+            if let border = badge.layer?.sublayers?.first(where: { $0.borderWidth > 0 }) {
+                border.borderColor = theme.iconBadgeStroke.cgColor
+            }
+        }
+
+        titleLabel?.textColor = theme.titleColor
+        subtitleLabel?.textColor = theme.subtitleColor
+    }
+
+    private static func makeTheme(isDark: Bool) -> Theme {
+        if isDark {
+            return Theme(
+                isDark: true,
+                backgroundTop: NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1),
+                backgroundBottom: NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1),
+                cardFill: NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 0.72),
+                titleColor: NSColor(calibratedWhite: 0.98, alpha: 1),
+                subtitleColor: NSColor(calibratedWhite: 0.78, alpha: 1),
+                iconBadgeFill: NSColor.white.withAlphaComponent(0.06),
+                iconBadgeStroke: NSColor.white.withAlphaComponent(0.10)
+            )
+        }
+
+        return Theme(
+            isDark: false,
+            backgroundTop: NSColor(calibratedWhite: 0.98, alpha: 1),
+            backgroundBottom: NSColor(calibratedWhite: 0.92, alpha: 1),
+            cardFill: NSColor.white.withAlphaComponent(0.70),
+            titleColor: NSColor(calibratedWhite: 0.10, alpha: 1),
+            subtitleColor: NSColor(calibratedWhite: 0.34, alpha: 1),
+            iconBadgeFill: NSColor.black.withAlphaComponent(0.04),
+            iconBadgeStroke: NSColor.black.withAlphaComponent(0.08)
+        )
+    }
+}
+