Răsfoiți Sursa

Add Google sign-in flow and post-login home dashboard.

Wire OAuth calendar-backed authentication into the desktop UI, then switch from the login screen to a live home view with clock and upcoming meeting details.

Made-with: Cursor
huzaifahayat12 3 luni în urmă
părinte
comite
6aa91f81ee
3 a modificat fișierele cu 853 adăugiri și 201 ștergeri
  1. 2 0
      zoom_app.xcodeproj/project.pbxproj
  2. 839 201
      zoom_app/ViewController.swift
  3. 12 0
      zoom_app/zoom_app.entitlements

+ 2 - 0
zoom_app.xcodeproj/project.pbxproj

@@ -248,6 +248,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = zoom_app/zoom_app.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
@@ -279,6 +280,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = zoom_app/zoom_app.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;

+ 839 - 201
zoom_app/ViewController.swift

@@ -6,20 +6,38 @@
 //
 
 import Cocoa
+import CryptoKit
+import Network
+import WebKit
 
 class ViewController: NSViewController {
+    private let googleOAuth = GoogleOAuthService.shared
     private let sidebarWidth: CGFloat = 78
-    // Dark-mode values copied from classroom_app Palette(isDarkMode: true).
     private let appBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
     private let sidebarBackground = NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
     private let sidebarActiveBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
     private let cardBackground = NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
-    private let separatorColor = NSColor(calibratedRed: 26 / 255, green: 27 / 255, blue: 30 / 255, alpha: 1)
+    private let secondaryCardBackground = NSColor(calibratedRed: 26 / 255, green: 28 / 255, blue: 33 / 255, alpha: 1)
     private let accentBlue = NSColor(calibratedRed: 56 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
+    private let accentOrange = NSColor(calibratedRed: 241 / 255, green: 116 / 255, blue: 49 / 255, alpha: 1)
     private let primaryText = NSColor(calibratedWhite: 0.98, alpha: 1)
     private let secondaryText = NSColor(calibratedWhite: 0.78, alpha: 1)
     private let mutedText = NSColor(calibratedWhite: 0.66, alpha: 1)
 
+    private let rootContainer = NSView()
+    private var loginView: NSView?
+    private var homeView: NSView?
+    private weak var googleButton: NSButton?
+    private weak var timeLabel: NSTextField?
+    private weak var dateLabel: NSTextField?
+    private weak var meetingTitleLabel: NSTextField?
+    private weak var meetingDetailLabel: NSTextField?
+    private weak var meetingHostLabel: NSTextField?
+    private weak var emptyMeetingLabel: NSTextField?
+    private weak var meetingCard: NSView?
+    private var clockTimer: Timer?
+    private var isSigningIn = false
+
     override func viewDidLoad() {
         super.viewDidLoad()
         setupUI()
@@ -27,159 +45,227 @@ class ViewController: NSViewController {
 
     override func viewDidAppear() {
         super.viewDidAppear()
-
-        // Keep a practical default size for this desktop mock.
-        view.window?.setContentSize(NSSize(width: 960, height: 700))
+        view.window?.setContentSize(NSSize(width: 1020, height: 690))
         view.window?.title = "zoom Workplace"
+        showLoginView()
     }
 
     private func setupUI() {
         view.wantsLayer = true
         view.layer?.backgroundColor = appBackground.cgColor
 
-        let sidebar = makeSidebar()
-        let content = makeContent()
-
-        view.addSubview(sidebar)
-        view.addSubview(content)
-
-        sidebar.translatesAutoresizingMaskIntoConstraints = false
-        content.translatesAutoresizingMaskIntoConstraints = false
-
+        rootContainer.translatesAutoresizingMaskIntoConstraints = false
+        view.addSubview(rootContainer)
         NSLayoutConstraint.activate([
-            sidebar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
-            sidebar.topAnchor.constraint(equalTo: view.topAnchor),
-            sidebar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
-            sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
-
-            content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
-            content.trailingAnchor.constraint(equalTo: view.trailingAnchor),
-            content.topAnchor.constraint(equalTo: view.topAnchor),
-            content.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+            rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+            rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+            rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
+            rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
         ])
     }
 
-    private func makeSidebar() -> NSView {
-        let sidebar = NSView()
-        sidebar.wantsLayer = true
-        sidebar.layer?.backgroundColor = sidebarBackground.cgColor
+    private func showLoginView() {
+        clockTimer?.invalidate()
+        homeView?.removeFromSuperview()
+        homeView = nil
 
-        let title = NSTextField(labelWithString: "Home")
-        title.font = .systemFont(ofSize: 11, weight: .medium)
-        title.textColor = primaryText
-        title.alignment = .center
-
-        let homeBox = NSBox()
-        homeBox.boxType = .custom
-        homeBox.borderType = .noBorder
-        homeBox.cornerRadius = 12
-        homeBox.fillColor = sidebarActiveBackground
+        if loginView == nil {
+            loginView = makeLoginView()
+        }
+        guard let loginView else { return }
+        attachToRoot(loginView)
+    }
 
-        let homeIcon = NSTextField(labelWithString: "⌂")
-        homeIcon.font = .systemFont(ofSize: 20, weight: .medium)
-        homeIcon.textColor = .white
-        homeIcon.alignment = .center
+    private func showHomeView(profile: GoogleUserProfile?) {
+        loginView?.removeFromSuperview()
+        homeView?.removeFromSuperview()
+        homeView = makeHomeView(profile: profile)
+        if let homeView { attachToRoot(homeView) }
+        startClock()
+        Task { await loadScheduledMeetings() }
+    }
 
-        homeBox.contentView?.addSubview(homeIcon)
-        homeIcon.translatesAutoresizingMaskIntoConstraints = false
+    private func attachToRoot(_ subview: NSView) {
+        subview.translatesAutoresizingMaskIntoConstraints = false
+        if subview.superview != rootContainer {
+            rootContainer.addSubview(subview)
+        }
         NSLayoutConstraint.activate([
-            homeIcon.centerXAnchor.constraint(equalTo: homeBox.contentView!.centerXAnchor),
-            homeIcon.centerYAnchor.constraint(equalTo: homeBox.contentView!.centerYAnchor)
+            subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
+            subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
+            subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
+            subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
         ])
+    }
 
-        let sidebarItems = ["Chat", "Phone", "Docs", "Whiteboards", "Clips", "More"]
-        var itemViews: [NSView] = []
-
-        for item in sidebarItems {
-            let container = NSView()
-            let icon = NSTextField(labelWithString: "◻︎")
-            icon.font = .systemFont(ofSize: 15, weight: .regular)
-            icon.textColor = secondaryText
-            icon.alignment = .center
-
-            let label = NSTextField(labelWithString: item)
-            label.font = .systemFont(ofSize: 12, weight: .regular)
-            label.textColor = secondaryText
-            label.alignment = .center
-
-            container.addSubview(icon)
-            container.addSubview(label)
-            icon.translatesAutoresizingMaskIntoConstraints = false
-            label.translatesAutoresizingMaskIntoConstraints = false
-
-            NSLayoutConstraint.activate([
-                icon.topAnchor.constraint(equalTo: container.topAnchor),
-                icon.centerXAnchor.constraint(equalTo: container.centerXAnchor),
-                label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
-                label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
-                label.bottomAnchor.constraint(equalTo: container.bottomAnchor)
-            ])
-            itemViews.append(container)
+    @objc private func googleLoginTapped() {
+        guard isSigningIn == false else { return }
+        isSigningIn = true
+        googleButton?.title = "..."
+        googleButton?.isEnabled = false
+
+        Task {
+            do {
+                let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
+                let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
+                await MainActor.run {
+                    self.isSigningIn = false
+                    self.googleButton?.title = "G"
+                    self.googleButton?.isEnabled = true
+                    self.showHomeView(profile: profile)
+                }
+            } catch {
+                await MainActor.run {
+                    self.isSigningIn = false
+                    self.googleButton?.title = "G"
+                    self.googleButton?.isEnabled = true
+                    self.showSimpleError("Google login failed", error: error)
+                }
+            }
         }
+    }
 
-        let stack = NSStackView(views: itemViews)
-        stack.orientation = .vertical
-        stack.spacing = 16
-        stack.alignment = .centerX
-        stack.distribution = .gravityAreas
+    private func showSimpleError(_ title: String, error: Error) {
+        let alert = NSAlert()
+        alert.alertStyle = .warning
+        alert.messageText = title
+        alert.informativeText = error.localizedDescription
+        alert.runModal()
+    }
 
-        sidebar.addSubview(homeBox)
-        sidebar.addSubview(title)
-        sidebar.addSubview(stack)
+    private struct ScheduledMeeting {
+        let title: String
+        let start: Date
+        let end: Date?
+        let host: String
+    }
 
-        homeBox.translatesAutoresizingMaskIntoConstraints = false
-        title.translatesAutoresizingMaskIntoConstraints = false
-        stack.translatesAutoresizingMaskIntoConstraints = false
+    @MainActor
+    private func applyMeeting(_ meeting: ScheduledMeeting?) {
+        if let meeting {
+            let dateFormatter = DateFormatter()
+            dateFormatter.dateFormat = "EEE, MMM d"
+            let timeFormatter = DateFormatter()
+            timeFormatter.dateFormat = "h:mm a"
+            let startText = timeFormatter.string(from: meeting.start)
+            let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
+            let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
+
+            meetingTitleLabel?.stringValue = meeting.title
+            meetingDetailLabel?.stringValue = "\(dateFormatter.string(from: meeting.start))\n\(range)"
+            meetingHostLabel?.stringValue = "Host: \(meeting.host)"
+            meetingCard?.isHidden = false
+            emptyMeetingLabel?.isHidden = true
+        } else {
+            meetingCard?.isHidden = true
+            emptyMeetingLabel?.isHidden = false
+        }
+    }
 
-        NSLayoutConstraint.activate([
-            homeBox.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 42),
-            homeBox.centerXAnchor.constraint(equalTo: sidebar.centerXAnchor),
-            homeBox.widthAnchor.constraint(equalToConstant: 56),
-            homeBox.heightAnchor.constraint(equalToConstant: 56),
+    private func loadScheduledMeetings() async {
+        do {
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
+            let meetings = try await fetchGoogleScheduledMeetings(accessToken: token)
+            await MainActor.run {
+                self.applyMeeting(meetings.first)
+            }
+        } catch {
+            await MainActor.run {
+                self.applyMeeting(nil)
+            }
+        }
+    }
 
-            title.topAnchor.constraint(equalTo: homeBox.bottomAnchor, constant: 8),
-            title.centerXAnchor.constraint(equalTo: sidebar.centerXAnchor),
+    private func fetchGoogleScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
+        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
+        let now = ISO8601DateFormatter().string(from: Date())
+        components.queryItems = [
+            URLQueryItem(name: "singleEvents", value: "true"),
+            URLQueryItem(name: "orderBy", value: "startTime"),
+            URLQueryItem(name: "timeMin", value: now),
+            URLQueryItem(name: "maxResults", value: "10")
+        ]
+        var request = URLRequest(url: components.url!)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load meetings")
+        }
 
-            stack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 30),
-            stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor),
-            stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor)
-        ])
+        struct APIResponse: Decodable {
+            struct Item: Decodable {
+                struct TimeValue: Decodable { let dateTime: String?; let date: String? }
+                let summary: String?
+                let creator: Creator?
+                struct Creator: Decodable { let displayName: String?; let email: String? }
+                let start: TimeValue
+                let end: TimeValue?
+            }
+            let items: [Item]
+        }
 
-        return sidebar
+        let decoded = try JSONDecoder().decode(APIResponse.self, from: data)
+        let iso = ISO8601DateFormatter()
+        let dateOnly = DateFormatter()
+        dateOnly.dateFormat = "yyyy-MM-dd"
+        dateOnly.timeZone = TimeZone.current
+
+        return decoded.items.compactMap { item in
+            let start = (item.start.dateTime.flatMap { iso.date(from: $0) }) ?? (item.start.date.flatMap { dateOnly.date(from: $0) })
+            let end = item.end?.dateTime.flatMap { iso.date(from: $0) } ?? item.end?.date.flatMap { dateOnly.date(from: $0) }
+            guard let start else { return nil }
+            return ScheduledMeeting(
+                title: item.summary?.isEmpty == false ? item.summary! : "Scheduled meeting",
+                start: start,
+                end: end,
+                host: item.creator?.displayName ?? item.creator?.email ?? "Google Calendar"
+            )
+        }
     }
 
