Compare commits

...
5 Commits
Author SHA1 Message Date
tomas.kracmarandClaude Sonnet 5 e45a7dbe81 Release 1.10.0
Mirror "Maybe" (tentative RSVP) events instead of dropping them.

Previously, with "Mirror accepted events only" on, events you'd replied
tentative to were skipped entirely. They're now mirrored with a "Maybe: "
marker in the title (after the normal prefix) and written with tentative
availability so Calendar shows them hatched. The marker is applied for any
tentative RSVP regardless of the accepted-only setting; an accepted<->
tentative flip re-syncs the title. Not carried on the merge path
(mergeGapMin > 0), which already discards per-event titles.

Block gains a `tentative` flag (in ==/hash); accepted-only gate lets
.tentative through alongside .accepted.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:30:55 +02:00
tomas.kracmarandClaude Sonnet 5 9e3dd93939 Release 1.9.2
Fix Sync Now toolbar button showing icon-only, no text label. Confirmed
via screenshot that 1.9.1's sidebar/toolbar fixes worked; this was the
remaining clarity issue -- forced .labelStyle(.titleAndIcon) so the label
always shows.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:48:42 +02:00
tomas.kracmarandClaude Sonnet 5 622a85f4fd Release 1.9.1
Fix sidebar not clickable: List(SidebarSection.allCases, selection:) ties
selection to Identifiable's id (String) while the binding was typed
SidebarSection? -- rebuilt with the standard List(selection:) { ForEach {
.tag(section) } } pattern instead, plus .listStyle(.sidebar).

