- Sidebar-navigation redesign: NavigationSplitView with Routes / Schedule / Activity Log replaces the 2x2 panel-card grid. Primary actions moved to the toolbar (dry-run/write, sync status, Sync Now); Export/Import/Reveal Log/Cleanup/Refresh/Recheck Permission moved to a toolbar overflow menu. - Routes are now a collapsed-by-default list -- click a row to expand its editor instead of always showing every field for every route. - Schedule view leads with live auto-sync status; the manual launchd schedule is demoted to an explicit optional section underneath. - Activity Log is readable rows (status icon + text) with a search filter and a Clear button, instead of a monospaced text dump. - Menu bar dropdown got icons and real Cmd+,/Cmd+Q shortcuts. - Explored as a design mockup first (published as an Artifact, light+dark), direction confirmed before writing any SwiftUI. Fixed along the way: routes were being silently dropped on most launches. reloadCalendars() pruned routes against a freshly-created EKEventStore's calendar fetch right after a permission grant, which can under-report calendars for a moment (especially remote/Exchange accounts) before they finish loading -- wrongly concluding a route's calendar was gone. Only survived because of a routes.v1 legacy backup key that itself never got updated after its first write, so any real route edit made in an affected session would have been silently reverted on the next launch. Fixed by skipping the prune specifically on the post-grant reload (reloadCalendars(pruneRoutes: false)) and keeping the legacy backup current on every save instead of frozen. All unit tests pass. Verified on a real install: routes stopped disappearing across repeated launches (checked via --status and reading the UserDefaults plist directly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
314 lines
13 KiB
Swift
314 lines
13 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import EventKit
|
|
import ServiceManagement
|
|
|
|
enum BusyMirrorSceneID {
|
|
static let mainWindow = "main-window"
|
|
}
|
|
|
|
@MainActor
|
|
final class BusyMirrorAppController: ObservableObject {
|
|
@Published private(set) var isSyncing = false
|
|
@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
|
|
syncRequestToken = UUID()
|
|
}
|
|
|
|
func clearPendingSyncRequest() {
|
|
hasPendingSyncRequest = false
|
|
}
|
|
|
|
func setSyncing(_ syncing: Bool) {
|
|
isSyncing = syncing
|
|
}
|
|
|
|
func setMainWindowVisible(_ visible: Bool) {
|
|
isMainWindowVisible = visible
|
|
}
|
|
|
|
func openMainWindow(using openWindow: OpenWindowAction) {
|
|
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 {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("BusyMirror")
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(appController.isSyncing ? Color.orange : (appController.lastRunFailed ? Color.red : Color.green))
|
|
.frame(width: 7, height: 7)
|
|
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()
|
|
|
|
Button {
|
|
let shouldOpenWindow = !appController.isMainWindowVisible
|
|
appController.requestSync()
|
|
if shouldOpenWindow {
|
|
appController.openMainWindow(using: openWindow)
|
|
}
|
|
} label: {
|
|
Label(appController.isSyncing ? "Syncing…" : "Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
.disabled(appController.isSyncing)
|
|
|
|
Button {
|
|
appController.openMainWindow(using: openWindow)
|
|
} label: {
|
|
Label("Open BusyMirror", systemImage: "macwindow")
|
|
}
|
|
|
|
Button {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
openSettings()
|
|
} label: {
|
|
Label("Preferences…", systemImage: "gearshape")
|
|
}
|
|
.keyboardShortcut(",", modifiers: .command)
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
NSApp.terminate(nil)
|
|
} label: {
|
|
Label("Quit BusyMirror", systemImage: "power")
|
|
}
|
|
.keyboardShortcut("q", modifiers: .command)
|
|
}
|
|
.padding(12)
|
|
.frame(width: 240, alignment: .leading)
|
|
}
|
|
}
|