-    private func makeContent() -> NSView {
+    // MARK: - Login UI
+
+    private func makeLoginView() -> NSView {
+        let root = NSView()
+        let sidebar = makeSidebar(items: ["Home", "Chat", "Phone", "Docs", "Whiteboards", "Clips", "More"], selected: "Home")
         let content = NSView()
 
-        let back = NSTextField(labelWithString: "‹  Back")
-        back.font = .systemFont(ofSize: 34, weight: .regular)
-        back.textColor = accentBlue
+        root.addSubview(sidebar)
+        root.addSubview(content)
+        sidebar.translatesAutoresizingMaskIntoConstraints = false
+        content.translatesAutoresizingMaskIntoConstraints = false
 
-        let logo = NSTextField(labelWithString: "zoom\nWorkplace")
-        logo.font = .systemFont(ofSize: 24, weight: .bold)
-        logo.textColor = primaryText
-        logo.alignment = .center
-        logo.maximumNumberOfLines = 2
+        NSLayoutConstraint.activate([
+            sidebar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            sidebar.topAnchor.constraint(equalTo: root.topAnchor),
+            sidebar.bottomAnchor.constraint(equalTo: root.bottomAnchor),
+            sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
+            content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
+            content.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            content.topAnchor.constraint(equalTo: root.topAnchor),
+            content.bottomAnchor.constraint(equalTo: root.bottomAnchor)
+        ])
 
-        let domain = NSTextField(labelWithString: "us05web.zoom.us")
-        domain.font = .systemFont(ofSize: 16, weight: .semibold)
-        domain.textColor = primaryText
-        domain.alignment = .center
+        let back = makeLabel("‹  Back", size: 32, color: accentBlue, weight: .regular, centered: false)
+        let logo = makeLabel("zoom\nWorkplace", size: 24, color: primaryText, weight: .bold, centered: true)
+        logo.maximumNumberOfLines = 2
+        let domain = makeLabel("us05web.zoom.us", size: 16, color: primaryText, weight: .semibold, centered: true)
 
         let emailField = NSTextField()
         emailField.placeholderString = "Email or phone number"
         emailField.font = .systemFont(ofSize: 20, weight: .regular)
         emailField.textColor = .white
-        emailField.focusRingType = .none
         emailField.wantsLayer = true
         emailField.layer?.cornerRadius = 10
         emailField.layer?.borderWidth = 1.5
         emailField.layer?.borderColor = accentBlue.cgColor
         emailField.layer?.backgroundColor = cardBackground.cgColor
+        emailField.focusRingType = .none
 
         let nextButton = NSButton(title: "Next", target: nil, action: nil)
         nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
-        nextButton.bezelStyle = .regularSquare
         nextButton.isBordered = false
         nextButton.wantsLayer = true
         nextButton.layer?.cornerRadius = 10
@@ -188,131 +274,683 @@ class ViewController: NSViewController {
 
         let divider = NSBox()
         divider.boxType = .separator
-        divider.borderColor = separatorColor
-
-        let signInText = NSTextField(labelWithString: "or sign in with")
-        signInText.font = .systemFont(ofSize: 14, weight: .regular)
-        signInText.textColor = secondaryText
-        signInText.alignment = .center
-
-        let ssoButton = makeSocialButton(icon: "🔑", text: "SSO")
-        let googleButton = makeSocialButton(icon: "G", text: "Google")
-        let appleButton = makeSocialButton(icon: "", text: "Apple")
-        let facebookButton = makeSocialButton(icon: "f", text: "Facebook")
-        let microsoftButton = makeSocialButton(icon: "■", text: "Microsoft")
-
-        let socialStack = NSStackView(views: [ssoButton, googleButton, appleButton, facebookButton, microsoftButton])
-        socialStack.orientation = .horizontal
-        socialStack.spacing = 14
-        socialStack.distribution = .fillEqually
-
-        let signup = NSTextField(labelWithString: "Don't have an account? Sign up")
-        signup.font = .systemFont(ofSize: 15, weight: .regular)
-        signup.textColor = primaryText
-        signup.alignment = .center
-
-        let footer = NSTextField(labelWithString: "Help    Terms    Privacy")
-        footer.font = .systemFont(ofSize: 14, weight: .regular)
-        footer.textColor = NSColor(calibratedRed: 72 / 255, green: 129 / 255, blue: 218 / 255, alpha: 1)
-        footer.alignment = .center
-
-        content.addSubview(back)
-        content.addSubview(logo)
-        content.addSubview(domain)
-        content.addSubview(emailField)
-        content.addSubview(nextButton)
-        content.addSubview(divider)
-        content.addSubview(signInText)
-        content.addSubview(socialStack)
-        content.addSubview(signup)
-        content.addSubview(footer)
-
-        [back, logo, domain, emailField, nextButton, divider, signInText, socialStack, signup, footer].forEach {
+        let socialText = makeLabel("or sign in with", size: 14, color: secondaryText, weight: .regular, centered: true)
+
+        let sso = makeSocialButton(icon: "🔑", text: "SSO")
+        let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
+        let apple = makeSocialButton(icon: "", text: "Apple")
+        let facebook = makeSocialButton(icon: "f", text: "Facebook")
+        let microsoft = makeSocialButton(icon: "■", text: "Microsoft")
+        self.googleButton = google.button
+
+        let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, microsoft.container])
+        social.orientation = .horizontal
+        social.spacing = 14
+        social.distribution = .fillEqually
+
+        let signup = makeLabel("Don't have an account? Sign up", size: 15, color: primaryText, weight: .regular, centered: true)
+        let footer = makeLabel("Help    Terms    Privacy", size: 14, color: accentBlue, weight: .regular, centered: true)
+
+        [back, logo, domain, emailField, nextButton, divider, socialText, social, signup, footer].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
+            content.addSubview($0)
         }
 
         NSLayoutConstraint.activate([
-            back.topAnchor.constraint(equalTo: content.topAnchor, constant: 26),
+            back.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
             back.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 34),
-
             logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
             logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
-
             domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
             domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
-
             emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
             emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
             emailField.widthAnchor.constraint(equalToConstant: 520),
             emailField.heightAnchor.constraint(equalToConstant: 52),
-
             nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
             nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
             nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
             nextButton.heightAnchor.constraint(equalToConstant: 52),
-
             divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
             divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
             divider.widthAnchor.constraint(equalTo: emailField.widthAnchor),
+            socialText.centerYAnchor.constraint(equalTo: divider.centerYAnchor),
+            socialText.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+            social.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 18),
+            social.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+            social.widthAnchor.constraint(equalTo: emailField.widthAnchor),
+            signup.topAnchor.constraint(equalTo: social.bottomAnchor, constant: 14),
+            signup.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+            footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
+            footer.centerXAnchor.constraint(equalTo: content.centerXAnchor)
+        ])
 