Fix "Write to calendars" hard to find: still present as the Dry Run/Write
toolbar segmented control, but likely lost to toolbar overflow alongside a
status readout, Sync Now, and the overflow menu all in one
ToolbarItemGroup. Moved status out to .navigationSubtitle (native, never
overflows), toolbar now just Dry Run/Write, Sync Now/Cancel, overflow menu.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:44:44 +02:00
tomas.kracmarandClaude Sonnet 5 77e3eeaf7b 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>
2026-08-27 09:37:21 +02:00
tomas.kracmarandClaude Sonnet 5 4e29b3716c Release 1.8.3
Fix Preferences missing from the standard app menu: 1.8.1's workaround for
LSUIElement (plain Window + openWindow) was never swapped back for a real
Settings{} scene after 1.8.2 removed LSUIElement, so there was still no
Preferences item in the app's own menu or Cmd+, binding, only a button in
the main window and a menu-bar-dropdown item. Restored Settings{} now that
the app menu exists to host it; menu bar dropdown and main window button
both use the standard openSettings()/SettingsLink instead of a custom
openWindow(id:). Net simplification -- removes the now-redundant window ID
and controller method.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 09:16:29 +02:00
11 changed files with 502 additions and 331 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 = 26;
CURRENT_PROJECT_VERSION = 31;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.8.2;
MARKETING_VERSION = 1.10.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 = 26;
CURRENT_PROJECT_VERSION = 31;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.8.2;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+5 -1
View File
@@ -8,6 +8,8 @@ struct Block: Hashable {
let notes: String? // source notes (for optional copy)
let occurrence: Date? // occurrenceDate for recurring instances
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
var tentative: Bool = false // source "Maybe" RSVP; mirrored with a marker
// ponytail: lost on the merge path (mergeGapMin > 0) along with the title; only tracked in per-event mode
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
static func span(start: Date, end: Date) -> Block {
@@ -22,7 +24,8 @@ struct Block: Hashable {
lhs.srcStableID == rhs.srcStableID &&
lhs.label == rhs.label &&
lhs.notes == rhs.notes &&
lhs.occurrence == rhs.occurrence
lhs.occurrence == rhs.occurrence &&
lhs.tentative == rhs.tentative
}
func hash(into hasher: inout Hasher) {
@@ -32,6 +35,7 @@ struct Block: Hashable {
hasher.combine(label)
hasher.combine(notes)
hasher.combine(occurrence)
hasher.combine(tentative)
}
}
+6 -9
View File
@@ -23,17 +23,14 @@ struct BusyMirrorApp: App {
.environmentObject(appController)
}
// A plain Window rather than a Settings scene: this was originally
// required because the app was LSUIElement (accessory) and got no
// standard app menu for Cmd+,/SettingsLink to hook into. Now that
// it's a standard app that menu exists, but openWindow(id:) already
// works reliably (same mechanism as the main window) so there's no
// reason to switch back.
Window("Preferences", id: BusyMirrorSceneID.preferencesWindow) {
// A real Settings scene: now that the app is standard (not
// LSUIElement), this gets the conventional Cmd+, and a "Preferences…"
// item in the app's own menu for free — the location people actually
// look, unlike a plain Window which only opens from wherever we
// explicitly put a button for it.
Settings {
PreferencesView()
.environmentObject(appController)
}
.defaultSize(width: 480, height: 560)
.windowResizability(.contentSize)
}
}
+251 -250
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()
@@ -61,7 +82,6 @@ struct Route: Identifiable, Hashable, Codable {
struct ContentView: View {
@EnvironmentObject private var appController: BusyMirrorAppController
@Environment(\.openWindow) private var openWindow
@State private var store = EKEventStore()
@State private var hasAccess = false
@State private var calendars: [EKCalendar] = []
@@ -71,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
@@ -384,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,
@@ -610,34 +567,143 @@ 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.")
private var sidebarView: some View {
List(selection: $selectedSection) {
ForEach(SidebarSection.allCases) { section in
Label {
HStack {
Text(section.title)
if section == .routes && !routes.isEmpty {
Spacer()
Text("\(routes.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} icon: {
Image(systemName: section.icon)
}
.tag(section)
}
}
.listStyle(.sidebar)
.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)
Button("Open Preferences…") {
appController.openPreferencesWindow(using: openWindow)
}
Spacer(minLength: 0)
}
Divider()
Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled)
.disabled(isRunning)
HStack(spacing: 10) {
Button("Export Settings…") { exportSettings() }
Button("Import Settings…") { importSettings() }
Button("Reveal Log File") {
NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL])
}
Spacer(minLength: 0)
}
Divider()
ScheduleSectionView(
scheduleMode: Binding(
get: { scheduleMode },
@@ -658,14 +724,54 @@ struct ContentView: View {
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
onScheduleTimeChanged: clampScheduleTime
)
}
}
.padding(20)
}
}
HStack(spacing: 10) {
private var statusSubtitle: String {
if let progressText { return progressText }
if isRunning { return "Running…" }
return hasAccess ? "\(calendars.count) calendars" : "No calendar access"
}
@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)
.help("Dry Run previews changes without writing. Write actually creates/updates/deletes events.")
if isRunning {
Button("Cancel") { cancelMirror() }
} else {
Button {
startMirrorNow()
} label: {
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
.labelStyle(.titleAndIcon)
}
.disabled(!canRunMirrorNow)
}
Menu {
Button("Export Settings…") { exportSettings() }
Button("Import Settings…") { importSettings() }
Divider()
Button("Reveal Log File") {
NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL])
}
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()
@@ -678,168 +784,39 @@ 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)
}
}
NavigationSplitView {
sidebarView
} detail: {
Group {
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)
}
accessNeededView
} 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)
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)
.navigationSubtitle(statusSubtitle)
.toolbar { toolbarContent }
}
.confirmationDialog(
"Delete mirrored placeholders?",
@@ -895,6 +872,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 }
@@ -1212,7 +1194,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()
}
@@ -1226,7 +1208,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()
}
@@ -1237,7 +1219,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
@@ -1247,15 +1229,25 @@ struct ContentView: View {
}
let fetched = store.calendars(for: .event)
calendars = sortedCalendars(fetched)
// 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()
// 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()
}
}
// Initialize IDs on first load
if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier }
// Rebuild index-based selections from stored IDs
rebuildSelectionsFromIDs()
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".
@@ -1518,6 +1510,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() {
+95 -4
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 }))
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))
.frame(minHeight: 180)
.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 -11
View File
@@ -5,7 +5,6 @@ import ServiceManagement
enum BusyMirrorSceneID {
static let mainWindow = "main-window"
static let preferencesWindow = "preferences-window"
}
@MainActor
@@ -67,11 +66,6 @@ final class BusyMirrorAppController: ObservableObject {
openWindow(id: BusyMirrorSceneID.mainWindow)
}
func openPreferencesWindow(using openWindow: OpenWindowAction) {
NSApp.activate(ignoringOtherApps: true)
openWindow(id: BusyMirrorSceneID.preferencesWindow)
}
// MARK: - Event-driven background sync
//
// Owned here (not by ContentView) because this controller lives for the
@@ -256,6 +250,7 @@ final class BusyMirrorAppController: ObservableObject {
struct BusyMirrorMenuBarView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@EnvironmentObject private var appController: BusyMirrorAppController
var body: some View {
@@ -263,9 +258,14 @@ struct BusyMirrorMenuBarView: View {
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)
@@ -273,28 +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…") {
appController.openPreferencesWindow(using: openWindow)
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)
+10 -6
View File
@@ -145,10 +145,12 @@ final class MirrorEngine {
var skippedStatus = 0
for ev in srcEvents {
if Task.isCancelled { break }
let myStatus = ev.attendees?.first(where: { $0.isCurrentUser })?.participantStatus
let isTentative = (myStatus == .tentative)
if config.mirrorAcceptedOnly, ev.hasAttendees {
let attendees = ev.attendees ?? []
if let me = attendees.first(where: { $0.isCurrentUser }) {
if me.participantStatus != .accepted {
if let me = ev.attendees?.first(where: { $0.isCurrentUser }) {
// "Maybe" replies still get mirrored, just marked (see displayTitle below)
if me.participantStatus != .accepted && me.participantStatus != .tentative {
skippedStatus += 1
continue
}
@@ -178,7 +180,7 @@ final class MirrorEngine {
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
let srcID = stableSourceIdentifier(for: ev)
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev), tentative: isTentative))
}
if skippedMirrors > 0 {
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
@@ -379,7 +381,8 @@ final class MirrorEngine {
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + effectiveTitle
let maybeMark = blk.tentative ? "Maybe: " : ""
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + maybeMark + effectiveTitle
let notes = desiredNotes(for: blk)
let desiredURL = buildMirrorURL(
targetCalID: tgt.calendarIdentifier,
@@ -409,6 +412,7 @@ final class MirrorEngine {
return
}
existing.title = displayTitle
existing.availability = blk.tentative ? .tentative : .busy
existing.startDate = blk.start
existing.endDate = blk.end
existing.isAllDay = false
@@ -467,7 +471,7 @@ final class MirrorEngine {
newEv.notes = notes
newEv.url = desiredURL
newEv.alarms = desiredAlarms(for: blk)
newEv.availability = .busy
newEv.availability = blk.tentative ? .tentative : .busy
do {
try store.save(newEv, span: .thisEvent, commit: true)
created += 1
+20 -3
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,18 +58,29 @@ 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) {
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)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove") }
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
Divider()
Toggle("Private", isOn: routeBinding.privacy)
@@ -86,6 +99,10 @@ struct RoutesSectionView: View {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove Route") }
}
}
}
.padding(12)
+8
View File
@@ -165,6 +165,14 @@ final class BlockMathTests: XCTestCase {
XCTAssertEqual(result.count, 2)
}
func testTentativeAffectsEqualityAndHash() {
let base = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: "x", notes: nil, occurrence: d, alarmOffsets: nil)
var maybe = base
maybe.tentative = true
XCTAssertNotEqual(base, maybe)
XCTAssertNotEqual(base.hashValue, maybe.hashValue)
}
func testUniqueBlocksByIDDifferentOccurrence() {
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
+36
View File
@@ -2,6 +2,42 @@
All notable changes to BusyMirror will be documented in this file.
## [1.10.0] - 2026-08-31
### Added
- **"Maybe" events are now mirrored.** Previously, when "Mirror accepted events only" was on, events you'd RSVP'd tentative to were dropped entirely. They're now mirrored with a `Maybe: ` marker in the title (after the normal prefix, e.g. `🪞 Maybe: Standup`) and written with tentative availability so Calendar shows them hatched. Applies regardless of the accepted-only setting — any tentative RSVP gets the marker. A flip between accepted and tentative re-syncs the title. Not carried on the merge path (`mergeGapMin > 0`), which already discards per-event titles. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift))
## [1.9.2] - 2026-08-27
### Fixed
- **"Sync Now" toolbar button showed only its icon**, no text — confirmed via screenshot: the Dry Run/Write toggle and toolbar layout were actually fine (1.9.1's fix worked, sidebar clicks too), but the circular-arrows icon next to it was unlabeled, easy to miss as "Sync Now" entirely. Forced `.labelStyle(.titleAndIcon)` so the text always shows regardless of toolbar width. ([ContentView.swift](BusyMirror/ContentView.swift))
## [1.9.1] - 2026-08-27
### Fixed
- **Sidebar not clickable.** `sidebarView` used `List(SidebarSection.allCases, selection:)` (the data-driven initializer), which ties selection to `Identifiable`'s `id` — a `String` for `SidebarSection` — while the binding was typed `SidebarSection?`. Rebuilt with the standard `List(selection:) { ForEach(...) { ... .tag(section) } }` pattern, which binds selection directly to the (`Hashable`) section value with no ambiguity. Also added `.listStyle(.sidebar)`, the standard modifier for a `NavigationSplitView` sidebar (vibrancy, selection color) that was missing.
- **"Write to calendars" hard to find.** It was still there as the Dry Run/Write segmented control in the toolbar, but likely lost to toolbar overflow alongside a status readout, Sync Now, and the overflow menu all crammed into one `ToolbarItemGroup`. Moved the calendar-count/status text out of the toolbar entirely into `.navigationSubtitle` (always visible, native, never subject to overflow), leaving the toolbar to just Dry Run/Write, Sync Now/Cancel, and the overflow menu.
Not independently confirmed by clicking through the UI (no GUI automation available in this environment) — the `List(selection:)` fix follows SwiftUI's standard, unambiguous sidebar-selection pattern.
## [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
- **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
+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.