Compare commits

...
2 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
tomas.kracmarandClaude Sonnet 5 61cea918a6 Release 1.6.1
- Sync reminders: new "Sync reminders when mirroring" option (global and
  per-route) copies source event alarms into mirrored placeholders. CLI flag
  --sync-reminders.
- CLI polish: --help, --list-calendars, --status (all support --json).
  Scheduled/headless runs record last-run time/result/summary. Failure paths
  now exit with distinct nonzero codes (2 = no calendar access, 3 = no saved
  routes) instead of always exiting 0.
- Fix settings silently wiped on upgrade: SettingsPayload's auto-synthesized
  Codable threw on any settings blob missing a field added since (e.g. the
  new syncReminders key), failing the whole decode and letting the next
  autosave persist an empty state over real routes/filters. Custom decode
  now falls back per-field like Route already did, and loadSettingsFromDefaults
  recovers routes from the legacy routes.v1 key if settings.v2 comes back
  empty, repairing installs already hit by this.
- Roadmap: event-driven background sync (SMAppService + EKEventStoreChanged +
  wake notification) and an external MCP wrapper around the CLI queued next;
  decided against direct Google/CalDAV API integration since EventKit already
  covers it via System Settings accounts.

All 47 unit tests pass (45 existing + 2 new SettingsPayloadTests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:10:23 +02:00
12 changed files with 641 additions and 31 deletions
+3 -1
View File
@@ -127,7 +127,9 @@ BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit
BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3" --write 1 --exit
```
Relevant flags: `--privacy`, `--copy-notes`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
Relevant flags: `--privacy`, `--copy-notes`, `--sync-reminders`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
Diagnostic/query flags (no calendar write, exit immediately): `--help`/`-h`, `--list-calendars [--json]`, `--status [--json]`. `--status` reads `lastRunAtISO`/`lastRunOK`/`lastRunSummary` in `UserDefaults`, written by `recordRunResult(ok:summary:)` at the end of every `--routes`/`--run-saved-routes` invocation. Exit codes: `2` = no calendar access, `3` = `--run-saved-routes` with no saved routes.
Scheduled runs are implemented by generating a `launchd` plist in `~/Library/LaunchAgents/com.cqrenet.BusyMirror.saved-routes.plist` and bootstrapping it with `launchctl`. The app removes and re-bootstraps the agent on every "Install Schedule" click.
+4 -4
View File
@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20;
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.5.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 = 20;
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.5.1;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+26 -3
View File
@@ -7,10 +7,31 @@ struct Block: Hashable {
let label: String? // source title (for dry-run / non-private)
let notes: String? // source notes (for optional copy)
let occurrence: Date? // occurrenceDate for recurring instances
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
static func span(start: Date, end: Date) -> Block {
Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: nil)
}
// Alarms are carried along for mirroring but do not affect time-based
// deduplication, merging, or overlap calculations.
static func == (lhs: Block, rhs: Block) -> Bool {
lhs.start == rhs.start &&
lhs.end == rhs.end &&
lhs.srcStableID == rhs.srcStableID &&
lhs.label == rhs.label &&
lhs.notes == rhs.notes &&
lhs.occurrence == rhs.occurrence
}
func hash(into hasher: inout Hasher) {
hasher.combine(start)
hasher.combine(end)
hasher.combine(srcStableID)
hasher.combine(label)
hasher.combine(notes)
hasher.combine(occurrence)
}
}
@@ -36,16 +57,18 @@ func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] {
let sorted = blocks.sorted { $0.start < $1.start }
var out: [Block] = []
var cur = Block.span(start: sorted[0].start, end: sorted[0].end)
var curAlarms = sorted[0].alarmOffsets
for b in sorted.dropFirst() {
let gap = b.start.timeIntervalSince(cur.end) / 60.0
if gap <= Double(gapMinutes) {
if b.end > cur.end { cur = Block.span(start: cur.start, end: b.end) }
} else {
out.append(cur)
out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
cur = Block.span(start: b.start, end: b.end)
curAlarms = b.alarmOffsets
}
}
out.append(cur)
out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
return out
}
+269 -7
View File
@@ -44,16 +44,18 @@ struct Route: Identifiable, Hashable, Codable {
var targetIDs: Set<String>
var privacy: Bool // true = hide details for this source
var copyNotes: Bool // copy description when privacy is OFF
var syncReminders: Bool // copy source event alarms into placeholder
var mergeGapHours: Int // per-route merge gap (hours)
var overlap: OverlapMode // per-route overlap behavior
var allDay: Bool // per-route mirror all-day
enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, mergeGapHours, overlap, allDay }
enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, syncReminders, mergeGapHours, overlap, allDay }
init(sourceID: String, targetIDs: Set<String>, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) {
init(sourceID: String, targetIDs: Set<String>, privacy: Bool, copyNotes: Bool, syncReminders: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) {
self.sourceID = sourceID
self.targetIDs = targetIDs
self.privacy = privacy
self.copyNotes = copyNotes
self.syncReminders = syncReminders
self.mergeGapHours = mergeGapHours
self.overlap = overlap
self.allDay = allDay
@@ -65,6 +67,7 @@ struct Route: Identifiable, Hashable, Codable {
self.targetIDs = try c.decode(Set<String>.self, forKey: .targetIDs)
self.privacy = try c.decode(Bool.self, forKey: .privacy)
self.copyNotes = try c.decode(Bool.self, forKey: .copyNotes)
self.syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false
self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours)
self.overlap = try c.decode(OverlapMode.self, forKey: .overlap)
self.allDay = try c.decode(Bool.self, forKey: .allDay)
@@ -89,6 +92,7 @@ struct ContentView: View {
private var mergeGapMin: Int { max(0, mergeGapHours * 60) }
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
@AppStorage("syncReminders") private var syncReminders: Bool = false // Copy source alarms into mirrored placeholders
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@@ -110,6 +114,10 @@ struct ContentView: View {
@State private var logText = "Ready."
@State private var isRunning = false
@State private var isCLIRun = false
@State private var cliRunErrorCount = 0
@AppStorage("lastRunAtISO") private var lastRunAtISO: String = ""
@AppStorage("lastRunOK") private var lastRunOK: Bool = true
@AppStorage("lastRunSummary") private var lastRunSummary: String = ""
@State private var confirmCleanup = false
@State private var mirrorTask: Task<Void, Never>? = nil
@State private var progressText: String? = nil
@@ -542,6 +550,7 @@ struct ContentView: View {
targetIDs: targetIDs,
privacy: hideDetails,
copyNotes: copyDescription,
syncReminders: syncReminders,
mergeGapHours: mergeGapHours,
overlap: overlapMode,
allDay: mirrorAllDay)
@@ -587,6 +596,9 @@ struct ContentView: View {
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
@@ -732,7 +744,8 @@ struct ContentView: View {
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
mirrorAcceptedOnly: mirrorAcceptedOnly,
autoDeleteMissing: autoDeleteMissing,
writeEnabled: writeEnabled
writeEnabled: writeEnabled,
syncReminders: r.syncReminders
)
let srcCal = calendars[sIdx]
let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier }
@@ -776,7 +789,8 @@ struct ContentView: View {
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
mirrorAcceptedOnly: mirrorAcceptedOnly,
autoDeleteMissing: autoDeleteMissing,
writeEnabled: writeEnabled
writeEnabled: writeEnabled,
syncReminders: syncReminders
)
}
@@ -904,6 +918,8 @@ struct ContentView: View {
.disabled(isRunning)
Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(isRunning || hideDetails)
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
.disabled(isRunning)
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
.disabled(isRunning)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
@@ -1295,6 +1311,9 @@ struct ContentView: View {
tryRunCLIIfPresent()
enforceNoSourceInTargets()
handlePendingMenuBarSyncIfNeeded()
if !isCLIRun {
appController.bootstrapBackgroundSync()
}
}
.onDisappear {
appController.setMainWindowVisible(false)
@@ -1309,6 +1328,7 @@ struct ContentView: View {
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
.onChange(of: hideDetails) { _ in saveSettingsToDefaults() }
.onChange(of: copyDescription) { _ in saveSettingsToDefaults() }
.onChange(of: syncReminders) { _ in saveSettingsToDefaults() }
.onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() }
.onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() }
.onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() }
@@ -1335,16 +1355,171 @@ struct ContentView: View {
.onChange(of: routes) { _ in
saveSettingsToDefaults()
handlePendingMenuBarSyncIfNeeded()
appController.armAutoSyncIfPossible()
}
}
// MARK: - CLI support
private static let cliHelpText = """
BusyMirror — mirror calendar events between EventKit calendars.
Usage:
BusyMirror --run-saved-routes [--write 1] [--exit]
BusyMirror --routes "1->2,3; 4->5" [--write 1] [--exit]
BusyMirror --list-calendars [--json]
BusyMirror --status [--json]
BusyMirror --help
Run modes:
--run-saved-routes Run the routes configured in the app's saved settings.
--routes SPEC Run ad-hoc routes by 1-based calendar index, e.g. "1->2,3".
--list-calendars Print available calendars (index, id, title, source) and exit.
--status Print last-run and schedule diagnostics and exit.
--help, -h Print this help and exit.
Options:
--json Machine-readable JSON output for --list-calendars / --status.
--write 1 Actually create/update/delete events (default: dry-run).
--exit Quit the app after the run completes.
--cleanup-only Only delete stale mirrored placeholders; don't mirror.
--privacy 1|0 Hide event details behind a placeholder title.
--copy-notes 1|0 Copy the source event's notes into the mirror.
--sync-reminders 1|0 Copy source event alarms into the mirror.
--all-day 1|0 Mirror all-day events.
--mode allow|skipCovered|fillGaps
--days-back N / --days-forward N
--merge-gap-hours N
--exclude-titles "token1, token2"
--exclude-organizers "alice@example.com, Example Org"
"""
private func recordRunResult(ok: Bool, summary: String) {
lastRunAtISO = ISO8601DateFormatter().string(from: Date())
lastRunOK = ok
lastRunSummary = summary
}
private struct CLICalendarInfo: Codable {
let index: Int
let id: String
let title: String
let source: String
let sourceType: String
let allowsModify: Bool
}
private struct CLIStatusInfo: Codable {
let lastRunAt: String?
let lastRunOK: Bool?
let lastRunSummary: String?
let scheduleInstalled: Bool
let scheduleSummary: String?
let routeCount: Int
let logFilePath: String
}
private func sourceTypeLabel(_ type: EKSourceType) -> String {
switch type {
case .local: return "local"
case .exchange: return "exchange"
case .calDAV: return "calDAV"
case .mobileMe: return "iCloud"
case .subscribed: return "subscribed"
case .birthdays: return "birthdays"
@unknown default: return "unknown"
}
}
private func printCalendars(json: Bool) {
let infos = calendars.enumerated().map { idx, cal in
CLICalendarInfo(
index: idx + 1,
id: cal.calendarIdentifier,
title: cal.title,
source: cal.source.title,
sourceType: sourceTypeLabel(cal.source.sourceType),
allowsModify: cal.allowsContentModifications
)
}
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(infos), let s = String(data: data, encoding: .utf8) {
print(s)
}
} else {
for info in infos {
print("\(info.index): \(info.title) [\(info.source), \(info.sourceType)]\(info.allowsModify ? "" : " (read-only)") id=\(info.id)")
}
}
}
private func printStatus(json: Bool) {
let info = CLIStatusInfo(
lastRunAt: lastRunAtISO.isEmpty ? nil : lastRunAtISO,
lastRunOK: lastRunAtISO.isEmpty ? nil : lastRunOK,
lastRunSummary: lastRunSummary.isEmpty ? nil : lastRunSummary,
scheduleInstalled: hasInstalledSchedule,
scheduleSummary: hasInstalledSchedule ? scheduleSummary : nil,
routeCount: routes.count,
logFilePath: AppLogStore.logFileURL.path
)
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(info), let s = String(data: data, encoding: .utf8) {
print(s)
}
} else {
print("Last run: \(info.lastRunAt ?? "never")\(info.lastRunAt != nil ? (info.lastRunOK == true ? " (ok)" : " (error)") : "")")
if let summary = info.lastRunSummary { print(" \(summary)") }
print("Schedule: \(info.scheduleInstalled ? (info.scheduleSummary ?? "installed") : "not installed")")
print("Saved routes: \(info.routeCount)")
print("Log file: \(info.logFilePath)")
}
}
func tryRunCLIIfPresent() {
let args = CommandLine.arguments
let jsonOutput = args.contains("--json")
if args.contains("--help") || args.contains("-h") {
isCLIRun = true
print(Self.cliHelpText)
NSApp.terminate(nil)
return
}
if args.contains("--status") {
isCLIRun = true
printStatus(json: jsonOutput)
NSApp.terminate(nil)
return
}
if args.contains("--list-calendars") {
isCLIRun = true
Task {
if hasAccess { await MainActor.run { reloadCalendars() } }
for _ in 0..<50 {
if hasAccess && !calendars.isEmpty { break }
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard hasAccess else {
FileHandle.standardError.write("No calendar access.\n".data(using: .utf8)!)
exit(2)
}
await MainActor.run { printCalendars(json: jsonOutput) }
NSApp.terminate(nil)
}
return
}
let routesIdx = args.firstIndex(of: "--routes")
let runSavedRoutes = args.contains("--run-saved-routes")
guard routesIdx != nil || runSavedRoutes else { return }
isCLIRun = true
cliRunErrorCount = 0
func boolArg(_ name: String, default def: Bool) -> Bool {
if let i = args.firstIndex(of: name), i+1 < args.count {
@@ -1365,6 +1540,7 @@ struct ContentView: View {
// Configure options from CLI flags
hideDetails = boolArg("--privacy", default: hideDetails)
copyDescription = boolArg("--copy-notes", default: copyDescription)
syncReminders = boolArg("--sync-reminders", default: syncReminders)
writeEnabled = boolArg("--write", default: writeEnabled)
mirrorAllDay = boolArg("--all-day", default: mirrorAllDay)
daysForward = intArg("--days-forward", default: daysForward)
@@ -1405,22 +1581,26 @@ struct ContentView: View {
}
guard hasAccess, !calendars.isEmpty else {
log("CLI: no calendar access; aborting")
NSApp.terminate(nil)
return
recordRunResult(ok: false, summary: "no calendar access")
exit(2)
}
let cliConfig = makeMirrorConfig()
if runSavedRoutes {
if routes.isEmpty {
log("CLI: no saved routes; aborting")
recordRunResult(ok: false, summary: "no saved routes")
exit(3)
} else if boolArg("--cleanup-only", default: false) {
for r in routes {
log("CLI: cleanup saved route \(r.sourceID)")
await runCleanupForRoute(r)
}
recordRunResult(ok: cliRunErrorCount == 0, summary: "cleaned up \(routes.count) saved route(s)")
} else {
var sessionGuard = Set<String>()
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routes.count) saved route(s)")
}
} else {
for part in routeParts where !part.isEmpty {
@@ -1450,6 +1630,7 @@ struct ContentView: View {
await engine.runMirror(store: store, config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
}
}
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routeParts.count) route(s)")
}
// Exit only when --exit is explicitly passed. isCLIRun alone does
// not force termination so that advanced users can open the UI with
@@ -1542,12 +1723,13 @@ struct ContentView: View {
}
// MARK: - Export / Import Settings
private struct SettingsPayload: Codable {
struct SettingsPayload: Codable {
var daysBack: Int
var daysForward: Int
var mergeGapHours: Int
var hideDetails: Bool
var copyDescription: Bool
var syncReminders: Bool = false
var mirrorAllDay: Bool
var filterByWorkHours: Bool = false
var workHoursStart: Int = 9
@@ -1566,6 +1748,67 @@ struct ContentView: View {
// optional metadata
var appVersion: String?
var exportedAt: Date = Date()
init(daysBack: Int, daysForward: Int, mergeGapHours: Int, hideDetails: Bool, copyDescription: Bool,
syncReminders: Bool = false, mirrorAllDay: Bool, filterByWorkHours: Bool, workHoursStart: Int,
workHoursEnd: Int, excludedTitleFilters: [String], excludedOrganizerFilters: [String],
mirrorAcceptedOnly: Bool, overlapMode: String, titlePrefix: String, placeholderTitle: String,
autoDeleteMissing: Bool, routes: [Route], selectedSourceID: String? = nil,
selectedTargetIDs: [String]? = nil, appVersion: String? = nil, exportedAt: Date = Date()) {
self.daysBack = daysBack
self.daysForward = daysForward
self.mergeGapHours = mergeGapHours
self.hideDetails = hideDetails
self.copyDescription = copyDescription
self.syncReminders = syncReminders
self.mirrorAllDay = mirrorAllDay
self.filterByWorkHours = filterByWorkHours
self.workHoursStart = workHoursStart
self.workHoursEnd = workHoursEnd
self.excludedTitleFilters = excludedTitleFilters
self.excludedOrganizerFilters = excludedOrganizerFilters
self.mirrorAcceptedOnly = mirrorAcceptedOnly
self.overlapMode = overlapMode
self.titlePrefix = titlePrefix
self.placeholderTitle = placeholderTitle
self.autoDeleteMissing = autoDeleteMissing
self.routes = routes
self.selectedSourceID = selectedSourceID
self.selectedTargetIDs = selectedTargetIDs
self.appVersion = appVersion
self.exportedAt = exportedAt
}
// Custom decode: every field added after the very first release must be
// read with decodeIfPresent so that a settings blob written by an older
// build (missing that key) doesn't fail the whole decode and silently
// wipe all saved routes/settings (see: settings.v2 losing data across
// the 1.5.1 -> 1.6.0 upgrade when `syncReminders` was added).
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
daysBack = try c.decodeIfPresent(Int.self, forKey: .daysBack) ?? 1
daysForward = try c.decodeIfPresent(Int.self, forKey: .daysForward) ?? 7
mergeGapHours = try c.decodeIfPresent(Int.self, forKey: .mergeGapHours) ?? 0
hideDetails = try c.decodeIfPresent(Bool.self, forKey: .hideDetails) ?? true
copyDescription = try c.decodeIfPresent(Bool.self, forKey: .copyDescription) ?? false
syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false
mirrorAllDay = try c.decodeIfPresent(Bool.self, forKey: .mirrorAllDay) ?? false
filterByWorkHours = try c.decodeIfPresent(Bool.self, forKey: .filterByWorkHours) ?? false
workHoursStart = try c.decodeIfPresent(Int.self, forKey: .workHoursStart) ?? 9
workHoursEnd = try c.decodeIfPresent(Int.self, forKey: .workHoursEnd) ?? 17
excludedTitleFilters = try c.decodeIfPresent([String].self, forKey: .excludedTitleFilters) ?? []
excludedOrganizerFilters = try c.decodeIfPresent([String].self, forKey: .excludedOrganizerFilters) ?? []
mirrorAcceptedOnly = try c.decodeIfPresent(Bool.self, forKey: .mirrorAcceptedOnly) ?? false
overlapMode = try c.decodeIfPresent(String.self, forKey: .overlapMode) ?? OverlapMode.allow.rawValue
titlePrefix = try c.decodeIfPresent(String.self, forKey: .titlePrefix) ?? "🪞 "
placeholderTitle = try c.decodeIfPresent(String.self, forKey: .placeholderTitle) ?? "Busy"
autoDeleteMissing = try c.decodeIfPresent(Bool.self, forKey: .autoDeleteMissing) ?? true
routes = try c.decodeIfPresent([Route].self, forKey: .routes) ?? []
selectedSourceID = try c.decodeIfPresent(String.self, forKey: .selectedSourceID)
selectedTargetIDs = try c.decodeIfPresent([String].self, forKey: .selectedTargetIDs)
appVersion = try c.decodeIfPresent(String.self, forKey: .appVersion)
exportedAt = try c.decodeIfPresent(Date.self, forKey: .exportedAt) ?? Date()
}
}
private func makeSnapshot() -> SettingsPayload {
@@ -1600,6 +1843,7 @@ struct ContentView: View {
mergeGapHours = s.mergeGapHours
hideDetails = s.hideDetails
copyDescription = s.copyDescription
syncReminders = s.syncReminders
mirrorAllDay = s.mirrorAllDay
filterByWorkHours = s.filterByWorkHours
workHoursStart = s.workHoursStart
@@ -1703,6 +1947,18 @@ struct ContentView: View {
do {
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
applySnapshot(snap)
// A build affected by an earlier settings.v2 decode failure (fixed
// in 1.6.0 — a newly added field with no decode fallback threw and
// wiped routes in memory, which a later autosave then persisted
// back as empty) can still be running with `routes: []` in
// settings.v2 while the untouched legacy `routes.v1` key still
// holds the real routes. Recover them if so.
if routes.isEmpty, let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey),
let recovered = try? JSONDecoder().decode([Route].self, from: legacyData), !recovered.isEmpty {
routes = recovered
log("Recovered \(recovered.count) route(s) from legacy backup (routes.v1).")
saveSettingsToDefaults()
}
} catch {
log("✗ Failed to load settings: \(error.localizedDescription)")
}
@@ -1737,6 +1993,12 @@ struct ContentView: View {
// MARK: - Logging
func log(_ s: String) {
AppLogStore.append(s)
if isCLIRun {
let lower = s.lowercased()
if lower.contains("error") || lower.contains("fail") {
cliRunErrorCount += 1
}
}
DispatchQueue.main.async {
logText.append("\n" + s)
let maxLines = 2000
+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 {
+1
View File
@@ -19,4 +19,5 @@ struct MirrorConfig {
let mirrorAcceptedOnly: Bool
let autoDeleteMissing: Bool
let writeEnabled: Bool
let syncReminders: Bool
}
+35 -1
View File
@@ -3,6 +3,24 @@ import EventKit
private let SAME_TIME_TOL_MIN: Double = 5
private func alarmOffsets(for event: EKEvent) -> [TimeInterval]? {
guard let alarms = event.alarms, !alarms.isEmpty else { return nil }
let offsets = alarms.compactMap { alarm -> TimeInterval? in
if alarm.relativeOffset != 0 {
return alarm.relativeOffset
}
if let absoluteDate = alarm.absoluteDate, let start = event.startDate {
return absoluteDate.timeIntervalSince(start)
}
return nil
}
return offsets.isEmpty ? nil : offsets
}
private func alarmsFromOffsets(_ offsets: [TimeInterval]) -> [EKAlarm] {
offsets.map { EKAlarm(relativeOffset: $0) }
}
struct MirrorRecord: Hashable, Codable {
var targetCalendarID: String
var sourceCalendarID: String
@@ -160,7 +178,7 @@ final class MirrorEngine {
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
let srcID = stableSourceIdentifier(for: ev)
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate))
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
}
if skippedMirrors > 0 {
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
@@ -324,6 +342,17 @@ final class MirrorEngine {
upsertMirrorRecord(for: blk, event: event)
}
func desiredAlarms(for blk: Block) -> [EKAlarm] {
guard config.syncReminders, let offsets = blk.alarmOffsets else { return [] }
return alarmsFromOffsets(offsets)
}
func alarmsEqual(_ a: [EKAlarm], _ b: [EKAlarm]) -> Bool {
let offsetsA = a.compactMap { $0.relativeOffset }.sorted()
let offsetsB = b.compactMap { $0.relativeOffset }.sorted()
return offsetsA == offsetsB
}
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
let curS = existing.startDate ?? blk.start
let curE = existing.endDate ?? blk.end
@@ -333,6 +362,9 @@ final class MirrorEngine {
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
if existing.isAllDay { return true }
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
let newAlarms = desiredAlarms(for: blk)
let existingAlarms = existing.alarms ?? []
if !alarmsEqual(newAlarms, existingAlarms) { return true }
return false
}
@@ -382,6 +414,7 @@ final class MirrorEngine {
existing.isAllDay = false
existing.notes = notes
existing.url = desiredURL
existing.alarms = desiredAlarms(for: blk)
do {
try store.save(existing, span: .thisEvent, commit: true)
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
@@ -433,6 +466,7 @@ final class MirrorEngine {
newEv.isAllDay = false
newEv.notes = notes
newEv.url = desiredURL
newEv.alarms = desiredAlarms(for: blk)
newEv.availability = .busy
do {
try store.save(newEv, span: .thisEvent, commit: true)
+20 -4
View File
@@ -5,14 +5,15 @@ final class BlockMathTests: XCTestCase {
private let d = Date(timeIntervalSince1970: 0)
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block {
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil, alarmOffsets: [TimeInterval]? = nil) -> Block {
Block(
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
srcStableID: id,
label: nil,
notes: nil,
occurrence: nil
occurrence: nil,
alarmOffsets: alarmOffsets
)
}
@@ -44,6 +45,21 @@ final class BlockMathTests: XCTestCase {
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
}
func testMergeBlocksPreservesFirstAlarms() {
let b1 = block(0, 10, alarmOffsets: [-900, -3600])
let b2 = block(10, 20, alarmOffsets: [-600])
let merged = mergeBlocks([b1, b2], gapMinutes: 0)
XCTAssertEqual(merged.count, 1)
XCTAssertEqual(merged[0].alarmOffsets, [-900, -3600])
}
func testBlockEqualityIgnoresAlarms() {
let b1 = block(0, 10, alarmOffsets: [-900])
let b2 = block(0, 10, alarmOffsets: [-1800])
XCTAssertEqual(b1, b2)
XCTAssertEqual(Set([b1, b2]).count, 1)
}
func testMergeBlocksUnsortedInput() {
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
let merged = mergeBlocks(blocks, gapMinutes: 0)
@@ -150,8 +166,8 @@ final class BlockMathTests: XCTestCase {
}
func testUniqueBlocksByIDDifferentOccurrence() {
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600))
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
let result = uniqueBlocks([b1, b2], trackByID: true)
XCTAssertEqual(result.count, 2)
}
@@ -0,0 +1,64 @@
import XCTest
@testable import BusyMirror
final class SettingsPayloadTests: XCTestCase {
// Only the fields present in the very first schema. Every key added since
// (syncReminders, filterByWorkHours, workHoursStart/End, excludedTitleFilters,
// excludedOrganizerFilters, mirrorAcceptedOnly, selectedSourceID/TargetIDs,
// appVersion, exportedAt) is deliberately missing here, simulating a
// settings.v2 blob written by an older build.
private let oldSchemaJSON = """
{
"daysBack": 3,
"daysForward": 10,
"mergeGapHours": 1,
"hideDetails": false,
"copyDescription": true,
"mirrorAllDay": true,
"overlapMode": "skipCovered",
"titlePrefix": "🪞 ",
"placeholderTitle": "Busy",
"autoDeleteMissing": true,
"routes": []
}
"""
func testDecodeOldSchemaMissingNewerKeysDoesNotThrow() throws {
let data = oldSchemaJSON.data(using: .utf8)!
let payload = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
// Fields present in the old blob are preserved.
XCTAssertEqual(payload.daysBack, 3)
XCTAssertEqual(payload.daysForward, 10)
XCTAssertEqual(payload.overlapMode, "skipCovered")
// Fields missing from the old blob fall back to their defaults instead
// of failing the whole decode.
XCTAssertEqual(payload.syncReminders, false)
XCTAssertEqual(payload.filterByWorkHours, false)
XCTAssertEqual(payload.workHoursStart, 9)
XCTAssertEqual(payload.workHoursEnd, 17)
XCTAssertEqual(payload.excludedTitleFilters, [])
XCTAssertEqual(payload.mirrorAcceptedOnly, false)
XCTAssertNil(payload.selectedSourceID)
XCTAssertNil(payload.selectedTargetIDs)
}
func testEncodeDecodeRoundTrip() throws {
let route = Route(sourceID: "a", targetIDs: ["b"], privacy: true, copyNotes: false, syncReminders: true, mergeGapHours: 1, overlap: .allow, allDay: false)
let original = ContentView.SettingsPayload(
daysBack: 2, daysForward: 5, mergeGapHours: 0, hideDetails: true, copyDescription: false,
mirrorAllDay: false, filterByWorkHours: true, workHoursStart: 8, workHoursEnd: 18,
excludedTitleFilters: ["standup"], excludedOrganizerFilters: [], mirrorAcceptedOnly: true,
overlapMode: "allow", titlePrefix: "🪞 ", placeholderTitle: "Busy", autoDeleteMissing: true,
routes: [route]
)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
XCTAssertEqual(decoded.daysBack, original.daysBack)
XCTAssertEqual(decoded.excludedTitleFilters, original.excludedTitleFilters)
XCTAssertEqual(decoded.routes.count, 1)
XCTAssertEqual(decoded.routes[0].sourceID, "a")
}
}
+16
View File
@@ -2,6 +2,22 @@
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
- **Sync reminders**: new "Sync reminders when mirroring" option (global and per-route) copies source event alarms/relative offsets into mirrored placeholders. This lets calendars that are synced to a phone ring for mirrored events. When merging is enabled, only the first event's alarms are preserved for a merged block. ([ContentView.swift](BusyMirror/ContentView.swift), [MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift))
- CLI flag `--sync-reminders` to enable reminder syncing from scripted/headless runs.
- CLI polish: `--help`/`-h`, `--list-calendars`, and `--status` (all support `--json` for machine-readable output). Scheduled/headless runs now record last-run time, success/failure, and a summary so `--status` can report real diagnostics instead of just log-file grepping. Failure paths (`no calendar access`, `no saved routes`) now exit with distinct nonzero codes instead of always exiting 0. ([ContentView.swift](BusyMirror/ContentView.swift))
### Fixed
- **Settings silently wiped on upgrade**: `SettingsPayload` used fully auto-synthesized `Codable`, so decoding a settings blob written by an older build (missing a field added since, e.g. `syncReminders`) threw `keyNotFound` and failed the *entire* decode — not just that one field. The app then ran with in-code defaults (empty routes, filters, etc.), and the next autosave persisted that empty state back over the real data. `SettingsPayload` now has a custom `init(from:)` that reads every field added after the first release with `decodeIfPresent` + its existing default, matching the pattern `Route` already used. `loadSettingsFromDefaults()` also now recovers routes from the legacy `routes.v1` backup key if `settings.v2` decodes successfully but with an empty `routes` array — repairing installs that already hit this bug before upgrading. ([ContentView.swift](BusyMirror/ContentView.swift), [SettingsPayloadTests.swift](BusyMirrorTests/SettingsPayloadTests.swift))
## [1.5.1] - 2026-05-27
### Fixed
+4
View File
@@ -45,11 +45,15 @@ See `CHANGELOG.md` for notable changes.
- `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit`
- Run the routes already saved in the app settings:
- `BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
- `--help` prints full flag documentation.
- `--list-calendars [--json]` prints available calendars (index, id, title, source/type) and exits.
- `--status [--json]` prints last-run time/result, schedule state, and saved-route count and exits.
- Flags exist for privacy, all-day, merge gap, days window, overlap mode, cleanup, and filters.
- Filters:
- `--exclude-titles "token1, token2"`
- `--exclude-organizers "alice@example.com, Example Org"`
- Tokens are comma or newline separated; matching is case-insensitive.
- Exit codes: `0` success, `2` no calendar access, `3` no saved routes (with `--run-saved-routes`).
## Logs
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
+16 -11
View File
@@ -12,19 +12,24 @@
- 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`
- 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
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
- Better scheduled-run diagnostics in the UI (last run / last error / next run)
- Better server-side privacy mapping (per-provider heuristics)
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
- Signed/notarized binaries and release pipeline
- CLI quality: friendlier `--routes` parsing and help flag
- “Dry-run by default” preference
## Then — V2 UI polish
- 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.
- Better server-side privacy mapping (per-provider heuristics).
## Later
- Background monitoring (macOS)
- Smarter cleanup & conflict resolution
- iOS/iPadOS helper (Shortcuts integration)
- Profiles & MDM/Managed Config support
- Signed/notarized binaries and release pipeline.
- Smarter cleanup & conflict resolution.
- iOS/iPadOS helper (Shortcuts integration).
- Profiles & MDM/Managed Config support.
## Decided against
- **Direct Google/CalDAV/Exchange API integration** (OAuth flows, token storage, per-provider clients so BusyMirror can mirror a non-local calendar without it being added to macOS). EventKit already surfaces any calendar added via System Settings → Internet Accounts — macOS does the sync, auth, and refresh. Building a parallel integration would duplicate the OS for the narrow case of an account that can't or won't be added system-wide (e.g. MDM-restricted work accounts). Not worth the OAuth/Keychain/per-provider-quirk surface for that.