-            signInText.centerXAnchor.constraint(equalTo: content.centerXAnchor),
-            signInText.centerYAnchor.constraint(equalTo: divider.centerYAnchor),
+        return root
+    }
 
-            socialStack.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 18),
-            socialStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
-            socialStack.widthAnchor.constraint(equalTo: emailField.widthAnchor),
+    // MARK: - Home UI
 
-            signup.topAnchor.constraint(equalTo: socialStack.bottomAnchor, constant: 14),
-            signup.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+    private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
+        let root = NSView()
+        let sidebar = makeSidebar(items: ["Home", "Meetings", "Chat", "Scheduler", "Hub", "More"], selected: "Home")
+        let content = NSView()
 
-            footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
-            footer.centerXAnchor.constraint(equalTo: content.centerXAnchor)
+        root.addSubview(sidebar)
+        root.addSubview(content)
+        sidebar.translatesAutoresizingMaskIntoConstraints = false
+        content.translatesAutoresizingMaskIntoConstraints = false
+
+        NSLayoutConstraint.activate([
+            sidebar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            sidebar.topAnchor.constraint(equalTo: root.topAnchor),
+            sidebar.bottomAnchor.constraint(equalTo: root.bottomAnchor),
+            sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
+            content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
+            content.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            content.topAnchor.constraint(equalTo: root.topAnchor),
+            content.bottomAnchor.constraint(equalTo: root.bottomAnchor)
+        ])
+
+        let topBar = NSView()
+        topBar.wantsLayer = true
+        topBar.layer?.backgroundColor = cardBackground.cgColor
+        topBar.layer?.cornerRadius = 10
+        let search = makeLabel("Search (\u{2318}E)", size: 17, color: mutedText, weight: .regular, centered: true)
+        let name = makeLabel(profile?.name ?? "User", size: 14, color: primaryText, weight: .medium, centered: false)
+        name.wantsLayer = true
+        name.layer?.backgroundColor = accentBlue.withAlphaComponent(0.28).cgColor
+        name.layer?.cornerRadius = 10
+
+        let timeTitle = makeLabel("--:--", size: 54, color: primaryText, weight: .bold, centered: true)
+        let dateTitle = makeLabel("-", size: 18, color: secondaryText, weight: .regular, centered: true)
+
+        let actions = NSStackView(views: [
+            makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
+            makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
+            makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue),
+            makeActionTile(title: "Share screen", symbol: "rectangle.portrait.and.arrow.forward", color: accentBlue),
+            makeActionTile(title: "My notes", symbol: "pencil", color: accentBlue)
+        ])
+        actions.orientation = .horizontal
+        actions.spacing = 26
+        actions.alignment = .centerY
+        actions.distribution = .fillEqually
+
+        let panel = NSView()
+        panel.wantsLayer = true
+        panel.layer?.backgroundColor = secondaryCardBackground.cgColor
+        panel.layer?.cornerRadius = 14
+
+        let panelHeader = makeLabel("Today, Apr 14", size: 32, color: primaryText, weight: .semibold, centered: true)
+        let noMeeting = makeLabel("No meetings scheduled.", size: 32, color: secondaryText, weight: .regular, centered: true)
+        let scheduledMeetingCard = NSView()
+        scheduledMeetingCard.wantsLayer = true
+        scheduledMeetingCard.layer?.backgroundColor = NSColor(calibratedRed: 35 / 255, green: 40 / 255, blue: 56 / 255, alpha: 1).cgColor
+        scheduledMeetingCard.layer?.cornerRadius = 16
+
+        let meetingTitle = makeLabel("Scheduled meeting", size: 35, color: primaryText, weight: .semibold, centered: false)
+        let meetingDetails = makeLabel("Today\n--:--", size: 22, color: secondaryText, weight: .regular, centered: false)
+        meetingDetails.maximumNumberOfLines = 2
+        let meetingHost = makeLabel("Host: Google Calendar", size: 20, color: secondaryText, weight: .regular, centered: false)
+        let openRecordings = makeLabel("Open recordings  ›", size: 30, color: secondaryText, weight: .regular, centered: false)
+        openRecordings.wantsLayer = true
+        openRecordings.layer?.backgroundColor = NSColor(calibratedRed: 31 / 255, green: 33 / 255, blue: 39 / 255, alpha: 1).cgColor
+
+        let contentColumn = NSView()
+        contentColumn.translatesAutoresizingMaskIntoConstraints = false
+        content.addSubview(contentColumn)
+
+        [topBar, search, name, timeTitle, dateTitle, actions, panel, panelHeader, noMeeting, scheduledMeetingCard, openRecordings].forEach {
+            $0.translatesAutoresizingMaskIntoConstraints = false
+            contentColumn.addSubview($0)
+        }
+        [meetingTitle, meetingDetails, meetingHost].forEach {
+            $0.translatesAutoresizingMaskIntoConstraints = false
+            scheduledMeetingCard.addSubview($0)
+        }
+
+        NSLayoutConstraint.activate([
+            contentColumn.topAnchor.constraint(equalTo: content.topAnchor),
+            contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor),
+            contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
+            contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
+
+            topBar.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 12),
+            topBar.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor, constant: 20),
+            topBar.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor, constant: -80),
+            topBar.heightAnchor.constraint(equalToConstant: 36),
+
+            search.centerXAnchor.constraint(equalTo: topBar.centerXAnchor),
+            search.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
+
+            name.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor, constant: -8),
+            name.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
+
+            timeTitle.topAnchor.constraint(equalTo: topBar.bottomAnchor, constant: 62),
+            timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
+            dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 8),
+            dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
+
+            actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 42),
+            actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
+            actions.widthAnchor.constraint(equalToConstant: 560),
+            actions.heightAnchor.constraint(equalToConstant: 96),
+
+            panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 32),
+            panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
+            panel.widthAnchor.constraint(equalToConstant: 640),
+            panel.heightAnchor.constraint(equalToConstant: 285),
+
+            panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 15),
+            panelHeader.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
+            noMeeting.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
+            noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
+
+            scheduledMeetingCard.topAnchor.constraint(equalTo: panel.topAnchor, constant: 58),
+            scheduledMeetingCard.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
+            scheduledMeetingCard.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
+            scheduledMeetingCard.bottomAnchor.constraint(equalTo: openRecordings.topAnchor, constant: -14),
+
+            meetingTitle.topAnchor.constraint(equalTo: scheduledMeetingCard.topAnchor, constant: 16),
+            meetingTitle.leadingAnchor.constraint(equalTo: scheduledMeetingCard.leadingAnchor, constant: 16),
+            meetingTitle.trailingAnchor.constraint(equalTo: scheduledMeetingCard.trailingAnchor, constant: -16),
+
+            meetingDetails.topAnchor.constraint(equalTo: meetingTitle.bottomAnchor, constant: 8),
+            meetingDetails.leadingAnchor.constraint(equalTo: meetingTitle.leadingAnchor),
+
+            meetingHost.topAnchor.constraint(equalTo: meetingDetails.bottomAnchor, constant: 8),
+            meetingHost.leadingAnchor.constraint(equalTo: meetingTitle.leadingAnchor),
+
+            openRecordings.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
+            openRecordings.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
+            openRecordings.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
+            openRecordings.heightAnchor.constraint(equalToConstant: 44)
         ])
 
