ad6ae396da
Bug fixes and code quality improvements: - Fix mirror index dirtied on every sync (MirrorRecord.updatedAt in equality) - Fix mirror URL corruption: encode calendar/source IDs before joining with ';' and use percentEncodedPath to prevent double-encoding - Fix cleanup route mutating UI calendar picker selection unnecessarily - Fix --exit flag redundancy (isCLIRun no longer implies termination) - Remove dead SKIP_ALL_DAY_DEFAULT constant - Replace deprecated FileHandle(forWritingAtPath:) with throwing variant - Add EKEventStoreChanged observer for live calendar list refresh - Extract AppLogStore into its own file (AppLogStore.swift) - Add Block.span(start🔚) factory; replace verbose nil-field constructions - Remove redundant MainActor.run{} wrappers inside @MainActor MirrorEngine - Fix SettingsPayload indentation inside ContentView All 45 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
9.1 KiB
Markdown
160 lines
9.1 KiB
Markdown
# BusyMirror — Agent Reference
|
||
|
||
> This file is written for AI coding agents. It assumes you know nothing about the project.
|
||
|
||
## 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.
|
||
|
||
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.
|
||
|
||
Key capabilities:
|
||
- Manual or route-driven multi-source mirroring
|
||
- Privacy mode: hide details (placeholder title)
|
||
- DRY-RUN mode to preview changes without writing
|
||
- Scheduled headless runs via a self-installed `launchd` LaunchAgent
|
||
- Settings autosave/restore, plus Import/Export JSON
|
||
- CLI support for headless/scripted runs
|
||
|
||
## Technology Stack
|
||
|
||
| Layer | Technology |
|
||
|-------|------------|
|
||
| Language | Swift 5.0 |
|
||
| UI Framework | SwiftUI + AppKit (menu bar, panels) |
|
||
| Calendar API | EventKit (`EKEventStore`, `EKEvent`, `EKCalendar`) |
|
||
| Persistence | `UserDefaults` (JSON-encoded settings), `@AppStorage` |
|
||
| Scheduling | `launchd` / `launchctl` (user LaunchAgent) |
|
||
| Build System | Xcode project (`BusyMirror.xcodeproj`) + Makefile |
|
||
| Target OS | macOS 15.5+ |
|
||
| Signing | Ad-hoc (`CODE_SIGN_IDENTITY = "-"`) — not notarized |
|
||
|
||
No external Swift Package Manager dependencies are used. The project is self-contained.
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
BusyMirror/
|
||
├── BusyMirrorApp.swift # App entry point; defines Window + MenuBarExtra
|
||
├── ContentView.swift # Main UI, settings, CLI, scheduling (≈1800 lines)
|
||
├── MirrorEngine.swift # EventKit mirror engine (read, deduplicate, merge, create/update/delete)
|
||
├── MirrorConfig.swift # Configuration struct passed to the engine
|
||
├── MirrorUtils.swift # URL builders, mirror detection, calendar labels
|
||
├── BlockMath.swift # Block merging, gap calculation, overlap logic (Block.span factory)
|
||
├── 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
|
||
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
||
└── Assets.xcassets/ # AppIcon set and accent color
|
||
|
||
BusyMirror.xcodeproj/ # Xcode project (PBXFileSystemSynchronizedRootGroup — new .swift files are auto-included)
|
||
BusyMirrorTests/ # Unit tests: BlockMathTests, EventFiltersTests, MirrorUtilsTests (45 tests)
|
||
BusyMirrorUITests/ # UI tests (empty)
|
||
```
|
||
|
||
**Architecture note:** `ContentView.swift` handles the SwiftUI view hierarchy, settings serialization, CLI argument parsing, `launchd` scheduling, and logging. The EventKit mirror engine lives in `MirrorEngine.swift` and is invoked from `ContentView` via `makeEngine()`. Pure helper logic (block math, filters, URL utilities) has been extracted into standalone files for testability.
|
||
|
||
When making changes, keep the existing data flow (`@EnvironmentObject`, `@AppStorage`, `@State`) intact in `ContentView.swift`.
|
||
|
||
## Build and Release Commands
|
||
|
||
### Makefile targets
|
||
|
||
```bash
|
||
make build-debug # Debug build via xcodebuild
|
||
make build-release # Release build via xcodebuild
|
||
make sign-app # Ad-hoc sign the Release app (strip xattr, codesign)
|
||
make app # Verify signed app exists
|
||
make package # Create BusyMirror-<version>-macOS.zip + .sha256
|
||
make clean # Clean derived data
|
||
```
|
||
|
||
Built products:
|
||
- Unsigned release: `build/DerivedData/Build/Products/Release/BusyMirror.app`
|
||
- Signed release: `build/ReleaseSigned/BusyMirror.app`
|
||
|
||
### Xcode
|
||
|
||
1. Open `BusyMirror.xcodeproj`.
|
||
2. Select **BusyMirror** scheme → **My Mac**.
|
||
3. **Product → Build** (or **Archive** for distribution).
|
||
|
||
### Versioning
|
||
|
||
- `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` live in `project.pbxproj`.
|
||
- The Makefile extracts `MARKETING_VERSION` automatically for ZIP naming.
|
||
- Update both Debug and Release build configurations when bumping the version.
|
||
|
||
## Code Style Guidelines
|
||
|
||
- **Language:** all code, comments, and user-facing strings are in **English**.
|
||
- **Concurrency:** `@MainActor` is required on methods that mutate SwiftUI `@State` or call EventKit on the main thread. The compiler enforces strict concurrency.
|
||
- **Formatting:** standard Swift style (4-space indentation). No external linter is configured.
|
||
- **Logging:** use the `log(_:)` method inside `ContentView`; it appends to both the on-screen log editor and the persistent file log (`~/Library/Logs/BusyMirror/BusyMirror.log`).
|
||
- **Error handling:** EventKit errors are caught and logged; they must never crash the app. The file logger swallows its own errors silently.
|
||
|
||
## Testing
|
||
|
||
- Unit tests exist in `BusyMirrorTests/` for `BlockMath`, `EventFilters`, and `MirrorUtils`.
|
||
- When adding logic, prefer extracting pure functions (e.g., block merging, gap calculation, filter logic) so they can be unit-tested.
|
||
- Manual testing checklist for releases:
|
||
1. Grant Calendar permission.
|
||
2. Select a source and target, run DRY-RUN, verify log output.
|
||
3. Toggle WRITE and run Mirror Now; verify placeholders appear in the target calendar.
|
||
4. Move a source event and re-run; verify the placeholder updates.
|
||
5. Test Cleanup Placeholders (dry-run and write).
|
||
6. Add a route, install a schedule, verify the LaunchAgent plist is created in `~/Library/LaunchAgents/`.
|
||
7. Trigger a menu-bar sync and confirm the window opens if not visible.
|
||
|
||
## Security and Privacy Considerations
|
||
|
||
- **Calendar data:** the app reads and writes the user’s calendars via EventKit. It must handle permission denial gracefully.
|
||
- **Sandbox:** the app uses the macOS app sandbox (`com.apple.security.app-sandbox`) and the `com.apple.security.personal-information.calendars` entitlement.
|
||
- **Signing:** releases are ad-hoc signed only (`codesign --sign -`). They are **not notarized**. Gatekeeper may block the app on first launch; users may need to right-click → Open.
|
||
- **Loop guard:** a `sessionGuard` set prevents mirroring an event into the same target twice in one run, and prefix-based detection (`titlePrefix`) prevents re-mirroring already-mirrored placeholders.
|
||
- **Logging:** log files are written to the user’s `~/Library/Logs/BusyMirror/`. No log data is transmitted externally.
|
||
|
||
## CLI and Scheduling
|
||
|
||
The binary supports headless execution:
|
||
|
||
```bash
|
||
# Run saved routes (used by the LaunchAgent)
|
||
BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit
|
||
|
||
# Manual route via 1-based UI indices
|
||
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`.
|
||
|
||
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.
|
||
|
||
## Key Files to Know
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `BusyMirror/ContentView.swift` | UI, settings, CLI, scheduling |
|
||
| `BusyMirror/MirrorEngine.swift` | EventKit mirror engine (runMirror, runCleanup, index persistence) |
|
||
| `BusyMirror/MirrorConfig.swift` | Configuration struct for mirror runs |
|
||
| `BusyMirror/MirrorUtils.swift` | Mirror URL builders, event detection, calendar labels |
|
||
| `BusyMirror/BlockMath.swift` | Block merging, gap calculation, overlap logic |
|
||
| `BusyMirror/EventFilters.swift` | Work-hours, title, and organizer filters |
|
||
| `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/BusyMirror.entitlements` | Sandbox + calendar entitlement |
|
||
| `Makefile` | Reproducible build, sign, and package targets |
|
||
| `CHANGELOG.md` | Release notes (human-readable) |
|
||
| `ROADMAP.md` | Planned features |
|
||
|
||
## Notes for Agents
|
||
|
||
- Do **not** add third-party dependencies unless the user explicitly asks. The project intentionally has zero external packages.
|
||
- If you refactor `ContentView.swift`, preserve `@AppStorage` keys and `UserDefaults` keys exactly; users have existing settings on disk.
|
||
- The mirror engine (`MirrorEngine.swift`) is `@MainActor` and accepts an `EKEventStore` plus a logging closure. It does not directly mutate SwiftUI `@State`; `ContentView` manages all view state.
|
||
- When modifying build settings, update both Debug and Release configurations in `project.pbxproj`, and update `CHANGELOG.md` if the change is user-visible.
|
||
- Do not run `git commit`, `git push`, or similar operations unless explicitly asked.
|