Files
meeting-assistant/MeetingAssistant/Native/MacOsMeetingIntegrations/main.swift
T
dh 2af7933a1b
PR and Push Build/Test / build-and-test (pull_request) Failing after 19m49s
feat(macos): enable meeting integrations
2026-08-06 08:19:26 +02:00

331 lines
12 KiB
Swift

import AppKit
import CoreGraphics
import EventKit
import Foundation
import ImageIO
import UniformTypeIdentifiers
private struct CalendarEventOutput: Encodable {
let id: String
let subject: String
let start: Date
let end: Date
let location: String
let notes: String
let url: String
let attendees: [String]
let isCanceled: Bool
}
private struct PromptRequest: Decodable {
let subject: String
let start: Date
}
private struct PromptOutput: Encodable {
let response: String
}
private struct ScreenshotOutput: Encodable {
let windowTitle: String?
}
private struct SelfTestOutput: Encodable {
let status: String
let features: [String]
}
private enum IntegrationError: LocalizedError {
case usage
case invalidDate(String)
case calendarPermission
case screenRecordingPermission
case noFrontmostApplication
case noActiveWindow
case screenshotCapture
case screenshotWrite
var errorDescription: String? {
switch self {
case .usage:
return "Usage: macos-meeting-integrations self-test | calendar <from-iso8601> <to-iso8601> | prompt <request.json> | screenshot <output.png> | crop <input.png> <output.png> <x> <y> <width> <height>"
case .invalidDate(let value):
return "Invalid ISO-8601 date: \(value)"
case .calendarPermission:
return "Calendar Full Access permission is required. Enable Meeting Assistant under System Settings > Privacy & Security > Calendars."
case .screenRecordingPermission:
return "Screen & System Audio Recording permission is required. Enable Meeting Assistant under System Settings > Privacy & Security."
case .noFrontmostApplication:
return "Could not determine the frontmost macOS application."
case .noActiveWindow:
return "Could not find an on-screen window for the frontmost macOS application."
case .screenshotCapture:
return "macOS could not capture the active application window."
case .screenshotWrite:
return "macOS could not write the active-window screenshot as PNG."
}
}
}
@main
private enum MacOsMeetingIntegrations {
@MainActor
static func main() async {
do {
let arguments = Array(CommandLine.arguments.dropFirst())
guard let command = arguments.first else {
throw IntegrationError.usage
}
switch command {
case "self-test":
try writeJson(SelfTestOutput(
status: "ok",
features: ["calendar", "prompt", "screenshot", "crop"]))
case "calendar":
guard arguments.count == 3 else {
throw IntegrationError.usage
}
let from = try parseDate(arguments[1])
let to = try parseDate(arguments[2])
try await writeCalendar(from: from, to: to)
case "prompt":
guard arguments.count == 2 else {
throw IntegrationError.usage
}
try showPrompt(requestPath: arguments[1])
case "screenshot":
guard arguments.count == 2 else {
throw IntegrationError.usage
}
try captureScreenshot(outputPath: arguments[1])
case "crop":
guard arguments.count == 7,
let x = Int(arguments[3]),
let y = Int(arguments[4]),
let width = Int(arguments[5]),
let height = Int(arguments[6]) else {
throw IntegrationError.usage
}
try cropScreenshot(
inputPath: arguments[1],
outputPath: arguments[2],
x: x,
y: y,
width: width,
height: height)
default:
throw IntegrationError.usage
}
} catch {
fputs("\(error.localizedDescription)\n", stderr)
if let integrationError = error as? IntegrationError,
case .calendarPermission = integrationError {
exit(77)
}
exit(1)
}
}
private static func parseDate(_ value: String) throws -> Date {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) {
return date
}
formatter.formatOptions = [.withInternetDateTime]
guard let date = formatter.date(from: value) else {
throw IntegrationError.invalidDate(value)
}
return date
}
private static func writeCalendar(from: Date, to: Date) async throws {
let store = EKEventStore()
guard try await requestCalendarAccess(store) else {
throw IntegrationError.calendarPermission
}
let predicate = store.predicateForEvents(withStart: from, end: to, calendars: nil)
let events = store.events(matching: predicate)
.map { event in
CalendarEventOutput(
id: event.eventIdentifier ?? "\(event.startDate.timeIntervalSince1970)|\(event.title ?? "")",
subject: event.title ?? "",
start: event.startDate,
end: event.endDate,
location: event.location ?? "",
notes: event.notes ?? "",
url: event.url?.absoluteString ?? "",
attendees: attendees(from: event),
isCanceled: event.status == .canceled)
}
.sorted { $0.start < $1.start }
try writeJson(events)
}
private static func requestCalendarAccess(_ store: EKEventStore) async throws -> Bool {
if #available(macOS 14.0, *) {
return try await store.requestFullAccessToEvents()
}
return try await withCheckedThrowingContinuation { continuation in
store.requestAccess(to: .event) { granted, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: granted)
}
}
}
}
private static func attendees(from event: EKEvent) -> [String] {
var values: [String] = []
if let organizer = event.organizer,
let value = participantText(organizer) {
values.append(value)
}
for participant in event.attendees ?? [] {
if let value = participantText(participant) {
values.append(value)
}
}
return Array(NSOrderedSet(array: values)) as? [String] ?? values
}
private static func participantText(_ participant: EKParticipant) -> String? {
let name = participant.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let email = participant.url
.absoluteString
.replacingOccurrences(of: "mailto:", with: "", options: .caseInsensitive)
if name.isEmpty {
return email.isEmpty ? nil : email
}
if email.isEmpty || name.caseInsensitiveCompare(email) == .orderedSame {
return name
}
return "\(name) <\(email)>"
}
@MainActor
private static func showPrompt(requestPath: String) throws {
let data = try Data(contentsOf: URL(fileURLWithPath: requestPath))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let request = try decoder.decode(PromptRequest.self, from: data)
let application = NSApplication.shared
application.setActivationPolicy(.accessory)
application.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = "Record meeting?"
alert.informativeText = "\(request.subject) starts at \(request.start.formatted(date: .omitted, time: .shortened))."
alert.alertStyle = .informational
alert.addButton(withTitle: "Record")
alert.addButton(withTitle: "Skip")
let response = alert.runModal() == .alertFirstButtonReturn ? "record" : "skip"
try writeJson(PromptOutput(response: response))
}
private static func captureScreenshot(outputPath: String) throws {
guard CGPreflightScreenCaptureAccess() || CGRequestScreenCaptureAccess() else {
throw IntegrationError.screenRecordingPermission
}
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
throw IntegrationError.noFrontmostApplication
}
guard let windows = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements],
kCGNullWindowID) as? [[String: Any]] else {
throw IntegrationError.noActiveWindow
}
let candidate = windows.first { window in
let ownerPid = window[kCGWindowOwnerPID as String] as? Int32
let layer = window[kCGWindowLayer as String] as? Int
let alpha = window[kCGWindowAlpha as String] as? Double
guard ownerPid == frontmost.processIdentifier,
layer == 0,
(alpha ?? 1) > 0,
let bounds = window[kCGWindowBounds as String] as? [String: Any],
let width = bounds["Width"] as? Double,
let height = bounds["Height"] as? Double else {
return false
}
return width > 1 && height > 1
}
guard let candidate,
let windowNumber = candidate[kCGWindowNumber as String] as? UInt32 else {
throw IntegrationError.noActiveWindow
}
guard let image = CGWindowListCreateImage(
.null,
.optionIncludingWindow,
CGWindowID(windowNumber),
[.boundsIgnoreFraming, .bestResolution]) else {
throw IntegrationError.screenshotCapture
}
try writePng(image, to: outputPath)
try writeJson(ScreenshotOutput(
windowTitle: candidate[kCGWindowName as String] as? String))
}
private static func cropScreenshot(
inputPath: String,
outputPath: String,
x: Int,
y: Int,
width: Int,
height: Int
) throws {
guard x >= 0,
y >= 0,
width > 0,
height > 0,
let source = CGImageSourceCreateWithURL(
URL(fileURLWithPath: inputPath) as CFURL,
nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil),
x + width <= image.width,
y + height <= image.height,
let cropped = image.cropping(to: CGRect(
x: x,
y: y,
width: width,
height: height)) else {
throw IntegrationError.screenshotCapture
}
try writePng(cropped, to: outputPath)
try writeJson(["status": "ok"])
}
private static func writePng(_ image: CGImage, to outputPath: String) throws {
let outputUrl = URL(fileURLWithPath: outputPath) as CFURL
guard let destination = CGImageDestinationCreateWithURL(
outputUrl,
UTType.png.identifier as CFString,
1,
nil) else {
throw IntegrationError.screenshotWrite
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw IntegrationError.screenshotWrite
}
}
private static func writeJson<T: Encodable>(_ value: T) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
FileHandle.standardOutput.write(try encoder.encode(value))
FileHandle.standardOutput.write(Data([0x0A]))
}
}