-        return content
+        timeLabel = timeTitle
+        dateLabel = dateTitle
+        meetingCard = scheduledMeetingCard
+        meetingTitleLabel = meetingTitle
+        meetingDetailLabel = meetingDetails
+        meetingHostLabel = meetingHost
+        emptyMeetingLabel = noMeeting
+        scheduledMeetingCard.isHidden = true
+        updateClock()
+        return root
+    }
+
+    private func startClock() {
+        clockTimer?.invalidate()
+        clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
+            self?.updateClock()
+        }
+        updateClock()
+    }
+
+    private func updateClock() {
+        let now = Date()
+        let timeFormatter = DateFormatter()
+        timeFormatter.dateFormat = "h:mm a"
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "EEEE, d MMM"
+        timeLabel?.stringValue = timeFormatter.string(from: now)
+        dateLabel?.stringValue = dateFormatter.string(from: now)
     }
 
-    private func makeSocialButton(icon: String, text: String) -> NSView {
+    // MARK: - Shared UI
+
+    private func makeSidebar(items: [String], selected: String) -> NSView {
+        let sidebar = NSView()
+        sidebar.wantsLayer = true
+        sidebar.layer?.backgroundColor = sidebarBackground.cgColor
+
+        let stack = NSStackView()
+        stack.orientation = .vertical
+        stack.spacing = 16
+        stack.alignment = .centerX
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        sidebar.addSubview(stack)
+
+        for item in items {
+            let row = NSView()
+            row.translatesAutoresizingMaskIntoConstraints = false
+            row.wantsLayer = true
+            let selectedRow = item == selected
+            row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.cgColor : NSColor.clear.cgColor
+            row.layer?.cornerRadius = 10
+            row.widthAnchor.constraint(equalToConstant: 70).isActive = true
+
+            let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
+            let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
+            [icon, label].forEach {
+                $0.translatesAutoresizingMaskIntoConstraints = false
+                row.addSubview($0)
+            }
+            NSLayoutConstraint.activate([
+                icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
+                icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
+                label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
+                label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
+                label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
+            ])
+            stack.addArrangedSubview(row)
+        }
+
+        NSLayoutConstraint.activate([
+            stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 18),
+            stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
+            stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
+        ])
+
+        return sidebar
+    }
+
+    private func makeActionTile(title: String, symbol: String, color: NSColor) -> NSView {
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+        root.widthAnchor.constraint(equalToConstant: 100).isActive = true
+        root.heightAnchor.constraint(equalToConstant: 96).isActive = true
+
+        let iconButton = NSButton()
+        iconButton.isBordered = false
+        iconButton.wantsLayer = true
+        iconButton.layer?.backgroundColor = color.cgColor
+        iconButton.layer?.cornerRadius = 18
+        iconButton.image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
+        iconButton.contentTintColor = .white
+        iconButton.imageScaling = .scaleProportionallyUpOrDown
+        let label = makeLabel(title, size: 14, color: secondaryText, weight: .regular, centered: true)
+
+        [iconButton, label].forEach {
+            $0.translatesAutoresizingMaskIntoConstraints = false
+            root.addSubview($0)
+        }
+        NSLayoutConstraint.activate([
+            iconButton.topAnchor.constraint(equalTo: root.topAnchor),
+            iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
+            iconButton.widthAnchor.constraint(equalToConstant: 62),
+            iconButton.heightAnchor.constraint(equalToConstant: 62),
+            label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 9),
+            label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
+            label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
+        ])
+        return root
+    }
+
+    private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
+        let label = NSTextField(labelWithString: text)
+        label.font = .systemFont(ofSize: size, weight: weight)
+        label.textColor = color
+        label.alignment = centered ? .center : .left
+        return label
+    }
+
+    private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
         let wrapper = NSView()
