|
@@ -6,20 +6,38 @@
|
|
|
//
|
|
//
|
|
|
|
|
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
|
|
+import CryptoKit
|
|
|
|
|
+import Network
|
|
|
|
|
+import WebKit
|
|
|
|
|
|
|
|
class ViewController: NSViewController {
|
|
class ViewController: NSViewController {
|
|
|
|
|
+ private let googleOAuth = GoogleOAuthService.shared
|
|
|
private let sidebarWidth: CGFloat = 78
|
|
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 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 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 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 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 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 primaryText = NSColor(calibratedWhite: 0.98, alpha: 1)
|
|
|
private let secondaryText = NSColor(calibratedWhite: 0.78, alpha: 1)
|
|
private let secondaryText = NSColor(calibratedWhite: 0.78, alpha: 1)
|
|
|
private let mutedText = NSColor(calibratedWhite: 0.66, 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() {
|
|
override func viewDidLoad() {
|
|
|
super.viewDidLoad()
|
|
super.viewDidLoad()
|
|
|
setupUI()
|
|
setupUI()
|
|
@@ -27,159 +45,227 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
override func viewDidAppear() {
|
|
override func viewDidAppear() {
|
|
|
super.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"
|
|
view.window?.title = "zoom Workplace"
|
|
|
|
|
+ showLoginView()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func setupUI() {
|
|
private func setupUI() {
|
|
|
view.wantsLayer = true
|
|
view.wantsLayer = true
|
|
|
view.layer?.backgroundColor = appBackground.cgColor
|
|
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([
|
|
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([
|
|
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 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()
|
|
let emailField = NSTextField()
|
|
|
emailField.placeholderString = "Email or phone number"
|
|
emailField.placeholderString = "Email or phone number"
|
|
|
emailField.font = .systemFont(ofSize: 20, weight: .regular)
|
|
emailField.font = .systemFont(ofSize: 20, weight: .regular)
|
|
|
emailField.textColor = .white
|
|
emailField.textColor = .white
|
|
|
- emailField.focusRingType = .none
|
|
|
|
|
emailField.wantsLayer = true
|
|
emailField.wantsLayer = true
|
|
|
emailField.layer?.cornerRadius = 10
|
|
emailField.layer?.cornerRadius = 10
|
|
|
emailField.layer?.borderWidth = 1.5
|
|
emailField.layer?.borderWidth = 1.5
|
|
|
emailField.layer?.borderColor = accentBlue.cgColor
|
|
emailField.layer?.borderColor = accentBlue.cgColor
|
|
|
emailField.layer?.backgroundColor = cardBackground.cgColor
|
|
emailField.layer?.backgroundColor = cardBackground.cgColor
|
|
|
|
|
+ emailField.focusRingType = .none
|
|
|
|
|
|
|
|
let nextButton = NSButton(title: "Next", target: nil, action: nil)
|
|
let nextButton = NSButton(title: "Next", target: nil, action: nil)
|
|
|
nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
|
- nextButton.bezelStyle = .regularSquare
|
|
|
|
|
nextButton.isBordered = false
|
|
nextButton.isBordered = false
|
|
|
nextButton.wantsLayer = true
|
|
nextButton.wantsLayer = true
|
|
|
nextButton.layer?.cornerRadius = 10
|
|
nextButton.layer?.cornerRadius = 10
|
|
@@ -188,131 +274,683 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
let divider = NSBox()
|
|
let divider = NSBox()
|
|
|
divider.boxType = .separator
|
|
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
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ content.addSubview($0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
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),
|
|
back.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 34),
|
|
|
-
|
|
|
|
|
logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
|
|
logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
|
|
|
logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
|
-
|
|
|
|
|
domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
|
|
domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
|
|
|
domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
|
-
|
|
|
|
|
emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
|
|
emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
|
|
|
emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
|
emailField.widthAnchor.constraint(equalToConstant: 520),
|
|
emailField.widthAnchor.constraint(equalToConstant: 520),
|
|
|
emailField.heightAnchor.constraint(equalToConstant: 52),
|
|
emailField.heightAnchor.constraint(equalToConstant: 52),
|
|
|
-
|
|
|
|
|
nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
|
|
nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
|
|
|
nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
|
nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
|
|
nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
|
|
|
nextButton.heightAnchor.constraint(equalToConstant: 52),
|
|
nextButton.heightAnchor.constraint(equalToConstant: 52),
|
|
|
-
|
|
|
|
|
divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
|
|
divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
|
|
|
divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
|
|
divider.widthAnchor.constraint(equalTo: emailField.widthAnchor),
|
|
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 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([
|
|
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."
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|