From edddf65288f427cc86c88179951637d85474cc4d Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 26 Aug 2026 21:26:07 +0200 Subject: [PATCH] Release 1.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- BusyMirror.xcodeproj/project.pbxproj | 8 +- BusyMirror/BusyMirrorApp.swift | 13 +- BusyMirror/CalendarDisplay.swift | 21 + BusyMirror/CalendarsSectionView.swift | 84 ++++ BusyMirror/ContentView.swift | 606 ++++---------------------- BusyMirror/LogSectionView.swift | 16 + BusyMirror/MenuBarSupport.swift | 42 +- BusyMirror/PreferencesView.swift | 165 +++++++ BusyMirror/RoutesSectionView.swift | 183 ++++++++ BusyMirror/ScheduleSectionView.swift | 102 +++++ CHANGELOG.md | 11 + ROADMAP.md | 9 +- 12 files changed, 730 insertions(+), 530 deletions(-) create mode 100644 BusyMirror/CalendarDisplay.swift create mode 100644 BusyMirror/CalendarsSectionView.swift create mode 100644 BusyMirror/LogSectionView.swift create mode 100644 BusyMirror/PreferencesView.swift create mode 100644 BusyMirror/RoutesSectionView.swift create mode 100644 BusyMirror/ScheduleSectionView.swift diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index feab97c..14e740d 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -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; diff --git a/BusyMirror/BusyMirrorApp.swift b/BusyMirror/BusyMirrorApp.swift index ddaa125..89a6894 100644 --- a/BusyMirror/BusyMirrorApp.swift +++ b/BusyMirror/BusyMirrorApp.swift @@ -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) + } } } diff --git a/BusyMirror/CalendarDisplay.swift b/BusyMirror/CalendarDisplay.swift new file mode 100644 index 0000000..de57b10 --- /dev/null +++ b/BusyMirror/CalendarDisplay.swift @@ -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)) + } +} diff --git a/BusyMirror/CalendarsSectionView.swift b/BusyMirror/CalendarsSectionView.swift new file mode 100644 index 0000000..2432d4a --- /dev/null +++ b/BusyMirror/CalendarsSectionView.swift @@ -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 + @Binding var targetIDs: Set + 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( + 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) + } + } +} diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index bca1e5e..2ce2cee 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -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,246 +448,17 @@ 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( - 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") { - guard let sid = sourceID, !targetIDs.isEmpty else { return } - let r = Route(sourceID: sid, - targetIDs: targetIDs, - privacy: hideDetails, - copyNotes: copyDescription, - syncReminders: syncReminders, - mergeGapHours: mergeGapHours, - overlap: overlapMode, - 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) -> 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) -> 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) -> 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 addRouteFromCurrentSelection() { + guard let sid = sourceID, !targetIDs.isEmpty else { return } + let r = Route(sourceID: sid, + targetIDs: targetIDs, + privacy: hideDetails, + copyNotes: copyDescription, + syncReminders: syncReminders, + mergeGapHours: mergeGapHours, + overlap: overlapMode, + allDay: mirrorAllDay) + routes.append(r) } private func runConfiguredRoutes(_ configuredRoutes: [Route], sessionGuard: inout Set) async { @@ -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) - } - 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) + HStack(spacing: 10) { + Text("Mirroring defaults, filters, and work hours moved to Preferences.") .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) + SettingsLink { + Text("Open Preferences…") + } + Spacer(minLength: 0) } - .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( - get: { scheduleMode }, - set: { newValue in - scheduleMode = newValue - scheduleWeekdaysOnly = (newValue == .weekdays) - } - )) { - ForEach(ScheduleMode.allCases) { mode in - Text(mode.title).tag(mode) - } + ScheduleSectionView( + scheduleMode: Binding( + get: { scheduleMode }, + set: { newValue in + scheduleMode = newValue + scheduleWeekdaysOnly = (newValue == .weekdays) } - .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 diff --git a/BusyMirror/LogSectionView.swift b/BusyMirror/LogSectionView.swift new file mode 100644 index 0000000..9145cb4 --- /dev/null +++ b/BusyMirror/LogSectionView.swift @@ -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)) + ) + } +} diff --git a/BusyMirror/MenuBarSupport.swift b/BusyMirror/MenuBarSupport.swift index 644eefa..c767cfe 100644 --- a/BusyMirror/MenuBarSupport.swift +++ b/BusyMirror/MenuBarSupport.swift @@ -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? private var fallbackTask: Task? - 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() diff --git a/BusyMirror/PreferencesView.swift b/BusyMirror/PreferencesView.swift new file mode 100644 index 0000000..f036b0d --- /dev/null +++ b/BusyMirror/PreferencesView.swift @@ -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) + } +} diff --git a/BusyMirror/RoutesSectionView.swift b/BusyMirror/RoutesSectionView.swift new file mode 100644 index 0000000..a55597b --- /dev/null +++ b/BusyMirror/RoutesSectionView.swift @@ -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) -> 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) -> 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) -> 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) + } +} diff --git a/BusyMirror/ScheduleSectionView.swift b/BusyMirror/ScheduleSectionView.swift new file mode 100644 index 0000000..a5ef7bc --- /dev/null +++ b/BusyMirror/ScheduleSectionView.swift @@ -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() } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 10bf341..03f52e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 93337a7..bbacb7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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.