Parcourir la Source

Add in-app Zoom meeting scheduling with timezone-aware form inputs.

This replaces web-based scheduling with a native scheduling sheet that creates meetings via Zoom API, supports 12-hour time plus timezone selection, and preserves the selected local date/time to avoid day rollover.

Made-with: Cursor
huzaifahayat12 il y a 4 jours
Parent
commit
c5d044d628
1 fichiers modifiés avec 456 ajouts et 11 suppressions
  1. 456 11
      zoom_app/ViewController.swift

+ 456 - 11
zoom_app/ViewController.swift

@@ -190,6 +190,13 @@ class ViewController: NSViewController {
190 190
     private weak var topBarPremiumButton: NSButton?
191 191
     private var paywallWindow: NSWindow?
192 192
     private var joinMeetingWindow: NSWindow?
193
+    private var scheduleMeetingWindow: NSWindow?
194
+    private weak var scheduleTopicField: NSTextField?
195
+    private weak var scheduleDateField: NSTextField?
196
+    private weak var scheduleTimeField: NSTextField?
197
+    private weak var scheduleTimeZoneCombo: NSComboBox?
198
+    private weak var scheduleDurationField: NSTextField?
199
+    private weak var scheduleSubmitButton: NSButton?
193 200
     private weak var joinURLField: NSTextField?
194 201
     private weak var joinMeetingIDField: NSTextField?
195 202
     private weak var joinPasscodeField: NSTextField?
@@ -550,12 +557,392 @@ class ViewController: NSViewController {
550 557
         }
551 558
     }
552 559
 