-        let iconLabel = NSTextField(labelWithString: icon)
-        iconLabel.font = .systemFont(ofSize: 22, weight: .medium)
-        iconLabel.alignment = .center
-        iconLabel.textColor = primaryText
-
-        let iconBox = NSBox()
-        iconBox.boxType = .custom
-        iconBox.cornerRadius = 12
-        iconBox.borderType = .noBorder
-        iconBox.fillColor = cardBackground
-
-        let textLabel = NSTextField(labelWithString: text)
-        textLabel.font = .systemFont(ofSize: 12, weight: .regular)
-        textLabel.textColor = secondaryText
-        textLabel.alignment = .center
-
-        wrapper.addSubview(iconBox)
-        wrapper.addSubview(textLabel)
-        iconBox.contentView?.addSubview(iconLabel)
-
-        iconBox.translatesAutoresizingMaskIntoConstraints = false
-        iconLabel.translatesAutoresizingMaskIntoConstraints = false
-        textLabel.translatesAutoresizingMaskIntoConstraints = false
+        let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
+        button.font = .systemFont(ofSize: 20, weight: .medium)
+        button.isBordered = false
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 12
+        button.layer?.backgroundColor = cardBackground.cgColor
+        button.contentTintColor = primaryText
+        button.translatesAutoresizingMaskIntoConstraints = false
+
+        let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
+        label.translatesAutoresizingMaskIntoConstraints = false
+        wrapper.addSubview(button)
+        wrapper.addSubview(label)
 
         NSLayoutConstraint.activate([
-            iconBox.topAnchor.constraint(equalTo: wrapper.topAnchor),
-            iconBox.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
-            iconBox.widthAnchor.constraint(equalToConstant: 52),
-            iconBox.heightAnchor.constraint(equalToConstant: 52),
+            button.topAnchor.constraint(equalTo: wrapper.topAnchor),
+            button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
+            button.widthAnchor.constraint(equalToConstant: 52),
+            button.heightAnchor.constraint(equalToConstant: 52),
+            label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
+            label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
+            label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
+        ])
+        return (wrapper, action == nil ? nil : button)
+    }
+}
+
+struct GoogleOAuthTokens: Codable, Equatable {
+    var accessToken: String
+    var refreshToken: String?
+    var expiresAt: Date
+    var scope: String?
+    var tokenType: String?
+}
+
+struct GoogleUserProfile: Codable, Equatable {
+    var name: String?
+    var email: String?
+    var picture: String?
+}
+
+enum GoogleOAuthError: Error {
+    case missingClientId
+    case missingClientSecret
+    case invalidCallbackURL
+    case missingAuthorizationCode
+    case tokenExchangeFailed(String)
+    case unableToOpenBrowser
+    case authenticationTimedOut
+}
+
+final class GoogleOAuthService: NSObject {
+    static let shared = GoogleOAuthService()
+    private var inAppOAuthWindowController: InAppOAuthWindowController?
+    private let clientId = "1027920783127-tu96fn69edr6fimn32nqh9rfj711fm2i.apps.googleusercontent.com"
+    private let clientSecret = "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"
+    private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
+    private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
+    private let tokenStore = KeychainTokenStore()
+
+    func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
+
+    func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
+        var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
+        }
+        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
+    }
+
+    func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
+        if let tokens = try tokenStore.readTokens(),
+           tokens.expiresAt.timeIntervalSinceNow > 60,
+           tokenHasCalendarScope(tokens.scope) {
+            return tokens.accessToken
+        }
+        let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
+        try tokenStore.writeTokens(tokens)
+        return tokens.accessToken
+    }
 
