ソースを参照

Add desktop widget feature

Adds a new Widgets section with desktop widgets (small/medium) and hooks widget actions into the existing Zoom navigation and meetings refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 ヶ月 前
コミット
962ca19bff
2 ファイル変更929 行追加9 行削除
  1. 5 1
      zoom_app/AppDelegate.swift
  2. 924 8
      zoom_app/ViewController.swift

+ 5 - 1
zoom_app/AppDelegate.swift

@@ -21,7 +21,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         case openHome = 10
         case openMeetings = 11
         case openScheduler = 12
-        case openSettings = 13
+        case openWidgets = 13
+        case openSettings = 14
         case newMeeting = 20
         case joinMeeting = 21
         case scheduleMeeting = 22
@@ -177,6 +178,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         appendMenuItem("Open Home", tag: .openHome, to: menu)
         appendMenuItem("Open Meetings", tag: .openMeetings, to: menu)
         appendMenuItem("Open Scheduler", tag: .openScheduler, to: menu)
+        appendMenuItem("Open Widgets", tag: .openWidgets, to: menu)
         appendMenuItem("Open Settings", tag: .openSettings, to: menu)
         menu.addItem(.separator())
         appendMenuItem("New Meeting", tag: .newMeeting, to: menu)
@@ -216,6 +218,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
             controller.menuBarShowSection(.meetings)
         case .openScheduler:
             controller.menuBarShowSection(.scheduler)
+        case .openWidgets:
+            controller.menuBarShowSection(.widgets)
         case .openSettings:
             controller.menuBarShowSection(.settings)
         case .newMeeting:

+ 924 - 8
zoom_app/ViewController.swift

@@ -6,8 +6,10 @@
 //
 
 import Cocoa
+import Combine
 import CryptoKit
 import StoreKit
+import SwiftUI
 import WebKit
 
 /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
@@ -47,6 +49,7 @@ class ViewController: NSViewController {
         case home = "Home"
         case meetings = "Meetings"
         case scheduler = "Scheduler"
+        case widgets = "Widgets"
         case settings = "Settings"
     }
 
@@ -269,6 +272,7 @@ class ViewController: NSViewController {
     private weak var schedulerMeetingsEmptyLabel: NSTextField?
     private weak var schedulerTodayButton: NSButton?
     private weak var meetingsPageRootView: NSView?
+    private weak var widgetsPageRootView: NSView?
     private weak var meetingsPageCategoryFilter: NSPopUpButton?
     private weak var meetingsPageFromDatePicker: NSDatePicker?
     private weak var meetingsPageToDatePicker: NSDatePicker?
@@ -550,7 +554,7 @@ class ViewController: NSViewController {
             }
         }
 
