Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e29b3716c | ||
|
|
d40bc986c5 |
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
## Project Overview
|
## 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:
|
Key capabilities:
|
||||||
- Manual or route-driven multi-source mirroring
|
- Manual or route-driven multi-source mirroring
|
||||||
@@ -44,7 +44,7 @@ BusyMirror/
|
|||||||
├── EventFilters.swift # Work-hours, title, and organizer filters
|
├── EventFilters.swift # Work-hours, title, and organizer filters
|
||||||
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
||||||
├── AppLogStore.swift # File-backed log store with rotation (AppLogStore enum)
|
├── 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
|
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
||||||
└── Assets.xcassets/ # AppIcon set and accent color
|
└── Assets.xcassets/ # AppIcon set and accent color
|
||||||
|
|
||||||
@@ -146,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/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
|
||||||
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
||||||
| `BusyMirror/AppLogStore.swift` | File-backed log with rotation (`~/Library/Logs/BusyMirror/`) |
|
| `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 |
|
| `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement |
|
||||||
| `Makefile` | Reproducible build, sign, and package targets |
|
| `Makefile` | Reproducible build, sign, and package targets |
|
||||||
| `CHANGELOG.md` | Release notes (human-readable) |
|
| `CHANGELOG.md` | Release notes (human-readable) |
|
||||||
|
|||||||
@@ -410,7 +410,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 25;
|
CURRENT_PROJECT_VERSION = 27;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.8.1;
|
MARKETING_VERSION = 1.8.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 25;
|
CURRENT_PROJECT_VERSION = 27;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.8.1;
|
MARKETING_VERSION = 1.8.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -23,17 +23,14 @@ struct BusyMirrorApp: App {
|
|||||||
.environmentObject(appController)
|
.environmentObject(appController)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plain Window (not a Settings scene) — LSUIElement (accessory)
|
// A real Settings scene: now that the app is standard (not
|
||||||
// apps don't get the standard app menu, so Cmd+, / the automatic
|
// LSUIElement), this gets the conventional Cmd+, and a "Preferences…"
|
||||||
// "Settings…" command has no menu to live in and SettingsLink has
|
// item in the app's own menu for free — the location people actually
|
||||||
// nothing reliable to trigger. openWindow(id:) is the same mechanism
|
// look, unlike a plain Window which only opens from wherever we
|
||||||
// that already reliably opens the main window from the menu bar, so
|
// explicitly put a button for it.
|
||||||
// reuse it here instead.
|
Settings {
|
||||||
Window("Preferences", id: BusyMirrorSceneID.preferencesWindow) {
|
|
||||||
PreferencesView()
|
PreferencesView()
|
||||||
.environmentObject(appController)
|
.environmentObject(appController)
|
||||||
}
|
}
|
||||||
.defaultSize(width: 480, height: 560)
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var appController: BusyMirrorAppController
|
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@State private var store = EKEventStore()
|
@State private var store = EKEventStore()
|
||||||
@State private var hasAccess = false
|
@State private var hasAccess = false
|
||||||
@State private var calendars: [EKCalendar] = []
|
@State private var calendars: [EKCalendar] = []
|
||||||
@@ -616,8 +615,8 @@ struct ContentView: View {
|
|||||||
Text("Mirroring defaults, filters, and work hours moved to Preferences.")
|
Text("Mirroring defaults, filters, and work hours moved to Preferences.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Button("Open Preferences…") {
|
SettingsLink {
|
||||||
appController.openPreferencesWindow(using: openWindow)
|
Text("Open Preferences…")
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>LSUIElement</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>15.5</string>
|
<string>15.5</string>
|
||||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import ServiceManagement
|
|||||||
|
|
||||||
enum BusyMirrorSceneID {
|
enum BusyMirrorSceneID {
|
||||||
static let mainWindow = "main-window"
|
static let mainWindow = "main-window"
|
||||||
static let preferencesWindow = "preferences-window"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -67,11 +66,6 @@ final class BusyMirrorAppController: ObservableObject {
|
|||||||
openWindow(id: BusyMirrorSceneID.mainWindow)
|
openWindow(id: BusyMirrorSceneID.mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openPreferencesWindow(using openWindow: OpenWindowAction) {
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
openWindow(id: BusyMirrorSceneID.preferencesWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Event-driven background sync
|
// MARK: - Event-driven background sync
|
||||||
//
|
//
|
||||||
// Owned here (not by ContentView) because this controller lives for the
|
// Owned here (not by ContentView) because this controller lives for the
|
||||||
@@ -256,6 +250,7 @@ final class BusyMirrorAppController: ObservableObject {
|
|||||||
|
|
||||||
struct BusyMirrorMenuBarView: View {
|
struct BusyMirrorMenuBarView: View {
|
||||||
@Environment(\.openWindow) private var openWindow
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@Environment(\.openSettings) private var openSettings
|
||||||
@EnvironmentObject private var appController: BusyMirrorAppController
|
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -287,7 +282,8 @@ struct BusyMirrorMenuBarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button("Preferences…") {
|
Button("Preferences…") {
|
||||||
appController.openPreferencesWindow(using: openWindow)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
openSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
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
|
## [1.8.1] - 2026-08-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
|
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)
|
## What it does (current)
|
||||||
- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
|
- 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.
|
- DRY-RUN mode: see what would be created/updated/deleted without writing.
|
||||||
- Activity Log in the app plus persistent file logging on disk.
|
- 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.
|
- 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`.
|
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
|
||||||
- Merge adjacent events with a configurable gap.
|
- Merge adjacent events with a configurable gap.
|
||||||
- Time window controls (days back/forward) and Work Hours filter.
|
- Time window controls (days back/forward) and Work Hours filter.
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@
|
|||||||
- 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig`
|
- 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)
|
- 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.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). Real `Settings { }` scene (⌘,) via `PreferencesView`, 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 Settings 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.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
|
## Next
|
||||||
1. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user