diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index dcdec29..feab97c 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.6.1; + MARKETING_VERSION = 1.7.0; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -440,7 +440,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.6.1; + MARKETING_VERSION = 1.7.0; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index 511196d..bca1e5e 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -1311,6 +1311,9 @@ struct ContentView: View { tryRunCLIIfPresent() enforceNoSourceInTargets() handlePendingMenuBarSyncIfNeeded() + if !isCLIRun { + appController.bootstrapBackgroundSync() + } } .onDisappear { appController.setMainWindowVisible(false) @@ -1352,6 +1355,7 @@ struct ContentView: View { .onChange(of: routes) { _ in saveSettingsToDefaults() handlePendingMenuBarSyncIfNeeded() + appController.armAutoSyncIfPossible() } } diff --git a/BusyMirror/MenuBarSupport.swift b/BusyMirror/MenuBarSupport.swift index a4b59db..644eefa 100644 --- a/BusyMirror/MenuBarSupport.swift +++ b/BusyMirror/MenuBarSupport.swift @@ -1,5 +1,7 @@ import SwiftUI import AppKit +import EventKit +import ServiceManagement enum BusyMirrorSceneID { static let mainWindow = "main-window" @@ -33,6 +35,187 @@ final class BusyMirrorAppController: ObservableObject { NSApp.activate(ignoringOtherApps: true) openWindow(id: BusyMirrorSceneID.mainWindow) } + + // MARK: - Event-driven background sync + // + // Owned here (not by ContentView) because this controller lives for the + // whole process, independent of the main window's lifecycle. ContentView's + // own EKEventStoreChanged observer is torn down in .onDisappear when the + // window closes — fine for keeping the UI's calendar list fresh while + // open, but useless for background operation, which is the entire point + // of replacing the old hourly launchd poll. This runs regardless of + // whether the window is open, closed, or never opened this session. + + private let backgroundStore = EKEventStore() + private var storeObserver: NSObjectProtocol? + private var wakeObserver: NSObjectProtocol? + private var debounceTask: Task? + private var fallbackTask: Task? + private var hasArmedAutoSync = false + + private let settingsDefaultsKey = "settings.v2" + private let legacyRoutesDefaultsKey = "routes.v1" + private let launchAgentLabel = "com.cqrenet.BusyMirror.saved-routes" + + private var launchAgentURL: URL { + let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) + return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false) + } + + /// Call once at app launch. Only activates if calendar access has already + /// been granted in a prior session — never prompts on its own, since a + /// permission dialog with no window open would be confusing. First-run + /// consent stays ContentView's job. + func bootstrapBackgroundSync() { + let status = EKEventStore.authorizationStatus(for: .event) + let hasAccess: Bool + if #available(macOS 14.0, *) { + hasAccess = status == .fullAccess + } else { + hasAccess = status == .authorized + } + guard hasAccess else { return } + armAutoSyncIfPossible() + } + + /// Re-check whether auto-sync should activate. Safe to call repeatedly + /// (e.g. from ContentView whenever the saved routes list changes) — it + /// only does anything the first time routes go from empty to non-empty. + func armAutoSyncIfPossible() { + guard !hasArmedAutoSync else { return } + guard !loadRoutesFromDefaults().isEmpty else { return } + hasArmedAutoSync = true + + if SMAppService.mainApp.status != .enabled { + try? SMAppService.mainApp.register() + AppLogStore.append("[auto-sync] Registered as login item.") + } + if FileManager.default.fileExists(atPath: launchAgentURL.path) { + removeLegacyLaunchdSchedule() + } + if storeObserver == nil { + storeObserver = NotificationCenter.default.addObserver( + forName: .EKEventStoreChanged, object: backgroundStore, queue: .main + ) { [weak self] _ in + self?.scheduleAutoSync(reason: "calendar changed") + } + } + if wakeObserver == nil { + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.scheduleAutoSync(reason: "system woke from sleep") + } + } + if fallbackTask == nil { + fallbackTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30 * 60 * 1_000_000_000) + guard !Task.isCancelled else { break } + self?.scheduleAutoSync(reason: "periodic fallback check") + } + } + } + AppLogStore.append("[auto-sync] Armed: watching for calendar changes.") + } + + private func removeLegacyLaunchdSchedule() { + let domain = "gui/\(getuid())" + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/launchctl") + proc.arguments = ["bootout", domain, launchAgentURL.path] + proc.standardOutput = Pipe() + proc.standardError = Pipe() + try? proc.run() + proc.waitUntilExit() + try? FileManager.default.removeItem(at: launchAgentURL) + AppLogStore.append("[auto-sync] Removed launchd schedule (superseded by change-driven sync).") + } + + /// Debounces bursts of EKEventStoreChanged notifications into one sync. + private func scheduleAutoSync(reason: String) { + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard !Task.isCancelled else { return } + await self?.runAutoSync(reason: reason) + } + } + + private func loadRoutesFromDefaults() -> [Route] { + let defaults = UserDefaults.standard + if let data = defaults.data(forKey: settingsDefaultsKey), + let snap = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: data) { + return snap.routes + } + if let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey), + let routes = try? JSONDecoder().decode([Route].self, from: legacyData) { + return routes + } + return [] + } + + private func runAutoSync(reason: String) async { + guard !isSyncing else { return } + let routes = loadRoutesFromDefaults() + guard !routes.isEmpty else { return } + + guard let snap = UserDefaults.standard.data(forKey: settingsDefaultsKey), + let settings = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: snap) else { + return + } + + setSyncing(true) + AppLogStore.append("[auto-sync] Triggered by \(reason).") + var errorCount = 0 + let engine = MirrorEngine(log: { s in + AppLogStore.append("[auto-sync] \(s)") + let lower = s.lowercased() + if lower.contains("error") || lower.contains("fail") { errorCount += 1 } + }) + + let calendars = backgroundStore.calendars(for: .event) + func calendar(id: String) -> EKCalendar? { calendars.first { $0.calendarIdentifier == id } } + + var ranAnyRoute = false + var sessionGuard = Set() + for route in routes { + guard let sourceCal = calendar(id: route.sourceID) else { continue } + let validTargetIDs = route.targetIDs.filter { $0 != route.sourceID } + let targets = validTargetIDs.compactMap(calendar(id:)) + guard !targets.isEmpty else { continue } + ranAnyRoute = true + let config = MirrorConfig( + daysBack: settings.daysBack, + daysForward: settings.daysForward, + mergeGapMin: max(0, route.mergeGapHours * 60), + hideDetails: route.privacy, + copyDescription: route.copyNotes, + mirrorAllDay: route.allDay, + overlapMode: route.overlap, + titlePrefix: settings.titlePrefix, + placeholderTitle: settings.placeholderTitle, + filterByWorkHours: settings.filterByWorkHours, + workHoursStart: settings.workHoursStart, + workHoursEnd: settings.workHoursEnd, + excludedTitleFilterTerms: settings.excludedTitleFilters.map { $0.lowercased() }, + excludedOrganizerFilterTerms: settings.excludedOrganizerFilters.map { $0.lowercased() }, + mirrorAcceptedOnly: settings.mirrorAcceptedOnly, + autoDeleteMissing: settings.autoDeleteMissing, + writeEnabled: true, + syncReminders: route.syncReminders + ) + await engine.runMirror(store: backgroundStore, config: config, sourceCalendar: sourceCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true) + } + + let summary = ranAnyRoute ? "auto-sync (\(reason)): ran \(routes.count) saved route(s)" : "auto-sync (\(reason)): no route had a valid source+target" + AppLogStore.append("[auto-sync] \(summary)") + UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: "lastRunAtISO") + UserDefaults.standard.set(errorCount == 0, forKey: "lastRunOK") + UserDefaults.standard.set(summary, forKey: "lastRunSummary") + setSyncing(false) + } } struct BusyMirrorMenuBarView: View { diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db8379..10bf341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to BusyMirror will be documented in this file. +## [1.7.0] - 2026-08-26 + +### Added +- **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll (which only fired if the Mac happened to be awake at that instant, and got throttled/coalesced by macOS). Once saved routes exist, the app registers as a login item (`SMAppService.mainApp`), removes any previously-installed `launchd` schedule, and reacts to `EKEventStoreChanged` (debounced ~3s), `NSWorkspace.didWakeNotification` (resync after sleep), and a 30-minute fallback timer as a safety net for a missed notification. Auto-sync always writes (`writeEnabled: true`) regardless of the interactive dry-run toggle, matching what the old scheduled `--write 1` runs did. +- This logic lives in `BusyMirrorAppController`, not `ContentView` — the controller is owned by the `App` struct for the whole process lifetime, whereas `ContentView`'s own `EKEventStoreChanged` observer is torn down in `.onDisappear` when the main window closes (confirmed: `MenuBarSupport.swift`'s "Sync Now" already reopens the window before syncing, which only makes sense if the window's state is discarded on close). Auto-sync needs to work with the window closed, so it can't live there. ([MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift)) + ## [1.6.1] - 2026-08-26 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 4a039e5..93337a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,20 +12,14 @@ - 1.3.6: in-app scheduling via `launchd` with hourly/daily/weekday modes - 1.3.6: generated macOS app icon set and packaged release assets - 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig` -- Calendar list already auto-refreshes on `EKEventStoreChanged` (does not yet trigger an auto-sync — see Next) - CLI diagnostics: `--help`, `--list-calendars [--json]`, `--status [--json]`, real exit codes (2 = no access, 3 = no saved routes), last-run tracking (time/ok/summary) +- 1.7.0: **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll. Auto-sync logic lives in `BusyMirrorAppController` (an app-lifetime object, not tied to `ContentView`'s window lifecycle — closing the main window used to tear down the `EKEventStoreChanged` observer along with it, which would have made auto-sync a no-op whenever the window was closed). Once saved routes exist: registers as a login item (`SMAppService.mainApp`), removes any old `launchd` schedule, watches `EKEventStoreChanged` (debounced ~3s) and `NSWorkspace.didWakeNotification`, plus a 30-min fallback timer as a safety net. Auto-sync always writes (`writeEnabled: true`), independent of the interactive dry-run toggle. -## Next — reliability (V2 groundwork) -1. **Event-driven background sync.** The existing hourly `launchd StartInterval` only fires while the Mac happens to be awake at that instant and gets throttled/coalesced by macOS, so it's not reliable. Replace polling with reacting: - - Keep the app running as a login item via `SMAppService.agent` instead of relying on launchd to relaunch it. - - Extend the existing `EKEventStoreChanged` observer (today it only reloads the calendar list) to also trigger a debounced (~2-5s) auto-sync of saved routes. - - Add an `NSWorkspace.didWakeNotification` handler to resync after sleep. - - Keep one coarse fallback timer (e.g. every 30 min) purely as a safety net for a missed notification — not the primary mechanism. - - `launchd`'s remaining job shrinks to "make sure the app is running," which `SMAppService` likely covers, so the plist-generation code may become unnecessary. -2. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently. +## Next +1. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently. ## Then — V2 UI polish -- Split `ContentView.swift` (~1800 lines doing UI + settings + CLI + scheduling) into per-section view models (Routes, Schedule, Privacy, Log) so views are small and native-feeling. +- Finish the `ContentView.swift` split into per-section view models (Routes, Schedule, Privacy, Log) — 1.7.0 already pulled the calendar-access/auto-sync state out into `BusyMirrorAppController`; the rest (UI/settings/CLI) is still one ~1900-line file. - Real `Settings { }` scene instead of the main window doubling as preferences. - Menu bar icon reflects state (idle / syncing / error) instead of a static icon. - Surface last-sync-time / last-error / next-check in the menu bar UI — the data now exists (`--status`'s `lastRunAtISO`/`lastRunOK`/`lastRunSummary`), this is just wiring it into the menu.