-            iconLabel.centerXAnchor.constraint(equalTo: iconBox.contentView!.centerXAnchor),
-            iconLabel.centerYAnchor.constraint(equalTo: iconBox.contentView!.centerYAnchor),
+    private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
+        _ = presentingWindow
+        let codeVerifier = Self.randomURLSafeString(length: 64)
+        let codeChallenge = Self.pkceChallenge(for: codeVerifier)
+        let state = Self.randomURLSafeString(length: 32)
+        let loopback = try await OAuthLoopbackServer.start()
+        defer { loopback.stop() }
+        let redirectURI = loopback.redirectURI
+
+        var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
+        components.queryItems = [
+            URLQueryItem(name: "client_id", value: clientId),
+            URLQueryItem(name: "redirect_uri", value: redirectURI),
+            URLQueryItem(name: "response_type", value: "code"),
+            URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
+            URLQueryItem(name: "state", value: state),
+            URLQueryItem(name: "code_challenge", value: codeChallenge),
+            URLQueryItem(name: "code_challenge_method", value: "S256"),
+            URLQueryItem(name: "access_type", value: "offline"),
+            URLQueryItem(name: "prompt", value: "consent")
+        ]
+
+        guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
+        let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
+        guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
+
+        let callbackURL = try await loopback.waitForCallback()
+        let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
+        guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
+        guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
+            throw GoogleOAuthError.missingAuthorizationCode
+        }
+        return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
+    }
 
