Compare commits

..
6 Commits
Author SHA1 Message Date
tomas.kracmarandClaude Sonnet 5 4e29b3716c Release 1.8.3
Fix Preferences missing from the standard app menu: 1.8.1's workaround for
LSUIElement (plain Window + openWindow) was never swapped back for a real
Settings{} scene after 1.8.2 removed LSUIElement, so there was still no
Preferences item in the app's own menu or Cmd+, binding, only a button in
the main window and a menu-bar-dropdown item. Restored Settings{} now that
the app menu exists to host it; menu bar dropdown and main window button
both use the standard openSettings()/SettingsLink instead of a custom
openWindow(id:). Net simplification -- removes the now-redundant window ID
and controller method.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 09:16:29 +02:00
tomas.kracmarandClaude Sonnet 5 d40bc986c5 Release 1.8.2
Standard app instead of menu-bar-only: removed LSUIElement from Info.plist.
Dock icon, Cmd+Tab, and the standard app menu (Cmd+Q) are back. The
accessory-app design was making the app hard to quit reliably -- no Dock
icon and no accessible app menu meant the only way to quit was finding the
menu bar dropdown's "Quit BusyMirror" item, which was blocking updates
(old process holds the bundle open, "app in use" on replace). Menu bar
extra stays as a secondary quick-access point.

Verified via `lsappinfo` (type="Foreground", was "UIElement") since this is
an app-type change a screenshot wouldn't show either way.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 07:20:19 +02:00
tomas.kracmarandClaude Sonnet 5 39c5cba237 Release 1.8.1
Fix Preferences window being unreachable in 1.8.0: BusyMirror is LSUIElement
(accessory), so it never gets the standard app menu that the automatic
Settings scene's Cmd+, / SettingsLink depend on. Replaced Settings{} with a
plain Window opened via openWindow(id:) -- the same mechanism already used
for the main window -- and added a "Preferences..." item to the menu bar
dropdown as a second, more discoverable entry point.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 23:04:55 +02:00
tomas.kracmarandClaude Sonnet 5 edddf65288 Release 1.8.0
- Real Settings (Cmd+,) window via PreferencesView, hosting the pure
  @AppStorage-backed defaults that used to live in the main window's
  "General Settings" panel. Both windows share the same live values since
  they're backed by the same UserDefaults keys.
- Menu bar icon reflects idle/syncing/error state; dropdown shows last-sync
  time/result and whether auto-sync is armed, sourced from the same
  lastRunAtISO/lastRunOK/lastRunSummary keys --status reads.
- ContentView.swift split from ~2000 lines into CalendarsSectionView,
  RoutesSectionView, ScheduleSectionView, LogSectionView + a small shared
  CalendarDisplay.swift. View-layer extraction only — state ownership and
  settings persistence deliberately left alone.
- Fixed a latent revert-on-relaunch bug caught while building the Settings
  window: moving preference controls out of ContentView meant they no longer
  re-triggered saveSettingsToDefaults(), so settings.v2 could go stale and
  the next launch would silently revert a just-changed preference via the
  shared applySnapshot path. Split launch-time restore (routes + selection
  only) from Import's full restore.

