forked from Manuel/meeting-assistant
331 lines
12 KiB
Swift
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]))
|
|
}
|
|
}
|