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