Release 1.9.0

- 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>
This commit is contained in:
2026-08-27 09:37:21 +02:00
co-authored by Claude Sonnet 5
parent 4e29b3716c
commit 77e3eeaf7b
7 changed files with 443 additions and 306 deletions
+4 -4
View File
@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.8.3;
MARKETING_VERSION = 1.9.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 = 27;
CURRENT_PROJECT_VERSION = 28;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.8.3;
MARKETING_VERSION = 1.9.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+265 -266
View File
@@ -21,6 +21,27 @@ enum ScheduleMode: String, CaseIterable, Identifiable {
}
}
enum SidebarSection: String, CaseIterable, Identifiable, Hashable {
case routes, schedule, log
var id: String { rawValue }
var title: String {
switch self {
case .routes: return "Routes"
case .schedule: return "Schedule"
case .log: return "Activity Log"
}
}
var icon: String {
switch self {
case .routes: return "arrow.triangle.branch"
case .schedule: return "clock"
case .log: return "terminal"
}
}
}
struct Route: Identifiable, Hashable, Codable {
let id = UUID()
@@ -70,6 +91,8 @@ struct ContentView: View {
@State private var sourceID: String? = nil
@State private var targetIDs = Set<String>()
@State private var routes: [Route] = []
@State private var selectedSection: SidebarSection? = .routes
@State private var manualSelectionExpanded = false
@AppStorage("daysForward") private var daysForward: Int = 7
@AppStorage("daysBack") private var daysBack: Int = 1
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@@ -383,71 +406,6 @@ struct ContentView: View {
targetIDs.remove(sid)
}
// MARK: - Extracted UI sections to simplify type-checking
private var selectedSourceName: String {
guard calendars.indices.contains(sourceIndex) else { return "Not selected" }
return calLabel(calendars[sourceIndex])
}
@ViewBuilder
private func statusPill(
_ title: String,
systemImage: String,
fill: Color,
foreground: Color = .white
) -> some View {
Label(title, systemImage: systemImage)
.font(.caption.weight(.bold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule(style: .continuous)
.fill(fill)
)
.foregroundStyle(foreground)
}
@ViewBuilder
private func panelCard<Content: View>(
title: String,
subtitle: String? = nil,
symbol: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(.black)
)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(.headline, design: .rounded).weight(.bold))
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
content()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.34), lineWidth: 1.2)
)
}
private func addRouteFromCurrentSelection() {
guard let sid = sourceID, !targetIDs.isEmpty else { return }
let r = Route(sourceID: sid,
@@ -609,62 +567,209 @@ struct ContentView: View {
}
@ViewBuilder
private func optionsSection() -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
Text("Mirroring defaults, filters, and work hours moved to Preferences.")
.font(.footnote)
.foregroundStyle(.secondary)
SettingsLink {
Text("Open Preferences…")
private var sidebarView: some View {
List(SidebarSection.allCases, selection: $selectedSection) { section in
Label {
HStack {
Text(section.title)
if section == .routes && !routes.isEmpty {
Spacer()
Text("\(routes.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
} icon: {
Image(systemName: section.icon)
}
.tag(section as SidebarSection?)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
}
@ViewBuilder
private var accessNeededView: some View {
VStack(spacing: 16) {
Image(systemName: "lock.fill")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("Calendar Permission Needed")
.font(.title3.weight(.semibold))
Text("BusyMirror needs access to read and mirror your events.")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Request Calendar Access") {
requestAccess()
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle("BusyMirror")
}
@ViewBuilder
private var routesDetailView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
DisclosureGroup("Manual Selection", isExpanded: $manualSelectionExpanded) {
VStack(alignment: .leading, spacing: 12) {
CalendarsSectionView(
calendars: calendars,
sourceIndex: $sourceIndex,
targetSelections: $targetSelections,
targetIDs: $targetIDs,
isRunning: isRunning
)
HStack {
Spacer()
Button("Add Route from Selection", action: addRouteFromCurrentSelection)
.buttonStyle(.borderedProminent)
.disabled(isRunning || sourceID == nil || targetIDs.isEmpty)
}
}
.padding(.top, 8)
}
.font(.headline)
Divider()
RoutesSectionView(
routes: $routes,
calendars: calendars,
isRunning: isRunning,
titlePrefix: titlePrefix,
placeholderTitle: placeholderTitle,
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
onAddRoute: addRouteFromCurrentSelection
)
}
.padding(20)
}
}
@ViewBuilder
private var scheduleDetailView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
Text("Automatic Sync")
.font(.title2.weight(.bold))
Text("Runs on its own when your calendars change — no schedule to manage.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: appController.autoSyncArmed ? "checkmark.circle.fill" : "circle.dashed")
.foregroundStyle(appController.autoSyncArmed ? .green : .secondary)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text(appController.autoSyncArmed ? "Auto-sync active" : "Auto-sync not active")
.font(.subheadline.weight(.semibold))
Text(appController.autoSyncArmed ? "Watching for calendar changes, wake, and a 30-min fallback" : "Add a saved route below to enable")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(14)
Divider()
HStack(spacing: 12) {
Image(systemName: "clock")
.foregroundStyle(.secondary)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Last sync")
.font(.subheadline.weight(.semibold))
Text(appController.lastRunStatusText)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(14)
}
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(nsColor: .controlBackgroundColor)))
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.primary.opacity(0.12)))
Divider()
VStack(alignment: .leading, spacing: 10) {
Text("Manual Schedule (optional)")
.font(.subheadline.weight(.semibold))
Text("Add a fixed-time schedule on top of auto-sync — a guaranteed full resync at a specific hour regardless of what changed.")
.font(.footnote)
.foregroundStyle(.secondary)
ScheduleSectionView(
scheduleMode: Binding(
get: { scheduleMode },
set: { newValue in
scheduleMode = newValue
scheduleWeekdaysOnly = (newValue == .weekdays)
}
),
scheduleIntervalHours: $scheduleIntervalHours,
scheduleHour: $scheduleHour,
scheduleMinute: $scheduleMinute,
isRunning: isRunning,
routesEmpty: routes.isEmpty,
hasInstalledSchedule: hasInstalledSchedule,
scheduleSummary: scheduleSummary,
onInstall: installSchedule,
onRemove: removeSchedule,
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
onScheduleTimeChanged: clampScheduleTime
)
}
}
.padding(20)
}
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItemGroup {
Picker("Mode", selection: $writeEnabled) {
Text("Dry Run").tag(false)
Text("Write").tag(true)
}
.pickerStyle(.segmented)
.frame(width: 150)
.disabled(isRunning)
HStack(spacing: 6) {
Circle()
.fill(isRunning ? Color.orange : (appController.lastRunFailed ? Color.red : Color.secondary))
.frame(width: 7, height: 7)
Text(progressText ?? (isRunning ? "Running…" : (hasAccess ? "\(calendars.count) calendars" : "No access")))
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
if isRunning {
Button("Cancel") { cancelMirror() }
} else {
Button {
startMirrorNow()
} label: {
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(!canRunMirrorNow)
}
Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled)
.disabled(isRunning)
HStack(spacing: 10) {
Menu {
Button("Export Settings…") { exportSettings() }
Button("Import Settings…") { importSettings() }
Divider()
Button("Reveal Log File") {
NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL])
}
Spacer(minLength: 0)
}
Divider()
ScheduleSectionView(
scheduleMode: Binding(
get: { scheduleMode },
set: { newValue in
scheduleMode = newValue
scheduleWeekdaysOnly = (newValue == .weekdays)
}
),
scheduleIntervalHours: $scheduleIntervalHours,
scheduleHour: $scheduleHour,
scheduleMinute: $scheduleMinute,
isRunning: isRunning,
routesEmpty: routes.isEmpty,
hasInstalledSchedule: hasInstalledSchedule,
scheduleSummary: scheduleSummary,
onInstall: installSchedule,
onRemove: removeSchedule,
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
onScheduleTimeChanged: clampScheduleTime
)
HStack(spacing: 10) {
Divider()
Button("Cleanup Placeholders") {
if writeEnabled {
// Real delete: ask for confirmation first
confirmCleanup = true
} else {
// Dry-run: run without confirmation
Task {
if routes.isEmpty {
await runCleanupForCurrentSelection()
@@ -677,168 +782,38 @@ struct ContentView: View {
}
}
.disabled(isRunning)
.buttonStyle(.bordered)
Button("Refresh Calendars") {
reloadCalendars(forceResetStore: true)
}
.disabled(isRunning)
.buttonStyle(.bordered)
Spacer(minLength: 0)
Divider()
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
requestAccess()
}
.disabled(isRunning)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
var body: some View {
GeometryReader { proxy in
let compactLayout = proxy.size.width < 1220
ZStack {
Color(nsColor: .underPageBackgroundColor)
.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text("BusyMirror")
.font(.system(size: 30, weight: .bold, design: .rounded))
Text("Mirror availability across calendars")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
HStack(spacing: 8) {
if hasAccess {
statusPill("\(calendars.count) calendars", systemImage: "calendar", fill: .black)
} else {
statusPill("No access", systemImage: "lock.fill", fill: .red)
}
Button {
guard !isRunning else { return }
writeEnabled.toggle()
log("Mode: \(writeEnabled ? "WRITE" : "DRY-RUN")")
} label: {
statusPill(writeEnabled ? "WRITE" : "DRY RUN", systemImage: writeEnabled ? "pencil" : "eye", fill: writeEnabled ? .red : .black)
}
.buttonStyle(.plain)
.help("Click to toggle write mode.")
if isRunning {
statusPill("RUNNING", systemImage: "arrow.triangle.2.circlepath", fill: .orange)
}
if let progressText {
Text(progressText)
.font(.caption)
.foregroundStyle(.secondary)
}
if isRunning {
Button("Cancel") {
cancelMirror()
}
.buttonStyle(.bordered)
.controlSize(.large)
} else {
Button("Mirror Now") {
startMirrorNow()
}
.disabled(!canRunMirrorNow)
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
requestAccess()
}
.disabled(isRunning)
.buttonStyle(.bordered)
}
}
if !hasAccess {
panelCard(
title: "Calendar Permission Needed",
subtitle: "BusyMirror needs access to read and mirror your events.",
symbol: "lock.fill"
) {
VStack(alignment: .leading, spacing: 10) {
Text("Calendar access is not granted yet. Use the button above to continue.")
.foregroundStyle(.secondary)
Button("Request Calendar Access") {
requestAccess()
}
.buttonStyle(.borderedProminent)
}
}
} else if compactLayout {
panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") {
CalendarsSectionView(
calendars: calendars,
sourceIndex: $sourceIndex,
targetSelections: $targetSelections,
targetIDs: $targetIDs,
isRunning: isRunning
)
}
panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") {
optionsSection()
}
panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") {
RoutesSectionView(
routes: $routes,
calendars: calendars,
isRunning: isRunning,
titlePrefix: titlePrefix,
placeholderTitle: placeholderTitle,
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
onAddRoute: addRouteFromCurrentSelection
)
}
panelCard(title: "Activity Log", subtitle: "Latest events and dry-run output", symbol: "terminal") {
LogSectionView(logText: logText)
}
} else {
HStack(alignment: .top, spacing: 14) {
VStack(spacing: 12) {
panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") {
CalendarsSectionView(
calendars: calendars,
sourceIndex: $sourceIndex,
targetSelections: $targetSelections,
targetIDs: $targetIDs,
isRunning: isRunning
)
}
panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") {
optionsSection()
}
}
.frame(width: 430, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 12) {
panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") {
RoutesSectionView(
routes: $routes,
calendars: calendars,
isRunning: isRunning,
titlePrefix: titlePrefix,
placeholderTitle: placeholderTitle,
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
onAddRoute: addRouteFromCurrentSelection
)
}
panelCard(title: "Activity Log", subtitle: "Latest events and dry-run output", symbol: "terminal") {
LogSectionView(logText: logText)
}
}
.frame(maxWidth: .infinity)
}
}
NavigationSplitView {
sidebarView
} detail: {
Group {
if !hasAccess {
accessNeededView
} else {
switch selectedSection ?? .routes {
case .routes: routesDetailView
case .schedule: scheduleDetailView
case .log: LogSectionView(logText: $logText)
}
.padding(18)
.frame(maxWidth: 1480, alignment: .topLeading)
.frame(minHeight: proxy.size.height, alignment: .topLeading)
}
}
.navigationTitle((selectedSection ?? .routes).title)
.toolbar { toolbarContent }
}
.confirmationDialog(
"Delete mirrored placeholders?",
@@ -894,6 +869,11 @@ struct ContentView: View {
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
.onChange(of: workHoursStart) { _ in saveSettingsToDefaults() }
.onChange(of: workHoursEnd) { _ in saveSettingsToDefaults() }
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
.onChange(of: excludedOrganizerFiltersRaw) { _ in saveSettingsToDefaults() }
.onChange(of: sourceIndex) { newValue in
// Track selected source by persistent ID and ensure it is not a target
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
@@ -1211,7 +1191,7 @@ struct ContentView: View {
if granted {
// Reinitialize the store after permission changes to ensure sources load
store = EKEventStore()
reloadCalendars()
reloadCalendars(pruneRoutes: false)
} else {
appController.clearPendingSyncRequest()
}
@@ -1225,7 +1205,7 @@ struct ContentView: View {
if granted {
// Reinitialize the store after permission changes to ensure sources load
store = EKEventStore()
reloadCalendars()
reloadCalendars(pruneRoutes: false)
} else {
appController.clearPendingSyncRequest()
}
@@ -1236,7 +1216,7 @@ struct ContentView: View {
}
@MainActor
func reloadCalendars(forceResetStore: Bool = false) {
func reloadCalendars(forceResetStore: Bool = false, pruneRoutes: Bool = true) {
if forceResetStore {
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
// Unregister the existing EKEventStoreChanged observer first — it targets the
@@ -1246,15 +1226,25 @@ struct ContentView: View {
}
let fetched = store.calendars(for: .event)
calendars = sortedCalendars(fetched)
let pruned = pruneStaleCalendarReferences()
// A freshly-created EKEventStore (right after a permission grant) can report an
// incomplete calendar list for a moment before remote sources (Exchange, CalDAV)
// finish hydrating — pruning against that snapshot wrongly concludes a route's
// calendar is gone and deletes it. Confirmed happening on a real install: routes
// dropped on the very next launch, only saved by the legacy routes.v1 fallback.
// requestAccess() passes pruneRoutes: false for exactly that reload; the
// EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click
// (a warm, already-stable store) still prune as before.
if pruneRoutes {
let pruned = pruneStaleCalendarReferences()
if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource {
log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).")
saveSettingsToDefaults()
}
}
// Initialize IDs on first load
if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier }
// Rebuild index-based selections from stored IDs
rebuildSelectionsFromIDs()
if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource {
log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).")
saveSettingsToDefaults()
}
log("Loaded \(calendars.count) calendars.")
// Register for live calendar-store changes the first time we have access,
// so the calendar list stays up-to-date without pressing "Refresh".
@@ -1517,6 +1507,15 @@ struct ContentView: View {
} catch {
log("✗ Failed to save settings: \(error.localizedDescription)")
}
// Keep the legacy routes-only backup current (not just a frozen
// historical snapshot) so it's a real safety net: if settings.v2 ever
// comes back with an empty `routes` array again (the exact bug fixed
// by reloadCalendars(pruneRoutes:) above), recovery restores the
// actual current routes, not whatever they were the first time this
// key was ever written.
if !routes.isEmpty, let routesData = try? JSONEncoder().encode(routes) {
UserDefaults.standard.set(routesData, forKey: legacyRoutesDefaultsKey)
}
}
private func loadSettingsFromDefaults() {
+96 -5
View File
@@ -1,16 +1,107 @@
import SwiftUI
/// Read-only activity log viewer — extracted from ContentView.
/// Activity log viewer — extracted from ContentView. Renders `logText`
/// (plain newline-separated lines, no timestamps — those only exist in the
/// persistent file log written by AppLogStore) as readable rows with a
/// status icon per line instead of a raw monospaced dump, plus a search
/// filter. "Clear" only empties this in-memory view; the file log on disk
/// (Reveal Log File, in the toolbar's overflow menu) is untouched.
struct LogSectionView: View {
let logText: String
@Binding var logText: String
@State private var searchText = ""
private enum LineKind {
case ok, error, warning, info
var symbol: String {
switch self {
case .ok: return "checkmark.circle.fill"
case .error: return "xmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .info: return "circle.fill"
}
}
var color: Color {
switch self {
case .ok: return .green
case .error: return .red
case .warning: return .orange
case .info: return .secondary
}
}
}
private struct Line: Identifiable {
let id: Int
let text: String
let kind: LineKind
}
private var lines: [Line] {
logText.split(separator: "\n", omittingEmptySubsequences: false)
.enumerated()
.map { idx, raw in
let text = String(raw)
let kind: LineKind
if text.hasPrefix("✓") { kind = .ok }
else if text.hasPrefix("✗") { kind = .error }
else if text.contains("SKIP") || text.contains("WARN") { kind = .warning }
else { kind = .info }
return Line(id: idx, text: text, kind: kind)
}
.filter { !$0.text.isEmpty }
.filter { searchText.isEmpty || $0.text.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
TextEditor(text: Binding(get: { logText }, set: { _ in }))
.font(.system(.body, design: .monospaced))
.frame(minHeight: 180)
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Text("Activity Log")
.font(.title2.weight(.bold))
Spacer()
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search log", text: $searchText)
.textFieldStyle(.plain)
}
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.frame(width: 180)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(Color.primary.opacity(0.06))
)
Button("Clear") { logText = "" }
.buttonStyle(.bordered)
.disabled(logText.isEmpty)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(lines) { line in
HStack(alignment: .top, spacing: 10) {
Image(systemName: line.kind.symbol)
.font(.system(size: 10))
.foregroundStyle(line.kind.color)
.padding(.top, 3)
Text(line.text)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Spacer(minLength: 0)
}
.padding(.vertical, 5)
Divider().opacity(0.4)
}
}
}
.frame(maxHeight: .infinity)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
.padding(20)
}
}
+22 -7
View File
@@ -258,9 +258,14 @@ struct BusyMirrorMenuBarView: View {
Text("BusyMirror")
.font(.headline)
Text(appController.isSyncing ? "Sync in progress." : appController.lastRunStatusText)
.font(.subheadline)
.foregroundStyle(appController.lastRunFailed ? .red : .secondary)
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)
@@ -268,29 +273,39 @@ struct BusyMirrorMenuBarView: View {
Divider()
Button(appController.isSyncing ? "Syncing…" : "Sync Now") {
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("Open BusyMirror") {
Button {
appController.openMainWindow(using: openWindow)
} label: {
Label("Open BusyMirror", systemImage: "macwindow")
}
Button("Preferences…") {
Button {
NSApp.activate(ignoringOtherApps: true)
openSettings()
} label: {
Label("Preferences…", systemImage: "gearshape")
}
.keyboardShortcut(",", modifiers: .command)
Divider()
Button("Quit BusyMirror") {
Button {
NSApp.terminate(nil)
} label: {
Label("Quit BusyMirror", systemImage: "power")
}
.keyboardShortcut("q", modifiers: .command)
}
.padding(12)
.frame(width: 240, alignment: .leading)
+41 -24
View File
@@ -15,6 +15,8 @@ struct RoutesSectionView: View {
let canAddRoute: Bool
let onAddRoute: () -> Void
@State private var expandedRouteID: UUID?
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
@@ -56,36 +58,51 @@ struct RoutesSectionView: View {
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
let isExpanded = expandedRouteID == route.id
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
Button {
expandedRouteID = isExpanded ? nil : route.id
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12)
.padding(.top, 3)
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
}
Spacer(minLength: 12)
}
Spacer(minLength: 12)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove") }
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider()
if isExpanded {
Divider()
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove Route") }
}
}
}
.padding(12)
+13
View File
@@ -2,6 +2,19 @@
All notable changes to BusyMirror will be documented in this file.
## [1.9.0] - 2026-08-27
### Added
- **Sidebar navigation redesign.** Main window is now a `NavigationSplitView`: a sidebar (Routes / Schedule / Activity Log, with a route-count badge) replaces the old 2×2 grid of panel cards. Primary actions (Dry Run/Write, sync status, Sync Now) moved to the window toolbar; Export/Import/Reveal Log/Cleanup Placeholders/Refresh Calendars/Recheck Permission moved into a toolbar overflow menu.
- **Routes** are now a real collapsed-by-default list — click a row to expand its editor (Private, Copy description, Sync reminders, Mirror all-day, merge gap, overlap, Remove) instead of every route always showing every field. The manual source/target picker lives in a collapsible "Manual Selection" section above the list.
- **Schedule** view leads with live auto-sync status (armed/watching, last sync) instead of a bare `launchd` form; the manual fixed-time schedule is still there, demoted to an explicitly optional section underneath — matches what it actually became once 1.7.0 shipped event-driven sync.
- **Activity Log** is now readable rows (status icon + text) instead of a monospaced text dump, with a search filter and a Clear button for the in-app view (the persistent file log on disk is untouched).
- Menu bar dropdown got icons on every item and real ⌘,/⌘Q keyboard shortcuts on Preferences/Quit.
- Design explored first as a mockup (light+dark) before any SwiftUI changes; direction confirmed before implementing.
### Fixed
- **Routes silently dropped on most launches, masked by a legacy fallback.** `reloadCalendars()` pruned routes referencing calendars not in the current EventKit fetch — but the fetch immediately following a permission grant (a freshly-created `EKEventStore`) can under-report calendars for a moment, especially remote accounts (Exchange/CalDAV), before they finish loading. Every affected launch wrongly deleted real routes, and only kept working because of a legacy `routes.v1` backup key kept rescuing them — a backup that itself was frozen at whatever it held the first time it was ever written, since nothing updated it afterward. Any real route edit made in a session that hit this bug would have been silently lost on the next launch. Fixed by skipping the prune specifically on the post-permission-grant reload (`reloadCalendars(pruneRoutes: false)`) — the EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click, both against an already-warm store, still prune as before — and by keeping the `routes.v1` backup itself current on every save instead of frozen. Confirmed via `--status`/reading the UserDefaults plist directly on a real install: routes stopped disappearing across repeated launches. ([ContentView.swift](BusyMirror/ContentView.swift))
## [1.8.3] - 2026-08-27
### Fixed
+2
View File
@@ -17,6 +17,8 @@
- 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.
- 1.8.3: restored the real `Settings{}` scene for Preferences now that the app menu exists to host it (1.8.1's `Window`+`openWindow` workaround was never swapped back after 1.8.2 removed `LSUIElement`) — Cmd+, and the app's own "Preferences…" menu item work again, not just a button and a menu-bar-dropdown item.
- 1.9.0: **sidebar-navigation redesign** (Routes / Schedule / Activity Log via `NavigationSplitView`, toolbar actions, collapsed-by-default route list, structured log view, refreshed menu bar) — explored as a mockup first, direction confirmed before implementing. Also fixed a real bug the redesign work surfaced: routes were being silently dropped on most launches (a freshly-created `EKEventStore` right after a permission grant can under-report calendars for a moment, especially remote accounts, so pruning against it wrongly deleted routes referencing calendars that were actually still there) — only ever recovered by a legacy backup key that was itself frozen at its first-ever value. Fixed the prune timing and made the backup stay current.
## 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.