- 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>
16 KiB
16 KiB
Changelog
All notable changes to BusyMirror will be documented in this file.
[1.8.0] - 2026-08-26
Added
- Real Settings (⌘,) window:
PreferencesViewnow 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/lastRunSummarykeys--statusreads, so CLI/interactive/auto-sync runs all feed the same indicator. ContentView.swiftsplit from ~2000 lines intoCalendarsSectionView,RoutesSectionView,ScheduleSectionView,LogSectionView(plus a small sharedCalendarDisplay.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 ofContentViewmeant changing them no longer re-triggeredsaveSettingsToDefaults(), so thesettings.v2snapshot blob could go stale relative to the individual UserDefaults keys. SinceloadSettingsFromDefaults()used the same fullapplySnapshotas 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 narrowrestoreLaunchState(routes + manual selection only — the only state that isn't already self-restoring via@AppStorage) separate fromapplySnapshot(unchanged, still used by Import where overwriting everything is the point). (ContentView.swift)
[1.7.0] - 2026-08-26
Added
- Event-driven background sync, replacing the hourly
launchd StartIntervalpoll (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-installedlaunchdschedule, and reacts toEKEventStoreChanged(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 1runs did. - This logic lives in
BusyMirrorAppController, notContentView— the controller is owned by theAppstruct for the whole process lifetime, whereasContentView's ownEKEventStoreChangedobserver is torn down in.onDisappearwhen 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)
[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, MirrorEngine.swift, BlockMath.swift)
- CLI flag
--sync-remindersto enable reminder syncing from scripted/headless runs. - CLI polish:
--help/-h,--list-calendars, and--status(all support--jsonfor machine-readable output). Scheduled/headless runs now record last-run time, success/failure, and a summary so--statuscan 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)
Fixed
- Settings silently wiped on upgrade:
SettingsPayloadused fully auto-synthesizedCodable, so decoding a settings blob written by an older build (missing a field added since, e.g.syncReminders) threwkeyNotFoundand 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.SettingsPayloadnow has a custominit(from:)that reads every field added after the first release withdecodeIfPresent+ its existing default, matching the patternRoutealready used.loadSettingsFromDefaults()also now recovers routes from the legacyroutes.v1backup key ifsettings.v2decodes successfully but with an emptyroutesarray — repairing installs that already hit this bug before upgrading. (ContentView.swift, SettingsPayloadTests.swift)
[1.5.1] - 2026-05-27
Fixed
- Mirror index dirtied on every sync:
MirrorRecordused synthesizedEquatablewhich includedupdatedAt: Date = Date(). BecauseupdatedAtis set to the current time whenever a record is constructed, the comparison used to detect changes always returned "not equal", causingUserDefaultsto be written on every sync run even when nothing changed. A custom==/hash(into:)now excludesupdatedAt. (MirrorEngine.swift) - Mirror URL corruption with special characters in calendar IDs:
buildMirrorURLplaced raw calendar and source IDs into the URL path without percent-encoding them. If any ID contained the;separator character the resulting URL would be mis-parsed on the next sync.mirrorURLComponentEncode(which already existed and was tested) is now called on all ID fields before they are joined. The path is set viapercentEncodedPathto preventURLComponentsfrom double-encoding the already-encoded values. (MirrorUtils.swift) - Dead constant: removed unused
SKIP_ALL_DAY_DEFAULT = truefromContentView.swift. - Deprecated
FileHandleAPI: replacedFileHandle(forWritingAtPath:)+handle.closeFile()with the modern throwingFileHandle(forWritingTo:),handle.seekToEnd(), andhandle.write(contentsOf:)inAppLogStore. (AppLogStore.swift) - Cleanup jumps calendar picker:
runCleanupForRoutewas mutatingsourceIndex,sourceID, andtargetIDsduring route cleanup, visibly shifting the picker in the UI. Cleanup does not need to update the UI selection; those mutations are removed. --exitflag redundancy:NSApp.terminatewas called wheneverisCLIRunwas true, making--exita no-op. The app now exits only when--exitis explicitly passed, so--routes/--run-saved-routescan be used without forcing termination.
Added
- Live calendar refresh: the calendar list now updates automatically when the system calendar database changes (
EKEventStoreChangednotification), removing the need to press "Refresh Calendars" after adding or removing a calendar. The observer is unregistered on view disappear and re-registered when theEKEventStoreis recreated. (ContentView.swift)
Changed
AppLogStoreextracted: moved from an inline private enum inContentView.swiftto its own fileAppLogStore.swiftfor easier navigation. (AppLogStore.swift)Block.spanfactory: addedBlock.span(start:end:)to replace the repetitiveBlock(start:end:srcStableID:nil:label:nil:notes:nil:occurrence:nil)construction pattern throughoutBlockMath.swiftandMirrorEngine.swift. (BlockMath.swift)- Removed redundant
MainActor.runwrappers:MirrorEngineis@MainActor; wrappingstore.save/store.removeintry await MainActor.run { }was unnecessary and added overhead. (MirrorEngine.swift) SettingsPayloadindentation: the nested struct was de-dented to column 0 insideContentView, making it look like a top-level type. Indentation is now consistent with the surrounding members.
Build
- Bump version to 1.5.1 (build 20).
[1.5.0] - 2026-05-27
Removed
- Mark Private feature: removed the non-functional server-side "Private" flagging for mirrored events. The Objective-C runtime hack (
setPrivate:, KVC onsensitivity/classification) never worked reliably and would have blocked App Store review. This simplifies the UI and removes a private-API liability.
Changed
- Extracted mirror engine: the ~500-line
runMirrorandrunCleanuplogic has been moved fromContentView.swiftinto a newMirrorEngine.swiftclass.ContentViewnow delegates to the engine viamakeEngine(). MirrorRecord, mirror index persistence, andSAME_TIME_TOL_MINnow live in the engine module.calLabelmoved toMirrorUtils.swiftso it can be shared between UI and engine.
Build
- Bump version to 1.5.0 (build 19).
[1.4.0] - 2026-05-27
Fixed
- Sandbox LaunchAgent: added
temporary-exceptionentitlement so scheduled runs work in the sandboxed app. - Mirror URL generation:
buildMirrorURLwas silently broken —URL(string:)rejects raw|characters on current macOS, so mirror metadata URLs were alwaysnil. Rebuilt withURLComponentsusing;separator and backward-compatible parser. - Crash on Cleanup:
runCleanup()no longer crashes if the selected source calendar was removed. - State corruption in multi-route runs:
runConfiguredRoutesno longer mutates global@Statesettings and restores them at the end of each loop; instead it passes aMirrorConfigstruct into the engine. - KVC safety: removed misleading
do-catcharoundsetValue:forKey:insetEventPrivateIfSupported()(Objective-C exceptions are uncatchable in Swift). - Log memory leak: in-memory log now caps at 2,000 lines.
- CLI race:
tryRunCLIIfPresent()now preloads calendars when access is already granted, eliminating the 10-second timeout race. - launchCtl output: stdout and stderr now use separate pipes instead of interleaving into one.
Added
- Cancel button: long-running mirrors now show a Cancel button; loops check
Task.isCancelledfor responsive cancellation. - Progress indicator: multi-route runs display
"Route X of Y"in the status area. - Unit tests: 45 tests across
BlockMathTests,MirrorUtilsTests, andEventFiltersTests. - Extracted modules:
BlockMath.swift,MirrorUtils.swift,EventFilters.swift, andMirrorConfig.swiftseparate pure logic from the UI monolith. - Target event cache: target calendars shared across routes are fetched only once per run session.
Changed
mergeGapMinis now a computed property instead of redundant@State.- Log editor is now read-only (still selectable/copyable).
SettingsPayload.excludedOrganizerFiltersis now non-optional for consistency.
Build
- Bump minimum macOS version to
15.5inInfo.plist. - Bump version to 1.4.0 (build 18).
[1.3.9] - 2026-04-09
- New: add a macOS menu bar extra with
Sync Now,Open BusyMirror, andQuit BusyMirror. - UX: menu bar sync requests reuse the existing mirror flow and can open the main window automatically when needed.
- UX: BusyMirror now runs as a menu bar-only app and no longer appears in the Dock.
- Build: bump version to 1.3.9 (build 17).
[1.3.8] - 2026-04-08
- Fix: release ZIPs now package
BusyMirror.appat the archive root instead of embedding the full build path. - Fix: release builds now apply an ad-hoc bundle signature before packaging so downloaded artifacts pass
codesign --verify --deep --strict. - Build: suppress resource fork sidecars in release ZIPs via
ditto --norsrc --keepParent. - Build: bump version to 1.3.8 (build 16).
[1.3.7] - 2026-03-24
- Fix: mirror reconciliation now survives target providers that strip BusyMirror's custom event URL metadata.
- Fix: moved and deleted source events are tracked via stable EventKit identifiers and a persisted local mirror index, so target placeholders update reliably.
- Fix: mirror updates now detect title and notes changes, not just start/end time changes.
- Build: bump version to 1.3.7 (build 15).
[1.3.6] - 2026-03-13
- Scheduling: add in-app
Scheduled runscontrols to install or remove a userlaunchdLaunchAgent from BusyMirror itself. - Scheduling: support
Hourly,Daily, andWeekdaysschedules; hourly mode runs saved routes viaStartInterval. - UX: generate and ship a proper macOS app icon set for BusyMirror.
- Build: bump version to 1.3.6 (build 14).
[1.3.4] - 2026-03-13
- Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run.
- Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates
EKEventStorefor a hard reload. - UX: the top bar
DRY RUN/WRITEstatus pill is clickable, the left column keeps its own height on desktop, and the app can reveal its log file from the UI. - Logging: mirror activity is persisted to
~/Library/Logs/BusyMirror/BusyMirror.logwith simple rotation toBusyMirror.previous.log. - CLI: add
--run-saved-routesso scheduledlaunchdruns can use the saved UI routes instead of fragile index-based route definitions.
[1.3.1] - 2025-10-13
- Fix: auto-delete of mirrored placeholders when the source is removed now works even if no source instances remain in the window. Also cleans legacy mirrors without URLs by matching exact times.
[1.3.2] - 2025-10-13
- New: Organizer filters — skip events by organizer (name/email/URL). UI under Options and persisted in settings.
- CLI: add
--exclude-organizers(and--exclude-titles) flags to control filters when running headless.
[1.2.4] - 2025-10-10
- Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present.
[1.3.0] - 2025-10-10
- New: Mark Private option to mirror with prefix + real title and set event privacy on supported servers; available globally and per-route; persisted.
- Misc: calendar access fixes, concurrency annotations, accepted‑only filter, settings autosave/restore, Mirror Now enablement.
[1.2.3] - 2025-10-10
- Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs.
- UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs.
- Build: bump version to 1.2.3 (build 5).
[1.2.1] - 2025-10-10
- Fix: reinitialize EKEventStore after permission grant to avoid “Loaded 0 calendars” right after approval.
- Fix: attendee status filter uses current user’s attendee
participantStatus == .acceptedinstead of unavailable APIs. - Concurrency: mark
requestAccess()andreloadCalendars()as@MainActorto satisfy strict concurrency checks. - Dev: add Makefile with
build-debug,build-release, andpackagetargets; produce versioned ZIP + SHA-256.
[1.2.0] - 2024-09-29
- Feature: multi-route mirroring, overlap modes, merge gaps, work hours filter, CLI support, export/import settings.