-            textLabel.topAnchor.constraint(equalTo: iconBox.bottomAnchor, constant: 6),
-            textLabel.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
-            textLabel.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
+    private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
+        request.httpMethod = "POST"
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+        request.httpBody = Self.formURLEncoded([
+            "client_id": clientId,
+            "client_secret": clientSecret,
+            "code": code,
+            "code_verifier": codeVerifier,
+            "redirect_uri": redirectURI,
+            "grant_type": "authorization_code"
         ])
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
+        }
+
+        struct TokenResponse: Decodable {
+            let access_token: String
+            let expires_in: Double
+            let refresh_token: String?
+            let scope: String?
+            let token_type: String?
+        }
+
+        let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
+        return GoogleOAuthTokens(
+            accessToken: decoded.access_token,
+            refreshToken: decoded.refresh_token,
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
+            scope: decoded.scope,
+            tokenType: decoded.token_type
+        )
+    }
+
+    private static func pkceChallenge(for verifier: String) -> String {
+        let digest = SHA256.hash(data: Data(verifier.utf8))
+        return Data(digest).base64URLEncodedString()
+    }
+
+    private static func randomURLSafeString(length: Int) -> String {
+        var bytes = [UInt8](repeating: 0, count: length)
+        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+        return Data(bytes).base64URLEncodedString()
+    }
+
+    private static func formURLEncoded(_ params: [String: String]) -> Data {
+        let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
+        let pairs = params.map { key, value in
+            let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
+            let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+            return "\(k)=\(v)"
+        }.joined(separator: "&")
+        return Data(pairs.utf8)
+    }
 
