Compare commits

...
1 Commits
Author SHA1 Message Date
tomas.kracmarandClaude Sonnet 5 08e9fe5325 Release 1.7.0
- Event-driven background sync, replacing the hourly launchd StartInterval
  poll (only fired if the Mac happened to be awake at that instant, and got
  throttled/coalesced by macOS). Once saved routes exist: registers as a
  login item (SMAppService.mainApp), removes any old launchd schedule, and
  reacts to EKEventStoreChanged (debounced ~3s), NSWorkspace.didWakeNotification,
  plus a 30-min fallback timer as a safety net. Auto-sync always writes,
  matching what the old scheduled runs did.
- This lives in BusyMirrorAppController (app-lifetime), not ContentView —
  ContentView's own EKEventStoreChanged observer is torn down when the main
  window closes, which would make auto-sync a no-op exactly when it needs to
  keep working (window closed, menu-bar-only). Confirmed via MenuBarSupport's
  existing "Sync Now" handler, which already has to reopen the window before
  it can run anything.

All 47 unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 18:37:23 +02:00
5 changed files with 201 additions and 14 deletions
+4 -4
View File
@@ -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;
+4
View File
@@ -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()
}
}
+183
View File
@@ -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<Void, Never>?
private var fallbackTask: Task<Void, Never>?
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<String>()
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 {
+6
View File
@@ -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
+4 -10
View File
@@ -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.