553
-    @objc private func scheduleMeetingWebTapped() {
554
-        guard let url = URL(string: "https://zoom.us/meeting/schedule") else { return }
555
-        let opened = NSWorkspace.shared.open(url)
556
-        if opened == false {
557
-            meetingsStatusLabel?.stringValue = "Unable to open Zoom schedule page."
560
+    @objc private func scheduleMeetingTapped() {
561
+        if let existing = scheduleMeetingWindow {
562
+            existing.makeKeyAndOrderFront(nil)
563
+            NSApp.activate(ignoringOtherApps: true)
564
+            DispatchQueue.main.async { [weak self] in
565
+                _ = self?.scheduleTopicField?.becomeFirstResponder()
566
+            }
567
+            return
568
+        }
569
+
570
+        let content = makeScheduleMeetingPanelContent()
571
+        let controller = NSViewController()
572
+        controller.view = content
573
+
574
+        let panel = NSPanel(
575
+            contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
576
+            styleMask: [.titled, .closable, .fullSizeContentView],
577
+            backing: .buffered,
578
+            defer: false
579
+        )
580
+        panel.title = "Schedule a meeting"
581
+        panel.titleVisibility = .hidden
582
+        panel.titlebarAppearsTransparent = true
583
+        panel.hidesOnDeactivate = true
584
+        panel.isReleasedWhenClosed = false
585
+        panel.standardWindowButton(.closeButton)?.isHidden = true
586
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
587
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
588
+        panel.isMovableByWindowBackground = true
589
+        panel.becomesKeyOnlyIfNeeded = false
590
+        panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
591
+        panel.center()
592
+        panel.contentViewController = controller
593
+        panel.delegate = self
594
+        applyWindowBackgroundForCurrentTheme(panel)
595
+        panel.makeKeyAndOrderFront(nil)
596
+        NSApp.activate(ignoringOtherApps: true)
597
+        scheduleMeetingWindow = panel
598
+        DispatchQueue.main.async { [weak self] in
599
+            _ = self?.scheduleTopicField?.becomeFirstResponder()
600
+        }
601
+    }
602
+
603
+    private func makeScheduleMeetingPanelContent() -> NSView {
604
+        let root = NSView()
605
+        root.translatesAutoresizingMaskIntoConstraints = false
606
+        root.wantsLayer = true
607
+        root.layer?.backgroundColor = appBackground.cgColor
608
+
609
+        let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
610
+        let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
611
+
612
+        let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
613
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
614
+        closeButton.isBordered = false
615
+        closeButton.bezelStyle = .regularSquare
616
+        closeButton.wantsLayer = true
617
+        closeButton.layer?.cornerRadius = 14
618
+        closeButton.normalColor = palette.inputBackground
619
+        closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
620
+        closeButton.layer?.borderColor = palette.inputBorder.cgColor
621
+        closeButton.layer?.borderWidth = 1
622
+        closeButton.font = .systemFont(ofSize: 13, weight: .bold)
623
+        closeButton.contentTintColor = secondaryText
624
+        closeButton.toolTip = "Close"
625
+        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
626
+        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
627
+
628
+        let titleRow = NSStackView()
629
+        titleRow.orientation = .horizontal
630
+        titleRow.alignment = .centerY
631
+        titleRow.spacing = 10
632
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
633
+        let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
634
+        titleStack.orientation = .vertical
635
+        titleStack.spacing = 2
636
+        titleStack.alignment = .leading
637
+        let titleSpacer = NSView()
638
+        titleSpacer.translatesAutoresizingMaskIntoConstraints = false
639
+        titleRow.addArrangedSubview(titleStack)
640
+        titleRow.addArrangedSubview(titleSpacer)
641
+        titleRow.addArrangedSubview(closeButton)
642
+
643
+        let headerDivider = NSView()
644
+        headerDivider.wantsLayer = true
645
+        headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
646
+        headerDivider.translatesAutoresizingMaskIntoConstraints = false
647
+
648
+        let topicBox = makeJoinFormField(placeholder: "Meeting topic")
649
+        scheduleTopicField = topicBox.textField
650
+        topicBox.textField.stringValue = "Zoom meeting"
651
+
652
+        let defaultStart = Date().addingTimeInterval(3600)
653
+        let dateOnlyFormatter = DateFormatter()
654
+        dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
655
+        dateOnlyFormatter.timeZone = TimeZone.current
656
+        dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
657
+        let time12Formatter = DateFormatter()
658
+        time12Formatter.locale = Locale(identifier: "en_US_POSIX")
659
+        time12Formatter.timeZone = TimeZone.current
660
+        time12Formatter.dateFormat = "h:mm a"
661
+
662
+        let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
663
+        scheduleDateField = dateBox.textField
664
+        dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
665
+
666
+        let timeBox = makeJoinFormField(placeholder: "2:30 PM")
667
+        scheduleTimeField = timeBox.textField
668
+        timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
669
+
670
+        let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
671
+
672
+        let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
673
+        let tzCombo = NSComboBox()
674
+        tzCombo.translatesAutoresizingMaskIntoConstraints = false
675
+        tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
676
+        tzCombo.textColor = primaryText
677
+        tzCombo.backgroundColor = palette.inputBackground
678
+        tzCombo.isSelectable = true
679
+        tzCombo.completes = true
680
+        tzCombo.numberOfVisibleItems = 10
681
+        tzCombo.addItems(withObjectValues: TimeZone.knownTimeZoneIdentifiers.sorted())
682
+        tzCombo.stringValue = TimeZone.current.identifier
683
+        tzCombo.toolTip = "IANA timezone (type to filter). Meeting start is interpreted in this zone."
684
+        scheduleTimeZoneCombo = tzCombo
685
+        let tzHint = makeLabel("Type to search, e.g. America/New_York or Europe/London", size: 11, color: mutedText, weight: .regular, centered: false)
686
+
687
+        let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
688
+        tzStack.orientation = .vertical
689
+        tzStack.spacing = 8
690
+        tzStack.alignment = .leading
691
+        tzStack.translatesAutoresizingMaskIntoConstraints = false
692
+
693
+        let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
694
+        durationBox.textField.stringValue = "60"
695
+        scheduleDurationField = durationBox.textField
696
+
697
+        let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
698
+        let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
699
+        let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
700
+
701
+        let topicStack = NSStackView(views: [topicLabel, topicBox])
702
+        topicStack.orientation = .vertical
703
+        topicStack.spacing = 8
704
+        topicStack.alignment = .leading
705
+        topicStack.translatesAutoresizingMaskIntoConstraints = false
706
+
707
+        let dateTimeRow = NSStackView(views: [dateBox, timeBox])
708
+        dateTimeRow.orientation = .horizontal
709
+        dateTimeRow.spacing = 10
710
+        dateTimeRow.alignment = .top
711
+        dateTimeRow.distribution = .fillEqually
712
+        dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
713
+
714
+        let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
715
+        startStack.orientation = .vertical
716
+        startStack.spacing = 8
717
+        startStack.alignment = .leading
718
+        startStack.translatesAutoresizingMaskIntoConstraints = false
719
+
720
+        let durationStack = NSStackView(views: [durationLabel, durationBox])
721
+        durationStack.orientation = .vertical
722
+        durationStack.spacing = 8
723
+        durationStack.alignment = .leading
724
+        durationStack.translatesAutoresizingMaskIntoConstraints = false
725
+
726
+        let formCard = NSView()
727
+        formCard.translatesAutoresizingMaskIntoConstraints = false
728
+        formCard.wantsLayer = true
729
+        formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
730
+        formCard.layer?.cornerRadius = 14
731
+        formCard.layer?.borderWidth = 1
732
+        formCard.layer?.borderColor = palette.inputBorder.cgColor
733
+
734
+        let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
735
+        cancelButton.isBordered = false
736
+        cancelButton.wantsLayer = true
737
+        cancelButton.layer?.cornerRadius = 10
738
+        cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
739
+        cancelButton.layer?.borderWidth = 1
740
+        cancelButton.layer?.borderColor = palette.inputBorder.cgColor
741
+        cancelButton.contentTintColor = primaryText
742
+        cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
743
+
744
+        let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
745
+        submitButton.isBordered = false
746
+        submitButton.wantsLayer = true
747
+        submitButton.layer?.cornerRadius = 10
748
+        submitButton.normalColor = accentBlue
749
+        submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
750
+        submitButton.contentTintColor = .white
751
+        submitButton.font = .systemFont(ofSize: 13, weight: .bold)
752
+        submitButton.keyEquivalent = "\r"
753
+        scheduleSubmitButton = submitButton
754
+
755
+        let buttons = NSStackView(views: [cancelButton, submitButton])
756
+        buttons.orientation = .horizontal
757
+        buttons.spacing = 12
758
+        buttons.alignment = .centerY
759
+        buttons.distribution = .fillEqually
760
+
761
+        let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
762
+        innerStack.orientation = .vertical
763
+        innerStack.spacing = 18
764
+        innerStack.alignment = .leading
765
+        innerStack.translatesAutoresizingMaskIntoConstraints = false
766
+        formCard.addSubview(innerStack)
767
+
768
+        [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
769
+
770
+        NSLayoutConstraint.activate([
771
+            titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
772
+            titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
773
+            titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
774
+
775
+            headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
776
+            headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
777
+            headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
778
+            headerDivider.heightAnchor.constraint(equalToConstant: 1),
779
+
780
+            formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
781
+            formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
782
+            formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
783
+            formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
784
+
785
+            innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
786
+            innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
787
+            innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
788
+            innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
789
+
790
+            topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
791
+            tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
792
+            tzCombo.heightAnchor.constraint(equalToConstant: 46),
793
+            dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
794
+            durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
795
+            buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
796
+            submitButton.heightAnchor.constraint(equalToConstant: 40),
797
+            cancelButton.heightAnchor.constraint(equalToConstant: 40)
798
+        ])
799
+
800
+        return root
801
+    }
802
+
803
+    @objc private func scheduleMeetingCancelTapped() {
804
+        scheduleMeetingWindow?.performClose(nil)
805
+    }
806
+
807
+    @objc private func scheduleMeetingSubmitTapped() {
808
+        let topicRaw = scheduleTopicField?.stringValue ?? ""
809
+        let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
810
+        guard topic.isEmpty == false else {
811
+            showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
812
+            return
558 813
         }
814
+        let tzId = scheduleTimeZoneCombo?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
815
+        if tzId.isEmpty == false, TimeZone(identifier: tzId) == nil {
816
+            showSimpleAlert(
817
+                title: "Timezone",
818
+                message: "Enter a valid IANA timezone (pick from the list or type to search), for example America/New_York."
819
+            )
820
+            return
821
+        }
822
+        guard let draft = scheduleMeetingDraftFromFormFields() else {
823
+            showSimpleAlert(
824
+                title: "Date & time",
825
+                message: "Enter the date as YYYY-MM-DD and the time in 12-hour form with AM or PM (in the selected timezone), for example 2026-04-17 and 2:30 PM."
826
+            )
827
+            return
828
+        }
829
+        if draft.startDate < Date().addingTimeInterval(-30) {
830
+            showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
831
+            return
832
+        }
833
+        let durationRaw = scheduleDurationField?.stringValue ?? ""
834
+        let digits = durationRaw.filter(\.isNumber)
835
+        guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
836
+            showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
837
+            return
838
+        }
839
+
840
+        scheduleSubmitButton?.isEnabled = false
841
+        let tz = draft.timeZone
842
+        Task {
843
+            do {
844
+                let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
845
+                guard configured else {
846
+                    await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
847
+                    return
848
+                }
849
+                let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
850
+                let result = try await createZoomMeeting(
851
+                    accessToken: token,
852
+                    topic: topic,
853
+                    startTimeWithOffset: draft.startTimeWithOffset,
854
+                    durationMinutes: duration,
855
+                    timeZone: tz
856
+                )
857
+                await MainActor.run {
858
+                    self.scheduleSubmitButton?.isEnabled = true
859
+                    self.scheduleMeetingWindow?.performClose(nil)
860
+                    self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
861
+                    self.triggerMeetingsRefresh(force: true)
862
+                    if let join = result.join_url, join.isEmpty == false {
863
+                        NSPasteboard.general.clearContents()
864
+                        NSPasteboard.general.setString(join, forType: .string)
865
+                        self.showSimpleAlert(
866
+                            title: "Meeting scheduled",
867
+                            message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
868
+                        )
869
+                    } else {
870
+                        self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
871
+                    }
872
+                }
873
+            } catch {
874
+                await MainActor.run {
875
+                    self.scheduleSubmitButton?.isEnabled = true
876
+                    if case ZoomOAuthError.missingRequiredScope(_) = error {
877
+                        self.zoomOAuth.clearSavedTokens()
878
+                        self.showSimpleAlert(
879
+                            title: "Zoom permissions",
880
+                            message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
881
+                        )
882
+                    } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
883
+                        let seconds = max(retryAfter ?? 300, 30)
884
+                        let minutes = Int(ceil(Double(seconds) / 60.0))
885
+                        self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
886
+                    } else {
887
+                        self.showSimpleError("Could not schedule", error: error)
888
+                    }
889
+                }
890
+            }
891
+        }
892
+    }
893
+
894
+    private func resetScheduleMeetingPanelReferences() {
895
+        scheduleMeetingWindow = nil
896
+        scheduleTopicField = nil
897
+        scheduleDateField = nil
898
+        scheduleTimeField = nil
899
+        scheduleTimeZoneCombo = nil
900
+        scheduleDurationField = nil
901
+        scheduleSubmitButton = nil
902
+    }
903
+
904
+    /// Resolves the schedule panel timezone (IANA id from combo, or system default).
905
+    private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
906
+        let raw = scheduleTimeZoneCombo?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
907
+        guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
908
+        return tz
909
+    }
910
+
911
+    private struct ScheduleMeetingDraft {
912
+        let startDate: Date
913
+        let startTimeWithOffset: String
914
+        let timeZone: TimeZone
915
+    }
916
+
917
+    /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
918
+    private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
919
+        let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
920
+        let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
921
+        guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
922
+        let tz = resolvedScheduleTimeZoneForMeeting()
923
+        let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
924
+        let combined = "\(datePart) \(normalizedTime)"
925
+        let df = DateFormatter()
926
+        df.locale = Locale(identifier: "en_US_POSIX")
927
+        df.timeZone = tz
928
+        df.isLenient = true
929
+        let formats = ["yyyy-MM-dd h:mm a", "yyyy-MM-dd hh:mm a", "yyyy-MM-dd h:mm:ss a"]
930
+        for format in formats {
931
+            df.dateFormat = format
932
+            if let d = df.date(from: combined) {
933
+                let localFormatter = DateFormatter()
934
+                localFormatter.locale = Locale(identifier: "en_US_POSIX")
935
+                localFormatter.timeZone = tz
936
+                localFormatter.calendar = Calendar(identifier: .gregorian)
937
+                localFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
938
+                return ScheduleMeetingDraft(
939
+                    startDate: d,
940
+                    startTimeWithOffset: localFormatter.string(from: d),
941
+                    timeZone: tz
942
+                )
943
+            }
944
+        }
945
+        return nil
559 946
     }
