Compare commits

...
3 Commits
Author SHA1 Message Date
tomas.kracmarandClaude Sonnet 5 d40bc986c5 Release 1.8.2
Standard app instead of menu-bar-only: removed LSUIElement from Info.plist.
Dock icon, Cmd+Tab, and the standard app menu (Cmd+Q) are back. The
accessory-app design was making the app hard to quit reliably -- no Dock
icon and no accessible app menu meant the only way to quit was finding the
menu bar dropdown's "Quit BusyMirror" item, which was blocking updates
(old process holds the bundle open, "app in use" on replace). Menu bar
extra stays as a secondary quick-access point.

Verified via `lsappinfo` (type="Foreground", was "UIElement") since this is
an app-type change a screenshot wouldn't show either way.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 07:20:19 +02:00
tomas.kracmarandClaude Sonnet 5 39c5cba237 Release 1.8.1
Fix Preferences window being unreachable in 1.8.0: BusyMirror is LSUIElement
(accessory), so it never gets the standard app menu that the automatic
Settings scene's Cmd+, / SettingsLink depend on. Replaced Settings{} with a
plain Window opened via openWindow(id:) -- the same mechanism already used
for the main window -- and added a "Preferences..." item to the menu bar
dropdown as a second, more discoverable entry point.

All unit tests pass.

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