-        private static func isTransactionActive(_ transaction: Transaction) -> Bool {
+        private static func isTransactionActive(_ transaction: StoreKit.Transaction) -> Bool {
             if transaction.revocationDate != nil { return false }
             if let expirationDate = transaction.expirationDate {
                 return expirationDate > Date()
@@ -575,6 +579,7 @@ class ViewController: NSViewController {
         ensureUsageSessionStartDate()
         scheduleUsageBasedRatingPromptIfNeeded()
         setupUI()
+        setupWidgetBridge()
         startStoreKit()
     }
 
@@ -596,6 +601,7 @@ class ViewController: NSViewController {
         showHomeView(profile: nil)
         syncBrandPositionForCurrentWindowState(animated: false)
         scheduleLaunchPaywallIfNeeded()
+        DesktopWidgetWindowManager.shared.restore()
     }
 
     override func viewDidLayout() {
@@ -678,6 +684,7 @@ class ViewController: NSViewController {
         meetingSearchResultsStack = nil
         meetingSearchEmptyLabel = nil
         meetingsPageRootView = nil
+        widgetsPageRootView = nil
         homeView?.removeFromSuperview()
         if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
             topBarProfileInitial = String(profileName.prefix(1)).uppercased()
@@ -2391,6 +2398,7 @@ class ViewController: NSViewController {
         }
         applyFilteredMeetings()
         applyMeetingsPageFilters(resetVisible: true)
+        updateWidgetMeetingStoreFromCurrentMeetings()
     }
 
     @MainActor
@@ -4840,7 +4848,7 @@ class ViewController: NSViewController {
         homeSidebarRowViews = [:]
         homeSidebarIconViews = [:]
         homeSidebarLabels = [:]
-        let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Settings"], selected: selectedHomeSidebarItem, style: .home)
+        let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Widgets", "Settings"], selected: selectedHomeSidebarItem, style: .home)
         let content = NSView()
         content.wantsLayer = true
         content.layer?.backgroundColor = NSColor.clear.cgColor
@@ -5050,6 +5058,8 @@ class ViewController: NSViewController {
         settingsView.isHidden = selectedHomeSidebarItem != "Settings"
         let meetingsPageView = makeMeetingsPageView()
         meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
+        let widgetsPageView = makeWidgetsPageView()
+        widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
 
         let searchOverlay = NSView()
         searchOverlay.wantsLayer = true
@@ -5321,7 +5331,7 @@ class ViewController: NSViewController {
         [searchIcon, searchField, searchHintLabel].forEach {
             searchPill.addSubview($0)
         }
-        [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, schedulerRoot].forEach {
+        [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, widgetsPageView, schedulerRoot].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
             mainContentColumn.addSubview($0)
         }
@@ -5580,6 +5590,7 @@ class ViewController: NSViewController {
         homePlaceholderLabel = placeholder
         homeSettingsView = settingsView
         meetingsPageRootView = meetingsPageView
+        widgetsPageRootView = widgetsPageView
         schedulerRootView = schedulerRoot
         self.schedulerMonthLabel = schedulerMonthLabel
         schedulerCalendarGridStack = schedulerCalendarGrid
@@ -5737,6 +5748,83 @@ class ViewController: NSViewController {
         return button
     }
 
+    private func makeWidgetsPageView() -> NSView {
+        let canAdd = isUserLoggedIn() && storeKitCoordinator.hasPremiumAccess
+        let host = NSHostingView(
+            rootView: WidgetsRootView(
+                canAddWidgets: canAdd,
+                onAddBlocked: { [weak self] in
+                    self?.showPaywall()
+                }
+            )
+        )
+        host.wantsLayer = true
+        host.layer?.backgroundColor = NSColor.clear.cgColor
+        return host
+    }
+
+    @MainActor
+    private func updateWidgetMeetingStoreFromCurrentMeetings() {
+        let upcoming = allScheduledMeetings
+            .filter { $0.start >= Date() }
+            .sorted(by: { $0.start < $1.start })
+            .prefix(3)
+            .map { meeting in
+                WidgetMeetingSnapshot(
+                    id: meeting.meetingID ?? meeting.title,
+                    title: meeting.title,
+                    timeText: widgetTimeText(for: meeting),
+                    joinLink: meeting.webURL?.absoluteString
+                )
+            }
+        WidgetMeetingStore.save(Array(upcoming))
+        DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
+    }
+
+    private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "h:mm a"
+        formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
+        return formatter.string(from: meeting.start)
+    }
+
+    private func setupWidgetBridge() {
+        let center = NotificationCenter.default
+        center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarShowSection(.home)
+        }
+        center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarShowSection(.meetings)
+        }
+        center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarShowSection(.scheduler)
+        }
+        center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarShowSection(.widgets)
+        }
+        center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarShowSection(.settings)
+        }
+        center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarTriggerNewMeeting()
+        }
+        center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarTriggerJoinMeeting()
+        }
+        center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
+            self?.menuBarTriggerScheduleMeeting()
+        }
+        center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
+            self?.triggerMeetingsRefresh(force: true)
+        }
+        center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
+            guard let self else { return }
+            guard let link = notification.userInfo?["link"] as? String,
+                  let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
+            self.openWebURLPreferringInApp(url)
+        }
+    }
+
     private func makeMeetingsPageView() -> NSView {
         let root = NSView()
         root.wantsLayer = true
@@ -6239,10 +6327,11 @@ class ViewController: NSViewController {
 
     @MainActor
     private func updateSelectedHomeSectionUI() {
-        let isHome = selectedHomeSidebarItem == "Home"
-        let isMeetings = selectedHomeSidebarItem == "Meetings"
-        let isSettings = selectedHomeSidebarItem == "Settings"
-        let isScheduler = selectedHomeSidebarItem == "Scheduler"
+        let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
+        let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
+        let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
+        let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
+        let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
         let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
         homeWelcomeLabel?.stringValue = "Home"
         homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
@@ -6260,7 +6349,7 @@ class ViewController: NSViewController {
             meetingsScrollView,
             refreshMeetingsButton
         ]
-        let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler
+        let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler || isWidgets
         dashboardViews.forEach { $0?.isHidden = hideDashboard }
         // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
         // and showed "No meetings…" on top of meeting cards when returning to Home.
@@ -6273,6 +6362,7 @@ class ViewController: NSViewController {
         homeSettingsView?.isHidden = isSettings == false
         meetingsPageRootView?.isHidden = isMeetings == false
         schedulerRootView?.isHidden = isScheduler == false
+        widgetsPageRootView?.isHidden = isWidgets == false
 
         if isHome {
             homePlaceholderLabel?.isHidden = true
@@ -6302,6 +6392,8 @@ class ViewController: NSViewController {
         case "Scheduler":
             // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
             return "calendar.badge.clock"
+        case "Widgets":
+            return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
         case "Settings":
             // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
             return filled ? "gearshape.fill" : "gearshape"
@@ -7564,3 +7656,827 @@ extension ZoomOAuthError: LocalizedError {
     }
 }
 
+// MARK: - Desktop Widgets (Zoom)
+
+private struct WidgetQuickAction: Identifiable, Hashable {
+    enum Destination: String, Hashable {
+        case home
+        case meetings
+        case scheduler
+        case widgets
+        case settings
+        case newMeeting
+        case joinMeeting
+        case scheduleMeeting
+        case refreshMeetings
+    }
+
+    let id: String
+    let title: String
+    let systemImage: String
+    let destination: Destination
+}
+
+private struct WidgetVariant: Identifiable, Hashable {
+    let id: String
+    let title: String
+    let subtitle: String
+    let size: WidgetSize
+    let quickActions: [WidgetQuickAction]
+}
+
+private enum WidgetTemplates {
+    static let zoomVariants: [WidgetVariant] = [
+        WidgetVariant(
+            id: "zoom_small",
+            title: "Zoom",
+            subtitle: "Quick actions",
+            size: .small,
+            quickActions: [
+                WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
+                WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
+                WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
+                WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
+            ]
+        ),
+        WidgetVariant(
+            id: "zoom_medium",
+            title: "Zoom",
+            subtitle: "Upcoming meetings",
+            size: .medium,
+            quickActions: [
+                WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
+                WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
+                WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
+                WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
+            ]
+        )
+    ]
+
+    static func variant(for variantID: String) -> WidgetVariant {
+        zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
+    }
+}
+
+private extension WidgetVariant {
+    var previewCardSize: CGSize {
+        switch size {
+        case .small:
+            return CGSize(width: 196, height: 196)
+        case .medium:
+            return CGSize(width: 340, height: 288)
+        }
+    }
+}
+
+private enum WidgetSize: String, CaseIterable, Codable, Hashable {
+    case small
+    case medium
+
+    var title: String {
+        switch self {
+        case .small: return "Small"
+        case .medium: return "Medium"
+        }
+    }
+}
+
+private struct WidgetInstance: Identifiable, Codable, Hashable {
+    struct Origin: Codable, Hashable {
+        var x: Double
+        var y: Double
+    }
+
+    let id: UUID
+    let variantID: String
+    var size: WidgetSize
+    var origin: Origin?
+
+    init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
+        self.id = id
+        self.variantID = variantID
+        self.size = size
+        self.origin = origin
+    }
+}
+
+private struct WidgetMeetingSnapshot: Codable, Identifiable {
+    let id: String
+    let title: String
+    let timeText: String
+    let joinLink: String?
+}
+
+private enum WidgetMeetingStore {
+    static let key = "zoom_app.widget.topMeetings"
+
+    static func load() -> [WidgetMeetingSnapshot] {
+        guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
+        return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
+    }
+
+    static func save(_ meetings: [WidgetMeetingSnapshot]) {
+        guard let data = try? JSONEncoder().encode(meetings) else { return }
+        UserDefaults.standard.set(data, forKey: key)
+    }
+}
+
+private enum WidgetAppNavigator {
+    static func open(target: WidgetQuickAction.Destination) {
+        DispatchQueue.main.async {
+            bringAppToFront()
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
+                NotificationCenter.default.post(name: notificationName(for: target), object: nil)
+            }
+        }
+    }
+
+    static func openMeetingLink(_ link: String) {
+        DispatchQueue.main.async {
+            bringAppToFront()
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
+                NotificationCenter.default.post(
+                    name: .widgetOpenMeetingLinkRequested,
+                    object: nil,
+                    userInfo: ["link": link]
+                )
+            }
+        }
+    }
+
+    private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
+        switch target {
+        case .home: return .widgetOpenHomePage
+        case .meetings: return .widgetOpenMeetingsPage
+        case .scheduler: return .widgetOpenSchedulerPage
+        case .widgets: return .widgetOpenWidgetsPage
+        case .settings: return .widgetOpenSettingsPage
+        case .newMeeting: return .widgetStartNewMeetingRequested
+        case .joinMeeting: return .widgetJoinMeetingRequested
+        case .scheduleMeeting: return .widgetScheduleMeetingRequested
+        case .refreshMeetings: return .widgetRefreshRequested
+        }
+    }
+
+    private static func bringAppToFront() {
+        NSApp.unhide(nil)
+        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
+        NSApp.activate(ignoringOtherApps: true)
+        NSApp.arrangeInFront(nil)
+        for window in NSApp.windows where window.contentViewController is ViewController {
+            if window.isMiniaturized {
+                window.deminiaturize(nil)
+            }
+            window.orderFrontRegardless()
+            window.makeKeyAndOrderFront(nil)
+        }
+    }
+}
+
+private extension Notification.Name {
+    static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
+    static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
+    static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
+    static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
+    static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
+    static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
+    static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
+    static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
+    static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
+    static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
+}
+
+private final class DesktopWidgetWindowManager: ObservableObject {
+    static let shared = DesktopWidgetWindowManager()
+
+    private let storageKey = "zoom_app.widget.instancesData"
+    private var windowsByInstanceID: [UUID: NSPanel] = [:]
+    @Published private(set) var instancesChangeToken: Int = 0
+    @Published private(set) var meetingsRefreshToken: Int = 0
+
+    private init() {}
+
+    func hasInstance(variantID: String) -> Bool {
+        loadInstances().contains { $0.variantID == variantID }
+    }
+
+    func show(instance: WidgetInstance) {
+        let variant = WidgetTemplates.variant(for: instance.variantID)
+        var resolved = instance
+        if resolved.origin == nil {
+            resolved.origin = nextAutoPlacement(for: variant)
+        }
+
+        let panel = makePanel(for: resolved, variant: variant)
+        windowsByInstanceID[resolved.id] = panel
+        persistUpsert(instance: resolved)
+        if let origin = resolved.origin {
+            panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
+            persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
+        } else {
+            panel.center()
+            persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
+        }
+        panel.orderFront(nil)
+        panel.orderFrontRegardless()
+        instancesChangeToken += 1
+    }
+
+    @discardableResult
+    func removeInstances(variantID: String) -> Int {
+        let instances = loadInstances()
+        let removed = instances.filter { $0.variantID == variantID }
+        let remaining = instances.filter { $0.variantID != variantID }
+        for instance in removed {
+            if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
+                panel.close()
+            }
+        }
+        saveInstances(remaining)
+        instancesChangeToken += 1
+        return removed.count
+    }
+
+    func restore() {
+        for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
+            show(instance: instance)
+        }
+    }
+
+    func refreshForMeetingsChange() {
+        resizeVisiblePanelsForCurrentContent()
+        meetingsRefreshToken += 1
+    }
+
+    private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
+        let size = panelSize(for: variant.size)
+        let panel = DesktopWidgetPanel(
+            contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
+            styleMask: [.nonactivatingPanel, .borderless],
+            backing: .buffered,
+            defer: false
+        )
+        panel.becomesKeyOnlyIfNeeded = false
+        panel.isReleasedWhenClosed = false
+        panel.isFloatingPanel = false
+        panel.isMovable = true
+        panel.hasShadow = false
+        panel.isOpaque = false
+        panel.backgroundColor = .clear
+        panel.isMovableByWindowBackground = true
+        panel.hidesOnDeactivate = false
+        panel.ignoresMouseEvents = false
+        let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
+        panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
+        panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
+
+        let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
+        host.translatesAutoresizingMaskIntoConstraints = false
+        let container = NSView()
+        container.wantsLayer = true
+        container.layer?.backgroundColor = NSColor.clear.cgColor
+        container.layer?.cornerRadius = 22
+        container.layer?.masksToBounds = true
+        container.addSubview(host)
+        NSLayoutConstraint.activate([
+            host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            host.topAnchor.constraint(equalTo: container.topAnchor),
+            host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
+        ])
+        panel.contentView = container
+
+        NotificationCenter.default.addObserver(
+            forName: NSWindow.didMoveNotification,
+            object: panel,
+            queue: .main
+        ) { [weak self, weak panel] _ in
+            guard let self, let panel else { return }
+            self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
+        }
+        return panel
+    }
+
+    private func panelSize(for size: WidgetSize) -> CGSize {
+        switch size {
+        case .small:
+            return CGSize(width: 220, height: 204)
+        case .medium:
+            return CGSize(width: 390, height: mediumPanelHeight())
+        }
+    }
+
+    private func mediumPanelHeight() -> CGFloat {
+        let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
+        switch visibleMeetingCount {
+        case 0:
+            return 252
+        case 1:
+            return 270
+        default:
+            return 288
+        }
+    }
+
+    private func resizeVisiblePanelsForCurrentContent() {
+        let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
+        for (instanceID, panel) in windowsByInstanceID {
+            guard let instance = instancesByID[instanceID] else { continue }
+            let variant = WidgetTemplates.variant(for: instance.variantID)
+            let targetSize = panelSize(for: variant.size)
+            let currentSize = panel.frame.size
+            guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
+                continue
+            }
+
+            var frame = panel.frame
+            let oldMaxY = frame.maxY
+            frame.size = targetSize
+            frame.origin.y = oldMaxY - targetSize.height
+            panel.setFrame(frame, display: true)
+            persistOrigin(instanceID: instanceID, origin: frame.origin)
+        }
+    }
+
+    private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
+        let size = panelSize(for: variant.size)
+        let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
+        let margin: CGFloat = 18
+        let gapX: CGFloat = 22
+        let gapY: CGFloat = 18
+        let cellW = size.width + gapX
+        let cellH = size.height + gapY
+        let existingRects = loadInstances().compactMap { instance -> NSRect? in
+            guard let origin = instance.origin else { return nil }
+            let instanceSize = panelSize(for: instance.size)
+            return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
+        }
+
+        let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
+        let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
+        let maxSlots = max(120, cols * rows)
+        for slot in 0..<maxSlots {
+            let row = slot / cols
+            let col = cols - 1 - (slot % cols)
+            let x = frame.minX + margin + CGFloat(col) * cellW
+            let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
+            let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
+            guard candidate.minX >= frame.minX + margin,
+                  candidate.maxX <= frame.maxX - margin,
+                  candidate.minY >= frame.minY + margin else {
+                continue
+            }
+            if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
+                return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
+            }
+        }
+        return WidgetInstance.Origin(
+            x: frame.midX - size.width / 2,
+            y: frame.midY - size.height / 2
+        )
+    }
+
+    private func close(instanceID: UUID) {
+        if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
+            panel.close()
+        }
+        let remaining = loadInstances().filter { $0.id != instanceID }
+        saveInstances(remaining)
+        instancesChangeToken += 1
+    }
+
+    private func loadInstances() -> [WidgetInstance] {
+        guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
+        let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
+        let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
+        let sanitized = decoded.compactMap { instance -> WidgetInstance? in
+            guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
+            var normalized = instance
+            normalized.size = expectedSize
+            return normalized
+        }
+        if sanitized != decoded {
+            saveInstances(sanitized)
+        }
+        return sanitized
+    }
+
+    private func saveInstances(_ instances: [WidgetInstance]) {
+        guard let data = try? JSONEncoder().encode(instances) else { return }
+        UserDefaults.standard.set(data, forKey: storageKey)
+    }
+
+    private func persistUpsert(instance: WidgetInstance) {
+        var instances = loadInstances()
+        if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
+            instances[idx] = instance
+        } else {
+            instances.append(instance)
+        }
+        saveInstances(instances)
+    }
+
+    private func persistOrigin(instanceID: UUID, origin: NSPoint) {
+        var instances = loadInstances()
+        guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
+        instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
+        saveInstances(instances)
+    }
+
+    private struct DesktopWidgetHostView: View {
+        let instanceID: UUID
+        let variant: WidgetVariant
+        @ObservedObject private var manager = DesktopWidgetWindowManager.shared
+
+        var body: some View {
+            DesktopWidgetView(variant: variant, isPreview: false)
+                .id(manager.meetingsRefreshToken)
+                .contextMenu {
+                    Button("Remove Widget", role: .destructive) {
+                        DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
+                    }
+                }
+        }
+    }
+}
+
+private final class DesktopWidgetPanel: NSPanel {
+    override var canBecomeKey: Bool { true }
+    override var canBecomeMain: Bool { false }
+
+    override func mouseDown(with event: NSEvent) {
+        NSApp.activate(ignoringOtherApps: true)
+        makeKeyAndOrderFront(nil)
+        super.mouseDown(with: event)
+    }
+}
+
+private struct WidgetsRootView: View {
+    @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
+    @Environment(\.colorScheme) private var colorScheme
+    @State private var toastMessage: String?
+    @State private var toastTask: Task<Void, Never>?
+    let canAddWidgets: Bool
+    let onAddBlocked: (() -> Void)?
+
+    private let variants = WidgetTemplates.zoomVariants
+
+    init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
+        self.canAddWidgets = canAddWidgets
+        self.onAddBlocked = onAddBlocked
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 16) {
+            HStack {
+                VStack(alignment: .leading, spacing: 3) {
+                    Text("Add Widget to Desktop")
+                        .font(.system(size: 20, weight: .bold))
+                    Text("Choose a size and add your Zoom desktop widget.")
+                        .font(.system(size: 13, weight: .medium))
+                        .foregroundStyle(secondaryTextColor)
+                }
+                Spacer()
+            }
+
+            GeometryReader { geo in
+                let spacing: CGFloat = 18
+                let rowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width } + CGFloat(max(variants.count - 1, 0)) * spacing
+                let scale = max(0.40, min(1, (geo.size.width - 28) / max(1, rowWidth)))
+                HStack(alignment: .top, spacing: spacing) {
+                    ForEach(variants) { variant in
+                        WidgetPreviewAddCard(
+                            variant: variant,
+                            layoutScale: scale,
+                            isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
+                            onToggle: { toggleWidget(variant: variant) }
+                        )
+                    }
+                    Spacer(minLength: 0)
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+            }
+            .frame(height: variants.map(\.previewCardSize.height).max() ?? 320)
+
+            Text("Tip: Right-click a desktop widget to remove it.")
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(secondaryTextColor)
+        }
+        .padding(18)
+        .background(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
+        )
+        .overlay(alignment: .top) {
+            if let toastMessage {
+                Text(toastMessage)
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
+                    .padding(.horizontal, 14)
+                    .padding(.vertical, 9)
+                    .background(
+                        RoundedRectangle(cornerRadius: 12, style: .continuous)
+                            .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
+                    )
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 12, style: .continuous)
+                            .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
+                    )
+                    .padding(.top, 8)
+            }
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+    }
+
+    private var secondaryTextColor: Color {
+        colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
+    }
+
+    private func toggleWidget(variant: WidgetVariant) {
+        if desktopWidgetManager.hasInstance(variantID: variant.id) {
+            let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
+            guard removed > 0 else { return }
+            showToast("Removed \(variant.size.title) widget from desktop.")
+        } else {
+            guard canAddWidgets else {
+                showToast("Widgets are Premium only. Upgrade to add widgets.")
+                onAddBlocked?()
+                return
+            }
+            desktopWidgetManager.show(
+                instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
+            )
+            showToast("Added \(variant.size.title) widget to desktop.")
+        }
+    }
+
+    private func showToast(_ message: String) {
+        toastTask?.cancel()
+        toastMessage = message
+        toastTask = Task { @MainActor in
+            try? await Task.sleep(nanoseconds: 1_800_000_000)
+            guard !Task.isCancelled else { return }
+            toastMessage = nil
+        }
+    }
+}
+
+private struct WidgetPreviewAddCard: View {
+    let variant: WidgetVariant
+    var layoutScale: CGFloat = 1
+    let isAdded: Bool
+    let onToggle: () -> Void
+    @State private var hovering = false
+
+    var body: some View {
+        ZStack(alignment: .topTrailing) {
+            Button(action: onToggle) {
+                WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
+                    .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+            }
+            .buttonStyle(.plain)
+
+            Button(action: onToggle) {
+                Image(systemName: isAdded ? "minus" : "plus")
+                    .font(.system(size: 12, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.95))
+                    .frame(width: 26, height: 26)
+                    .background(Circle().fill(isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95)))
+                    .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
+            }
+            .buttonStyle(.plain)
+            .offset(x: 10 * layoutScale, y: -10 * layoutScale)
+            .opacity(isAdded ? 1 : (hovering ? 1 : 0))
+            .animation(.easeOut(duration: 0.12), value: hovering)
+        }
+        .onHover { hovering = $0 }
+    }
+}
+
+private struct WidgetPreviewCard: View {
+    let variant: WidgetVariant
+    var layoutScale: CGFloat = 1
+
+    var body: some View {
+        let size = variant.previewCardSize
+        let scale = max(0.01, layoutScale)
+        DesktopWidgetView(variant: variant, isPreview: true)
+            .frame(width: size.width, height: size.height)
+            .scaleEffect(scale)
+            .frame(width: size.width * scale, height: size.height * scale)
+            .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+            .overlay(
+                RoundedRectangle(cornerRadius: 18, style: .continuous)
+                    .stroke(Color.white.opacity(0.12), lineWidth: 1)
+            )
+    }
+}
+
+private struct DesktopWidgetView: View {
+    let variant: WidgetVariant
+    var isPreview: Bool
+
+    private var topMeetings: [WidgetMeetingSnapshot] {
+        WidgetMeetingStore.load().prefix(3).map { $0 }
+    }
+
+    var body: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [
+                            Color(red: 0.11, green: 0.45, blue: 0.98),
+                            Color(red: 0.14, green: 0.72, blue: 0.98)
+                        ],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+                .overlay(
+                    RoundedRectangle(cornerRadius: 22, style: .continuous)
+                        .stroke(Color.white.opacity(0.14), lineWidth: 1)
+                )
+
+            VStack(alignment: .leading, spacing: 12) {
+                widgetHeader
+                contentBody
+                if variant.size != .small {
+                    Spacer(minLength: 0)
+                }
+            }
+            .padding(.horizontal, 14)
+            .padding(.bottom, variant.size == .medium ? 16 : 14)
+            .padding(.top, variant.size == .medium ? 18 : 10)
+        }
+    }
+
+    @ViewBuilder
+    private var contentBody: some View {
+        switch variant.size {
+        case .small:
+            VStack(alignment: .leading, spacing: 10) {
+                Text("Quick actions")
+                    .font(.system(size: 11.5, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.78))
+                compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
+                HStack(spacing: 8) {
+                    compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
+                    compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
+                }
+            }
+        case .medium:
+            meetingBlock(maxRows: 2)
+            LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
+                ForEach(variant.quickActions.prefix(4)) { action in
+                    actionTile(action)
+                }
+            }
+        }
+    }
+
+    private var widgetHeader: some View {
+        HStack(spacing: 10) {
+            Image(systemName: "video.fill")
+                .font(.system(size: 16, weight: .bold))
+                .foregroundStyle(.white.opacity(0.92))
+                .frame(width: 30, height: 30)
+                .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.black.opacity(0.18)))
+            VStack(alignment: .leading, spacing: 2) {
+                Text(variant.title)
+                    .font(.system(size: 16, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.95))
+                Text(variant.subtitle)
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.8))
+            }
+            Spacer(minLength: 0)
+        }
+    }
+
+    private func meetingBlock(maxRows: Int) -> some View {
+        VStack(alignment: .leading, spacing: 9) {
+            Text("Upcoming meetings")
+                .font(.system(size: 11.5, weight: .semibold))
+                .foregroundStyle(.white.opacity(0.80))
+            if topMeetings.isEmpty {
+                Text("No upcoming meetings")
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.82))
+            } else {
+                VStack(alignment: .leading, spacing: 8) {
+                    ForEach(topMeetings.prefix(maxRows)) { meeting in
+                        meetingRow(meeting)
+                    }
+                }
+            }
+        }
+    }
+
+    private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
+        Button(action: {
+            if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
+                WidgetAppNavigator.openMeetingLink(link)
+            } else {
+                WidgetAppNavigator.open(target: .meetings)
+            }
+        }) {
+            HStack(spacing: 8) {
+                Image(systemName: "video.fill")
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.88))
+                VStack(alignment: .leading, spacing: 1) {
+                    Text(meeting.title)
+                        .font(.system(size: 11.5, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.93))
+                        .lineLimit(1)
+                    Text(meeting.timeText)
+                        .font(.system(size: 10.5, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.74))
+                        .lineLimit(1)
+                }
+                Spacer(minLength: 0)
+            }
+            .padding(.horizontal, 10)
+            .padding(.vertical, 7)
+            .background(
+                RoundedRectangle(cornerRadius: 10, style: .continuous)
+                    .fill(Color.black.opacity(0.20))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 10, style: .continuous)
+                    .stroke(Color.white.opacity(0.08), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+        .disabled(isPreview)
+        .allowsHitTesting(!isPreview)
+    }
+
+    private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
+        Button(action: { WidgetAppNavigator.open(target: destination) }) {
+            HStack(spacing: 5) {
+                Image(systemName: icon)
+                    .font(.system(size: 10.5, weight: .semibold))
+                    .frame(width: 14)
+                Text(title)
+                    .font(.system(size: 11.5, weight: .bold))
+                    .lineLimit(1)
+                    .minimumScaleFactor(0.82)
+                    .layoutPriority(1)
+            }
+            .foregroundStyle(.white.opacity(0.97))
+            .padding(.horizontal, 10)
+            .padding(.vertical, 8)
+            .frame(maxWidth: .infinity)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(Color.black.opacity(0.30))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .stroke(Color.white.opacity(0.24), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+        .disabled(isPreview)
+        .allowsHitTesting(!isPreview)
+    }
+
+    private func actionTile(_ action: WidgetQuickAction) -> some View {
+        Button(action: { open(action: action) }) {
+            HStack(spacing: 8) {
+                Image(systemName: action.systemImage)
+                    .font(.system(size: 11, weight: .semibold))
+                Text(action.title)
+                    .font(.system(size: 12, weight: .bold))
+                    .lineLimit(1)
+                Spacer(minLength: 0)
+            }
+            .foregroundStyle(.white.opacity(0.92))
+            .padding(.horizontal, 10)
+            .padding(.vertical, 9)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(Color.black.opacity(0.24))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .stroke(Color.white.opacity(0.10), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+        .disabled(isPreview)
+        .allowsHitTesting(!isPreview)
+    }
+
+    private func open(action: WidgetQuickAction) {
+        guard !isPreview else { return }
+        WidgetAppNavigator.open(target: action.destination)
+    }
+}
+