560 947
 
561 948
     @objc private func joinMeetingTapped() {
@@ -1250,7 +1637,7 @@ class ViewController: NSViewController {
1250 1637
         let alert = NSAlert()
1251 1638
         alert.alertStyle = .informational
1252 1639
         alert.messageText = "Configure Zoom OAuth"
1253
-        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically."
1640
+        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
1254 1641
 
1255 1642
         let wrapper = NSStackView()
1256 1643
         wrapper.orientation = .vertical
@@ -1332,6 +1719,54 @@ class ViewController: NSViewController {
1332 1719
         return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
1333 1720
     }
1334 1721
 
1722
+    private struct ZoomCreateMeetingBody: Encodable {
1723
+        let topic: String
1724
+        let type: Int
1725
+        let start_time: String
1726
+        let duration: Int
1727
+        let timezone: String
1728
+    }
1729
+
1730
+    private struct ZoomCreateMeetingAPIResult: Decodable {
1731
+        let join_url: String?
1732
+    }
1733
+
1734
+    /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
1735
+    private func createZoomMeeting(accessToken: String, topic: String, startTimeWithOffset: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
1736
+        let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
1737
+        var request = URLRequest(url: url)
1738
+        request.httpMethod = "POST"
1739
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
1740
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
1741
+
1742
+        let body = ZoomCreateMeetingBody(
1743
+            topic: topic,
1744
+            type: 2,
1745
+            start_time: startTimeWithOffset,
1746
+            duration: durationMinutes,
1747
+            timezone: timeZone.identifier
1748
+        )
1749
+        request.httpBody = try JSONEncoder().encode(body)
1750
+
1751
+        let (data, response) = try await URLSession.shared.data(for: request)
1752
+        guard let http = response as? HTTPURLResponse else {
1753
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
1754
+        }
1755
+        if http.statusCode == 429 {
1756
+            let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
1757
+            let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
1758
+            throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
1759
+        }
1760
+        guard (200..<300).contains(http.statusCode) else {
1761
+            let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
1762
+            if raw.localizedCaseInsensitiveContains("does not contain scopes") {
1763
+                throw ZoomOAuthError.missingRequiredScope(raw)
1764
+            }
1765
+            throw GoogleOAuthError.tokenExchangeFailed(raw)
1766
+        }
1767
+        return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
1768
+    }
1769
+
1335 1770
     private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
1336 1771
         struct ZoomMeeting: Decodable {
1337 1772
             let id: Int?
@@ -2591,7 +3026,7 @@ class ViewController: NSViewController {
2591 3026
         let actions = NSStackView(views: [
2592 3027
             makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
2593 3028
             makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
2594
-            makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
3029
+            makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
2595 3030
         ])
2596 3031
         actions.orientation = .horizontal
2597 3032
         actions.spacing = 12
@@ -3090,6 +3525,7 @@ class ViewController: NSViewController {
3090 3525
     @MainActor
3091 3526
     private func updateSelectedHomeSectionUI() {
3092 3527
         let isHome = selectedHomeSidebarItem == "Home"
3528
+        let isScheduler = selectedHomeSidebarItem == "Scheduler"
3093 3529
         let isSettings = selectedHomeSidebarItem == "Settings"
3094 3530
         let title = selectedHomeSidebarItem
3095 3531
 
@@ -3109,7 +3545,8 @@ class ViewController: NSViewController {
3109 3545
             meetingsScrollView,
3110 3546
             refreshMeetingsButton
3111 3547
         ]
3112
-        let hideDashboard = isHome == false || isSettings
3548
+        // Keep the main dashboard (including Schedule) visible on Home and Scheduler; other sidebar items are placeholders.
3549
+        let hideDashboard = (isHome == false && isScheduler == false) || isSettings
3113 3550
         dashboardViews.forEach { $0?.isHidden = hideDashboard }
3114 3551
         // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
3115 3552
         // and showed "No meetings…" on top of meeting cards when returning to Home.
@@ -3121,7 +3558,7 @@ class ViewController: NSViewController {
3121 3558
         }
3122 3559
         homeSettingsView?.isHidden = isSettings == false
3123 3560
 
3124
-        if isHome {
3561
+        if isHome || isScheduler {
3125 3562
             homePlaceholderLabel?.isHidden = true
3126 3563
         } else {
3127 3564
             // Keep non-Home pages empty for now.
@@ -3409,8 +3846,13 @@ class ViewController: NSViewController {
3409 3846
 
3410 3847
 extension ViewController: NSWindowDelegate {
3411 3848
     func windowWillClose(_ notification: Notification) {
3412
-        guard let window = notification.object as? NSWindow, window === joinMeetingWindow else { return }
3413
-        resetJoinMeetingPanelReferences()
3849
+        guard let window = notification.object as? NSWindow else { return }
3850
+        if window === joinMeetingWindow {
3851
+            resetJoinMeetingPanelReferences()
3852
+        }
3853
+        if window === scheduleMeetingWindow {
3854
+            resetScheduleMeetingPanelReferences()
3855
+        }
3414 3856
     }
3415 3857
 }
3416 3858
 
@@ -3911,7 +4353,10 @@ final class ZoomOAuthService: NSObject {
3911 4353
         return parts.contains { part in
3912 4354
             part == "meeting:read"
3913 4355
                 || part == "meeting:read:admin"
4356
+                || part == "meeting:write"
4357
+                || part == "meeting:write:admin"
3914 4358
                 || part.contains("meeting:read")
4359
+                || part.contains("meeting:write")
3915 4360
                 || part.contains("list_meetings")
3916 4361
                 || part.contains("list_user_meetings")
3917 4362
         }