All 47 unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:26:07 +02:00
15 changed files with 767 additions and 538 deletions
+4 -4
View File
@@ -4,9 +4,9 @@
## Project Overview
**BusyMirror** is a macOS menu-bar utility that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
**BusyMirror** is a macOS utility (standard app + menu bar extra) that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a menu-bar-only app (`LSUIElement`) with no Dock icon.
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a standard app (Dock icon, ⌘Q) and also has a `MenuBarExtra` for quick sync/status.
Key capabilities:
- Manual or route-driven multi-source mirroring
@@ -44,7 +44,7 @@ BusyMirror/
├── EventFilters.swift # Work-hours, title, and organizer filters
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
├── AppLogStore.swift # File-backed log store with rotation (AppLogStore enum)
├── Info.plist # LSUIElement, calendar usage descriptions
├── Info.plist # calendar/reminders usage descriptions
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
└── Assets.xcassets/ # AppIcon set and accent color
@@ -146,7 +146,7 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau
| `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
| `BusyMirror/AppLogStore.swift` | File-backed log with rotation (`~/Library/Logs/BusyMirror/`) |
| `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions |
| `BusyMirror/Info.plist` | calendar/reminders usage descriptions |
| `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement |
| `Makefile` | Reproducible build, sign, and package targets |
| `CHANGELOG.md` | Release notes (human-readable) |
+4 -4
View File
@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 26;
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.2;
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 = 26;
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.2;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+20 -1
View File
@@ -4,6 +4,12 @@ import SwiftUI
struct BusyMirrorApp: App {
@StateObject private var appController = BusyMirrorAppController()
private var menuBarIcon: String {
if appController.isSyncing { return "arrow.triangle.2.circlepath.circle.fill" }
if appController.lastRunFailed { return "exclamationmark.triangle.fill" }
return "calendar.badge.clock"
}
var body: some Scene {
Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) {
ContentView()
@@ -12,9 +18,22 @@ 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)
}
// A plain Window rather than a Settings scene: this was originally
// required because the app was LSUIElement (accessory) and got no
// standard app menu for Cmd+,/SettingsLink to hook into. Now that
// it's a standard app that menu exists, but openWindow(id:) already
// works reliably (same mechanism as the main window) so there's no
// reason to switch back.
Window("Preferences", id: BusyMirrorSceneID.preferencesWindow) {
PreferencesView()
.environmentObject(appController)
}
.defaultSize(width: 480, height: 560)
.windowResizability(.contentSize)
}
}
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
import EventKit
// Small calendar display helpers shared by ContentView, RoutesSectionView,
// and CalendarsSectionView.
func calColor(_ cal: EKCalendar) -> Color {
#if os(macOS)
return Color(cal.cgColor ?? NSColor.systemGray.cgColor)
#else
return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor)
#endif
}
@ViewBuilder
func calChip(_ cal: EKCalendar) -> some View {
HStack(spacing: 6) {
Circle().fill(calColor(cal)).frame(width: 10, height: 10)
Text(calLabel(cal))
}
}
+84
View File
@@ -0,0 +1,84 @@
import SwiftUI
import EventKit
/// The manual source/target calendar picker — extracted from ContentView.
/// Bindings mutate ContentView's own @State directly.
struct CalendarsSectionView: View {
let calendars: [EKCalendar]
@Binding var sourceIndex: Int
@Binding var targetSelections: Set<Int>
@Binding var targetIDs: Set<String>
let isRunning: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Source calendar")
.font(.subheadline.weight(.semibold))
Picker("Source", selection: $sourceIndex) {
ForEach(Array(calendars.indices), id: \.self) { i in
Text("\(i + 1): \(calLabel(calendars[i]))").tag(i)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(isRunning || calendars.isEmpty)
Divider()
HStack {
Text("Target calendars")
.font(.subheadline.weight(.semibold))
Spacer()
Text("\(targetIDs.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(Array(calendars.indices), id: \.self) { i in
let isSource = (i == sourceIndex)
let binding = Binding<Bool>(
get: { !isSource && targetSelections.contains(i) },
set: { newValue in
// Never allow selecting the source as a target
if isSource { return }
if newValue {
targetSelections.insert(i)
targetIDs.insert(calendars[i].calendarIdentifier)
} else {
targetSelections.remove(i)
targetIDs.remove(calendars[i].calendarIdentifier)
}
}
)
Toggle(isOn: binding) {
HStack(spacing: 8) {
Text("\(i + 1).")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
calChip(calendars[i])
}
.padding(.vertical, 3)
}
.toggleStyle(.switch)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(0.18), lineWidth: 1)
)
.disabled(isRunning || isSource)
.opacity(isSource ? 0.5 : 1)
}
}
}
.frame(minHeight: 170, maxHeight: 260)
}
}
}
+93 -514
View File
@@ -21,22 +21,6 @@ enum ScheduleMode: String, CaseIterable, Identifiable {
}
}
// Calendar color helpers
private func calColor(_ cal: EKCalendar) -> Color {
#if os(macOS)
return Color(cal.cgColor ?? NSColor.systemGray.cgColor)
#else
return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor)
#endif
}
@ViewBuilder
private func calChip(_ cal: EKCalendar) -> some View {
HStack(spacing: 6) {
Circle().fill(calColor(cal)).frame(width: 10, height: 10)
Text(calLabel(cal))
}
}
struct Route: Identifiable, Hashable, Codable {
let id = UUID()
@@ -77,6 +61,7 @@ struct Route: Identifiable, Hashable, Codable {
struct ContentView: View {
@EnvironmentObject private var appController: BusyMirrorAppController
@Environment(\.openWindow) private var openWindow
@State private var store = EKEventStore()
@State private var hasAccess = false
@State private var calendars: [EKCalendar] = []
@@ -464,246 +449,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<Bool>(
get: { !isSource && targetSelections.contains(i) },
set: { newValue in
// Never allow selecting the source as a target
if isSource { return }
if newValue {
targetSelections.insert(i)
targetIDs.insert(calendars[i].calendarIdentifier)
} else {
targetSelections.remove(i)
targetIDs.remove(calendars[i].calendarIdentifier)
}
}
)
Toggle(isOn: binding) {
HStack(spacing: 8) {
Text("\(i + 1).")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
calChip(calendars[i])
}
.padding(.vertical, 3)
}
.toggleStyle(.switch)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(0.18), lineWidth: 1)
)
.disabled(isRunning || isSource)
.opacity(isSource ? 0.5 : 1)
}
}
}
.frame(minHeight: 170, maxHeight: 260)
}
}
@ViewBuilder
private func routesSection() -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Routes (multi-source)")
.font(.headline)
Spacer()
Button("Add from current selection") {
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<Route>) -> some View {
let route = routeBinding.wrappedValue
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
}
Spacer(minLength: 12)
Button(role: .destructive) { removeRoute(id: route.id) } label: { Text("Remove") }
}
Divider()
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
)
}
@ViewBuilder
private func sourceSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("Source")
.font(.caption)
.foregroundStyle(.secondary)
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal))
.fontWeight(.semibold)
}
} else {
Text(labelForCalendar(id: route.sourceID))
.fontWeight(.semibold)
}
}
}
@ViewBuilder
private func targetSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Targets")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
Text(calLabel(tCal))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
} else {
Text(labelForCalendar(id: tid))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
}
}
}
}
}
}
@ViewBuilder
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Merge gap")
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
.frame(width: 56)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
.font(.subheadline)
}
@ViewBuilder
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Overlap")
Picker("Overlap", selection: routeBinding.overlap) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.frame(width: 170)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
.font(.subheadline)
}
private func removeRoute(id: UUID) {
routes.removeAll { $0.id == id }
private func 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<String>) async {
@@ -856,188 +612,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)
Button("Open Preferences…") {
appController.openPreferencesWindow(using: openWindow)
}
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 +638,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 +691,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 +771,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 +817,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 +1424,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 +1525,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
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>15.5</string>
<key>NSCalendarsFullAccessUsageDescription</key>
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
/// Read-only activity log viewer — extracted from ContentView.
struct LogSectionView: View {
let logText: String
var body: some View {
TextEditor(text: Binding(get: { logText }, set: { _ in }))
.font(.system(.body, design: .monospaced))
.frame(minHeight: 180)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
}
+48 -4
View File
@@ -5,6 +5,7 @@ import ServiceManagement
enum BusyMirrorSceneID {
static let mainWindow = "main-window"
static let preferencesWindow = "preferences-window"
}
@MainActor
@@ -13,6 +14,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
@@ -36,6 +67,11 @@ final class BusyMirrorAppController: ObservableObject {
openWindow(id: BusyMirrorSceneID.mainWindow)
}
func openPreferencesWindow(using openWindow: OpenWindowAction) {
NSApp.activate(ignoringOtherApps: true)
openWindow(id: BusyMirrorSceneID.preferencesWindow)
}
// MARK: - Event-driven background sync
//
// Owned here (not by ContentView) because this controller lives for the
@@ -51,7 +87,6 @@ final class BusyMirrorAppController: ObservableObject {
private var wakeObserver: NSObjectProtocol?
private var debounceTask: Task<Void, Never>?
private var fallbackTask: Task<Void, Never>?
private var hasArmedAutoSync = false
private let settingsDefaultsKey = "settings.v2"
private let legacyRoutesDefaultsKey = "routes.v1"
@@ -83,9 +118,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 +249,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 +263,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()
@@ -246,6 +286,10 @@ struct BusyMirrorMenuBarView: View {
appController.openMainWindow(using: openWindow)
}
Button("Preferences…") {
appController.openPreferencesWindow(using: openWindow)
}
Divider()
Button("Quit BusyMirror") {
+165
View File
@@ -0,0 +1,165 @@
import SwiftUI
/// Real macOS Settings (⌘,) window. Holds only the pure, globally-persisted
/// defaults (all @AppStorage, shared automatically with ContentView's copies
/// of the same keys) — anything tied to live session state (routes, the
/// dry-run toggle, calendar access) stays in the main window since it has no
/// meaning outside that window's lifecycle.
struct PreferencesView: View {
@EnvironmentObject private var appController: BusyMirrorAppController
@AppStorage("daysForward") private var daysForward: Int = 7
@AppStorage("daysBack") private var daysBack: Int = 1
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@AppStorage("hideDetails") private var hideDetails: Bool = true
@AppStorage("copyDescription") private var copyDescription: Bool = false
@AppStorage("syncReminders") private var syncReminders: Bool = false
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 "
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy"
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private var disabled: Bool { appController.isSyncing }
private func clampWorkHours() {
let clampedStart = min(max(workHoursStart, 0), 23)
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min(max(workHoursEnd, 1), 24)
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min(workHoursStart + 1, 24)
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
var body: some View {
Form {
Section("Default time window") {
HStack {
Text("Days back")
TextField("1", value: $daysBack, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Days forward")
TextField("7", value: $daysForward, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Default merge gap (hours)")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
}
.onChange(of: daysBack) { v in daysBack = max(0, v) }
.onChange(of: daysForward) { v in daysForward = max(0, v) }
.onChange(of: mergeGapHours) { v in mergeGapHours = max(0, v) }
Section("Mirroring defaults") {
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(hideDetails)
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
}
.disabled(disabled)
Section("Placeholder title") {
HStack {
Text("Title prefix")
TextField("🪞 ", text: $titlePrefix)
.frame(width: 90)
}
HStack {
Text("Placeholder title")
TextField("Busy", text: $placeholderTitle)
.frame(width: 170)
}
}
.disabled(disabled)
Section("Work hours") {
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
if filterByWorkHours {
HStack {
Text("Start hour")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.frame(width: 56)
Text("End hour")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.frame(width: 56)
Text("(local time, end exclusive)").foregroundStyle(.secondary)
}
.onChange(of: workHoursStart) { _ in clampWorkHours() }
.onChange(of: workHoursEnd) { _ in clampWorkHours() }
}
}
.disabled(disabled)
Section("Skip filters") {
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip organizers (name or email, one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedOrganizerFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.disabled(disabled)
}
.formStyle(.grouped)
.frame(width: 480, height: 560)
}
}
+183
View File
@@ -0,0 +1,183 @@
import SwiftUI
import EventKit
/// The "Routes (multi-source)" panel — extracted from ContentView so the
/// route list/editor is its own small, native-feeling view instead of one
/// piece of a 2000-line file. Owns no persisted state itself: `routes` is a
/// binding into ContentView's own @State (which still owns saving it to
/// UserDefaults), and everything else here is read-only context passed down.
struct RoutesSectionView: View {
@Binding var routes: [Route]
let calendars: [EKCalendar]
let isRunning: Bool
let titlePrefix: String
let placeholderTitle: String
let canAddRoute: Bool
let onAddRoute: () -> Void
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private func labelForCalendar(id: String) -> String {
calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Routes (multi-source)")
.font(.headline)
Spacer()
Button("Add from current selection", action: onAddRoute)
.disabled(isRunning || !canAddRoute)
.buttonStyle(.borderedProminent)
Button("Clear") { routes.removeAll() }
.disabled(isRunning || routes.isEmpty)
.buttonStyle(.bordered)
}
if routes.isEmpty {
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
LazyVStack(spacing: 10) {
ForEach($routes, id: \.id) { routeBinding in
routeCard(for: routeBinding)
}
}
}
}
}
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
}
Spacer(minLength: 12)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove") }
}
Divider()
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
)
}
@ViewBuilder
private func sourceSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("Source")
.font(.caption)
.foregroundStyle(.secondary)
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal))
.fontWeight(.semibold)
}
} else {
Text(labelForCalendar(id: route.sourceID))
.fontWeight(.semibold)
}
}
}
@ViewBuilder
private func targetSummaryView(for route: Route) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Targets")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
Text(calLabel(tCal))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
} else {
Text(labelForCalendar(id: tid))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 999, style: .continuous)
.fill(Color.primary.opacity(0.1))
)
}
}
}
}
}
}
@ViewBuilder
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Merge gap")
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
.frame(width: 56)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
.font(.subheadline)
}
@ViewBuilder
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 8) {
Text("Overlap")
Picker("Overlap", selection: routeBinding.overlap) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.frame(width: 170)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
.font(.subheadline)
}
}
+102
View File
@@ -0,0 +1,102 @@
import SwiftUI
/// Manual/fixed-time `launchd` scheduling — extracted from ContentView. Since
/// 1.7.0 this is a fallback/override; the primary reliability mechanism is
/// the event-driven auto-sync in `BusyMirrorAppController`, which arms itself
/// automatically once routes exist and needs no UI.
struct ScheduleSectionView: View {
@Binding var scheduleMode: ScheduleMode
@Binding var scheduleIntervalHours: Int
@Binding var scheduleHour: Int
@Binding var scheduleMinute: Int
let isRunning: Bool
let routesEmpty: Bool
let hasInstalledSchedule: Bool
let scheduleSummary: String
let onInstall: () -> Void
let onRemove: () -> Void
let onRevealLaunchAgent: () -> Void
let onScheduleTimeChanged: () -> Void
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private static let smallIntFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 1
f.maximumFractionDigits = 0
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Scheduled runs (manual override)")
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Mode", selection: $scheduleMode) {
ForEach(ScheduleMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(isRunning)
Spacer(minLength: 0)
}
if scheduleMode == .hourly {
HStack(spacing: 8) {
Text("Every")
TextField("1", value: $scheduleIntervalHours, formatter: Self.smallIntFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(scheduleIntervalHours == 1 ? "hour" : "hours")
Spacer(minLength: 0)
}
} else {
HStack(spacing: 8) {
Text("Time")
TextField("8", value: $scheduleHour, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(":")
TextField("0", value: $scheduleMinute, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Spacer(minLength: 0)
}
}
Text("Creates a LaunchAgent that runs the installed app with saved routes in write mode.")
.foregroundStyle(.secondary)
.font(.footnote)
Text(hasInstalledSchedule ? "Installed: \(scheduleSummary)" : "Not installed")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button("Install Schedule", action: onInstall)
.disabled(isRunning || routesEmpty)
Button("Remove Schedule", action: onRemove)
.disabled(isRunning || !hasInstalledSchedule)
Button("Reveal LaunchAgent", action: onRevealLaunchAgent)
.disabled(!hasInstalledSchedule)
Spacer(minLength: 0)
}
}
.onChange(of: scheduleHour) { _ in onScheduleTimeChanged() }
.onChange(of: scheduleMinute) { _ in onScheduleTimeChanged() }
.onChange(of: scheduleIntervalHours) { _ in onScheduleTimeChanged() }
}
}
+21
View File
@@ -2,6 +2,27 @@
All notable changes to BusyMirror will be documented in this file.
## [1.8.2] - 2026-08-27
### Changed
- **Standard app instead of menu-bar-only.** Removed `LSUIElement` from `Info.plist`: BusyMirror now shows a Dock icon, appears in Cmd+Tab, and gets the standard app menu (Cmd+Q to quit, among others). The menu bar extra stays as a secondary quick-access point. This also fixes the practical problem it was causing: with no Dock icon and no accessible app menu, there was no reliable way to quit the app to let an update replace the bundle — you had to know the menu bar dropdown's "Quit BusyMirror" existed and use exactly that. Verified via `lsappinfo` (`type="Foreground"`, previously `UIElement`) since this app type change isn't something a screenshot would catch either.
## [1.8.1] - 2026-08-26
### Fixed
- **Preferences window unreachable**: 1.8.0's Settings scene (⌘,) and its `SettingsLink` button didn't work because BusyMirror is an `LSUIElement` (accessory) app — those don't get the standard app menu, so there's no menu for the automatic "Settings…" command or Cmd+, to live in. Replaced the `Settings { }` scene with a plain `Window(id: "preferences-window")`, opened via `openWindow(id:)` — the same mechanism that already reliably opens the main window from the menu bar. Also added a "Preferences…" item to the menu bar dropdown itself, so there are two direct routes in instead of one that depended on OS menu plumbing this app type doesn't get. ([BusyMirrorApp.swift](BusyMirror/BusyMirrorApp.swift), [MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift), [ContentView.swift](BusyMirror/ContentView.swift))
## [1.8.0] - 2026-08-26
### Added
- **Real Settings (⌘,) window**: `PreferencesView` now hosts the pure-defaults controls (time window, privacy/mirroring defaults, title prefix/placeholder, work hours, skip filters) that used to live in the main window's "General Settings" panel. They're `@AppStorage`-backed so both windows always see the same live values. Main window keeps everything with session/live state: routes, calendar picker, dry-run toggle, Export/Import, manual scheduling, cleanup.
- **Menu bar icon reflects state**: idle (`calendar.badge.clock`), syncing (`arrow.triangle.2.circlepath.circle.fill`), or last run failed (`exclamationmark.triangle.fill`).
- **Menu bar dropdown diagnostics**: last-sync time and result, and whether auto-sync is currently armed — sourced from the same `lastRunAtISO`/`lastRunOK`/`lastRunSummary` keys `--status` reads, so CLI/interactive/auto-sync runs all feed the same indicator.
- **`ContentView.swift` split** from ~2000 lines into `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (plus a small shared `CalendarDisplay.swift`). View-layer extraction — state ownership and settings persistence were deliberately left untouched, since that's exactly the area the 1.6.0/1.6.1 release had to fix a real data-loss bug in.
### Fixed
- **Latent revert-on-relaunch bug**, caught while building the Settings window: moving the `@AppStorage`-backed preference controls out of `ContentView` meant changing them no longer re-triggered `saveSettingsToDefaults()`, so the `settings.v2` snapshot blob could go stale relative to the individual UserDefaults keys. Since `loadSettingsFromDefaults()` used the same full `applySnapshot` as Import, the next launch would silently revert a preference you'd just changed in Preferences back to whatever the stale blob had. Fixed by splitting launch-time restore into a narrow `restoreLaunchState` (routes + manual selection only — the only state that isn't already self-restoring via `@AppStorage`) separate from `applySnapshot` (unchanged, still used by Import where overwriting everything is the point). ([ContentView.swift](BusyMirror/ContentView.swift))
## [1.7.0] - 2026-08-26
### Added
+2 -2
View File
@@ -2,7 +2,7 @@
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync manually or open the main window; it no longer appears in the Dock.
On macOS, BusyMirror runs as a standard app (Dock icon, ⌘Q to quit) and also has a menu bar icon for quick sync/status without opening the main window.
## What it does (current)
- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
@@ -13,7 +13,7 @@ On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync m
- DRY-RUN mode: see what would be created/updated/deleted without writing.
- Activity Log in the app plus persistent file logging on disk.
- In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section.
- Menu bar controls: trigger `Sync Now`, open the main window, or quit without keeping a Dock icon around.
- Menu bar controls: trigger `Sync Now`, open the main window, open Preferences, or quit.
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
- Merge adjacent events with a configurable gap.
- Time window controls (days back/forward) and Work Hours filter.
+4 -7
View File
@@ -14,16 +14,13 @@
- 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). 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.
## 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.