Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e9fe5325 |
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -451,7 +451,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -1311,6 +1311,9 @@ struct ContentView: View {
|
||||
tryRunCLIIfPresent()
|
||||
enforceNoSourceInTargets()
|
||||
handlePendingMenuBarSyncIfNeeded()
|
||||
if !isCLIRun {
|
||||
appController.bootstrapBackgroundSync()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
appController.setMainWindowVisible(false)
|
||||
@@ -1352,6 +1355,7 @@ struct ContentView: View {
|
||||
.onChange(of: routes) { _ in
|
||||
saveSettingsToDefaults()
|
||||
handlePendingMenuBarSyncIfNeeded()
|
||||
appController.armAutoSyncIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import EventKit
|
||||
import ServiceManagement
|
||||
|
||||
enum BusyMirrorSceneID {
|
||||
static let mainWindow = "main-window"
|
||||
@@ -33,6 +35,187 @@ final class BusyMirrorAppController: ObservableObject {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
openWindow(id: BusyMirrorSceneID.mainWindow)
|
||||
}
|
||||
|
||||
// MARK: - Event-driven background sync
|
||||
//
|
||||
// Owned here (not by ContentView) because this controller lives for the
|
||||
// whole process, independent of the main window's lifecycle. ContentView's
|
||||
// own EKEventStoreChanged observer is torn down in .onDisappear when the
|
||||
// window closes — fine for keeping the UI's calendar list fresh while
|
||||
// open, but useless for background operation, which is the entire point
|
||||
// of replacing the old hourly launchd poll. This runs regardless of
|
||||
// whether the window is open, closed, or never opened this session.
|
||||
|
||||
private let backgroundStore = EKEventStore()
|
||||
private var storeObserver: NSObjectProtocol?
|
||||
private var wakeObserver: NSObjectProtocol?
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
private var fallbackTask: Task<Void, Never>?
|
||||
private var hasArmedAutoSync = false
|
||||
|
||||
private let settingsDefaultsKey = "settings.v2"
|
||||
private let legacyRoutesDefaultsKey = "routes.v1"
|
||||
private let launchAgentLabel = "com.cqrenet.BusyMirror.saved-routes"
|
||||
|
||||
private var launchAgentURL: URL {
|
||||
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||
return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Call once at app launch. Only activates if calendar access has already
|
||||
/// been granted in a prior session — never prompts on its own, since a
|
||||
/// permission dialog with no window open would be confusing. First-run
|
||||
/// consent stays ContentView's job.
|
||||
func bootstrapBackgroundSync() {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let hasAccess: Bool
|
||||
if #available(macOS 14.0, *) {
|
||||
hasAccess = status == .fullAccess
|
||||
} else {
|
||||
hasAccess = status == .authorized
|
||||
}
|
||||
guard hasAccess else { return }
|
||||
armAutoSyncIfPossible()
|
||||
}
|
||||
|
||||
/// Re-check whether auto-sync should activate. Safe to call repeatedly
|
||||
/// (e.g. from ContentView whenever the saved routes list changes) — it
|
||||
/// only does anything the first time routes go from empty to non-empty.
|
||||
func armAutoSyncIfPossible() {
|
||||
guard !hasArmedAutoSync else { return }
|
||||
guard !loadRoutesFromDefaults().isEmpty else { return }
|
||||
hasArmedAutoSync = true
|
||||
|
||||
if SMAppService.mainApp.status != .enabled {
|
||||
try? SMAppService.mainApp.register()
|
||||
AppLogStore.append("[auto-sync] Registered as login item.")
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
|
||||
removeLegacyLaunchdSchedule()
|
||||
}
|
||||
if storeObserver == nil {
|
||||
storeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .EKEventStoreChanged, object: backgroundStore, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.scheduleAutoSync(reason: "calendar changed")
|
||||
}
|
||||
}
|
||||
if wakeObserver == nil {
|
||||
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.scheduleAutoSync(reason: "system woke from sleep")
|
||||
}
|
||||
}
|
||||
if fallbackTask == nil {
|
||||
fallbackTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 30 * 60 * 1_000_000_000)
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.scheduleAutoSync(reason: "periodic fallback check")
|
||||
}
|
||||
}
|
||||
}
|
||||
AppLogStore.append("[auto-sync] Armed: watching for calendar changes.")
|
||||
}
|
||||
|
||||
private func removeLegacyLaunchdSchedule() {
|
||||
let domain = "gui/\(getuid())"
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||
proc.arguments = ["bootout", domain, launchAgentURL.path]
|
||||
proc.standardOutput = Pipe()
|
||||
proc.standardError = Pipe()
|
||||
try? proc.run()
|
||||
proc.waitUntilExit()
|
||||
try? FileManager.default.removeItem(at: launchAgentURL)
|
||||
AppLogStore.append("[auto-sync] Removed launchd schedule (superseded by change-driven sync).")
|
||||
}
|
||||
|
||||
/// Debounces bursts of EKEventStoreChanged notifications into one sync.
|
||||
private func scheduleAutoSync(reason: String) {
|
||||
debounceTask?.cancel()
|
||||
debounceTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.runAutoSync(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRoutesFromDefaults() -> [Route] {
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = defaults.data(forKey: settingsDefaultsKey),
|
||||
let snap = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: data) {
|
||||
return snap.routes
|
||||
}
|
||||
if let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey),
|
||||
let routes = try? JSONDecoder().decode([Route].self, from: legacyData) {
|
||||
return routes
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func runAutoSync(reason: String) async {
|
||||
guard !isSyncing else { return }
|
||||
let routes = loadRoutesFromDefaults()
|
||||
guard !routes.isEmpty else { return }
|
||||
|
||||
guard let snap = UserDefaults.standard.data(forKey: settingsDefaultsKey),
|
||||
let settings = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: snap) else {
|
||||
return
|
||||
}
|
||||
|
||||
setSyncing(true)
|
||||
AppLogStore.append("[auto-sync] Triggered by \(reason).")
|
||||
var errorCount = 0
|
||||
let engine = MirrorEngine(log: { s in
|
||||
AppLogStore.append("[auto-sync] \(s)")
|
||||
let lower = s.lowercased()
|
||||
if lower.contains("error") || lower.contains("fail") { errorCount += 1 }
|
||||
})
|
||||
|
||||
let calendars = backgroundStore.calendars(for: .event)
|
||||
func calendar(id: String) -> EKCalendar? { calendars.first { $0.calendarIdentifier == id } }
|
||||
|
||||
var ranAnyRoute = false
|
||||
var sessionGuard = Set<String>()
|
||||
for route in routes {
|
||||
guard let sourceCal = calendar(id: route.sourceID) else { continue }
|
||||
let validTargetIDs = route.targetIDs.filter { $0 != route.sourceID }
|
||||
let targets = validTargetIDs.compactMap(calendar(id:))
|
||||
guard !targets.isEmpty else { continue }
|
||||
ranAnyRoute = true
|
||||
let config = MirrorConfig(
|
||||
daysBack: settings.daysBack,
|
||||
daysForward: settings.daysForward,
|
||||
mergeGapMin: max(0, route.mergeGapHours * 60),
|
||||
hideDetails: route.privacy,
|
||||
copyDescription: route.copyNotes,
|
||||
mirrorAllDay: route.allDay,
|
||||
overlapMode: route.overlap,
|
||||
titlePrefix: settings.titlePrefix,
|
||||
placeholderTitle: settings.placeholderTitle,
|
||||
filterByWorkHours: settings.filterByWorkHours,
|
||||
workHoursStart: settings.workHoursStart,
|
||||
workHoursEnd: settings.workHoursEnd,
|
||||
excludedTitleFilterTerms: settings.excludedTitleFilters.map { $0.lowercased() },
|
||||
excludedOrganizerFilterTerms: settings.excludedOrganizerFilters.map { $0.lowercased() },
|
||||
mirrorAcceptedOnly: settings.mirrorAcceptedOnly,
|
||||
autoDeleteMissing: settings.autoDeleteMissing,
|
||||
writeEnabled: true,
|
||||
syncReminders: route.syncReminders
|
||||
)
|
||||
await engine.runMirror(store: backgroundStore, config: config, sourceCalendar: sourceCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true)
|
||||
}
|
||||
|
||||
let summary = ranAnyRoute ? "auto-sync (\(reason)): ran \(routes.count) saved route(s)" : "auto-sync (\(reason)): no route had a valid source+target"
|
||||
AppLogStore.append("[auto-sync] \(summary)")
|
||||
UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: "lastRunAtISO")
|
||||
UserDefaults.standard.set(errorCount == 0, forKey: "lastRunOK")
|
||||
UserDefaults.standard.set(summary, forKey: "lastRunSummary")
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct BusyMirrorMenuBarView: View {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to BusyMirror will be documented in this file.
|
||||
|
||||
## [1.7.0] - 2026-08-26
|
||||
|
||||
### Added
|
||||
- **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll (which only fired if the Mac happened to be awake at that instant, and got throttled/coalesced by macOS). Once saved routes exist, the app registers as a login item (`SMAppService.mainApp`), removes any previously-installed `launchd` schedule, and reacts to `EKEventStoreChanged` (debounced ~3s), `NSWorkspace.didWakeNotification` (resync after sleep), and a 30-minute fallback timer as a safety net for a missed notification. Auto-sync always writes (`writeEnabled: true`) regardless of the interactive dry-run toggle, matching what the old scheduled `--write 1` runs did.
|
||||
- This logic lives in `BusyMirrorAppController`, not `ContentView` — the controller is owned by the `App` struct for the whole process lifetime, whereas `ContentView`'s own `EKEventStoreChanged` observer is torn down in `.onDisappear` when the main window closes (confirmed: `MenuBarSupport.swift`'s "Sync Now" already reopens the window before syncing, which only makes sense if the window's state is discarded on close). Auto-sync needs to work with the window closed, so it can't live there. ([MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift))
|
||||
|
||||
## [1.6.1] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
+4
-10
@@ -12,20 +12,14 @@
|
||||
- 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`
|
||||
- Calendar list already auto-refreshes on `EKEventStoreChanged` (does not yet trigger an auto-sync — see Next)
|
||||
- CLI diagnostics: `--help`, `--list-calendars [--json]`, `--status [--json]`, real exit codes (2 = no access, 3 = no saved routes), last-run tracking (time/ok/summary)
|
||||
- 1.7.0: **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll. Auto-sync logic lives in `BusyMirrorAppController` (an app-lifetime object, not tied to `ContentView`'s window lifecycle — closing the main window used to tear down the `EKEventStoreChanged` observer along with it, which would have made auto-sync a no-op whenever the window was closed). Once saved routes exist: registers as a login item (`SMAppService.mainApp`), removes any old `launchd` schedule, watches `EKEventStoreChanged` (debounced ~3s) and `NSWorkspace.didWakeNotification`, plus a 30-min fallback timer as a safety net. Auto-sync always writes (`writeEnabled: true`), independent of the interactive dry-run toggle.
|
||||
|
||||
## Next — reliability (V2 groundwork)
|
||||
1. **Event-driven background sync.** The existing hourly `launchd StartInterval` only fires while the Mac happens to be awake at that instant and gets throttled/coalesced by macOS, so it's not reliable. Replace polling with reacting:
|
||||
- Keep the app running as a login item via `SMAppService.agent` instead of relying on launchd to relaunch it.
|
||||
- Extend the existing `EKEventStoreChanged` observer (today it only reloads the calendar list) to also trigger a debounced (~2-5s) auto-sync of saved routes.
|
||||
- Add an `NSWorkspace.didWakeNotification` handler to resync after sleep.
|
||||
- Keep one coarse fallback timer (e.g. every 30 min) purely as a safety net for a missed notification — not the primary mechanism.
|
||||
- `launchd`'s remaining job shrinks to "make sure the app is running," which `SMAppService` likely covers, so the plist-generation code may become unnecessary.
|
||||
2. **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.
|
||||
## 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.
|
||||
|
||||
## Then — V2 UI polish
|
||||
- Split `ContentView.swift` (~1800 lines doing UI + settings + CLI + scheduling) into per-section view models (Routes, Schedule, Privacy, Log) so views are small and native-feeling.
|
||||
- Finish the `ContentView.swift` split into per-section view models (Routes, Schedule, Privacy, Log) — 1.7.0 already pulled the calendar-access/auto-sync state out into `BusyMirrorAppController`; the rest (UI/settings/CLI) is still one ~1900-line file.
|
||||
- Real `Settings { }` scene instead of the main window doubling as preferences.
|
||||
- Menu bar icon reflects state (idle / syncing / error) instead of a static icon.
|
||||
- Surface last-sync-time / last-error / next-check in the menu bar UI — the data now exists (`--status`'s `lastRunAtISO`/`lastRunOK`/`lastRunSummary`), this is just wiring it into the menu.
|
||||
|
||||
Reference in New Issue
Block a user