All 47 unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:26:07 +02:00
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
20 changed files with 1386 additions and 550 deletions
+7 -5
View File
@@ -4,9 +4,9 @@
## Project Overview
**BusyMirror** is a macOS menu-bar utility that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
**BusyMirror** is a macOS utility (standard app + menu bar extra) that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a menu-bar-only app (`LSUIElement`) with no Dock icon.
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a standard app (Dock icon, ⌘Q) and also has a `MenuBarExtra` for quick sync/status.
Key capabilities:
- Manual or route-driven multi-source mirroring
@@ -44,7 +44,7 @@ BusyMirror/
├── EventFilters.swift # Work-hours, title, and organizer filters
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
├── AppLogStore.swift # File-backed log store with rotation (AppLogStore enum)
├── Info.plist # LSUIElement, calendar usage descriptions
├── Info.plist # calendar/reminders usage descriptions
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
└── Assets.xcassets/ # AppIcon set and accent color
@@ -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.
@@ -144,7 +146,7 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau
| `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
| `BusyMirror/AppLogStore.swift` | File-backed log with rotation (`~/Library/Logs/BusyMirror/`) |
| `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions |
| `BusyMirror/Info.plist` | calendar/reminders usage descriptions |
| `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement |
| `Makefile` | Reproducible build, sign, and package targets |
| `CHANGELOG.md` | Release notes (human-readable) |
+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 = 27;
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.8.3;
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 = 27;
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.8.3;
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
}
+17 -1
View File
@@ -4,6 +4,12 @@ import SwiftUI
struct BusyMirrorApp: App {
@StateObject private var appController = BusyMirrorAppController()
private var menuBarIcon: String {
if appController.isSyncing { return "arrow.triangle.2.circlepath.circle.fill" }
if appController.lastRunFailed { return "exclamationmark.triangle.fill" }
return "calendar.badge.clock"
}
var body: some Scene {
Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) {
ContentView()
@@ -12,9 +18,19 @@ struct BusyMirrorApp: App {
}
.defaultSize(width: 1120, height: 760)
MenuBarExtra("BusyMirror", systemImage: appController.isSyncing ? "arrow.triangle.2.circlepath.circle.fill" : "calendar.badge.clock") {
MenuBarExtra("BusyMirror", systemImage: menuBarIcon) {
BusyMirrorMenuBarView()
.environmentObject(appController)
}
// A real Settings scene: now that the app is standard (not
// LSUIElement), this gets the conventional Cmd+, and a "Preferences…"
// item in the app's own menu for free — the location people actually
// look, unlike a plain Window which only opens from wherever we
// explicitly put a button for it.
Settings {
PreferencesView()
.environmentObject(appController)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
import EventKit
// Small calendar display helpers shared by ContentView, RoutesSectionView,
// and CalendarsSectionView.
func calColor(_ cal: EKCalendar) -> Color {
#if os(macOS)
return Color(cal.cgColor ?? NSColor.systemGray.cgColor)
#else
return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor)
#endif
}
@ViewBuilder
func calChip(_ cal: EKCalendar) -> some View {
HStack(spacing: 6) {
Circle().fill(calColor(cal)).frame(width: 10, height: 10)
Text(calLabel(cal))
}
}
+84
View File
@@ -0,0 +1,84 @@
import SwiftUI
import EventKit
/// The manual source/target calendar picker — extracted from ContentView.
/// Bindings mutate ContentView's own @State directly.
struct CalendarsSectionView: View {
let calendars: [EKCalendar]
@Binding var sourceIndex: Int
@Binding var targetSelections: Set<Int>
@Binding var targetIDs: Set<String>
let isRunning: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Source calendar")
.font(.subheadline.weight(.semibold))
Picker("Source", selection: $sourceIndex) {
ForEach(Array(calendars.indices), id: \.self) { i in
Text("\(i + 1): \(calLabel(calendars[i]))").tag(i)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(isRunning || calendars.isEmpty)
Divider()
HStack {
Text("Target calendars")
.font(.subheadline.weight(.semibold))
Spacer()
Text("\(targetIDs.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(Array(calendars.indices), id: \.self) { i in
let isSource = (i == sourceIndex)
let binding = Binding<Bool>(
get: { !isSource && targetSelections.contains(i) },
set: { newValue in
// Never allow selecting the source as a target
if isSource { return }
if newValue {
targetSelections.insert(i)
targetIDs.insert(calendars[i].calendarIdentifier)
} else {
targetSelections.remove(i)
targetIDs.remove(calendars[i].calendarIdentifier)
}
}
)
Toggle(isOn: binding) {
HStack(spacing: 8) {
Text("\(i + 1).")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
calChip(calendars[i])
}
.padding(.vertical, 3)
}
.toggleStyle(.switch)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(0.18), lineWidth: 1)
)
.disabled(isRunning || isSource)
.opacity(isSource ? 0.5 : 1)
}
}
}
.frame(minHeight: 170, maxHeight: 260)
}
}
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>15.5</string>
<key>NSCalendarsFullAccessUsageDescription</key>
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
/// Read-only activity log viewer — extracted from ContentView.
struct LogSectionView: View {
let logText: String
var body: some View {
TextEditor(text: Binding(get: { logText }, set: { _ in }))
.font(.system(.body, design: .monospaced))
.frame(minHeight: 180)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
}
+224 -1
View File
@@ -1,5 +1,7 @@
import SwiftUI
import AppKit
import EventKit
import ServiceManagement
enum BusyMirrorSceneID {
static let mainWindow = "main-window"
@@ -11,6 +13,36 @@ final class BusyMirrorAppController: ObservableObject {
@Published private(set) var hasPendingSyncRequest = false
@Published private(set) var syncRequestToken = UUID()
@Published private(set) var isMainWindowVisible = false
@Published private(set) var lastRunFailed = false
@Published private(set) var autoSyncArmed = false
init() {
refreshLastRunStatus()
}
/// Re-reads the shared lastRun* UserDefaults keys. Call after any run —
/// interactive, CLI, or auto-sync — writes them, so the menu bar icon and
/// dropdown reflect the outcome regardless of which path produced it.
func refreshLastRunStatus() {
guard let atISO = UserDefaults.standard.string(forKey: "lastRunAtISO"), !atISO.isEmpty else {
lastRunFailed = false
return
}
lastRunFailed = (UserDefaults.standard.object(forKey: "lastRunOK") as? Bool) == false
}
/// Human-readable "last sync" line for the menu bar dropdown. Computed on
/// demand (not published) since the dropdown's content is rebuilt each
/// time it's opened.
var lastRunStatusText: String {
guard let atISO = UserDefaults.standard.string(forKey: "lastRunAtISO"), !atISO.isEmpty,
let date = ISO8601DateFormatter().date(from: atISO) else {
return "No sync yet."
}
let relative = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
let ok = (UserDefaults.standard.object(forKey: "lastRunOK") as? Bool) != false
return ok ? "Last sync: \(relative)" : "Last sync failed: \(relative)"
}
func requestSync() {
hasPendingSyncRequest = true
@@ -33,10 +65,192 @@ 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 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 !autoSyncArmed else { return }
guard !loadRoutesFromDefaults().isEmpty else { return }
autoSyncArmed = 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")
refreshLastRunStatus()
setSyncing(false)
}
}
struct BusyMirrorMenuBarView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@EnvironmentObject private var appController: BusyMirrorAppController
var body: some View {
@@ -44,8 +258,12 @@ struct BusyMirrorMenuBarView: View {
Text("BusyMirror")
.font(.headline)
Text(appController.isSyncing ? "Sync in progress." : "Use your saved routes or current selection.")
Text(appController.isSyncing ? "Sync in progress." : appController.lastRunStatusText)
.font(.subheadline)
.foregroundStyle(appController.lastRunFailed ? .red : .secondary)
Text(appController.autoSyncArmed ? "Auto-sync: watching for calendar changes." : "Auto-sync: not active (add a saved route to enable).")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
@@ -63,6 +281,11 @@ struct BusyMirrorMenuBarView: View {
appController.openMainWindow(using: openWindow)
}
Button("Preferences…") {
NSApp.activate(ignoringOtherApps: true)
openSettings()
}
Divider()
Button("Quit BusyMirror") {
+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)
+165
View File
@@ -0,0 +1,165 @@
import SwiftUI
/// Real macOS Settings (⌘,) window. Holds only the pure, globally-persisted
/// defaults (all @AppStorage, shared automatically with ContentView's copies
/// of the same keys) — anything tied to live session state (routes, the
/// dry-run toggle, calendar access) stays in the main window since it has no
/// meaning outside that window's lifecycle.
struct PreferencesView: View {
@EnvironmentObject private var appController: BusyMirrorAppController
@AppStorage("daysForward") private var daysForward: Int = 7
@AppStorage("daysBack") private var daysBack: Int = 1
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@AppStorage("hideDetails") private var hideDetails: Bool = true
@AppStorage("copyDescription") private var copyDescription: Bool = false
@AppStorage("syncReminders") private var syncReminders: Bool = false
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 "
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy"
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private var disabled: Bool { appController.isSyncing }
private func clampWorkHours() {
let clampedStart = min(max(workHoursStart, 0), 23)
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min(max(workHoursEnd, 1), 24)
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min(workHoursStart + 1, 24)
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
var body: some View {
Form {
Section("Default time window") {
HStack {
Text("Days back")
TextField("1", value: $daysBack, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Days forward")
TextField("7", value: $daysForward, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Default merge gap (hours)")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
}
.onChange(of: daysBack) { v in daysBack = max(0, v) }
.onChange(of: daysForward) { v in daysForward = max(0, v) }
.onChange(of: mergeGapHours) { v in mergeGapHours = max(0, v) }
Section("Mirroring defaults") {
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(hideDetails)
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
}
.disabled(disabled)
Section("Placeholder title") {
HStack {
Text("Title prefix")
TextField("🪞 ", text: $titlePrefix)
.frame(width: 90)
}
HStack {
Text("Placeholder title")
TextField("Busy", text: $placeholderTitle)
.frame(width: 170)
}
}
.disabled(disabled)
Section("Work hours") {
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
if filterByWorkHours {
HStack {
Text("Start hour")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.frame(width: 56)
Text("End hour")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.frame(width: 56)
Text("(local time, end exclusive)").foregroundStyle(.secondary)
}
.onChange(of: workHoursStart) { _ in clampWorkHours() }
.onChange(of: workHoursEnd) { _ in clampWorkHours() }
}
}
.disabled(disabled)
Section("Skip filters") {
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip organizers (name or email, one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedOrganizerFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.disabled(disabled)
}
.formStyle(.grouped)
.frame(width: 480, height: 560)
}
}
+183
View File
@@ -0,0 +1,183 @@
import SwiftUI
import EventKit
/// The "Routes (multi-source)" panel — extracted from ContentView so the
/// route list/editor is its own small, native-feeling view instead of one
/// piece of a 2000-line file. Owns no persisted state itself: `routes` is a
/// binding into ContentView's own @State (which still owns saving it to
/// UserDefaults), and everything else here is read-only context passed down.
struct RoutesSectionView: View {
@Binding var routes: [Route]
let calendars: [EKCalendar]
let isRunning: Bool
let titlePrefix: String
let placeholderTitle: String
let canAddRoute: Bool
let onAddRoute: () -> Void
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private func labelForCalendar(id: String) -> String {
calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Routes (multi-source)")
.font(.headline)
Spacer()
Button("Add from current selection", action: onAddRoute)
.disabled(isRunning || !canAddRoute)
.buttonStyle(.borderedProminent)
Button("Clear") { routes.removeAll() }
.disabled(isRunning || routes.isEmpty)
.buttonStyle(.bordered)
}
if routes.isEmpty {
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
LazyVStack(spacing: 10) {
ForEach($routes, id: \.id) { routeBinding in
routeCard(for: routeBinding)
}
}
}
}
}
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
}
Spacer(minLength: 12)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove") }
}
Divider()
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
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.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
)
}
@ViewBuilder
private func sourceSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("Source")
.font(.caption)
.foregroundStyle(.secondary)
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal))
.fontWeight(.semibold)
}
} else {
Text(labelForCalendar(id: route.sourceID))
.fontWeight(.semibold)
}
}
}
@ViewBuilder
private func targetSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Targets")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
Text(calLabel(tCal))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
} else {
Text(labelForCalendar(id: tid))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
}
}
}
}
}
}
@ViewBuilder
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Merge gap")
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
.frame(width: 56)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
.font(.subheadline)
}
@ViewBuilder
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Overlap")
Picker("Overlap", selection: routeBinding.overlap) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.frame(width: 170)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
.font(.subheadline)
}
}
+102
View File
@@ -0,0 +1,102 @@
import SwiftUI
/// Manual/fixed-time `launchd` scheduling — extracted from ContentView. Since
/// 1.7.0 this is a fallback/override; the primary reliability mechanism is
/// the event-driven auto-sync in `BusyMirrorAppController`, which arms itself
/// automatically once routes exist and needs no UI.
struct ScheduleSectionView: View {
@Binding var scheduleMode: ScheduleMode
@Binding var scheduleIntervalHours: Int
@Binding var scheduleHour: Int
@Binding var scheduleMinute: Int
let isRunning: Bool
let routesEmpty: Bool
let hasInstalledSchedule: Bool
let scheduleSummary: String
let onInstall: () -> Void
let onRemove: () -> Void
let onRevealLaunchAgent: () -> Void
let onScheduleTimeChanged: () -> Void
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private static let smallIntFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 1
f.maximumFractionDigits = 0
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Scheduled runs (manual override)")
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Mode", selection: $scheduleMode) {
ForEach(ScheduleMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(isRunning)
Spacer(minLength: 0)
}
if scheduleMode == .hourly {
HStack(spacing: 8) {
Text("Every")
TextField("1", value: $scheduleIntervalHours, formatter: Self.smallIntFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(scheduleIntervalHours == 1 ? "hour" : "hours")
Spacer(minLength: 0)
}
} else {
HStack(spacing: 8) {
Text("Time")
TextField("8", value: $scheduleHour, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(":")
TextField("0", value: $scheduleMinute, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Spacer(minLength: 0)
}
}
Text("Creates a LaunchAgent that runs the installed app with saved routes in write mode.")
.foregroundStyle(.secondary)
.font(.footnote)
Text(hasInstalledSchedule ? "Installed: \(scheduleSummary)" : "Not installed")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button("Install Schedule", action: onInstall)
.disabled(isRunning || routesEmpty)
Button("Remove Schedule", action: onRemove)
.disabled(isRunning || !hasInstalledSchedule)
Button("Reveal LaunchAgent", action: onRevealLaunchAgent)
.disabled(!hasInstalledSchedule)
Spacer(minLength: 0)
}
}
.onChange(of: scheduleHour) { _ in onScheduleTimeChanged() }
.onChange(of: scheduleMinute) { _ in onScheduleTimeChanged() }
.onChange(of: scheduleIntervalHours) { _ in onScheduleTimeChanged() }
}
}
+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")
}
}
+42
View File
@@ -2,6 +2,48 @@
All notable changes to BusyMirror will be documented in this file.
## [1.8.3] - 2026-08-27
### Fixed
- **Preferences missing from the standard app menu.** 1.8.1's fix for Preferences (a plain `Window` opened via `openWindow`) worked around `LSUIElement` having no app menu, but 1.8.2 removed `LSUIElement` and the workaround was never swapped back — so there was still no "Preferences…" in the app's own menu or Cmd+, response, only a button buried in the main window and a menu-bar-dropdown item. Restored a real `Settings { }` scene now that the app menu exists to host it; both the menu bar dropdown and the main window's button now call the standard `openSettings()` action (via `SettingsLink` in the main window) instead of a custom `openWindow(id:)`. ([BusyMirrorApp.swift](BusyMirror/BusyMirrorApp.swift), [MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift), [ContentView.swift](BusyMirror/ContentView.swift))
## [1.8.2] - 2026-08-27
### Changed
- **Standard app instead of menu-bar-only.** Removed `LSUIElement` from `Info.plist`: BusyMirror now shows a Dock icon, appears in Cmd+Tab, and gets the standard app menu (Cmd+Q to quit, among others). The menu bar extra stays as a secondary quick-access point. This also fixes the practical problem it was causing: with no Dock icon and no accessible app menu, there was no reliable way to quit the app to let an update replace the bundle — you had to know the menu bar dropdown's "Quit BusyMirror" existed and use exactly that. Verified via `lsappinfo` (`type="Foreground"`, previously `UIElement`) since this app type change isn't something a screenshot would catch either.
## [1.8.1] - 2026-08-26
### Fixed
- **Preferences window unreachable**: 1.8.0's Settings scene (⌘,) and its `SettingsLink` button didn't work because BusyMirror is an `LSUIElement` (accessory) app — those don't get the standard app menu, so there's no menu for the automatic "Settings…" command or Cmd+, to live in. Replaced the `Settings { }` scene with a plain `Window(id: "preferences-window")`, opened via `openWindow(id:)` — the same mechanism that already reliably opens the main window from the menu bar. Also added a "Preferences…" item to the menu bar dropdown itself, so there are two direct routes in instead of one that depended on OS menu plumbing this app type doesn't get. ([BusyMirrorApp.swift](BusyMirror/BusyMirrorApp.swift), [MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift), [ContentView.swift](BusyMirror/ContentView.swift))
## [1.8.0] - 2026-08-26
### Added
- **Real Settings (⌘,) window**: `PreferencesView` now hosts the pure-defaults controls (time window, privacy/mirroring defaults, title prefix/placeholder, work hours, skip filters) that used to live in the main window's "General Settings" panel. They're `@AppStorage`-backed so both windows always see the same live values. Main window keeps everything with session/live state: routes, calendar picker, dry-run toggle, Export/Import, manual scheduling, cleanup.
- **Menu bar icon reflects state**: idle (`calendar.badge.clock`), syncing (`arrow.triangle.2.circlepath.circle.fill`), or last run failed (`exclamationmark.triangle.fill`).
- **Menu bar dropdown diagnostics**: last-sync time and result, and whether auto-sync is currently armed — sourced from the same `lastRunAtISO`/`lastRunOK`/`lastRunSummary` keys `--status` reads, so CLI/interactive/auto-sync runs all feed the same indicator.
- **`ContentView.swift` split** from ~2000 lines into `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (plus a small shared `CalendarDisplay.swift`). View-layer extraction — state ownership and settings persistence were deliberately left untouched, since that's exactly the area the 1.6.0/1.6.1 release had to fix a real data-loss bug in.
### Fixed
- **Latent revert-on-relaunch bug**, caught while building the Settings window: moving the `@AppStorage`-backed preference controls out of `ContentView` meant changing them no longer re-triggered `saveSettingsToDefaults()`, so the `settings.v2` snapshot blob could go stale relative to the individual UserDefaults keys. Since `loadSettingsFromDefaults()` used the same full `applySnapshot` as Import, the next launch would silently revert a preference you'd just changed in Preferences back to whatever the stale blob had. Fixed by splitting launch-time restore into a narrow `restoreLaunchState` (routes + manual selection only — the only state that isn't already self-restoring via `@AppStorage`) separate from `applySnapshot` (unchanged, still used by Import where overwriting everything is the point). ([ContentView.swift](BusyMirror/ContentView.swift))
## [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
+6 -2
View File
@@ -2,7 +2,7 @@
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync manually or open the main window; it no longer appears in the Dock.
On macOS, BusyMirror runs as a standard app (Dock icon, ⌘Q to quit) and also has a menu bar icon for quick sync/status without opening the main window.
## What it does (current)
- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
@@ -13,7 +13,7 @@ On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync m
- DRY-RUN mode: see what would be created/updated/deleted without writing.
- Activity Log in the app plus persistent file logging on disk.
- In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section.
- Menu bar controls: trigger `Sync Now`, open the main window, or quit without keeping a Dock icon around.
- Menu bar controls: trigger `Sync Now`, open the main window, open Preferences, or quit.
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
- Merge adjacent events with a configurable gap.
- Time window controls (days back/forward) and Work Hours filter.
@@ -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`.
+14 -12
View File
@@ -12,19 +12,21 @@
- 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.
- 1.8.0: **V2 UI polish.** `ContentView.swift` split from ~2000 lines into focused view files — `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (state stays owned by `ContentView`/`@AppStorage`; these are view-layer extractions, not a full MVVM rewrite — the settings-persistence model didn't need touching and touching it is exactly how the 1.6.0/1.6.1 data-loss bug happened). A preferences window hosting the pure `@AppStorage`-backed defaults (time window, privacy/mirroring defaults, work hours, skip filters) that used to live in the main window; this surfaced a latent bug — moving fields out of `ContentView` meant they stopped re-triggering `saveSettingsToDefaults()`, so on next launch `applySnapshot` would have silently reverted a preference changed in the new window using the stale blob. Fixed by splitting launch-time restore (`restoreLaunchState`, routes/selections only, since @AppStorage fields already self-restore) from Import's full restore (`applySnapshot`, unchanged). Menu bar icon now reflects idle/syncing/error state, and the dropdown shows last-sync time/result and whether auto-sync is armed.
- 1.8.1: the preferences window (originally a `Settings{}` scene) didn't actually open — `LSUIElement` apps get no standard app menu, so Cmd+,/`SettingsLink` had no menu to hook into. Replaced with a plain `Window` opened via `openWindow(id:)`, plus a "Preferences…" menu bar item as a second entry point.
- 1.8.2: **standard app, not menu-bar-only.** Removed `LSUIElement` — Dock icon, Cmd+Tab, standard app menu (Cmd+Q) are back. The accessory-app design (no Dock icon) turned out to make the app hard to quit reliably, which blocked replacing the bundle during updates. Menu bar extra stays as a secondary quick-access point.
## 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)
## Then
- Signed/notarized binaries and release pipeline
- CLI quality: friendlier `--routes` parsing and help flag
- “Dry-run by default” preference
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.
2. **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.