feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
#include "CSignalSupport.h"
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
|
||||
static char g_socket_path[104 * 4];
|
||||
static volatile sig_atomic_t g_has_path = 0;
|
||||
|
||||
void reyna_store_socket_path(const char *path) {
|
||||
if (!path) {
|
||||
g_socket_path[0] = '\0';
|
||||
g_has_path = 0;
|
||||
return;
|
||||
}
|
||||
strncpy(g_socket_path, path, sizeof(g_socket_path)-1);
|
||||
g_socket_path[sizeof(g_socket_path)-1] = '\0';
|
||||
g_has_path = 1;
|
||||
}
|
||||
|
||||
void reyna_cleanup_socket_sync(void) {
|
||||
if (!g_has_path) return;
|
||||
if (g_socket_path[0] == '\0') return;
|
||||
unlink(g_socket_path);
|
||||
}
|
||||
|
||||
static void reyna_signal_handler(int sig) {
|
||||
(void)sig;
|
||||
reyna_cleanup_socket_sync();
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
void reyna_install_signal_handlers(void) {
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = reyna_signal_handler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGHUP, &sa, NULL);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#ifndef CSignalSupport_h
|
||||
#define CSignalSupport_h
|
||||
#include <sys/types.h>
|
||||
|
||||
void reyna_store_socket_path(const char *path);
|
||||
void reyna_install_signal_handlers(void);
|
||||
void reyna_cleanup_socket_sync(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,4 @@
|
||||
module CSignalSupport {
|
||||
header "CSignalSupport.h"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// Thin executable wrapper preserving AF_UNIX protocol — all logic lives in ReynaCLIHostCore shared library.
|
||||
// This file is the source of truth for the SwiftPM binary AND referenced by the Xcode app target's main.
|
||||
|
||||
runReynaCLIHost(arguments: CommandLine.arguments)
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
// Public entry point used by both SwiftPM executable and Xcode app target.
|
||||
// Preserves AF_UNIX privacy-host protocol exactly as before.
|
||||
// Headless design: socket-server or stdin JSON-lines mode only.
|
||||
|
||||
public func runReynaCLIHost(arguments: [String] = CommandLine.arguments) -> Never {
|
||||
if let idx = arguments.firstIndex(of: "--socket") {
|
||||
let nextIdx = idx + 1
|
||||
guard nextIdx < arguments.count else {
|
||||
fputs("error: --socket requires a path argument\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
let socketPath = arguments[nextIdx]
|
||||
runSocketServer(socketPath: socketPath)
|
||||
} else {
|
||||
runStdinLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func extractRecoverableId(from data: Data) -> String? {
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let id = obj["id"] as? String {
|
||||
return id
|
||||
}
|
||||
guard let str = String(data: data, encoding: .utf8) else { return nil }
|
||||
let pattern = "\"id\"\\s*:\\s*\"([^\"]*)\""
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
|
||||
let match = regex.firstMatch(in: str, options: [], range: NSRange(str.startIndex..., in: str)),
|
||||
match.numberOfRanges >= 2,
|
||||
let r = Range(match.range(at: 1), in: str) else {
|
||||
return nil
|
||||
}
|
||||
return String(str[r])
|
||||
}
|
||||
|
||||
func writeResponse(_ response: Response) {
|
||||
guard let jsonData = try? JSONEncoder().encode(response),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8),
|
||||
let outData = (jsonString + "\n").data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
FileHandle.standardOutput.write(outData)
|
||||
}
|
||||
|
||||
func handleLine(_ lineData: Data) {
|
||||
if lineData.isEmpty { return }
|
||||
if let s = String(data: lineData, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
if let request = try? JSONDecoder().decode(Request.self, from: lineData) {
|
||||
let response = dispatch(request: request)
|
||||
writeResponse(response)
|
||||
} else {
|
||||
let recoveredId = extractRecoverableId(from: lineData)
|
||||
let err = ErrorPayload(code: "invalid_request", message: "Invalid request JSON")
|
||||
let resp = Response(id: recoveredId ?? "", ok: false, result: nil, error: err)
|
||||
writeResponse(resp)
|
||||
}
|
||||
}
|
||||
|
||||
func runStdinLoop() -> Never {
|
||||
while let line = readLine() {
|
||||
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
|
||||
if let data = line.data(using: .utf8) {
|
||||
handleLine(data)
|
||||
} else {
|
||||
let err = ErrorPayload(code: "invalid_request", message: "Invalid request encoding")
|
||||
let resp = Response(id: "", ok: false, result: nil, error: err)
|
||||
writeResponse(resp)
|
||||
}
|
||||
}
|
||||
Darwin.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// Explicit calendar authorization provider – ONLY location allowed to call requestFullAccessToEvents.
|
||||
// Production list path (EventKitCalendarProvider) must remain read-only and never prompt.
|
||||
|
||||
// Public auth status mirrored from EKAuthorizationStatus without importing EventKit into protocol file
|
||||
enum CalendarAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case writeOnly
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol CalendarAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus
|
||||
func requestFullAccess() throws -> Bool
|
||||
}
|
||||
|
||||
// Internal main-run-loop pumping bridge – bounded wait that pumps run loop instead of blocking it.
|
||||
// Provides deterministic seam via injected starter closure.
|
||||
struct EventKitMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
/// Waits up to `timeout` seconds for `starter` to invoke completion.
|
||||
/// The starter is expected to eventually call completion, potentially from main run loop.
|
||||
/// This method pumps the current run loop (which is the main run loop when called on main thread)
|
||||
/// so that EventKit's main-run-loop delivered completion can run instead of deadlocking.
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
// Trigger the underlying async request
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
// Pump run loop until done or timeout. Uses RunLoop.current.run(mode:before:) to avoid busy spin.
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
// 20ms slice – small enough to be responsive, large enough to avoid spin
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw CalendarProviderError.unavailable("calendar authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw CalendarProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct EventKitCalendarAuthorizationProvider: CalendarAuthorizationProviding {
|
||||
// Allow injection of bridge for tests while keeping default production behavior
|
||||
var bridge: EventKitMainRunLoopBridge = EventKitMainRunLoopBridge()
|
||||
|
||||
// Convert EK status to our enum
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus {
|
||||
let s = EKEventStore.authorizationStatus(for: .event)
|
||||
switch s {
|
||||
case .fullAccess, .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
case .writeOnly:
|
||||
return .writeOnly
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded async-to-sync bridge, max 30s, pumping main run loop. ONLY place calling requestFullAccessToEvents.
|
||||
func requestFullAccess() throws -> Bool {
|
||||
if #available(macOS 14.0, *) {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw CalendarProviderError.unavailable("requestFullAccessToEvents requires macOS 14+")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// Production EventKit provider – read-only for list, mutating only for create.
|
||||
// Requirements:
|
||||
// - Only checks authorization status (never triggers prompt) for list/create.
|
||||
// - Lists calendars when status permits.
|
||||
// - Never calls prompting APIs except in auth provider.
|
||||
// - If permission absent/denied/restricted, throw permissionRequired.
|
||||
|
||||
struct EventKitCalendarProvider: CalendarListProviding, CalendarEventsListProviding, CalendarEventCreateProviding {
|
||||
|
||||
// MARK: - Auth check shared
|
||||
private func requireAuthorizedOrThrow() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
break
|
||||
case .writeOnly:
|
||||
// writeOnly does not allow reading calendars/events; but for create we could allow? Task says already-authorized access but no permission request; map denied/not-determined to permission_required for all.
|
||||
// Simpler: for events list, writeOnly -> permissionRequired; for create, writeOnly should also require check but writeOnly actually allows writing. However to keep deterministic, attempt to respect writeOnly for create?
|
||||
// Spec: map denied/not-determined to permission_required, provider failures to calendar_unavailable.
|
||||
// To be safe: for list -> permissionRequired, for create we will check below differently? But shared throw would block create with writeOnly unnecessarily.
|
||||
// We differentiate inside methods. For this helper, allow writeOnly as authorized for mutating path? Caller should call specific check.
|
||||
throw CalendarProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: Bool = false) throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
if allowsWriteOnlyRead {
|
||||
return
|
||||
}
|
||||
// For read paths (list calendars, list events), writeOnly does NOT permit read -> permission_required
|
||||
throw CalendarProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar list (existing)
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
|
||||
|
||||
let store = EKEventStore()
|
||||
let ekCalendars = store.calendars(for: .event)
|
||||
|
||||
let items: [CalendarListItem] = ekCalendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .birthday:
|
||||
typeString = "birthday"
|
||||
case .calDAV:
|
||||
typeString = "caldav"
|
||||
case .exchange:
|
||||
typeString = "exchange"
|
||||
case .local:
|
||||
typeString = "local"
|
||||
case .subscription:
|
||||
typeString = "subscription"
|
||||
@unknown default:
|
||||
typeString = "unknown"
|
||||
}
|
||||
return CalendarListItem(
|
||||
id: cal.calendarIdentifier,
|
||||
title: cal.title,
|
||||
source: sourceTitle,
|
||||
type: typeString
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Calendar helpers for events
|
||||
private func allCalendars(from store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .event)
|
||||
}
|
||||
|
||||
private func resolveCalendarForList(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> [EKCalendar] {
|
||||
// Return array of matching calendars (filtered)
|
||||
// Rules:
|
||||
// - if calendarId provided (stable ID), exact match only one; if unknown -> invalidRequest
|
||||
// - else if calendarTitle provided, exact title match; must be unique else invalidRequest (ambiguous) or unknown => invalidRequest
|
||||
// - else all calendars
|
||||
let calendars = allCalendars(from: store)
|
||||
|
||||
if let cid = calendarId, !cid.isEmpty {
|
||||
// ID wins
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == cid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
}
|
||||
|
||||
if let title = calendarTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// no filter -> all
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveCalendarForCreate(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> EKCalendar {
|
||||
// Create must not default to arbitrary.
|
||||
// If neither id nor title -> invalidRequest
|
||||
let calendars = allCalendars(from: store)
|
||||
|
||||
if let cid = calendarId, !cid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
|
||||
}
|
||||
// also check writable via allowsContentModifications; isImmutable also relevant but we use allowsContentModifications
|
||||
return found
|
||||
}
|
||||
|
||||
if let title = calendarTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// No calendar specified – per spec "no default first arbitrary calendar"
|
||||
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - Events list
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
// Permission: read requires full access
|
||||
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
|
||||
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveCalendarForList(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
|
||||
} catch let err as CalendarProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Calendar lookup failed")
|
||||
}
|
||||
|
||||
if targetCalendars.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
// EK predicate
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: targetCalendars)
|
||||
let ekEvents: [EKEvent] = store.events(matching: predicate)
|
||||
|
||||
// Map and sort deterministically, enforce overlap check (predicate already does but ensure)
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
var items: [CalendarEventItem] = []
|
||||
items.reserveCapacity(min(ekEvents.count, limit))
|
||||
|
||||
for ev in ekEvents {
|
||||
guard let evStart = ev.startDate, let evEnd = ev.endDate else { continue }
|
||||
// Enforce overlap (EventKit predicate should already overlap but safe)
|
||||
if evEnd < start || evStart > end { continue }
|
||||
|
||||
guard let cal = ev.calendar else { continue }
|
||||
let item = CalendarEventItem(
|
||||
id: ev.eventIdentifier ?? ev.calendarItemIdentifier,
|
||||
title: ev.title ?? "",
|
||||
start: iso.string(from: evStart),
|
||||
end: iso.string(from: evEnd),
|
||||
all_day: ev.isAllDay,
|
||||
calendar_id: cal.calendarIdentifier,
|
||||
calendar_title: cal.title,
|
||||
notes: ev.notes,
|
||||
location: ev.location
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort by start, then title, then id, then truncate to limit
|
||||
items.sort {
|
||||
if $0.start != $1.start { return $0.start < $1.start }
|
||||
if $0.title != $1.title { return $0.title < $1.title }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Event create
|
||||
func createEvent(
|
||||
title: String,
|
||||
start: Date,
|
||||
end: Date,
|
||||
allDay: Bool,
|
||||
notes: String?,
|
||||
location: String?,
|
||||
calendarId: String?,
|
||||
calendarTitle: String?
|
||||
) throws -> CalendarEventItem {
|
||||
// For create, we allow fullAccess, authorized, and writeOnly (since writeOnly permits creation)
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
break
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveCalendarForCreate(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
|
||||
} catch let err as CalendarProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Calendar lookup failed")
|
||||
}
|
||||
|
||||
// Validate start < end already done in dispatch, but double check
|
||||
if start >= end {
|
||||
throw CalendarProviderError.invalidRequest("start must occur before end")
|
||||
}
|
||||
|
||||
let ekEvent = EKEvent(eventStore: store)
|
||||
ekEvent.title = title
|
||||
ekEvent.startDate = start
|
||||
ekEvent.endDate = end
|
||||
ekEvent.isAllDay = allDay
|
||||
ekEvent.notes = notes
|
||||
ekEvent.location = location
|
||||
ekEvent.calendar = destination
|
||||
|
||||
do {
|
||||
try store.save(ekEvent, span: .thisEvent, commit: true)
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Failed to save event: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
return CalendarEventItem(
|
||||
id: ekEvent.eventIdentifier ?? ekEvent.calendarItemIdentifier,
|
||||
title: ekEvent.title ?? title,
|
||||
start: iso.string(from: ekEvent.startDate ?? start),
|
||||
end: iso.string(from: ekEvent.endDate ?? end),
|
||||
all_day: ekEvent.isAllDay,
|
||||
calendar_id: destination.calendarIdentifier,
|
||||
calendar_title: destination.title,
|
||||
notes: ekEvent.notes,
|
||||
location: ekEvent.location
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
// Explicit Contacts authorization provider – ONLY location allowed to call requestAccess(for:)
|
||||
enum ContactsBridgingError: Error, Equatable, Sendable {
|
||||
case timeout
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
struct ContactsMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw ContactsProviderError.unavailable("contacts authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsAuthorizationProvider: ContactsAuthorizationProviding {
|
||||
var bridge: ContactsMainRunLoopBridge = ContactsMainRunLoopBridge()
|
||||
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus {
|
||||
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch s {
|
||||
case .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func requestAccess() throws -> Bool {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
// MARK: - Contacts data models
|
||||
struct ContactListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let organization: String
|
||||
let modifiedAt: String
|
||||
}
|
||||
|
||||
struct ContactEmailLabelValue: Codable, Equatable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContactPhoneLabelValue: Codable, Equatable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContactDetailItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let firstName: String
|
||||
let lastName: String
|
||||
let organization: String
|
||||
let jobTitle: String
|
||||
let emails: [ContactEmailLabelValue]
|
||||
let phones: [ContactPhoneLabelValue]
|
||||
let modifiedAt: String
|
||||
}
|
||||
|
||||
struct ContactCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let organization: String
|
||||
}
|
||||
|
||||
// MARK: - Contacts provider errors
|
||||
enum ContactsProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
case notFound(String)
|
||||
}
|
||||
|
||||
// MARK: - Contact provider protocols
|
||||
protocol ContactsAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus
|
||||
func requestAccess() throws -> Bool
|
||||
}
|
||||
|
||||
enum ContactsAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol ContactsSearchProviding: Sendable {
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem]
|
||||
}
|
||||
|
||||
protocol ContactsReadProviding: Sendable {
|
||||
func readContact(id: String) throws -> ContactDetailItem
|
||||
}
|
||||
|
||||
protocol ContactsCreateProviding: Sendable {
|
||||
func createContact(
|
||||
firstName: String?,
|
||||
lastName: String?,
|
||||
organization: String?,
|
||||
jobTitle: String?,
|
||||
note: String?,
|
||||
email: ContactEmailLabelValue?,
|
||||
phone: ContactPhoneLabelValue?
|
||||
) throws -> ContactCreateResult
|
||||
}
|
||||
|
||||
protocol FullContactsProviding: ContactsSearchProviding, ContactsReadProviding, ContactsCreateProviding {}
|
||||
|
||||
// MARK: - Production Contacts providers
|
||||
|
||||
private func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
|
||||
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let squashed = combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
|
||||
return squashed
|
||||
}
|
||||
|
||||
struct ContactsSearchProvider: ContactsSearchProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
try requireAuthorized()
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor
|
||||
]
|
||||
let fetchRequest = CNContactFetchRequest(keysToFetch: keys)
|
||||
var items: [ContactListItem] = []
|
||||
let q = query?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let shouldFilter = !(q?.isEmpty ?? true)
|
||||
|
||||
do {
|
||||
try store.enumerateContacts(with: fetchRequest) { contact, stop in
|
||||
// Production crash fix: the formatter accesses unfetched
|
||||
// properties like middleName (CNPropertyNotFetchedException is ObjC exception
|
||||
// and is uncatchable in Swift). Construct deterministically from only fetched keys.
|
||||
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
|
||||
let org = contact.organizationName
|
||||
if shouldFilter, let lowerQ = q {
|
||||
let haystack = "\(fullName)\n\(org)".lowercased()
|
||||
if !haystack.contains(lowerQ) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Deterministic: do not leak current time; nil modification date yields stable empty string
|
||||
let modifiedStr: String = ""
|
||||
items.append(ContactListItem(
|
||||
id: contact.identifier,
|
||||
name: fullName,
|
||||
organization: org,
|
||||
modifiedAt: modifiedStr
|
||||
))
|
||||
if items.count >= limit {
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
} catch let err as NSError {
|
||||
// Permission or other failure
|
||||
if err.domain == CNErrorDomain {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
// Deterministic: sorted by name then org then id
|
||||
items.sort {
|
||||
if $0.name != $1.name { return $0.name < $1.name }
|
||||
if $0.organization != $1.organization { return $0.organization < $1.organization }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsReadProvider: ContactsReadProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw ContactsProviderError.invalidRequest("Missing required argument: id")
|
||||
}
|
||||
try requireAuthorized()
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactJobTitleKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor
|
||||
]
|
||||
do {
|
||||
let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys)
|
||||
// Same crash root cause as search: the system formatter can touch unfetched keys.
|
||||
// Use only the keys we fetched to avoid ObjC CNPropertyNotFetchedException.
|
||||
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
|
||||
// Deterministic: nil modification date yields stable empty string, not current time
|
||||
let modifiedStr = ""
|
||||
|
||||
let emails = contact.emailAddresses.map { labeled in
|
||||
ContactEmailLabelValue(
|
||||
label: CNLabeledValue<NSString>.localizedString(forLabel: labeled.label ?? ""),
|
||||
value: labeled.value as String
|
||||
)
|
||||
}
|
||||
let phones = contact.phoneNumbers.map { labeled in
|
||||
ContactPhoneLabelValue(
|
||||
label: CNLabeledValue<CNPhoneNumber>.localizedString(forLabel: labeled.label ?? ""),
|
||||
value: labeled.value.stringValue
|
||||
)
|
||||
}
|
||||
return ContactDetailItem(
|
||||
id: contact.identifier,
|
||||
name: fullName,
|
||||
firstName: contact.givenName,
|
||||
lastName: contact.familyName,
|
||||
organization: contact.organizationName,
|
||||
jobTitle: contact.jobTitle,
|
||||
emails: emails,
|
||||
phones: phones,
|
||||
modifiedAt: modifiedStr
|
||||
)
|
||||
} catch let err as CNError {
|
||||
if err.code == .recordDoesNotExist {
|
||||
throw ContactsProviderError.notFound("Contact not found: \(id)")
|
||||
}
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
} catch let err as ContactsProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw ContactsProviderError.unavailable(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsCreateProvider: ContactsCreateProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func createContact(
|
||||
firstName: String?,
|
||||
lastName: String?,
|
||||
organization: String?,
|
||||
jobTitle: String?,
|
||||
note: String?,
|
||||
email: ContactEmailLabelValue?,
|
||||
phone: ContactPhoneLabelValue?
|
||||
) throws -> ContactCreateResult {
|
||||
try requireAuthorized()
|
||||
let mutable = CNMutableContact()
|
||||
mutable.givenName = firstName ?? ""
|
||||
mutable.familyName = lastName ?? ""
|
||||
mutable.organizationName = organization ?? ""
|
||||
mutable.jobTitle = jobTitle ?? ""
|
||||
if let n = note {
|
||||
mutable.note = n
|
||||
}
|
||||
if let em = email {
|
||||
mutable.emailAddresses = [CNLabeledValue(label: em.label.isEmpty ? CNLabelWork : em.label, value: em.value as NSString)]
|
||||
}
|
||||
if let ph = phone {
|
||||
mutable.phoneNumbers = [CNLabeledValue(label: ph.label.isEmpty ? CNLabelPhoneNumberMobile : ph.label, value: CNPhoneNumber(stringValue: ph.value))]
|
||||
}
|
||||
let store = CNContactStore()
|
||||
let saveRequest = CNSaveRequest()
|
||||
saveRequest.add(mutable, toContainerWithIdentifier: nil)
|
||||
do {
|
||||
try store.execute(saveRequest)
|
||||
} catch let err as NSError {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
// CNMutableContact doesn't trigger key-fetch checks; safe deterministic construction still.
|
||||
// Prefer only locally available strings – avoids future formatter regressions.
|
||||
let fullName = displayNameFromFetchedParts(givenName: mutable.givenName, familyName: mutable.familyName)
|
||||
return ContactCreateResult(
|
||||
id: mutable.identifier,
|
||||
name: fullName,
|
||||
organization: organization ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// ONLY place allowed to call requestFullAccessToReminders
|
||||
enum RemindersAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case writeOnly
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol RemindersAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus
|
||||
func requestFullAccess() throws -> Bool
|
||||
}
|
||||
|
||||
struct RemindersMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw RemindersProviderError.unavailable("reminders authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct RemindersAuthorizationProvider: RemindersAuthorizationProviding {
|
||||
var bridge: RemindersMainRunLoopBridge = RemindersMainRunLoopBridge()
|
||||
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus {
|
||||
let s = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch s {
|
||||
case .fullAccess, .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
case .writeOnly:
|
||||
return .writeOnly
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func requestFullAccess() throws -> Bool {
|
||||
if #available(macOS 14.0, *) {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for macOS 13: requestAccess(to: .reminder)
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestAccess(to: .reminder) { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// MARK: - Reminders data models
|
||||
struct ReminderListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let source: String
|
||||
let type: String // local/caldav/exchange/etc
|
||||
}
|
||||
|
||||
struct ReminderItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let completed: Bool
|
||||
let due: String? // ISO8601 or nil
|
||||
let priority: Int // 0-9 (EKReminderPriority mapped)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case list_id
|
||||
case list_title
|
||||
case title
|
||||
case notes
|
||||
case completed
|
||||
case due
|
||||
case priority
|
||||
}
|
||||
}
|
||||
|
||||
struct ReminderCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
enum RemindersProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
|
||||
var isPermission: Bool {
|
||||
if case .permissionRequired = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider protocols
|
||||
protocol RemindersListsProviding: Sendable {
|
||||
func listReminderLists() throws -> [ReminderListItem]
|
||||
}
|
||||
|
||||
protocol RemindersListProviding: Sendable {
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem]
|
||||
}
|
||||
|
||||
protocol RemindersCreateProviding: Sendable {
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult
|
||||
}
|
||||
|
||||
protocol FullRemindersProviding: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {}
|
||||
|
||||
// MARK: - Production EventKit provider (scoped read non-prompt; create requires explicit list)
|
||||
struct EventKitRemindersProvider: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {
|
||||
|
||||
// Shared auth check – only checks status, never prompts
|
||||
private func requireAuthorizedForRead() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
// writeOnly does not permit listing reminders per EventKit; treat as permission_required for list operations
|
||||
throw RemindersProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForCreate() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func allReminderCalendars(store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .reminder)
|
||||
}
|
||||
|
||||
private func resolveForList(store: EKEventStore, listId: String?, listTitle: String?) throws -> [EKCalendar] {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == lid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveForCreate(store: EKEventStore, listId: String?, listTitle: String?) throws -> EKCalendar {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == lid }) else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
throw RemindersProviderError.invalidRequest("Reminders list must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - lists
|
||||
func listReminderLists() throws -> [ReminderListItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
return calendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .local: typeString = "local"
|
||||
case .calDAV: typeString = "caldav"
|
||||
case .exchange: typeString = "exchange"
|
||||
case .subscription: typeString = "subscription"
|
||||
case .birthday: typeString = "birthday"
|
||||
@unknown default: typeString = "unknown"
|
||||
}
|
||||
return ReminderListItem(id: cal.calendarIdentifier, title: cal.title, source: sourceTitle, type: typeString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - list reminders
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveForList(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders lookup failed")
|
||||
}
|
||||
if targetCalendars.isEmpty { return [] }
|
||||
|
||||
let predicate = store.predicateForReminders(in: targetCalendars)
|
||||
var fetched: [EKReminder] = []
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var fetchError: Error? = nil
|
||||
store.fetchReminders(matching: predicate) { rems in
|
||||
fetched = rems ?? []
|
||||
sem.signal()
|
||||
}
|
||||
// fetchReminders is async on newer APIs? In EventKit even on macOS 13 fetchReminders matching is async via completion.
|
||||
// Wait bounded 10s
|
||||
let waitRes = sem.wait(timeout: .now() + 10)
|
||||
if waitRes == .timedOut {
|
||||
throw RemindersProviderError.unavailable("Reminders fetch timed out")
|
||||
}
|
||||
if let err = fetchError {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
var items: [ReminderItem] = []
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
for rem in fetched {
|
||||
let isCompleted = rem.isCompleted
|
||||
if let filterCompleted = completed, filterCompleted != isCompleted { continue }
|
||||
guard let cal = rem.calendar else { continue }
|
||||
let dueStr: String?
|
||||
if let comps = rem.dueDateComponents, let d = Calendar.current.date(from: comps) {
|
||||
dueStr = iso.string(from: d)
|
||||
} else {
|
||||
dueStr = nil
|
||||
}
|
||||
let item = ReminderItem(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: cal.calendarIdentifier,
|
||||
list_title: cal.title,
|
||||
title: rem.title ?? "",
|
||||
notes: rem.notes,
|
||||
completed: isCompleted,
|
||||
due: dueStr,
|
||||
priority: rem.priority
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort: due, title, id
|
||||
items.sort {
|
||||
let due0 = $0.due ?? ""
|
||||
let due1 = $1.due ?? ""
|
||||
if due0 != due1 { return due0 < due1 }
|
||||
if $0.title != $1.title { return $0.title < $1.title }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - create
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
try requireAuthorizedForCreate()
|
||||
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RemindersProviderError.invalidRequest("Missing required argument: title")
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveForCreate(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders list lookup failed")
|
||||
}
|
||||
|
||||
let rem = EKReminder(eventStore: store)
|
||||
rem.title = title
|
||||
rem.calendar = destination
|
||||
rem.notes = notes
|
||||
if let p = priority {
|
||||
rem.priority = p
|
||||
}
|
||||
if let dueDate = due {
|
||||
let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: dueDate)
|
||||
rem.dueDateComponents = comps
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(rem, commit: true)
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Failed to save reminder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return ReminderCreateResult(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: destination.calendarIdentifier,
|
||||
list_title: destination.title,
|
||||
title: rem.title ?? title
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
// MARK: - Pure, testable validation types
|
||||
|
||||
struct LStatInfo {
|
||||
var uid: uid_t
|
||||
var mode: mode_t // full st_mode
|
||||
var isSymlink: Bool
|
||||
var isDir: Bool
|
||||
var exists: Bool
|
||||
}
|
||||
|
||||
/// Authoritative result of an lstat call: present, absent (ENOENT), or failed with errno.
|
||||
enum LStatResult {
|
||||
case present(LStatInfo)
|
||||
case absent
|
||||
case failed(errnoCode: Int32)
|
||||
}
|
||||
|
||||
/// New authoritative provider that never swallows errors.
|
||||
typealias LStatResultProvider = (String) -> LStatResult
|
||||
|
||||
/// Legacy optional provider kept for existing pure tests: nil == absent (ENOENT).
|
||||
/// New code should use LStatResultProvider.
|
||||
typealias LStatProvider = (String) -> LStatInfo?
|
||||
|
||||
// MARK: - Trust boundary documentation
|
||||
|
||||
/*
|
||||
Trust boundary for socket parent-chain validation – tiered model:
|
||||
|
||||
Tiers (evaluated per existing component, fail-closed on lstat errors):
|
||||
|
||||
1) Platform-trusted ancestors – explicit allowlist ONLY:
|
||||
"/", "/Users", "/private", "/var", "/tmp",
|
||||
"/private/tmp", "/var/tmp", "/private/var", "/private/var/tmp"
|
||||
Hard-coded in `platformTrustedRootPaths`.
|
||||
- lstat non-symlink dir (except /var and /tmp which are known macOS symlinks and allowed as symlink)
|
||||
- uid 0
|
||||
- non-tmp platform paths ("/", "/Users", "/private", "/private/var"): no group/other write (mode & 022 == 0), 0755 allowed
|
||||
- tmp platform paths ("/private/tmp", "/var/tmp", "/private/var/tmp", plus "/tmp","/var" as dirs): uid 0 only, may be 1777 sticky
|
||||
|
||||
2) User-owned intermediate ancestors (e.g. $HOME = /Users/<user>, ~/Library, ~/Library/Application Support, ...):
|
||||
- not a symlink
|
||||
- a directory
|
||||
- owned by current uid (getuid())
|
||||
- no group/other *write* (mode & 022 == 0)
|
||||
-> allows 0700, 0750, 0755 (standard macOS home is 0750 = rwxr-x---) but rejects 0770/0777 or any writable bit
|
||||
Reason: home 0750 is default on some installs; privacy is still enforced by tier 3.
|
||||
|
||||
3) Dedicated runtime socket parent – the immediate parent dir of the socket (e.g. .../reyna-cli/privacy):
|
||||
- not a symlink
|
||||
- a directory
|
||||
- owned by current uid
|
||||
- strictly no group/other bits at all (mode & 077 == 0) => 0700 family only, rejects 0750/0755
|
||||
+ socket file itself must be 0600 (enforced in SocketServer bind/chmod)
|
||||
|
||||
- Never trust arbitrary root-owned intermediate paths outside explicit allowlist.
|
||||
|
||||
This is the fix for: home 0750 was incorrectly rejected (validator required 0700 for all user components),
|
||||
causing "Refusing socket path: parent component /Users/<user> has group/other permissions: 750".
|
||||
Now tier 2 allows 0750 for home/intermediates, tier 3 keeps 0700 for the privacy dir.
|
||||
*/
|
||||
|
||||
let platformTrustedRootPaths: Set<String> = [
|
||||
"/",
|
||||
"/Users",
|
||||
"/private",
|
||||
"/var",
|
||||
"/tmp",
|
||||
"/private/tmp",
|
||||
"/var/tmp",
|
||||
"/private/var",
|
||||
"/private/var/tmp"
|
||||
]
|
||||
|
||||
// Symlink-allowed platform paths – macOS ships /tmp -> private/tmp and /var -> private/var
|
||||
let platformSymlinkAllowedPaths: Set<String> = [
|
||||
"/tmp",
|
||||
"/var"
|
||||
]
|
||||
|
||||
func isRootTrustedPath(_ p: String) -> Bool {
|
||||
return platformTrustedRootPaths.contains(p)
|
||||
}
|
||||
|
||||
func isSymlinkAllowedPlatformPath(_ p: String) -> Bool {
|
||||
return platformSymlinkAllowedPaths.contains(p)
|
||||
}
|
||||
|
||||
func rejectIfDotComponentsPure(in socketPath: String) throws {
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
for comp in url.pathComponents {
|
||||
if comp == "." || comp == ".." {
|
||||
throw NSError(domain: "SocketServer", code: 20, userInfo: [NSLocalizedDescriptionKey: "Socket path must not contain '.' or '..' components: \(socketPath)"])
|
||||
}
|
||||
}
|
||||
let standardized = url.standardized.path
|
||||
if standardized != socketPath {
|
||||
let stdComps = URL(fileURLWithPath: standardized).pathComponents
|
||||
let origComps = url.pathComponents
|
||||
if stdComps != origComps {
|
||||
for c in stdComps {
|
||||
if c == "." || c == ".." {
|
||||
throw NSError(domain: "SocketServer", code: 21, userInfo: [NSLocalizedDescriptionKey: "Socket path contains invalid components after standardization"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core validation using authoritative provider
|
||||
|
||||
private func lstatInfo(from st: stat) -> LStatInfo {
|
||||
let isSymlink = (st.st_mode & S_IFMT) == S_IFLNK
|
||||
let isDir = (st.st_mode & S_IFMT) == S_IFDIR
|
||||
// For symlink itself, isDir should be false so caller can distinguish
|
||||
return LStatInfo(uid: st.st_uid, mode: st.st_mode, isSymlink: isSymlink, isDir: isSymlink ? false : isDir, exists: true)
|
||||
}
|
||||
|
||||
/// Single-component authoritative validator reused by both chain validation and ensureParentDirectories.
|
||||
/// This is the sole place that encodes trusted-root vs user-owned policy.
|
||||
/// Tiers:
|
||||
/// 1) platform trusted (explicit allowlist) – uid 0, dir, no g/o write except tmp exemptions, symlink allowed only for /tmp / /var
|
||||
/// 2) user-owned intermediate ancestors – uid current, not symlink, dir, mode & 022 == 0 (allows 0700/0750/0755, rejects writable)
|
||||
/// 3) dedicated runtime parent (immediate socket parent) – uid current, not symlink, dir, mode & 077 == 0 (requires 0700 family)
|
||||
///
|
||||
/// - For symlink: only /tmp and /var may be symlink (macOS aliases), else reject.
|
||||
/// - For non-dir file: always reject (including /tmp /var as dir target).
|
||||
func validateSingleLStatInfoOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
if info.isSymlink {
|
||||
if isSymlinkAllowedPlatformPath(path) {
|
||||
return
|
||||
}
|
||||
throw NSError(domain: "SocketServer", code: 23, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) is a symlink"])
|
||||
}
|
||||
if !info.isDir {
|
||||
// A regular file (or other non-dir) at any parent component, including /tmp /var, must reject
|
||||
throw NSError(domain: "SocketServer", code: 10, userInfo: [NSLocalizedDescriptionKey: "Parent path exists but is not a directory: \(path)"])
|
||||
}
|
||||
if path == "/" || isRootTrustedPath(path) {
|
||||
if path == "/tmp" || path == "/var" {
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if path == "/private/tmp" || path == "/var/tmp" || path == "/private/var/tmp" {
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
if (info.mode & 0o022) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(info.mode & 0o777, radix: 8))"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.uid != currentUID {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
let perms = info.mode & 0o777
|
||||
if isDedicatedRuntimeParent {
|
||||
// Tier 3: dedicated runtime must be exactly 0700 family – no group/other bits
|
||||
if (perms & 0o077) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
|
||||
}
|
||||
} else {
|
||||
// Tier 2: intermediate user-owned – no group/other write (allows 0750/0755, rejects 0770/0777)
|
||||
if (perms & 0o022) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateParentChainPureResultProvider(socketPath: String, currentUID: uid_t, provider: LStatResultProvider) throws {
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
let parentPath = parentURL.path
|
||||
if parentPath.isEmpty || parentPath == "/" { return }
|
||||
|
||||
let comps = parentURL.pathComponents // starts with "/"
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
let result = provider(cur)
|
||||
switch result {
|
||||
case .absent:
|
||||
continue
|
||||
case .failed(let errnoCode):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for parent component \(cur): \(String(cString: strerror(errnoCode)))"])
|
||||
case .present(let info):
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure parent-chain validator with injectable lstat and uid (legacy nil==ENOENT shim).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - socketPath: absolute socket path
|
||||
/// - currentUID: uid of current process
|
||||
/// - provider: returns LStatInfo? (nil if ENOENT, else info). Must use lstat, not stat.
|
||||
/// - Throws: on policy violation
|
||||
func validateParentChainPure(socketPath: String, currentUID: uid_t, provider: LStatProvider) throws {
|
||||
// Adapt legacy optional provider into authoritative result provider
|
||||
let adapted: LStatResultProvider = { path in
|
||||
if let info = provider(path) {
|
||||
return .present(info)
|
||||
} else {
|
||||
return .absent
|
||||
}
|
||||
}
|
||||
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: adapted)
|
||||
}
|
||||
|
||||
// MARK: - Live lstat adapter
|
||||
|
||||
/// Authoritative live lstat that never swallows non-ENOENT errors.
|
||||
func liveLStatResultProvider(path: String) -> LStatResult {
|
||||
var st = stat()
|
||||
if lstat(path, &st) != 0 {
|
||||
if errno == ENOENT { return .absent }
|
||||
return .failed(errnoCode: errno)
|
||||
}
|
||||
return .present(lstatInfo(from: st))
|
||||
}
|
||||
|
||||
/// Legacy optional lstat provider. Now fail-closed: returns nil ONLY for ENOENT, and for
|
||||
/// other errors returns a present but invalid sentinel that will cause validation to reject
|
||||
/// (never treated as missing). Prefer liveLStatResultProvider.
|
||||
func liveLStatProvider(path: String) -> LStatInfo? {
|
||||
switch liveLStatResultProvider(path: path) {
|
||||
case .absent:
|
||||
return nil
|
||||
case .present(let info):
|
||||
return info
|
||||
case .failed:
|
||||
// Fail-closed sentinel: not a directory, wrong uid, triggers rejection if misused directly
|
||||
// We return an info that will be rejected as non-directory
|
||||
return LStatInfo(uid: uid_t.max, mode: 0, isSymlink: false, isDir: false, exists: true)
|
||||
}
|
||||
}
|
||||
|
||||
func validateExistingParentChainLive(for socketPath: String) throws {
|
||||
let currentUID = getuid()
|
||||
// Single authoritative scan using liveLStatResultProvider; no pre-scan duplicate, no nil-swallow
|
||||
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: { liveLStatResultProvider(path: $0) })
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import CSignalSupport
|
||||
|
||||
// Pure decision extracted for unit testing.
|
||||
func isPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
|
||||
return peerUID == currentUID
|
||||
}
|
||||
|
||||
// MARK: - Path validation
|
||||
// Trust boundary: see SocketPathValidation.swift for full documentation.
|
||||
// Tiered model:
|
||||
// 1) platform allowlist root-owned (uid 0, no g/o write except tmp)
|
||||
// 2) user-owned intermediates: uid current, no g/o *write* (mode & 022 == 0) -> allows 0700/0750/0755, rejects 0770/0777
|
||||
// 3) dedicated runtime (immediate socket parent): uid current, mode & 077 == 0 -> requires 0700 family only.
|
||||
// Socket itself 0600.
|
||||
|
||||
// MARK: - Reused single-component validator (authoritative)
|
||||
// NOTE: this is the ONLY place allowed to decide if an existing component is safe.
|
||||
// It must stay in sync with validateParentChainPureResultProvider logic.
|
||||
func validateExistingComponentLiveOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
// Centralized call to shared validation in SocketPathValidation
|
||||
try validateSingleLStatInfoOrThrow(path: path, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicatedRuntimeParent)
|
||||
}
|
||||
|
||||
func ensureParentDirectories(for socketPath: String) throws {
|
||||
// Authoritative validation reused; fail-closed on lstat errors
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
try validateExistingParentChainLive(for: socketPath)
|
||||
|
||||
let fm = FileManager.default
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
let parent = url.deletingLastPathComponent()
|
||||
let parentPath = parent.path
|
||||
if parentPath.isEmpty { return }
|
||||
|
||||
let comps = parent.pathComponents
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
// Create missing component with 0700 – privacy preserving. Even intermediates now get 0700.
|
||||
do {
|
||||
try fm.createDirectory(atPath: cur, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
chmod(cur, 0o700)
|
||||
} catch {
|
||||
// mkdir race: re-lstat and revalidate rather than assuming missing
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
throw error
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
do {
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
// Reuse authoritative single-component validator (no duplicated policy)
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safeUnlinkIfStaleSocket(at path: String) throws {
|
||||
var st = stat()
|
||||
let r = lstat(path, &st)
|
||||
if r != 0 {
|
||||
if errno == ENOENT { return }
|
||||
throw NSError(domain: "SocketServer", code: 11, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
let isSock = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
if !isSock {
|
||||
throw NSError(domain: "SocketServer", code: 12, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: \(path) exists and is not a socket"])
|
||||
}
|
||||
if st.st_uid != getuid() {
|
||||
throw NSError(domain: "SocketServer", code: 13, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: socket at \(path) not owned by current uid"])
|
||||
}
|
||||
if unlink(path) != 0 && errno != ENOENT {
|
||||
throw NSError(domain: "SocketServer", code: 14, userInfo: [NSLocalizedDescriptionKey: "Failed to unlink stale socket \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
}
|
||||
|
||||
private let kMaxRequestBytes = 64 * 1024
|
||||
private let kClientRecvTimeoutSec = 5
|
||||
|
||||
private func makeErrorResponse(id: String, code: String, message: String) -> Data? {
|
||||
let err = ErrorPayload(code: code, message: message)
|
||||
let resp = Response(id: id, ok: false, result: nil, error: err)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
}
|
||||
|
||||
private func processRequestData(_ data: Data) -> Data? {
|
||||
if data.isEmpty { return nil }
|
||||
if let s = String(data: data, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
if let req = try? JSONDecoder().decode(Request.self, from: data) {
|
||||
let resp = dispatch(request: req)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
} else {
|
||||
let recovered = extractRecoverableId(from: data) ?? ""
|
||||
return makeErrorResponse(id: recovered, code: "invalid_request", message: "Invalid request JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func runSocketServer(socketPath: String) -> Never {
|
||||
if !socketPath.hasPrefix("/") {
|
||||
fputs("error: --socket path must be absolute: \(socketPath)\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
if socketPath.utf8.count >= 104 {
|
||||
fputs("error: --socket path too long\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
|
||||
do {
|
||||
try ensureParentDirectories(for: socketPath)
|
||||
try safeUnlinkIfStaleSocket(at: socketPath)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
Darwin.exit(3)
|
||||
}
|
||||
|
||||
// Store for signal cleanup in C
|
||||
socketPath.withCString { cStr in
|
||||
reyna_store_socket_path(cStr)
|
||||
}
|
||||
reyna_install_signal_handlers()
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
if fd < 0 {
|
||||
fputs("error: socket() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
Darwin.exit(4)
|
||||
}
|
||||
_ = fcntl(fd, F_SETFD, FD_CLOEXEC)
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in
|
||||
strncpy(p, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oldMask = umask(0o077)
|
||||
let bindRes = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
bind(fd, saddr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
umask(oldMask)
|
||||
|
||||
if bindRes != 0 {
|
||||
fputs("error: bind() \(socketPath): \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
Darwin.exit(5)
|
||||
}
|
||||
|
||||
if chmod(socketPath, 0o600) != 0 {
|
||||
fputs("warning: chmod 0600 failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
}
|
||||
|
||||
if listen(fd, 32) != 0 {
|
||||
fputs("error: listen() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(6)
|
||||
}
|
||||
|
||||
// Main loop: one client at a time, one request per connection
|
||||
while true {
|
||||
let cfd = accept(fd, nil, nil)
|
||||
if cfd < 0 {
|
||||
if errno == EINTR { continue }
|
||||
fputs("error: accept() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
break
|
||||
}
|
||||
|
||||
// --- Peer credential check (macOS getpeereid) ---
|
||||
var peerEuid: uid_t = 0
|
||||
var peerEgid: gid_t = 0
|
||||
if getpeereid(cfd, &peerEuid, &peerEgid) != 0 {
|
||||
// If we cannot obtain peer credentials, reject
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
if !isPeerAuthorized(peerUID: peerEuid, currentUID: getuid()) {
|
||||
if let d = makeErrorResponse(id: "", code: "unauthorized", message: "Peer UID not authorized") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set receive timeout to bound slow clients
|
||||
var tv = timeval()
|
||||
tv.tv_sec = kClientRecvTimeoutSec
|
||||
tv.tv_usec = 0
|
||||
setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
|
||||
var buf = Data()
|
||||
buf.reserveCapacity(8192)
|
||||
var tmp = [UInt8](repeating: 0, count: 4096)
|
||||
var exceeded = false
|
||||
var gotAny = false
|
||||
var timedOut = false
|
||||
|
||||
// Poll-based timeout additionally enforced
|
||||
while true {
|
||||
// Wait for data with timeout
|
||||
var pfd = pollfd(fd: cfd, events: Int16(POLLIN), revents: 0)
|
||||
let pollTimeoutMs: Int32 = Int32(kClientRecvTimeoutSec * 1000)
|
||||
let pr = poll(&pfd, 1, pollTimeoutMs)
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
break
|
||||
}
|
||||
if pr == 0 {
|
||||
// timeout
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
let n = recv(cfd, &tmp, tmp.count, 0)
|
||||
if n < 0 {
|
||||
if errno == EINTR { continue }
|
||||
if errno == EWOULDBLOCK || errno == EAGAIN {
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if n == 0 { break }
|
||||
gotAny = true
|
||||
|
||||
// Oversized handling with newline-in-same-chunk fix
|
||||
if buf.count + n > kMaxRequestBytes {
|
||||
// Look for newline in the new chunk
|
||||
var newlineIdx: Int? = nil
|
||||
for i in 0..<n {
|
||||
if tmp[i] == 0x0A {
|
||||
newlineIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if let nl = newlineIdx {
|
||||
// First line length = buf.count + nl (excluding newline char itself)
|
||||
let firstLineLen = buf.count + nl
|
||||
if firstLineLen <= kMaxRequestBytes {
|
||||
// Accept up to newline and ignore rest
|
||||
buf.append(contentsOf: tmp[0..<nl])
|
||||
// Break to process – we have a complete line within limit
|
||||
break
|
||||
} else {
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// No newline in this chunk and would exceed -> oversized
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.append(contentsOf: tmp[0..<n])
|
||||
if buf.contains(0x0A) { break }
|
||||
}
|
||||
|
||||
if timedOut {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if !gotAny && !exceeded {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if exceeded {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
let lineData: Data
|
||||
if let idx = buf.firstIndex(of: 0x0A) {
|
||||
lineData = buf.prefix(upTo: idx)
|
||||
} else {
|
||||
lineData = buf
|
||||
}
|
||||
|
||||
if lineData.count > kMaxRequestBytes {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if let resp = processRequestData(lineData) {
|
||||
_ = resp.withUnsafeBytes { p in
|
||||
var sent = 0
|
||||
while sent < resp.count {
|
||||
let n = send(cfd, p.baseAddress!.advanced(by: sent), resp.count - sent, 0)
|
||||
if n <= 0 { break }
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
}
|
||||
close(cfd)
|
||||
}
|
||||
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - System info data models (read-only, no TCC)
|
||||
|
||||
struct SystemInfoItem: Codable, Equatable, Sendable {
|
||||
let macos_version: String
|
||||
let build: String?
|
||||
let uname: String?
|
||||
let hw_model: String?
|
||||
let cpu_brand: String?
|
||||
let is_macos_26_plus: Bool?
|
||||
let speech_analyzer_expected: String?
|
||||
}
|
||||
|
||||
struct SpeechApiStatusItem: Codable, Equatable, Sendable {
|
||||
let system: SystemInfoItem
|
||||
let swift_availability: [String: String]? // minimal stub
|
||||
let conclusion: String
|
||||
}
|
||||
|
||||
enum SystemProviderError: Error, Equatable, Sendable {
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
protocol SystemInfoProviding: Sendable {
|
||||
func getSystemInfo() throws -> SystemInfoItem
|
||||
func getSpeechApiStatus() throws -> SpeechApiStatusItem
|
||||
}
|
||||
|
||||
// Production provider - reads sw_vers / uname / sysctl, no permission needed
|
||||
struct ProductionSystemInfoProvider: SystemInfoProviding {
|
||||
func getSystemInfo() throws -> SystemInfoItem {
|
||||
let ver = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let verString = "\(ver.majorVersion).\(ver.minorVersion).\(ver.patchVersion)"
|
||||
// Best-effort hw model / cpu / uname without spawning processes in Swift? Use sysctl/mib.
|
||||
var hwModel: String? = nil
|
||||
var cpuBrand: String? = nil
|
||||
var unameStr: String? = nil
|
||||
// Use ProcessInfo hostName as fallback for minimal
|
||||
// For hw.model, use sysctlbyname where possible - but keep simple fallback to avoid C interop complexity
|
||||
// We'll try reading via sysctl nametable via Foundation
|
||||
#if os(macOS)
|
||||
hwModel = sysctlString("hw.model")
|
||||
cpuBrand = sysctlString("machdep.cpu.brand_string")
|
||||
#endif
|
||||
let m = ver.majorVersion
|
||||
let is26 = m >= 26
|
||||
let expected = is26 ? "likely available (macOS 26+)" : "not available - requires macOS 26+"
|
||||
return SystemInfoItem(
|
||||
macos_version: verString,
|
||||
build: nil,
|
||||
uname: unameStr,
|
||||
hw_model: hwModel,
|
||||
cpu_brand: cpuBrand,
|
||||
is_macos_26_plus: is26,
|
||||
speech_analyzer_expected: expected
|
||||
)
|
||||
}
|
||||
|
||||
func getSpeechApiStatus() throws -> SpeechApiStatusItem {
|
||||
let info = try getSystemInfo()
|
||||
let major = ProcessInfo.processInfo.operatingSystemVersion.majorVersion
|
||||
let conclusion: String
|
||||
if major >= 26 {
|
||||
conclusion = "macOS 26+ detected — SpeechAnalyzer/SpeechTranscriber should be available per Apple docs."
|
||||
} else {
|
||||
conclusion = "macOS \(info.macos_version) detected — SpeechAnalyzer requires macOS 26+."
|
||||
}
|
||||
return SpeechApiStatusItem(system: info, swift_availability: nil, conclusion: conclusion)
|
||||
}
|
||||
|
||||
private func sysctlString(_ name: String) -> String? {
|
||||
var size = 0
|
||||
let rc1 = sysctlbyname(name, nil, &size, nil, 0)
|
||||
if rc1 != 0 { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
let rc2 = sysctlbyname(name, &buffer, &size, nil, 0)
|
||||
if rc2 != 0 { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user