forked from Manuel/meeting-assistant
feat(macos): enable meeting integrations
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cloud.schweigert.meeting-assistant.integrations</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Meeting Assistant Integrations</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>Meeting Assistant reads calendar events to enrich meeting notes and offer recording prompts.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Meeting Assistant reads calendar events to enrich meeting notes and offer recording prompts.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,68 @@
|
||||
function safe(read, fallback) {
|
||||
try {
|
||||
const value = read();
|
||||
return value === null || value === undefined ? fallback : value;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function participantText(participant) {
|
||||
const name = String(safe(() => participant.displayName(), "")).trim();
|
||||
const email = String(safe(() => participant.email(), "")).trim();
|
||||
if (!name) {
|
||||
return email;
|
||||
}
|
||||
if (!email || name.toLowerCase() === email.toLowerCase()) {
|
||||
return name;
|
||||
}
|
||||
return `${name} <${email}>`;
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
if (argv.length !== 2) {
|
||||
throw new Error("Usage: calendar-events.js <from-iso8601> <to-iso8601>");
|
||||
}
|
||||
|
||||
const from = new Date(argv[0]);
|
||||
const to = new Date(argv[1]);
|
||||
const calendar = Application("Calendar");
|
||||
const result = [];
|
||||
|
||||
for (const sourceCalendar of calendar.calendars()) {
|
||||
const events = sourceCalendar.events.whose({
|
||||
_and: [
|
||||
{ startDate: { _lessThan: to } },
|
||||
{ endDate: { _greaterThan: from } }
|
||||
]
|
||||
})();
|
||||
for (const event of events) {
|
||||
const start = event.startDate();
|
||||
const end = event.endDate();
|
||||
const attendees = safe(() => event.attendees(), [])
|
||||
.map(participantText)
|
||||
.filter(value => value.length > 0);
|
||||
const organizer = String(safe(() => event.organizer(), "")).trim();
|
||||
if (organizer && !attendees.includes(organizer)) {
|
||||
attendees.unshift(organizer);
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: String(safe(() => event.uid(), `${start.toISOString()}|${event.summary()}`)),
|
||||
subject: String(safe(() => event.summary(), "")),
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
location: String(safe(() => event.location(), "")),
|
||||
notes: String(safe(() => event.description(), "")),
|
||||
url: String(safe(() => event.url(), "")),
|
||||
attendees: attendees,
|
||||
isCanceled: String(safe(() => event.status(), ""))
|
||||
.toLowerCase()
|
||||
.includes("cancel")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((left, right) => left.start.localeCompare(right.start));
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
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]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user