-        return wrapper
+    private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
+        guard let scopeValue else { return false }
+        return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
+    }
+}
+
+private extension Data {
+    func base64URLEncodedString() -> String {
+        base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
+    }
+}
+
+final class KeychainTokenStore {
+    private let defaultsKey: String
+    private let defaults: UserDefaults
+
+    init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
+         account: String = "googleOAuthTokens",
+         defaults: UserDefaults = .standard) {
+        self.defaultsKey = "\(service).\(account)"
+        self.defaults = defaults
+    }
+
+    func readTokens() throws -> GoogleOAuthTokens? {
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
+        return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
+    }
+
+    func writeTokens(_ tokens: GoogleOAuthTokens) throws {
+        let data = try JSONEncoder().encode(tokens)
+        defaults.set(data, forKey: defaultsKey)
+    }
+}
+
+private final class OAuthLoopbackServer {
+    private let queue = DispatchQueue(label: "google.oauth.loopback.server")
+    private let listener: NWListener
+    private var readyContinuation: CheckedContinuation<Void, Error>?
+    private var callbackContinuation: CheckedContinuation<URL, Error>?
+    private var callbackURL: URL?
+
+    private init(listener: NWListener) {
+        self.listener = listener
+    }
+
+    static func start() async throws -> OAuthLoopbackServer {
+        let listener = try NWListener(using: .tcp, on: .any)
+        let server = OAuthLoopbackServer(listener: listener)
+        try await server.startListening()
+        return server
+    }
+
+    var redirectURI: String {
+        let port = listener.port?.rawValue ?? 0
+        return "http://127.0.0.1:\(port)/oauth2redirect"
+    }
+
+    func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
+        try await withThrowingTaskGroup(of: URL.self) { group in
+            group.addTask { [weak self] in
+                guard let self else { throw GoogleOAuthError.invalidCallbackURL }
+                return try await self.awaitCallback()
+            }
+            group.addTask {
+                try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
+                throw GoogleOAuthError.authenticationTimedOut
+            }
+            let url = try await group.next()!
+            group.cancelAll()
+            return url
+        }
+    }
+
+    func stop() {
+        listener.cancel()
+    }
+
+    private func startListening() async throws {
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+            queue.async {
+                self.readyContinuation = continuation
+                self.listener.stateUpdateHandler = { [weak self] state in
+                    guard let self else { return }
+                    switch state {
+                    case .ready:
+                        if let readyContinuation = self.readyContinuation {
+                            self.readyContinuation = nil
+                            readyContinuation.resume()
+                        }
+                    case .failed(let error):
+                        if let readyContinuation = self.readyContinuation {
+                            self.readyContinuation = nil
+                            readyContinuation.resume(throwing: error)
+                        }
+                    case .cancelled:
+                        if let readyContinuation = self.readyContinuation {
+                            self.readyContinuation = nil
+                            readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
+                        }
+                    default:
+                        break
+                    }
+                }
+                self.listener.newConnectionHandler = { [weak self] connection in
+                    self?.handle(connection: connection)
+                }
+                self.listener.start(queue: self.queue)
+            }
+        }
+    }
+
+    private func awaitCallback() async throws -> URL {
+        if let callbackURL { return callbackURL }
+        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
+            queue.async { self.callbackContinuation = continuation }
+        }
+    }
+
+    private func handle(connection: NWConnection) {
+        connection.start(queue: queue)
+        connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
+            guard let self else { return }
+            let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
+            var parsedURL: URL?
+            if let requestLine {
+                let parts = requestLine.split(separator: " ")
+                if parts.count >= 2 {
+                    parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
+                }
+            }
+            self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
+            if let parsedURL {
+                self.callbackURL = parsedURL
+                self.callbackContinuation?.resume(returning: parsedURL)
+                self.callbackContinuation = nil
+                self.listener.cancel()
+            }
+            connection.cancel()
+        }
+    }
+
+    private func sendHTTPResponse(connection: NWConnection, success: Bool) {
+        let body = success ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>" : "<html><body><h3>Authentication failed</h3></body></html>"
+        let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
+        connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
+    }
+}
+
+@MainActor
+private final class OAuthWebViewContainerView: NSView {
+    private let webView: WKWebView
+    init(webView: WKWebView) {
+        self.webView = webView
+        super.init(frame: .zero)
+        addSubview(webView)
+    }
+    @available(*, unavailable) required init?(coder: NSCoder) { nil }
+    override func layout() {
+        super.layout()
+        webView.frame = bounds
+    }
+}
+
+@MainActor
+private final class InAppOAuthWindowController: NSWindowController {
+    private let webView: WKWebView
+    init() {
+        self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
+        let container = OAuthWebViewContainerView(webView: webView)
+        let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
+        window.title = "Google Sign-In"
+        window.contentView = container
+        super.init(window: window)
+    }
+    @available(*, unavailable) required init?(coder: NSCoder) { nil }
+    func load(url: URL) { webView.load(URLRequest(url: url)) }
+}
+
+extension GoogleOAuthError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .missingClientId: return "Missing Google OAuth Client ID."
+        case .missingClientSecret: return "Missing Google OAuth Client Secret."
+        case .invalidCallbackURL: return "Invalid OAuth callback URL."
+        case .missingAuthorizationCode: return "Google did not return an authorization code."
+        case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
+        case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
+        case .authenticationTimedOut: return "Google sign-in timed out."
+        }
     }
 }
 

+ 12 - 0
zoom_app/zoom_app.entitlements

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.app-sandbox</key>
+	<true/>
+	<key>com.apple.security.network.client</key>
+	<true/>
+	<key>com.apple.security.network.server</key>
+	<true/>
+</dict>
+</plist>