Release 1.8.0

- Real Settings (Cmd+,) window via PreferencesView, hosting the pure
  @AppStorage-backed defaults that used to live in the main window's
  "General Settings" panel. Both windows share the same live values since
  they're backed by the same UserDefaults keys.
- Menu bar icon reflects idle/syncing/error state; dropdown shows last-sync
  time/result and whether auto-sync is armed, sourced from the same
  lastRunAtISO/lastRunOK/lastRunSummary keys --status reads.
- ContentView.swift split from ~2000 lines into CalendarsSectionView,
  RoutesSectionView, ScheduleSectionView, LogSectionView + a small shared
  CalendarDisplay.swift. View-layer extraction only — state ownership and
  settings persistence deliberately left alone.
- Fixed a latent revert-on-relaunch bug caught while building the Settings
  window: moving preference controls out of ContentView meant they no longer
  re-triggered saveSettingsToDefaults(), so settings.v2 could go stale and
  the next launch would silently revert a just-changed preference via the
  shared applySnapshot path. Split launch-time restore (routes + selection
  only) from Import's full restore.

All 47 unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:26:07 +02:00
co-authored by Claude Sonnet 5
parent 08e9fe5325
commit edddf65288
12 changed files with 730 additions and 530 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 = 23;
CURRENT_PROJECT_VERSION = 24;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.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 = 23;
CURRENT_PROJECT_VERSION = 24;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+12 -1
View File
@@ -4,6 +4,12 @@ import SwiftUI
struct BusyMirrorApp: App {
@StateObject private var appController = BusyMirrorAppController()
private var menuBarIcon: String {
if appController.isSyncing { return "arrow.triangle.2.circlepath.circle.fill" }
if appController.lastRunFailed { return "exclamationmark.triangle.fill" }
return "calendar.badge.clock"
}
var body: some Scene {
Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) {
ContentView()
@@ -12,9 +18,14 @@ struct BusyMirrorApp: App {
}
.defaultSize(width: 1120, height: 760)
MenuBarExtra("BusyMirror", systemImage: appController.isSyncing ? "arrow.triangle.2.circlepath.circle.fill" : "calendar.badge.clock") {
MenuBarExtra("BusyMirror", systemImage: menuBarIcon) {
BusyMirrorMenuBarView()
.environmentObject(appController)
}
Settings {
PreferencesView()
.environmentObject(appController)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
import EventKit
// Small calendar display helpers shared by ContentView, RoutesSectionView,
// and CalendarsSectionView.
func calColor(_ cal: EKCalendar) -> Color {
#if os(macOS)
return Color(cal.cgColor ?? NSColor.systemGray.cgColor)
#else
return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor)
#endif
}
@ViewBuilder
func calChip(_ cal: EKCalendar) -> some View {
HStack(spacing: 6) {
Circle().fill(calColor(cal)).frame(width: 10, height: 10)
Text(calLabel(cal))
}
}
+84
View File
@@ -0,0 +1,84 @@
import SwiftUI
import EventKit
/// The manual source/target calendar picker — extracted from ContentView.
/// Bindings mutate ContentView's own @State directly.
struct CalendarsSectionView: View {
let calendars: [EKCalendar]
@Binding var sourceIndex: Int
@Binding var targetSelections: Set<Int>
@Binding var targetIDs: Set<String>
let isRunning: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Source calendar")
.font(.subheadline.weight(.semibold))
Picker("Source", selection: $sourceIndex) {
ForEach(Array(calendars.indices), id: \.self) { i in
Text("\(i + 1): \(calLabel(calendars[i]))").tag(i)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(isRunning || calendars.isEmpty)
Divider()
HStack {
Text("Target calendars")
.font(.subheadline.weight(.semibold))
Spacer()
Text("\(targetIDs.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(Array(calendars.indices), id: \.self) { i in
let isSource = (i == sourceIndex)
let binding = Binding<Bool>(
get: { !isSource && targetSelections.contains(i) },
set: { newValue in
// Never allow selecting the source as a target
if isSource { return }
if newValue {
targetSelections.insert(i)
targetIDs.insert(calendars[i].calendarIdentifier)
} else {
targetSelections.remove(i)
targetIDs.remove(calendars[i].calendarIdentifier)
}
}
)
Toggle(isOn: binding) {
HStack(spacing: 8) {
Text("\(i + 1).")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
calChip(calendars[i])
}
.padding(.vertical, 3)
}
.toggleStyle(.switch)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(0.18), lineWidth: 1)
)
.disabled(isRunning || isSource)
.opacity(isSource ? 0.5 : 1)
}
}
}
.frame(minHeight: 170, maxHeight: 260)
}
}
}
+78 -500
View File
@@ -21,22 +21,6 @@ enum ScheduleMode: String, CaseIterable, Identifiable {
}
}
// Calendar color helpers
private func calColor(_ cal: EKCalendar) -> Color {
#if os(macOS)
return Color(cal.cgColor ?? NSColor.systemGray.cgColor)
#else
return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor)
#endif
}
@ViewBuilder
private func calChip(_ cal: EKCalendar) -> some View {
HStack(spacing: 6) {
Circle().fill(calColor(cal)).frame(width: 10, height: 10)
Text(calLabel(cal))
}
}
struct Route: Identifiable, Hashable, Codable {
let id = UUID()
@@ -464,87 +448,7 @@ struct ContentView: View {
)
}
@ViewBuilder
private func calendarsSection() -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Source calendar")
.font(.subheadline.weight(.semibold))
Picker("Source", selection: $sourceIndex) {
ForEach(Array(calendars.indices), id: \.self) { i in
Text("\(i + 1): \(calLabel(calendars[i]))").tag(i)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(isRunning || calendars.isEmpty)
Divider()
HStack {
Text("Target calendars")
.font(.subheadline.weight(.semibold))
Spacer()
Text("\(targetIDs.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(Array(calendars.indices), id: \.self) { i in
let isSource = (i == sourceIndex)
let binding = Binding<Bool>(
get: { !isSource && targetSelections.contains(i) },
set: { newValue in
// Never allow selecting the source as a target
if isSource { return }
if newValue {
targetSelections.insert(i)
targetIDs.insert(calendars[i].calendarIdentifier)
} else {
targetSelections.remove(i)
targetIDs.remove(calendars[i].calendarIdentifier)
}
}
)
Toggle(isOn: binding) {
HStack(spacing: 8) {
Text("\(i + 1).")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
calChip(calendars[i])
}
.padding(.vertical, 3)
}
.toggleStyle(.switch)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(0.18), lineWidth: 1)
)
.disabled(isRunning || isSource)
.opacity(isSource ? 0.5 : 1)
}
}
}
.frame(minHeight: 170, maxHeight: 260)
}
}
@ViewBuilder
private func routesSection() -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Routes (multi-source)")
.font(.headline)
Spacer()
Button("Add from current selection") {
private func addRouteFromCurrentSelection() {
guard let sid = sourceID, !targetIDs.isEmpty else { return }
let r = Route(sourceID: sid,
targetIDs: targetIDs,
@@ -556,155 +460,6 @@ struct ContentView: View {
allDay: mirrorAllDay)
routes.append(r)
}
.disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty)
.buttonStyle(.borderedProminent)
Button("Clear") { routes.removeAll() }
.disabled(isRunning || routes.isEmpty)
.buttonStyle(.bordered)
}
if routes.isEmpty {
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
LazyVStack(spacing: 10) {
ForEach($routes, id: \.id) { routeBinding in
routeCard(for: routeBinding)
}
}
}
}
}
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
}
Spacer(minLength: 12)
Button(role: .destructive) { removeRoute(id: route.id) } label: { Text("Remove") }
}
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.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
)
}
@ViewBuilder
private func sourceSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("Source")
.font(.caption)
.foregroundStyle(.secondary)
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal))
.fontWeight(.semibold)
}
} else {
Text(labelForCalendar(id: route.sourceID))
.fontWeight(.semibold)
}
}
}
@ViewBuilder
private func targetSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Targets")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
Text(calLabel(tCal))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
} else {
Text(labelForCalendar(id: tid))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
}
}
}
}
}
}
@ViewBuilder
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Merge gap")
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
.frame(width: 56)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
.font(.subheadline)
}
@ViewBuilder
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Overlap")
Picker("Overlap", selection: routeBinding.overlap) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.frame(width: 170)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
.font(.subheadline)
}
private func removeRoute(id: UUID) {
routes.removeAll { $0.id == id }
}
private func runConfiguredRoutes(_ configuredRoutes: [Route], sessionGuard: inout Set<String>) async {
var ranAnyRoute = false
@@ -856,188 +611,20 @@ struct ContentView: View {
@ViewBuilder
private func optionsSection() -> some View {
VStack(alignment: .leading, spacing: 14) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
HStack(spacing: 8) {
Text("Days back")
TextField("1", value: $daysBack, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.disabled(isRunning)
}
HStack(spacing: 8) {
Text("Days forward")
TextField("7", value: $daysForward, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.disabled(isRunning)
}
HStack(spacing: 8) {
Text("Default merge gap")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.disabled(isRunning)
Text("h").foregroundStyle(.secondary)
HStack(spacing: 10) {
Text("Mirroring defaults, filters, and work hours moved to Preferences.")
.font(.footnote)
.foregroundStyle(.secondary)
SettingsLink {
Text("Open Preferences…")
}
Spacer(minLength: 0)
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("Days back")
TextField("1", value: $daysBack, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.disabled(isRunning)
}
HStack(spacing: 8) {
Text("Days forward")
TextField("7", value: $daysForward, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.disabled(isRunning)
}
HStack(spacing: 8) {
Text("Default merge gap")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.disabled(isRunning)
Text("h").foregroundStyle(.secondary)
}
}
}
.onChange(of: daysBack) { v in daysBack = max(0, v) }
.onChange(of: daysForward) { v in daysForward = max(0, v) }
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
Divider()
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
.disabled(isRunning)
Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(isRunning || hideDetails)
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
.disabled(isRunning)
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
.disabled(isRunning)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
.disabled(isRunning)
Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
.disabled(isRunning)
ViewThatFits(in: .horizontal) {
HStack(spacing: 8) {
Text("Title prefix")
TextField("🪞 ", text: $titlePrefix)
.textFieldStyle(.roundedBorder)
.frame(width: 90)
.disabled(isRunning)
Text("Placeholder title")
TextField("Busy", text: $placeholderTitle)
.textFieldStyle(.roundedBorder)
.frame(width: 170)
.disabled(isRunning)
Text("(prefix may be blank)").foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("Title prefix")
TextField("🪞 ", text: $titlePrefix)
.textFieldStyle(.roundedBorder)
.frame(width: 90)
.disabled(isRunning)
}
HStack(spacing: 8) {
Text("Placeholder title")
TextField("Busy", text: $placeholderTitle)
.textFieldStyle(.roundedBorder)
.frame(width: 170)
.disabled(isRunning)
}
}
}
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
.disabled(isRunning)
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
if filterByWorkHours {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Start hour")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text("End hour")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text("(local time)").foregroundStyle(.secondary)
}
Text("Events starting outside this range are skipped; end hour is exclusive.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: workHoursStart) { _ in
clampWorkHours()
saveSettingsToDefaults()
}
.onChange(of: workHoursEnd) { _ in
clampWorkHours()
saveSettingsToDefaults()
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 82)
.disabled(isRunning)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
VStack(alignment: .leading, spacing: 6) {
Text("Skip organizers (name or email, one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedOrganizerFiltersRaw)
.font(.body)
.frame(minHeight: 82)
.disabled(isRunning)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
Text("Checks organizer name, email, or URL. Case-insensitive.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: excludedOrganizerFiltersRaw) { _ in saveSettingsToDefaults() }
Divider()
Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled)
.disabled(isRunning)
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
.disabled(isRunning)
HStack(spacing: 10) {
Button("Export Settings…") { exportSettings() }
@@ -1050,71 +637,26 @@ struct ContentView: View {
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Scheduled runs")
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Mode", selection: Binding(
ScheduleSectionView(
scheduleMode: Binding(
get: { scheduleMode },
set: { newValue in
scheduleMode = newValue
scheduleWeekdaysOnly = (newValue == .weekdays)
}
)) {
ForEach(ScheduleMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(isRunning)
Spacer(minLength: 0)
}
if scheduleMode == .hourly {
HStack(spacing: 8) {
Text("Every")
TextField("1", value: $scheduleIntervalHours, formatter: Self.smallIntFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(scheduleIntervalHours == 1 ? "hour" : "hours")
Spacer(minLength: 0)
}
} else {
HStack(spacing: 8) {
Text("Time")
TextField("8", value: $scheduleHour, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(":")
TextField("0", value: $scheduleMinute, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Spacer(minLength: 0)
}
}
Text("Creates a LaunchAgent that runs the installed app with saved routes in write mode.")
.foregroundStyle(.secondary)
.font(.footnote)
Text(hasInstalledSchedule ? "Installed: \(scheduleSummary)" : "Not installed")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button("Install Schedule") { installSchedule() }
.disabled(isRunning || routes.isEmpty)
Button("Remove Schedule") { removeSchedule() }
.disabled(isRunning || !hasInstalledSchedule)
Button("Reveal LaunchAgent") {
NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL])
}
.disabled(!hasInstalledSchedule)
Spacer(minLength: 0)
}
}
.onChange(of: scheduleHour) { _ in clampScheduleTime() }
.onChange(of: scheduleMinute) { _ in clampScheduleTime() }
.onChange(of: scheduleIntervalHours) { _ in clampScheduleTime() }
),
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) {
Button("Cleanup Placeholders") {
@@ -1148,17 +690,6 @@ struct ContentView: View {
}
}
@ViewBuilder
private func logSection() -> some View {
TextEditor(text: Binding(get: { logText }, set: { _ in }))
.font(.system(.body, design: .monospaced))
.frame(minHeight: 180)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
var body: some View {
GeometryReader { proxy in
let compactLayout = proxy.size.width < 1220
@@ -1239,24 +770,44 @@ struct ContentView: View {
}
} else if compactLayout {
panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") {
calendarsSection()
CalendarsSectionView(
calendars: calendars,
sourceIndex: $sourceIndex,
targetSelections: $targetSelections,
targetIDs: $targetIDs,
isRunning: isRunning
)
}
panelCard(title: "General Settings", subtitle: "Mirroring rules, filters, and actions", symbol: "slider.horizontal.3") {
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") {
routesSection()
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") {
logSection()
LogSectionView(logText: logText)
}
} else {
HStack(alignment: .top, spacing: 14) {
VStack(spacing: 12) {
panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") {
calendarsSection()
CalendarsSectionView(
calendars: calendars,
sourceIndex: $sourceIndex,
targetSelections: $targetSelections,
targetIDs: $targetIDs,
isRunning: isRunning
)
}
panelCard(title: "General Settings", subtitle: "Mirroring rules, filters, and actions", symbol: "slider.horizontal.3") {
panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") {
optionsSection()
}
}
@@ -1265,10 +816,18 @@ struct ContentView: View {
VStack(spacing: 12) {
panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") {
routesSection()
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") {
logSection()
LogSectionView(logText: logText)
}
}
.frame(maxWidth: .infinity)
@@ -1864,6 +1423,25 @@ struct ContentView: View {
rebuildSelectionsFromIDs()
}
/// Used only at launch (`loadSettingsFromDefaults`) — restores just the
/// state that has no other persistence (`routes` and the manual
/// source/target selection are plain `@State`, not `@AppStorage`).
/// Deliberately does NOT touch the @AppStorage-backed fields even though
/// they're also present in the decoded snapshot: those already restore
/// themselves from their own UserDefaults keys before this ever runs, and
/// since Preferences is now a separate window, a value changed there
/// wouldn't necessarily have re-triggered `saveSettingsToDefaults()` —
/// applying the (possibly stale) snapshot copy on top would silently
/// revert a preference the user just changed. `applySnapshot` (the full
/// version) stays reserved for Import, where overwriting everything from
/// the imported file is exactly the point.
private func restoreLaunchState(from s: SettingsPayload) {
routes = s.routes
if let selSrc = s.selectedSourceID { sourceID = selSrc }
if let selTgts = s.selectedTargetIDs { targetIDs = Set(selTgts) }
rebuildSelectionsFromIDs()
}
private func exportSettings() {
let panel = NSSavePanel()
panel.allowedFileTypes = ["json"]
@@ -1946,7 +1524,7 @@ struct ContentView: View {
if let data = defaults.data(forKey: settingsDefaultsKey) {
do {
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
applySnapshot(snap)
restoreLaunchState(from: snap)
// A build affected by an earlier settings.v2 decode failure (fixed
// in 1.6.0 — a newly added field with no decode fallback threw and
// wiped routes in memory, which a later autosave then persisted
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
/// Read-only activity log viewer — extracted from ContentView.
struct LogSectionView: View {
let logText: String
var body: some View {
TextEditor(text: Binding(get: { logText }, set: { _ in }))
.font(.system(.body, design: .monospaced))
.frame(minHeight: 180)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
}
+38 -4
View File
@@ -13,6 +13,36 @@ final class BusyMirrorAppController: ObservableObject {
@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
@@ -51,7 +81,6 @@ final class BusyMirrorAppController: ObservableObject {
private var wakeObserver: NSObjectProtocol?
private var debounceTask: Task<Void, Never>?
private var fallbackTask: Task<Void, Never>?
private var hasArmedAutoSync = false
private let settingsDefaultsKey = "settings.v2"
private let legacyRoutesDefaultsKey = "routes.v1"
@@ -83,9 +112,9 @@ final class BusyMirrorAppController: ObservableObject {
/// (e.g. from ContentView whenever the saved routes list changes) — it
/// only does anything the first time routes go from empty to non-empty.
func armAutoSyncIfPossible() {
guard !hasArmedAutoSync else { return }
guard !autoSyncArmed else { return }
guard !loadRoutesFromDefaults().isEmpty else { return }
hasArmedAutoSync = true
autoSyncArmed = true
if SMAppService.mainApp.status != .enabled {
try? SMAppService.mainApp.register()
@@ -214,6 +243,7 @@ final class BusyMirrorAppController: ObservableObject {
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)
}
}
@@ -227,8 +257,12 @@ struct BusyMirrorMenuBarView: View {
Text("BusyMirror")
.font(.headline)
Text(appController.isSyncing ? "Sync in progress." : "Use your saved routes or current selection.")
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()
+165
View File
@@ -0,0 +1,165 @@
import SwiftUI
/// Real macOS Settings (⌘,) window. Holds only the pure, globally-persisted
/// defaults (all @AppStorage, shared automatically with ContentView's copies
/// of the same keys) — anything tied to live session state (routes, the
/// dry-run toggle, calendar access) stays in the main window since it has no
/// meaning outside that window's lifecycle.
struct PreferencesView: View {
@EnvironmentObject private var appController: BusyMirrorAppController
@AppStorage("daysForward") private var daysForward: Int = 7
@AppStorage("daysBack") private var daysBack: Int = 1
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@AppStorage("hideDetails") private var hideDetails: Bool = true
@AppStorage("copyDescription") private var copyDescription: Bool = false
@AppStorage("syncReminders") private var syncReminders: Bool = false
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 "
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy"
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private var disabled: Bool { appController.isSyncing }
private func clampWorkHours() {
let clampedStart = min(max(workHoursStart, 0), 23)
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min(max(workHoursEnd, 1), 24)
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min(workHoursStart + 1, 24)
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
var body: some View {
Form {
Section("Default time window") {
HStack {
Text("Days back")
TextField("1", value: $daysBack, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Days forward")
TextField("7", value: $daysForward, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Default merge gap (hours)")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
}
.onChange(of: daysBack) { v in daysBack = max(0, v) }
.onChange(of: daysForward) { v in daysForward = max(0, v) }
.onChange(of: mergeGapHours) { v in mergeGapHours = max(0, v) }
Section("Mirroring defaults") {
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(hideDetails)
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
}
.disabled(disabled)
Section("Placeholder title") {
HStack {
Text("Title prefix")
TextField("🪞 ", text: $titlePrefix)
.frame(width: 90)
}
HStack {
Text("Placeholder title")
TextField("Busy", text: $placeholderTitle)
.frame(width: 170)
}
}
.disabled(disabled)
Section("Work hours") {
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
if filterByWorkHours {
HStack {
Text("Start hour")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.frame(width: 56)
Text("End hour")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.frame(width: 56)
Text("(local time, end exclusive)").foregroundStyle(.secondary)
}
.onChange(of: workHoursStart) { _ in clampWorkHours() }
.onChange(of: workHoursEnd) { _ in clampWorkHours() }
}
}
.disabled(disabled)
Section("Skip filters") {
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip organizers (name or email, one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedOrganizerFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.disabled(disabled)
}
.formStyle(.grouped)
.frame(width: 480, height: 560)
}
}
+183
View File
@@ -0,0 +1,183 @@
import SwiftUI
import EventKit
/// The "Routes (multi-source)" panel — extracted from ContentView so the
/// route list/editor is its own small, native-feeling view instead of one
/// piece of a 2000-line file. Owns no persisted state itself: `routes` is a
/// binding into ContentView's own @State (which still owns saving it to
/// UserDefaults), and everything else here is read-only context passed down.
struct RoutesSectionView: View {
@Binding var routes: [Route]
let calendars: [EKCalendar]
let isRunning: Bool
let titlePrefix: String
let placeholderTitle: String
let canAddRoute: Bool
let onAddRoute: () -> Void
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private func labelForCalendar(id: String) -> String {
calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Routes (multi-source)")
.font(.headline)
Spacer()
Button("Add from current selection", action: onAddRoute)
.disabled(isRunning || !canAddRoute)
.buttonStyle(.borderedProminent)
Button("Clear") { routes.removeAll() }
.disabled(isRunning || routes.isEmpty)
.buttonStyle(.bordered)
}
if routes.isEmpty {
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
LazyVStack(spacing: 10) {
ForEach($routes, id: \.id) { routeBinding in
routeCard(for: routeBinding)
}
}
}
}
}
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
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") }
}
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.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
)
}
@ViewBuilder
private func sourceSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("Source")
.font(.caption)
.foregroundStyle(.secondary)
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal))
.fontWeight(.semibold)
}
} else {
Text(labelForCalendar(id: route.sourceID))
.fontWeight(.semibold)
}
}
}
@ViewBuilder
private func targetSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Targets")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
Text(calLabel(tCal))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
} else {
Text(labelForCalendar(id: tid))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
}
}
}
}
}
}
@ViewBuilder
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Merge gap")
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
.frame(width: 56)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
.font(.subheadline)
}
@ViewBuilder
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Overlap")
Picker("Overlap", selection: routeBinding.overlap) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.frame(width: 170)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
.font(.subheadline)
}
}
+102
View File
@@ -0,0 +1,102 @@
import SwiftUI
/// Manual/fixed-time `launchd` scheduling — extracted from ContentView. Since
/// 1.7.0 this is a fallback/override; the primary reliability mechanism is
/// the event-driven auto-sync in `BusyMirrorAppController`, which arms itself
/// automatically once routes exist and needs no UI.
struct ScheduleSectionView: View {
@Binding var scheduleMode: ScheduleMode
@Binding var scheduleIntervalHours: Int
@Binding var scheduleHour: Int
@Binding var scheduleMinute: Int
let isRunning: Bool
let routesEmpty: Bool
let hasInstalledSchedule: Bool
let scheduleSummary: String
let onInstall: () -> Void
let onRemove: () -> Void
let onRevealLaunchAgent: () -> Void
let onScheduleTimeChanged: () -> Void
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private static let smallIntFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 1
f.maximumFractionDigits = 0
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Scheduled runs (manual override)")
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Mode", selection: $scheduleMode) {
ForEach(ScheduleMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(isRunning)
Spacer(minLength: 0)
}
if scheduleMode == .hourly {
HStack(spacing: 8) {
Text("Every")
TextField("1", value: $scheduleIntervalHours, formatter: Self.smallIntFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(scheduleIntervalHours == 1 ? "hour" : "hours")
Spacer(minLength: 0)
}
} else {
HStack(spacing: 8) {
Text("Time")
TextField("8", value: $scheduleHour, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(":")
TextField("0", value: $scheduleMinute, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Spacer(minLength: 0)
}
}
Text("Creates a LaunchAgent that runs the installed app with saved routes in write mode.")
.foregroundStyle(.secondary)
.font(.footnote)
Text(hasInstalledSchedule ? "Installed: \(scheduleSummary)" : "Not installed")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button("Install Schedule", action: onInstall)
.disabled(isRunning || routesEmpty)
Button("Remove Schedule", action: onRemove)
.disabled(isRunning || !hasInstalledSchedule)
Button("Reveal LaunchAgent", action: onRevealLaunchAgent)
.disabled(!hasInstalledSchedule)
Spacer(minLength: 0)
}
}
.onChange(of: scheduleHour) { _ in onScheduleTimeChanged() }
.onChange(of: scheduleMinute) { _ in onScheduleTimeChanged() }
.onChange(of: scheduleIntervalHours) { _ in onScheduleTimeChanged() }
}
}
+11
View File
@@ -2,6 +2,17 @@
All notable changes to BusyMirror will be documented in this file.
## [1.8.0] - 2026-08-26
### Added
- **Real Settings (⌘,) window**: `PreferencesView` now hosts the pure-defaults controls (time window, privacy/mirroring defaults, title prefix/placeholder, work hours, skip filters) that used to live in the main window's "General Settings" panel. They're `@AppStorage`-backed so both windows always see the same live values. Main window keeps everything with session/live state: routes, calendar picker, dry-run toggle, Export/Import, manual scheduling, cleanup.
- **Menu bar icon reflects state**: idle (`calendar.badge.clock`), syncing (`arrow.triangle.2.circlepath.circle.fill`), or last run failed (`exclamationmark.triangle.fill`).
- **Menu bar dropdown diagnostics**: last-sync time and result, and whether auto-sync is currently armed — sourced from the same `lastRunAtISO`/`lastRunOK`/`lastRunSummary` keys `--status` reads, so CLI/interactive/auto-sync runs all feed the same indicator.
- **`ContentView.swift` split** from ~2000 lines into `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (plus a small shared `CalendarDisplay.swift`). View-layer extraction — state ownership and settings persistence were deliberately left untouched, since that's exactly the area the 1.6.0/1.6.1 release had to fix a real data-loss bug in.
### Fixed
- **Latent revert-on-relaunch bug**, caught while building the Settings window: moving the `@AppStorage`-backed preference controls out of `ContentView` meant changing them no longer re-triggered `saveSettingsToDefaults()`, so the `settings.v2` snapshot blob could go stale relative to the individual UserDefaults keys. Since `loadSettingsFromDefaults()` used the same full `applySnapshot` as Import, the next launch would silently revert a preference you'd just changed in Preferences back to whatever the stale blob had. Fixed by splitting launch-time restore into a narrow `restoreLaunchState` (routes + manual selection only — the only state that isn't already self-restoring via `@AppStorage`) separate from `applySnapshot` (unchanged, still used by Import where overwriting everything is the point). ([ContentView.swift](BusyMirror/ContentView.swift))
## [1.7.0] - 2026-08-26
### Added
+2 -7
View File
@@ -14,16 +14,11 @@
- 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig`
- CLI diagnostics: `--help`, `--list-calendars [--json]`, `--status [--json]`, real exit codes (2 = no access, 3 = no saved routes), last-run tracking (time/ok/summary)
- 1.7.0: **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll. Auto-sync logic lives in `BusyMirrorAppController` (an app-lifetime object, not tied to `ContentView`'s window lifecycle — closing the main window used to tear down the `EKEventStoreChanged` observer along with it, which would have made auto-sync a no-op whenever the window was closed). Once saved routes exist: registers as a login item (`SMAppService.mainApp`), removes any old `launchd` schedule, watches `EKEventStoreChanged` (debounced ~3s) and `NSWorkspace.didWakeNotification`, plus a 30-min fallback timer as a safety net. Auto-sync always writes (`writeEnabled: true`), independent of the interactive dry-run toggle.
- 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). Real `Settings { }` scene (⌘,) via `PreferencesView`, 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 Settings 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.
## Next
1. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently.
## Then — V2 UI polish
- Finish the `ContentView.swift` split into per-section view models (Routes, Schedule, Privacy, Log) — 1.7.0 already pulled the calendar-access/auto-sync state out into `BusyMirrorAppController`; the rest (UI/settings/CLI) is still one ~1900-line file.
- Real `Settings { }` scene instead of the main window doubling as preferences.
- Menu bar icon reflects state (idle / syncing / error) instead of a static icon.
- Surface last-sync-time / last-error / next-check in the menu bar UI — the data now exists (`--status`'s `lastRunAtISO`/`lastRunOK`/`lastRunSummary`), this is just wiring it into the menu.
- Better server-side privacy mapping (per-provider heuristics).
2. **Better server-side privacy mapping (per-provider heuristics).**
## Later
- Signed/notarized binaries and release pipeline.