forge-openclaw-plugin 0.2.27 → 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/assets/{board-C6jCchjI.js → board-q8cfwaAW.js} +2 -2
- package/dist/assets/{board-C6jCchjI.js.map → board-q8cfwaAW.js.map} +1 -1
- package/dist/assets/index-C6PCeHD_.css +1 -0
- package/dist/assets/index-bfHIqj0-.js +85 -0
- package/dist/assets/index-bfHIqj0-.js.map +1 -0
- package/dist/assets/{motion-DFHrH2rd.js → motion-DHfqFntt.js} +2 -2
- package/dist/assets/{motion-DFHrH2rd.js.map → motion-DHfqFntt.js.map} +1 -1
- package/dist/assets/{table-ZL7Di_u3.js → table-DLweENXt.js} +2 -2
- package/dist/assets/{table-ZL7Di_u3.js.map → table-DLweENXt.js.map} +1 -1
- package/dist/assets/{ui-CKNPpz7q.js → ui-BV0OYxkH.js} +2 -2
- package/dist/assets/{ui-CKNPpz7q.js.map → ui-BV0OYxkH.js.map} +1 -1
- package/dist/assets/{vendor-DoNZuFhn.js → vendor-OwcH20PM.js} +204 -204
- package/dist/assets/vendor-OwcH20PM.js.map +1 -0
- package/dist/index.html +7 -7
- package/dist/server/server/migrations/044_macos_local_calendar_provider.sql +21 -0
- package/dist/server/server/src/app.js +331 -14
- package/dist/server/server/src/openapi.js +828 -3
- package/dist/server/server/src/repositories/calendar.js +295 -12
- package/dist/server/server/src/repositories/tasks.js +36 -17
- package/dist/server/server/src/services/calendar-runtime.js +613 -32
- package/dist/server/server/src/services/life-force-model.js +20 -0
- package/dist/server/server/src/services/life-force.js +1333 -97
- package/dist/server/server/src/services/macos-calendar-helper.js +748 -0
- package/dist/server/server/src/types.js +67 -3
- package/dist/server/src/lib/api-error.js +2 -0
- package/dist/server/src/lib/api.js +39 -2
- package/dist/server/src/lib/calendar-name-deduper.js +2 -0
- package/dist/server/src/lib/snapshot-normalizer.js +2 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/044_macos_local_calendar_provider.sql +21 -0
- package/skills/forge-openclaw/SKILL.md +38 -5
- package/skills/forge-openclaw/entity_conversation_playbooks.md +326 -5
- package/skills/forge-openclaw/psyche_entity_playbooks.md +57 -0
- package/dist/assets/index-DVvS8iiU.css +0 -1
- package/dist/assets/index-zYB-9Dfo.js +0 -85
- package/dist/assets/index-zYB-9Dfo.js.map +0 -1
- package/dist/assets/vendor-DoNZuFhn.js.map +0 -1
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { resolveDataDir } from "../db.js";
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
const HELPER_SOURCE = String.raw `
|
|
10
|
+
import AppKit
|
|
11
|
+
import EventKit
|
|
12
|
+
import Foundation
|
|
13
|
+
|
|
14
|
+
enum HelperError: Error {
|
|
15
|
+
case invalidRequest(String)
|
|
16
|
+
case unavailable(String)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func emit(_ payload: [String: Any]) {
|
|
20
|
+
let data = try! JSONSerialization.data(withJSONObject: payload, options: [])
|
|
21
|
+
FileHandle.standardOutput.write(data)
|
|
22
|
+
if let responsePath = responseFilePath() {
|
|
23
|
+
try? data.write(to: URL(fileURLWithPath: responsePath), options: .atomic)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func responseFilePath() -> String? {
|
|
28
|
+
let args = CommandLine.arguments
|
|
29
|
+
guard let responseIndex = args.firstIndex(of: "--response-file"), responseIndex + 1 < args.count else {
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
32
|
+
return args[responseIndex + 1]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func readRequest() throws -> [String: Any] {
|
|
36
|
+
let args = CommandLine.arguments
|
|
37
|
+
guard let requestIndex = args.firstIndex(of: "--request-base64"), requestIndex + 1 < args.count else {
|
|
38
|
+
throw HelperError.invalidRequest("Missing --request-base64 argument.")
|
|
39
|
+
}
|
|
40
|
+
let encoded = args[requestIndex + 1]
|
|
41
|
+
guard let data = Data(base64Encoded: encoded) else {
|
|
42
|
+
throw HelperError.invalidRequest("Invalid base64 request payload.")
|
|
43
|
+
}
|
|
44
|
+
let json = try JSONSerialization.jsonObject(with: data, options: [])
|
|
45
|
+
guard let payload = json as? [String: Any] else {
|
|
46
|
+
throw HelperError.invalidRequest("Request payload must be a JSON object.")
|
|
47
|
+
}
|
|
48
|
+
return payload
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func authStatusText() -> String {
|
|
52
|
+
let status = EKEventStore.authorizationStatus(for: .event)
|
|
53
|
+
if #available(macOS 14.0, *) {
|
|
54
|
+
switch status {
|
|
55
|
+
case .notDetermined:
|
|
56
|
+
return "not_determined"
|
|
57
|
+
case .restricted:
|
|
58
|
+
return "restricted"
|
|
59
|
+
case .denied:
|
|
60
|
+
return "denied"
|
|
61
|
+
case .fullAccess:
|
|
62
|
+
return "full_access"
|
|
63
|
+
case .writeOnly:
|
|
64
|
+
return "denied"
|
|
65
|
+
@unknown default:
|
|
66
|
+
return "unavailable"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
switch status {
|
|
71
|
+
case .notDetermined:
|
|
72
|
+
return "not_determined"
|
|
73
|
+
case .restricted:
|
|
74
|
+
return "restricted"
|
|
75
|
+
case .denied:
|
|
76
|
+
return "denied"
|
|
77
|
+
case .authorized, .fullAccess:
|
|
78
|
+
return "full_access"
|
|
79
|
+
case .writeOnly:
|
|
80
|
+
return "denied"
|
|
81
|
+
@unknown default:
|
|
82
|
+
return "unavailable"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func requestAccess(store: EKEventStore) throws -> [String: Any] {
|
|
87
|
+
if authStatusText() == "full_access" {
|
|
88
|
+
return [
|
|
89
|
+
"granted": true,
|
|
90
|
+
"status": "full_access"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
95
|
+
var granted = false
|
|
96
|
+
var capturedError: Error?
|
|
97
|
+
|
|
98
|
+
if #available(macOS 14.0, *) {
|
|
99
|
+
store.requestFullAccessToEvents { allowed, error in
|
|
100
|
+
granted = allowed
|
|
101
|
+
capturedError = error
|
|
102
|
+
semaphore.signal()
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
store.requestAccess(to: .event) { allowed, error in
|
|
106
|
+
granted = allowed
|
|
107
|
+
capturedError = error
|
|
108
|
+
semaphore.signal()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
semaphore.wait()
|
|
113
|
+
if let capturedError {
|
|
114
|
+
throw capturedError
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
"granted": granted,
|
|
119
|
+
"status": authStatusText()
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func sourceTypeText(_ sourceType: EKSourceType) -> String {
|
|
124
|
+
switch sourceType {
|
|
125
|
+
case .local:
|
|
126
|
+
return "local"
|
|
127
|
+
case .exchange:
|
|
128
|
+
return "exchange"
|
|
129
|
+
case .calDAV:
|
|
130
|
+
return "caldav"
|
|
131
|
+
case .mobileMe:
|
|
132
|
+
return "mobileme"
|
|
133
|
+
case .subscribed:
|
|
134
|
+
return "subscribed"
|
|
135
|
+
case .birthdays:
|
|
136
|
+
return "birthdays"
|
|
137
|
+
@unknown default:
|
|
138
|
+
return "unknown"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func sourceIdentifier(_ source: EKSource?) -> String {
|
|
143
|
+
source?.sourceIdentifier ?? "unknown-source"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func sourceTitle(_ source: EKSource?) -> String {
|
|
147
|
+
source?.title ?? "Unknown source"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func sourceTypeValue(_ source: EKSource?) -> String {
|
|
151
|
+
guard let source else {
|
|
152
|
+
return "unknown"
|
|
153
|
+
}
|
|
154
|
+
return sourceTypeText(source.sourceType)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func calendarDescription(_ calendar: EKCalendar) -> String {
|
|
158
|
+
""
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func calendarTimezoneIdentifier() -> String {
|
|
162
|
+
TimeZone.current.identifier
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func calendarTypeText(_ calendarType: EKCalendarType) -> String {
|
|
166
|
+
switch calendarType {
|
|
167
|
+
case .local:
|
|
168
|
+
return "local"
|
|
169
|
+
case .calDAV:
|
|
170
|
+
return "caldav"
|
|
171
|
+
case .exchange:
|
|
172
|
+
return "exchange"
|
|
173
|
+
case .subscription:
|
|
174
|
+
return "subscription"
|
|
175
|
+
case .birthday:
|
|
176
|
+
return "birthday"
|
|
177
|
+
@unknown default:
|
|
178
|
+
return "unknown"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func colorHex(_ color: CGColor?) -> String {
|
|
183
|
+
guard let color else {
|
|
184
|
+
return "#7dd3fc"
|
|
185
|
+
}
|
|
186
|
+
let nsColor = NSColor(cgColor: color)?.usingColorSpace(.sRGB) ?? NSColor.systemBlue
|
|
187
|
+
let red = Int(round(nsColor.redComponent * 255.0))
|
|
188
|
+
let green = Int(round(nsColor.greenComponent * 255.0))
|
|
189
|
+
let blue = Int(round(nsColor.blueComponent * 255.0))
|
|
190
|
+
return String(format: "#%02x%02x%02x", red, green, blue)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func availabilityText(_ availability: EKEventAvailability) -> String {
|
|
194
|
+
switch availability {
|
|
195
|
+
case .free:
|
|
196
|
+
return "free"
|
|
197
|
+
default:
|
|
198
|
+
return "busy"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func isoString(_ date: Date?) -> String? {
|
|
203
|
+
guard let date else {
|
|
204
|
+
return nil
|
|
205
|
+
}
|
|
206
|
+
let formatter = ISO8601DateFormatter()
|
|
207
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
208
|
+
return formatter.string(from: date)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func parseIsoDate(_ value: String) -> Date? {
|
|
212
|
+
let fractionalFormatter = ISO8601DateFormatter()
|
|
213
|
+
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
214
|
+
if let parsed = fractionalFormatter.date(from: value) {
|
|
215
|
+
return parsed
|
|
216
|
+
}
|
|
217
|
+
let standardFormatter = ISO8601DateFormatter()
|
|
218
|
+
standardFormatter.formatOptions = [.withInternetDateTime]
|
|
219
|
+
return standardFormatter.date(from: value)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func discover(store: EKEventStore) throws -> [String: Any] {
|
|
223
|
+
guard authStatusText() == "full_access" else {
|
|
224
|
+
throw HelperError.unavailable("Forge needs Calendar full access before it can read calendars already configured on this Mac.")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let calendars = store.calendars(for: .event)
|
|
228
|
+
let grouped = Dictionary(grouping: calendars, by: { sourceIdentifier($0.source) })
|
|
229
|
+
let sources = grouped.keys.sorted().compactMap { sourceId -> [String: Any]? in
|
|
230
|
+
guard let calendarsForSource = grouped[sourceId], let first = calendarsForSource.first else {
|
|
231
|
+
return nil
|
|
232
|
+
}
|
|
233
|
+
let source = first.source
|
|
234
|
+
let mappedCalendars = calendarsForSource
|
|
235
|
+
.sorted { lhs, rhs in
|
|
236
|
+
if lhs.title == rhs.title {
|
|
237
|
+
return lhs.calendarIdentifier < rhs.calendarIdentifier
|
|
238
|
+
}
|
|
239
|
+
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
|
240
|
+
}
|
|
241
|
+
.map { calendar in
|
|
242
|
+
[
|
|
243
|
+
"sourceId": sourceIdentifier(source),
|
|
244
|
+
"sourceTitle": sourceTitle(source),
|
|
245
|
+
"sourceType": sourceTypeValue(source),
|
|
246
|
+
"calendarId": calendar.calendarIdentifier,
|
|
247
|
+
"title": calendar.title,
|
|
248
|
+
"description": calendarDescription(calendar),
|
|
249
|
+
"color": colorHex(calendar.cgColor),
|
|
250
|
+
"timezone": calendarTimezoneIdentifier(),
|
|
251
|
+
"calendarType": calendarTypeText(calendar.type),
|
|
252
|
+
"isPrimary": calendar.allowsContentModifications && calendar.title.localizedCaseInsensitiveCompare("calendar") == .orderedSame,
|
|
253
|
+
"canWrite": calendar.allowsContentModifications
|
|
254
|
+
] as [String : Any]
|
|
255
|
+
}
|
|
256
|
+
return [
|
|
257
|
+
"sourceId": sourceIdentifier(source),
|
|
258
|
+
"sourceTitle": sourceTitle(source),
|
|
259
|
+
"sourceType": sourceTypeValue(source),
|
|
260
|
+
"accountLabel": sourceTitle(source),
|
|
261
|
+
"calendars": mappedCalendars
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
"status": authStatusText(),
|
|
267
|
+
"sources": sources
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func eventPayload(_ event: EKEvent) -> [String: Any] {
|
|
272
|
+
[
|
|
273
|
+
"eventId": event.eventIdentifier ?? "",
|
|
274
|
+
"externalId": event.calendarItemExternalIdentifier ?? NSNull(),
|
|
275
|
+
"calendarId": event.calendar.calendarIdentifier,
|
|
276
|
+
"title": event.title ?? "(untitled event)",
|
|
277
|
+
"startAt": isoString(event.startDate) ?? "",
|
|
278
|
+
"endAt": isoString(event.endDate) ?? "",
|
|
279
|
+
"allDay": event.isAllDay,
|
|
280
|
+
"availability": availabilityText(event.availability),
|
|
281
|
+
"location": event.location ?? "",
|
|
282
|
+
"notes": event.notes ?? "",
|
|
283
|
+
"occurrenceDate": isoString(event.occurrenceDate) ?? NSNull(),
|
|
284
|
+
"lastModifiedAt": isoString(event.lastModifiedDate) ?? NSNull()
|
|
285
|
+
]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
func calendarForIdentifier(_ store: EKEventStore, _ identifier: String) -> EKCalendar? {
|
|
289
|
+
store.calendar(withIdentifier: identifier)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func listEvents(store: EKEventStore, payload: [String: Any]) throws -> [String: Any] {
|
|
293
|
+
guard authStatusText() == "full_access" else {
|
|
294
|
+
throw HelperError.unavailable("Forge needs Calendar full access before it can read local events.")
|
|
295
|
+
}
|
|
296
|
+
guard let calendarIds = payload["calendarIds"] as? [String], !calendarIds.isEmpty else {
|
|
297
|
+
throw HelperError.invalidRequest("calendarIds are required.")
|
|
298
|
+
}
|
|
299
|
+
guard let startRaw = payload["start"] as? String, let start = parseIsoDate(startRaw) else {
|
|
300
|
+
throw HelperError.invalidRequest("A valid start ISO timestamp is required.")
|
|
301
|
+
}
|
|
302
|
+
guard let endRaw = payload["end"] as? String, let end = parseIsoDate(endRaw) else {
|
|
303
|
+
throw HelperError.invalidRequest("A valid end ISO timestamp is required.")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let calendars = calendarIds.compactMap { calendarForIdentifier(store, $0) }
|
|
307
|
+
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
|
|
308
|
+
let events = store.events(matching: predicate)
|
|
309
|
+
.sorted { lhs, rhs in
|
|
310
|
+
if lhs.startDate == rhs.startDate {
|
|
311
|
+
return (lhs.title ?? "") < (rhs.title ?? "")
|
|
312
|
+
}
|
|
313
|
+
return lhs.startDate < rhs.startDate
|
|
314
|
+
}
|
|
315
|
+
.map(eventPayload)
|
|
316
|
+
|
|
317
|
+
return ["events": events]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func ensureForgeCalendar(store: EKEventStore, payload: [String: Any]) throws -> [String: Any] {
|
|
321
|
+
guard authStatusText() == "full_access" else {
|
|
322
|
+
throw HelperError.unavailable("Forge needs Calendar full access before it can create or choose a local Forge calendar.")
|
|
323
|
+
}
|
|
324
|
+
guard let sourceId = payload["sourceId"] as? String, !sourceId.isEmpty else {
|
|
325
|
+
throw HelperError.invalidRequest("sourceId is required.")
|
|
326
|
+
}
|
|
327
|
+
let calendars = store.calendars(for: .event).filter {
|
|
328
|
+
sourceIdentifier($0.source) == sourceId
|
|
329
|
+
}
|
|
330
|
+
guard let source = calendars.first?.source else {
|
|
331
|
+
throw HelperError.invalidRequest("Unknown macOS calendar source.")
|
|
332
|
+
}
|
|
333
|
+
if let existing = calendars.first(where: { $0.title.localizedCaseInsensitiveCompare("Forge") == .orderedSame && $0.allowsContentModifications }) {
|
|
334
|
+
return ["calendar": [
|
|
335
|
+
"sourceId": source.sourceIdentifier,
|
|
336
|
+
"sourceTitle": source.title,
|
|
337
|
+
"sourceType": sourceTypeText(source.sourceType),
|
|
338
|
+
"calendarId": existing.calendarIdentifier,
|
|
339
|
+
"title": existing.title,
|
|
340
|
+
"description": calendarDescription(existing),
|
|
341
|
+
"color": colorHex(existing.cgColor),
|
|
342
|
+
"timezone": calendarTimezoneIdentifier(),
|
|
343
|
+
"calendarType": calendarTypeText(existing.type),
|
|
344
|
+
"isPrimary": false,
|
|
345
|
+
"canWrite": existing.allowsContentModifications
|
|
346
|
+
]]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let newCalendar = EKCalendar(for: .event, eventStore: store)
|
|
350
|
+
newCalendar.source = source
|
|
351
|
+
newCalendar.title = "Forge"
|
|
352
|
+
newCalendar.cgColor = NSColor(calibratedRed: 0.49, green: 0.83, blue: 0.99, alpha: 1.0).cgColor
|
|
353
|
+
try store.saveCalendar(newCalendar, commit: true)
|
|
354
|
+
|
|
355
|
+
return ["calendar": [
|
|
356
|
+
"sourceId": source.sourceIdentifier,
|
|
357
|
+
"sourceTitle": source.title,
|
|
358
|
+
"sourceType": sourceTypeText(source.sourceType),
|
|
359
|
+
"calendarId": newCalendar.calendarIdentifier,
|
|
360
|
+
"title": newCalendar.title,
|
|
361
|
+
"description": calendarDescription(newCalendar),
|
|
362
|
+
"color": colorHex(newCalendar.cgColor),
|
|
363
|
+
"timezone": calendarTimezoneIdentifier(),
|
|
364
|
+
"calendarType": calendarTypeText(newCalendar.type),
|
|
365
|
+
"isPrimary": false,
|
|
366
|
+
"canWrite": newCalendar.allowsContentModifications
|
|
367
|
+
]]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
func upsertEvent(store: EKEventStore, payload: [String: Any]) throws -> [String: Any] {
|
|
371
|
+
guard authStatusText() == "full_access" else {
|
|
372
|
+
throw HelperError.unavailable("Forge needs Calendar full access before it can write local events.")
|
|
373
|
+
}
|
|
374
|
+
guard let calendarId = payload["calendarId"] as? String, let calendar = store.calendar(withIdentifier: calendarId) else {
|
|
375
|
+
throw HelperError.invalidRequest("calendarId is required.")
|
|
376
|
+
}
|
|
377
|
+
if !calendar.allowsContentModifications {
|
|
378
|
+
throw HelperError.unavailable("That local calendar is read-only.")
|
|
379
|
+
}
|
|
380
|
+
guard let title = payload["title"] as? String, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
381
|
+
throw HelperError.invalidRequest("title is required.")
|
|
382
|
+
}
|
|
383
|
+
guard let startRaw = payload["startAt"] as? String, let start = parseIsoDate(startRaw) else {
|
|
384
|
+
throw HelperError.invalidRequest("A valid startAt ISO timestamp is required.")
|
|
385
|
+
}
|
|
386
|
+
guard let endRaw = payload["endAt"] as? String, let end = parseIsoDate(endRaw) else {
|
|
387
|
+
throw HelperError.invalidRequest("A valid endAt ISO timestamp is required.")
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let eventId = payload["eventId"] as? String
|
|
391
|
+
let event = eventId.flatMap { store.event(withIdentifier: $0) } ?? EKEvent(eventStore: store)
|
|
392
|
+
event.calendar = calendar
|
|
393
|
+
event.title = title
|
|
394
|
+
event.startDate = start
|
|
395
|
+
event.endDate = end
|
|
396
|
+
event.notes = payload["notes"] as? String
|
|
397
|
+
event.location = payload["location"] as? String
|
|
398
|
+
event.isAllDay = (payload["allDay"] as? Bool) ?? false
|
|
399
|
+
try store.save(event, span: .thisEvent, commit: true)
|
|
400
|
+
return ["event": eventPayload(event)]
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
func deleteEvent(store: EKEventStore, payload: [String: Any]) throws -> [String: Any] {
|
|
404
|
+
guard authStatusText() == "full_access" else {
|
|
405
|
+
throw HelperError.unavailable("Forge needs Calendar full access before it can delete local events.")
|
|
406
|
+
}
|
|
407
|
+
guard let eventId = payload["eventId"] as? String, !eventId.isEmpty else {
|
|
408
|
+
throw HelperError.invalidRequest("eventId is required.")
|
|
409
|
+
}
|
|
410
|
+
guard let event = store.event(withIdentifier: eventId) else {
|
|
411
|
+
return ["deleted": true]
|
|
412
|
+
}
|
|
413
|
+
try store.remove(event, span: .thisEvent, commit: true)
|
|
414
|
+
return ["deleted": true]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
do {
|
|
418
|
+
let payload = try readRequest()
|
|
419
|
+
let store = EKEventStore()
|
|
420
|
+
let command = payload["command"] as? String ?? ""
|
|
421
|
+
let result: [String: Any]
|
|
422
|
+
switch command {
|
|
423
|
+
case "auth_status":
|
|
424
|
+
result = ["status": authStatusText()]
|
|
425
|
+
case "request_access":
|
|
426
|
+
result = try requestAccess(store: store)
|
|
427
|
+
case "discover":
|
|
428
|
+
result = try discover(store: store)
|
|
429
|
+
case "list_events":
|
|
430
|
+
result = try listEvents(store: store, payload: payload)
|
|
431
|
+
case "ensure_forge_calendar":
|
|
432
|
+
result = try ensureForgeCalendar(store: store, payload: payload)
|
|
433
|
+
case "upsert_event":
|
|
434
|
+
result = try upsertEvent(store: store, payload: payload)
|
|
435
|
+
case "delete_event":
|
|
436
|
+
result = try deleteEvent(store: store, payload: payload)
|
|
437
|
+
default:
|
|
438
|
+
throw HelperError.invalidRequest("Unknown command: \(command)")
|
|
439
|
+
}
|
|
440
|
+
var response = result
|
|
441
|
+
response["ok"] = true
|
|
442
|
+
emit(response)
|
|
443
|
+
} catch {
|
|
444
|
+
emit([
|
|
445
|
+
"ok": false,
|
|
446
|
+
"error": String(describing: error)
|
|
447
|
+
])
|
|
448
|
+
}
|
|
449
|
+
`;
|
|
450
|
+
const HELPER_INFO_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
451
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
452
|
+
<plist version="1.0">
|
|
453
|
+
<dict>
|
|
454
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
455
|
+
<string>en</string>
|
|
456
|
+
<key>CFBundleDisplayName</key>
|
|
457
|
+
<string>Forge macOS Calendar Helper</string>
|
|
458
|
+
<key>CFBundleExecutable</key>
|
|
459
|
+
<string>ForgeMacOSCalendarHelper</string>
|
|
460
|
+
<key>CFBundleIdentifier</key>
|
|
461
|
+
<string>ai.openclaw.forge.macos-calendar-helper</string>
|
|
462
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
463
|
+
<string>6.0</string>
|
|
464
|
+
<key>CFBundleName</key>
|
|
465
|
+
<string>ForgeMacOSCalendarHelper</string>
|
|
466
|
+
<key>CFBundlePackageType</key>
|
|
467
|
+
<string>APPL</string>
|
|
468
|
+
<key>CFBundleShortVersionString</key>
|
|
469
|
+
<string>1.0</string>
|
|
470
|
+
<key>CFBundleVersion</key>
|
|
471
|
+
<string>1</string>
|
|
472
|
+
<key>LSUIElement</key>
|
|
473
|
+
<true/>
|
|
474
|
+
<key>NSCalendarsFullAccessUsageDescription</key>
|
|
475
|
+
<string>Forge uses Calendar access to read and write the calendars already configured on this Mac.</string>
|
|
476
|
+
<key>NSCalendarsUsageDescription</key>
|
|
477
|
+
<string>Forge uses Calendar access to read and write the calendars already configured on this Mac.</string>
|
|
478
|
+
</dict>
|
|
479
|
+
</plist>
|
|
480
|
+
`;
|
|
481
|
+
function helperCacheDir() {
|
|
482
|
+
return path.join(resolveDataDir(), ".forge-native", "macos-calendar-helper");
|
|
483
|
+
}
|
|
484
|
+
function helperSourcePath() {
|
|
485
|
+
return path.join(helperCacheDir(), "ForgeMacOSCalendarHelper.swift");
|
|
486
|
+
}
|
|
487
|
+
function helperAppPath() {
|
|
488
|
+
return path.join(helperCacheDir(), "ForgeMacOSCalendarHelper.app");
|
|
489
|
+
}
|
|
490
|
+
function helperContentsPath() {
|
|
491
|
+
return path.join(helperAppPath(), "Contents");
|
|
492
|
+
}
|
|
493
|
+
function helperMacOSPath() {
|
|
494
|
+
return path.join(helperContentsPath(), "MacOS");
|
|
495
|
+
}
|
|
496
|
+
function helperInfoPlistPath() {
|
|
497
|
+
return path.join(helperContentsPath(), "Info.plist");
|
|
498
|
+
}
|
|
499
|
+
function helperBinaryPath() {
|
|
500
|
+
return path.join(helperMacOSPath(), "ForgeMacOSCalendarHelper");
|
|
501
|
+
}
|
|
502
|
+
async function ensureHelperCompiled() {
|
|
503
|
+
if (process.platform !== "darwin") {
|
|
504
|
+
throw new Error("Forge macOS local calendars are only available on macOS.");
|
|
505
|
+
}
|
|
506
|
+
const cacheDir = helperCacheDir();
|
|
507
|
+
const sourcePath = helperSourcePath();
|
|
508
|
+
const binaryPath = helperBinaryPath();
|
|
509
|
+
const infoPlistPath = helperInfoPlistPath();
|
|
510
|
+
const sourceHash = createHash("sha256").update(HELPER_SOURCE).digest("hex");
|
|
511
|
+
const plistHash = createHash("sha256").update(HELPER_INFO_PLIST).digest("hex");
|
|
512
|
+
await mkdir(cacheDir, { recursive: true });
|
|
513
|
+
await mkdir(helperMacOSPath(), { recursive: true });
|
|
514
|
+
let existingSource = "";
|
|
515
|
+
let existingPlist = "";
|
|
516
|
+
try {
|
|
517
|
+
existingSource = await readFile(sourcePath, "utf8");
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
existingSource = "";
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
existingPlist = await readFile(infoPlistPath, "utf8");
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
existingPlist = "";
|
|
527
|
+
}
|
|
528
|
+
if (existingSource !== HELPER_SOURCE) {
|
|
529
|
+
await writeFile(sourcePath, HELPER_SOURCE, "utf8");
|
|
530
|
+
}
|
|
531
|
+
if (existingPlist !== HELPER_INFO_PLIST) {
|
|
532
|
+
await writeFile(infoPlistPath, HELPER_INFO_PLIST, "utf8");
|
|
533
|
+
}
|
|
534
|
+
let needsCompile = existingSource !== HELPER_SOURCE || existingPlist !== HELPER_INFO_PLIST;
|
|
535
|
+
if (!needsCompile) {
|
|
536
|
+
try {
|
|
537
|
+
const [sourceStats, plistStats, binaryStats] = await Promise.all([
|
|
538
|
+
stat(sourcePath),
|
|
539
|
+
stat(infoPlistPath),
|
|
540
|
+
stat(binaryPath)
|
|
541
|
+
]);
|
|
542
|
+
needsCompile =
|
|
543
|
+
binaryStats.mtimeMs < sourceStats.mtimeMs ||
|
|
544
|
+
binaryStats.mtimeMs < plistStats.mtimeMs;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
needsCompile = true;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!needsCompile) {
|
|
551
|
+
return { binaryPath, sourceHash, plistHash };
|
|
552
|
+
}
|
|
553
|
+
await execFile("xcrun", [
|
|
554
|
+
"swiftc",
|
|
555
|
+
"-O",
|
|
556
|
+
"-framework",
|
|
557
|
+
"EventKit",
|
|
558
|
+
"-framework",
|
|
559
|
+
"AppKit",
|
|
560
|
+
sourcePath,
|
|
561
|
+
"-o",
|
|
562
|
+
binaryPath
|
|
563
|
+
]);
|
|
564
|
+
return { binaryPath, sourceHash, plistHash };
|
|
565
|
+
}
|
|
566
|
+
async function runHelperViaApp(payload) {
|
|
567
|
+
const { binaryPath } = await ensureHelperCompiled();
|
|
568
|
+
const appPath = helperAppPath();
|
|
569
|
+
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
570
|
+
const responsePath = path.join(helperCacheDir(), `response-${randomUUID()}.json`);
|
|
571
|
+
try {
|
|
572
|
+
await execFile("open", [
|
|
573
|
+
"-W",
|
|
574
|
+
"-n",
|
|
575
|
+
appPath,
|
|
576
|
+
"--args",
|
|
577
|
+
"--request-base64",
|
|
578
|
+
encoded,
|
|
579
|
+
"--response-file",
|
|
580
|
+
responsePath
|
|
581
|
+
], {
|
|
582
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
583
|
+
env: {
|
|
584
|
+
...process.env,
|
|
585
|
+
TMPDIR: process.env.TMPDIR ?? os.tmpdir()
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
const raw = await readFile(responsePath, "utf8");
|
|
589
|
+
const parsed = JSON.parse(raw);
|
|
590
|
+
if (!parsed.ok) {
|
|
591
|
+
throw new Error(parsed.error);
|
|
592
|
+
}
|
|
593
|
+
return parsed;
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
await rm(responsePath, { force: true }).catch(() => undefined);
|
|
597
|
+
void binaryPath;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function runHelper(payload) {
|
|
601
|
+
const mockRaw = process.env.FORGE_MACOS_LOCAL_MOCK_JSON?.trim();
|
|
602
|
+
if (mockRaw) {
|
|
603
|
+
const mock = JSON.parse(mockRaw);
|
|
604
|
+
const status = mock.status ?? "full_access";
|
|
605
|
+
switch (payload.command) {
|
|
606
|
+
case "auth_status":
|
|
607
|
+
return { status };
|
|
608
|
+
case "request_access":
|
|
609
|
+
return {
|
|
610
|
+
granted: mock.granted ?? status === "full_access",
|
|
611
|
+
status
|
|
612
|
+
};
|
|
613
|
+
case "discover":
|
|
614
|
+
return {
|
|
615
|
+
status,
|
|
616
|
+
sources: mock.sources ?? []
|
|
617
|
+
};
|
|
618
|
+
case "list_events": {
|
|
619
|
+
const calendarIds = Array.isArray(payload.calendarIds)
|
|
620
|
+
? payload.calendarIds.filter((value) => typeof value === "string")
|
|
621
|
+
: [];
|
|
622
|
+
return {
|
|
623
|
+
events: (mock.events ?? []).filter((event) => calendarIds.includes(event.calendarId))
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
case "ensure_forge_calendar": {
|
|
627
|
+
const sourceId = typeof payload.sourceId === "string" ? payload.sourceId : "";
|
|
628
|
+
const forgeCalendar = mock.sources
|
|
629
|
+
?.find((source) => source.sourceId === sourceId)
|
|
630
|
+
?.calendars.find((calendar) => calendar.title === "Forge") ?? null;
|
|
631
|
+
if (!forgeCalendar) {
|
|
632
|
+
throw new Error("Mock macOS local source is missing a Forge calendar.");
|
|
633
|
+
}
|
|
634
|
+
return { calendar: forgeCalendar };
|
|
635
|
+
}
|
|
636
|
+
case "upsert_event": {
|
|
637
|
+
const eventId = typeof payload.eventId === "string" && payload.eventId.trim().length > 0
|
|
638
|
+
? payload.eventId
|
|
639
|
+
: "mock_macos_event";
|
|
640
|
+
return {
|
|
641
|
+
event: {
|
|
642
|
+
eventId,
|
|
643
|
+
externalId: eventId,
|
|
644
|
+
calendarId: String(payload.calendarId ?? ""),
|
|
645
|
+
title: String(payload.title ?? ""),
|
|
646
|
+
startAt: String(payload.startAt ?? ""),
|
|
647
|
+
endAt: String(payload.endAt ?? ""),
|
|
648
|
+
allDay: Boolean(payload.allDay),
|
|
649
|
+
availability: "busy",
|
|
650
|
+
location: typeof payload.location === "string" ? payload.location : "",
|
|
651
|
+
notes: typeof payload.notes === "string" ? payload.notes : "",
|
|
652
|
+
occurrenceDate: null,
|
|
653
|
+
lastModifiedAt: new Date().toISOString()
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
case "delete_event":
|
|
658
|
+
return { deleted: true };
|
|
659
|
+
default:
|
|
660
|
+
throw new Error(`Unknown mock macOS helper command ${String(payload.command)}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return runHelperViaApp(payload);
|
|
664
|
+
}
|
|
665
|
+
export function buildMacOSLocalCalendarUrl(sourceId, calendarId) {
|
|
666
|
+
return `forge-macos-local://calendar/${encodeURIComponent(sourceId)}/${encodeURIComponent(calendarId)}/`;
|
|
667
|
+
}
|
|
668
|
+
export function parseMacOSLocalCalendarUrl(urlValue) {
|
|
669
|
+
const url = new URL(urlValue);
|
|
670
|
+
if (url.protocol !== "forge-macos-local:" || url.hostname !== "calendar") {
|
|
671
|
+
throw new Error(`Forge could not parse macOS local calendar URL ${urlValue}.`);
|
|
672
|
+
}
|
|
673
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
674
|
+
if (parts.length < 2) {
|
|
675
|
+
throw new Error(`Forge could not parse macOS local calendar URL ${urlValue}.`);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
sourceId: decodeURIComponent(parts[0] ?? ""),
|
|
679
|
+
calendarId: decodeURIComponent(parts[1] ?? "")
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
export async function getMacOSCalendarAuthStatus() {
|
|
683
|
+
if (process.platform !== "darwin") {
|
|
684
|
+
return { status: "unavailable" };
|
|
685
|
+
}
|
|
686
|
+
return runHelper({
|
|
687
|
+
command: "auth_status"
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
export async function requestMacOSCalendarAccess() {
|
|
691
|
+
if (process.platform !== "darwin") {
|
|
692
|
+
return {
|
|
693
|
+
granted: false,
|
|
694
|
+
status: "unavailable"
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
return runHelper({
|
|
698
|
+
command: "request_access"
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
export async function openMacOSCalendarPrivacySettings() {
|
|
702
|
+
if (process.platform !== "darwin") {
|
|
703
|
+
return { opened: false };
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
await execFile("open", [
|
|
707
|
+
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars"
|
|
708
|
+
]);
|
|
709
|
+
return { opened: true };
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
return { opened: false };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
export async function discoverMacOSLocalCalendars() {
|
|
716
|
+
const payload = await runHelper({
|
|
717
|
+
command: "discover"
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
status: payload.status,
|
|
721
|
+
requestedAt: new Date().toISOString(),
|
|
722
|
+
sources: payload.sources
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
export async function listMacOSLocalEvents(input) {
|
|
726
|
+
return runHelper({
|
|
727
|
+
command: "list_events",
|
|
728
|
+
...input
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
export async function ensureMacOSLocalForgeCalendar(sourceId) {
|
|
732
|
+
return runHelper({
|
|
733
|
+
command: "ensure_forge_calendar",
|
|
734
|
+
sourceId
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
export async function upsertMacOSLocalEvent(input) {
|
|
738
|
+
return runHelper({
|
|
739
|
+
command: "upsert_event",
|
|
740
|
+
...input
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
export async function deleteMacOSLocalEvent(eventId) {
|
|
744
|
+
return runHelper({
|
|
745
|
+
command: "delete_event",
|
|
746
|
+
eventId
|
|
747
|
+
});
|
|
748
|
+
}
|