Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3dd93939 | ||
|
|
622a85f4fd | ||
|
|
77e3eeaf7b | ||
|
|
4e29b3716c |
@@ -410,7 +410,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.8.2;
|
MARKETING_VERSION = 1.9.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.8.2;
|
MARKETING_VERSION = 1.9.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -23,17 +23,14 @@ struct BusyMirrorApp: App {
|
|||||||
.environmentObject(appController)
|
.environmentObject(appController)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plain Window rather than a Settings scene: this was originally
|
// A real Settings scene: now that the app is standard (not
|
||||||
// required because the app was LSUIElement (accessory) and got no
|
// LSUIElement), this gets the conventional Cmd+, and a "Preferences…"
|
||||||
// standard app menu for Cmd+,/SettingsLink to hook into. Now that
|
// item in the app's own menu for free — the location people actually
|
||||||
// it's a standard app that menu exists, but openWindow(id:) already
|
// look, unlike a plain Window which only opens from wherever we
|
||||||
// works reliably (same mechanism as the main window) so there's no
|
// explicitly put a button for it.
|
||||||
// reason to switch back.
|
Settings {
|
||||||
Window("Preferences", id: BusyMirrorSceneID.preferencesWindow) {
|
|
||||||
PreferencesView()
|
PreferencesView()
|
||||||
.environmentObject(appController)
|
.environmentObject(appController)
|
||||||
}
|
}
|
||||||
.defaultSize(width: 480, height: 560)
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-250
@@ -21,6 +21,27 @@ enum ScheduleMode: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SidebarSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case routes, schedule, log
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .routes: return "Routes"
|
||||||
|
case .schedule: return "Schedule"
|
||||||
|
case .log: return "Activity Log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .routes: return "arrow.triangle.branch"
|
||||||
|
case .schedule: return "clock"
|
||||||
|
case .log: return "terminal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
struct Route: Identifiable, Hashable, Codable {
|
struct Route: Identifiable, Hashable, Codable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -61,7 +82,6 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var appController: BusyMirrorAppController
|
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@State private var store = EKEventStore()
|
@State private var store = EKEventStore()
|
||||||
@State private var hasAccess = false
|
@State private var hasAccess = false
|
||||||
@State private var calendars: [EKCalendar] = []
|
@State private var calendars: [EKCalendar] = []
|
||||||
@@ -71,6 +91,8 @@ struct ContentView: View {
|
|||||||
@State private var sourceID: String? = nil
|
@State private var sourceID: String? = nil
|
||||||
@State private var targetIDs = Set<String>()
|
@State private var targetIDs = Set<String>()
|
||||||
@State private var routes: [Route] = []
|
@State private var routes: [Route] = []
|
||||||
|
@State private var selectedSection: SidebarSection? = .routes
|
||||||
|
@State private var manualSelectionExpanded = false
|
||||||
@AppStorage("daysForward") private var daysForward: Int = 7
|
@AppStorage("daysForward") private var daysForward: Int = 7
|
||||||
@AppStorage("daysBack") private var daysBack: Int = 1
|
@AppStorage("daysBack") private var daysBack: Int = 1
|
||||||
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
||||||
@@ -384,71 +406,6 @@ struct ContentView: View {
|
|||||||
targetIDs.remove(sid)
|
targetIDs.remove(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Extracted UI sections to simplify type-checking
|
|
||||||
private var selectedSourceName: String {
|
|
||||||
guard calendars.indices.contains(sourceIndex) else { return "Not selected" }
|
|
||||||
return calLabel(calendars[sourceIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func statusPill(
|
|
||||||
_ title: String,
|
|
||||||
systemImage: String,
|
|
||||||
fill: Color,
|
|
||||||
foreground: Color = .white
|
|
||||||
) -> some View {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.font(.caption.weight(.bold))
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
Capsule(style: .continuous)
|
|
||||||
.fill(fill)
|
|
||||||
)
|
|
||||||
.foregroundStyle(foreground)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func panelCard<Content: View>(
|
|
||||||
title: String,
|
|
||||||
subtitle: String? = nil,
|
|
||||||
symbol: String,
|
|
||||||
@ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(alignment: .center, spacing: 8) {
|
|
||||||
Image(systemName: symbol)
|
|
||||||
.font(.system(size: 11, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
||||||
.fill(.black)
|
|
||||||
)
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(.headline, design: .rounded).weight(.bold))
|
|
||||||
if let subtitle {
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
.padding(14)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.fill(Color(nsColor: .windowBackgroundColor))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.stroke(Color.primary.opacity(0.34), lineWidth: 1.2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addRouteFromCurrentSelection() {
|
private func addRouteFromCurrentSelection() {
|
||||||
guard let sid = sourceID, !targetIDs.isEmpty else { return }
|
guard let sid = sourceID, !targetIDs.isEmpty else { return }
|
||||||
let r = Route(sourceID: sid,
|
let r = Route(sourceID: sid,
|
||||||
@@ -610,34 +567,143 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func optionsSection() -> some View {
|
private var sidebarView: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
List(selection: $selectedSection) {
|
||||||
HStack(spacing: 10) {
|
ForEach(SidebarSection.allCases) { section in
|
||||||
Text("Mirroring defaults, filters, and work hours moved to Preferences.")
|
Label {
|
||||||
|
HStack {
|
||||||
|
Text(section.title)
|
||||||
|
if section == .routes && !routes.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
Text("\(routes.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
}
|
||||||
|
.tag(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var accessNeededView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Calendar Permission Needed")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
Text("BusyMirror needs access to read and mirror your events.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Request Calendar Access") {
|
||||||
|
requestAccess()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.navigationTitle("BusyMirror")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var routesDetailView: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
DisclosureGroup("Manual Selection", isExpanded: $manualSelectionExpanded) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
CalendarsSectionView(
|
||||||
|
calendars: calendars,
|
||||||
|
sourceIndex: $sourceIndex,
|
||||||
|
targetSelections: $targetSelections,
|
||||||
|
targetIDs: $targetIDs,
|
||||||
|
isRunning: isRunning
|
||||||
|
)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Add Route from Selection", action: addRouteFromCurrentSelection)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isRunning || sourceID == nil || targetIDs.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
RoutesSectionView(
|
||||||
|
routes: $routes,
|
||||||
|
calendars: calendars,
|
||||||
|
isRunning: isRunning,
|
||||||
|
titlePrefix: titlePrefix,
|
||||||
|
placeholderTitle: placeholderTitle,
|
||||||
|
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
|
||||||
|
onAddRoute: addRouteFromCurrentSelection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var scheduleDetailView: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Automatic Sync")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
Text("Runs on its own when your calendars change — no schedule to manage.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: appController.autoSyncArmed ? "checkmark.circle.fill" : "circle.dashed")
|
||||||
|
.foregroundStyle(appController.autoSyncArmed ? .green : .secondary)
|
||||||
|
.font(.title3)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(appController.autoSyncArmed ? "Auto-sync active" : "Auto-sync not active")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(appController.autoSyncArmed ? "Watching for calendar changes, wake, and a 30-min fallback" : "Add a saved route below to enable")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
Divider()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.title3)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Last sync")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(appController.lastRunStatusText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(nsColor: .controlBackgroundColor)))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.primary.opacity(0.12)))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Manual Schedule (optional)")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Add a fixed-time schedule on top of auto-sync — a guaranteed full resync at a specific hour regardless of what changed.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Button("Open Preferences…") {
|
|
||||||
appController.openPreferencesWindow(using: openWindow)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled)
|
|
||||||
.disabled(isRunning)
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Button("Export Settings…") { exportSettings() }
|
|
||||||
Button("Import Settings…") { importSettings() }
|
|
||||||
Button("Reveal Log File") {
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL])
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
ScheduleSectionView(
|
ScheduleSectionView(
|
||||||
scheduleMode: Binding(
|
scheduleMode: Binding(
|
||||||
get: { scheduleMode },
|
get: { scheduleMode },
|
||||||
@@ -658,14 +724,54 @@ struct ContentView: View {
|
|||||||
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
|
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
|
||||||
onScheduleTimeChanged: clampScheduleTime
|
onScheduleTimeChanged: clampScheduleTime
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
private var statusSubtitle: String {
|
||||||
|
if let progressText { return progressText }
|
||||||
|
if isRunning { return "Running…" }
|
||||||
|
return hasAccess ? "\(calendars.count) calendars" : "No calendar access"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarContent: some ToolbarContent {
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Picker("Mode", selection: $writeEnabled) {
|
||||||
|
Text("Dry Run").tag(false)
|
||||||
|
Text("Write").tag(true)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 150)
|
||||||
|
.disabled(isRunning)
|
||||||
|
.help("Dry Run previews changes without writing. Write actually creates/updates/deletes events.")
|
||||||
|
|
||||||
|
if isRunning {
|
||||||
|
Button("Cancel") { cancelMirror() }
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
startMirrorNow()
|
||||||
|
} label: {
|
||||||
|
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
}
|
||||||
|
.disabled(!canRunMirrorNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Button("Export Settings…") { exportSettings() }
|
||||||
|
Button("Import Settings…") { importSettings() }
|
||||||
|
Divider()
|
||||||
|
Button("Reveal Log File") {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL])
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
Button("Cleanup Placeholders") {
|
Button("Cleanup Placeholders") {
|
||||||
if writeEnabled {
|
if writeEnabled {
|
||||||
// Real delete: ask for confirmation first
|
|
||||||
confirmCleanup = true
|
confirmCleanup = true
|
||||||
} else {
|
} else {
|
||||||
// Dry-run: run without confirmation
|
|
||||||
Task {
|
Task {
|
||||||
if routes.isEmpty {
|
if routes.isEmpty {
|
||||||
await runCleanupForCurrentSelection()
|
await runCleanupForCurrentSelection()
|
||||||
@@ -678,168 +784,39 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
.buttonStyle(.bordered)
|
|
||||||
|
|
||||||
Button("Refresh Calendars") {
|
Button("Refresh Calendars") {
|
||||||
reloadCalendars(forceResetStore: true)
|
reloadCalendars(forceResetStore: true)
|
||||||
}
|
}
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
.buttonStyle(.bordered)
|
Divider()
|
||||||
|
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
|
||||||
Spacer(minLength: 0)
|
requestAccess()
|
||||||
|
}
|
||||||
|
.disabled(isRunning)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
NavigationSplitView {
|
||||||
let compactLayout = proxy.size.width < 1220
|
sidebarView
|
||||||
ZStack {
|
} detail: {
|
||||||
Color(nsColor: .underPageBackgroundColor)
|
Group {
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text("BusyMirror")
|
|
||||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
||||||
Text("Mirror availability across calendars")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if hasAccess {
|
|
||||||
statusPill("\(calendars.count) calendars", systemImage: "calendar", fill: .black)
|
|
||||||
} else {
|
|
||||||
statusPill("No access", systemImage: "lock.fill", fill: .red)
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
guard !isRunning else { return }
|
|
||||||
writeEnabled.toggle()
|
|
||||||
log("Mode: \(writeEnabled ? "WRITE" : "DRY-RUN")")
|
|
||||||
} label: {
|
|
||||||
statusPill(writeEnabled ? "WRITE" : "DRY RUN", systemImage: writeEnabled ? "pencil" : "eye", fill: writeEnabled ? .red : .black)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("Click to toggle write mode.")
|
|
||||||
if isRunning {
|
|
||||||
statusPill("RUNNING", systemImage: "arrow.triangle.2.circlepath", fill: .orange)
|
|
||||||
}
|
|
||||||
if let progressText {
|
|
||||||
Text(progressText)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
if isRunning {
|
|
||||||
Button("Cancel") {
|
|
||||||
cancelMirror()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.large)
|
|
||||||
} else {
|
|
||||||
Button("Mirror Now") {
|
|
||||||
startMirrorNow()
|
|
||||||
}
|
|
||||||
.disabled(!canRunMirrorNow)
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.large)
|
|
||||||
}
|
|
||||||
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
|
|
||||||
requestAccess()
|
|
||||||
}
|
|
||||||
.disabled(isRunning)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasAccess {
|
if !hasAccess {
|
||||||
panelCard(
|
accessNeededView
|
||||||
title: "Calendar Permission Needed",
|
|
||||||
subtitle: "BusyMirror needs access to read and mirror your events.",
|
|
||||||
symbol: "lock.fill"
|
|
||||||
) {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text("Calendar access is not granted yet. Use the button above to continue.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Button("Request Calendar Access") {
|
|
||||||
requestAccess()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if compactLayout {
|
|
||||||
panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") {
|
|
||||||
CalendarsSectionView(
|
|
||||||
calendars: calendars,
|
|
||||||
sourceIndex: $sourceIndex,
|
|
||||||
targetSelections: $targetSelections,
|
|
||||||
targetIDs: $targetIDs,
|
|
||||||
isRunning: isRunning
|
|
||||||
)
|
|
||||||
}
|
|
||||||
panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") {
|
|
||||||
optionsSection()
|
|
||||||
}
|
|
||||||
panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") {
|
|
||||||
RoutesSectionView(
|
|
||||||
routes: $routes,
|
|
||||||
calendars: calendars,
|
|
||||||
isRunning: isRunning,
|
|
||||||
titlePrefix: titlePrefix,
|
|
||||||
placeholderTitle: placeholderTitle,
|
|
||||||
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
|
|
||||||
onAddRoute: addRouteFromCurrentSelection
|
|
||||||
)
|
|
||||||
}
|
|
||||||
panelCard(title: "Activity Log", subtitle: "Latest events and dry-run output", symbol: "terminal") {
|
|
||||||
LogSectionView(logText: logText)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
HStack(alignment: .top, spacing: 14) {
|
switch selectedSection ?? .routes {
|
||||||
VStack(spacing: 12) {
|
case .routes: routesDetailView
|
||||||
panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") {
|
case .schedule: scheduleDetailView
|
||||||
CalendarsSectionView(
|
case .log: LogSectionView(logText: $logText)
|
||||||
calendars: calendars,
|
|
||||||
sourceIndex: $sourceIndex,
|
|
||||||
targetSelections: $targetSelections,
|
|
||||||
targetIDs: $targetIDs,
|
|
||||||
isRunning: isRunning
|
|
||||||
)
|
|
||||||
}
|
|
||||||
panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") {
|
|
||||||
optionsSection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 430, alignment: .topLeading)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") {
|
|
||||||
RoutesSectionView(
|
|
||||||
routes: $routes,
|
|
||||||
calendars: calendars,
|
|
||||||
isRunning: isRunning,
|
|
||||||
titlePrefix: titlePrefix,
|
|
||||||
placeholderTitle: placeholderTitle,
|
|
||||||
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
|
|
||||||
onAddRoute: addRouteFromCurrentSelection
|
|
||||||
)
|
|
||||||
}
|
|
||||||
panelCard(title: "Activity Log", subtitle: "Latest events and dry-run output", symbol: "terminal") {
|
|
||||||
LogSectionView(logText: logText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(18)
|
.navigationTitle((selectedSection ?? .routes).title)
|
||||||
.frame(maxWidth: 1480, alignment: .topLeading)
|
.navigationSubtitle(statusSubtitle)
|
||||||
.frame(minHeight: proxy.size.height, alignment: .topLeading)
|
.toolbar { toolbarContent }
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete mirrored placeholders?",
|
"Delete mirrored placeholders?",
|
||||||
@@ -895,6 +872,11 @@ struct ContentView: View {
|
|||||||
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
|
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
|
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
|
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
|
||||||
|
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
|
||||||
|
.onChange(of: workHoursStart) { _ in saveSettingsToDefaults() }
|
||||||
|
.onChange(of: workHoursEnd) { _ in saveSettingsToDefaults() }
|
||||||
|
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
|
||||||
|
.onChange(of: excludedOrganizerFiltersRaw) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: sourceIndex) { newValue in
|
.onChange(of: sourceIndex) { newValue in
|
||||||
// Track selected source by persistent ID and ensure it is not a target
|
// Track selected source by persistent ID and ensure it is not a target
|
||||||
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
||||||
@@ -1212,7 +1194,7 @@ struct ContentView: View {
|
|||||||
if granted {
|
if granted {
|
||||||
// Reinitialize the store after permission changes to ensure sources load
|
// Reinitialize the store after permission changes to ensure sources load
|
||||||
store = EKEventStore()
|
store = EKEventStore()
|
||||||
reloadCalendars()
|
reloadCalendars(pruneRoutes: false)
|
||||||
} else {
|
} else {
|
||||||
appController.clearPendingSyncRequest()
|
appController.clearPendingSyncRequest()
|
||||||
}
|
}
|
||||||
@@ -1226,7 +1208,7 @@ struct ContentView: View {
|
|||||||
if granted {
|
if granted {
|
||||||
// Reinitialize the store after permission changes to ensure sources load
|
// Reinitialize the store after permission changes to ensure sources load
|
||||||
store = EKEventStore()
|
store = EKEventStore()
|
||||||
reloadCalendars()
|
reloadCalendars(pruneRoutes: false)
|
||||||
} else {
|
} else {
|
||||||
appController.clearPendingSyncRequest()
|
appController.clearPendingSyncRequest()
|
||||||
}
|
}
|
||||||
@@ -1237,7 +1219,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func reloadCalendars(forceResetStore: Bool = false) {
|
func reloadCalendars(forceResetStore: Bool = false, pruneRoutes: Bool = true) {
|
||||||
if forceResetStore {
|
if forceResetStore {
|
||||||
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
|
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
|
||||||
// Unregister the existing EKEventStoreChanged observer first — it targets the
|
// Unregister the existing EKEventStoreChanged observer first — it targets the
|
||||||
@@ -1247,15 +1229,25 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
let fetched = store.calendars(for: .event)
|
let fetched = store.calendars(for: .event)
|
||||||
calendars = sortedCalendars(fetched)
|
calendars = sortedCalendars(fetched)
|
||||||
|
// A freshly-created EKEventStore (right after a permission grant) can report an
|
||||||
|
// incomplete calendar list for a moment before remote sources (Exchange, CalDAV)
|
||||||
|
// finish hydrating — pruning against that snapshot wrongly concludes a route's
|
||||||
|
// calendar is gone and deletes it. Confirmed happening on a real install: routes
|
||||||
|
// dropped on the very next launch, only saved by the legacy routes.v1 fallback.
|
||||||
|
// requestAccess() passes pruneRoutes: false for exactly that reload; the
|
||||||
|
// EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click
|
||||||
|
// (a warm, already-stable store) still prune as before.
|
||||||
|
if pruneRoutes {
|
||||||
let pruned = pruneStaleCalendarReferences()
|
let pruned = pruneStaleCalendarReferences()
|
||||||
// Initialize IDs on first load
|
|
||||||
if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier }
|
|
||||||
// Rebuild index-based selections from stored IDs
|
|
||||||
rebuildSelectionsFromIDs()
|
|
||||||
if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource {
|
if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource {
|
||||||
log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).")
|
log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).")
|
||||||
saveSettingsToDefaults()
|
saveSettingsToDefaults()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Initialize IDs on first load
|
||||||
|
if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier }
|
||||||
|
// Rebuild index-based selections from stored IDs
|
||||||
|
rebuildSelectionsFromIDs()
|
||||||
log("Loaded \(calendars.count) calendars.")
|
log("Loaded \(calendars.count) calendars.")
|
||||||
// Register for live calendar-store changes the first time we have access,
|
// Register for live calendar-store changes the first time we have access,
|
||||||
// so the calendar list stays up-to-date without pressing "Refresh".
|
// so the calendar list stays up-to-date without pressing "Refresh".
|
||||||
@@ -1518,6 +1510,15 @@ struct ContentView: View {
|
|||||||
} catch {
|
} catch {
|
||||||
log("✗ Failed to save settings: \(error.localizedDescription)")
|
log("✗ Failed to save settings: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
// Keep the legacy routes-only backup current (not just a frozen
|
||||||
|
// historical snapshot) so it's a real safety net: if settings.v2 ever
|
||||||
|
// comes back with an empty `routes` array again (the exact bug fixed
|
||||||
|
// by reloadCalendars(pruneRoutes:) above), recovery restores the
|
||||||
|
// actual current routes, not whatever they were the first time this
|
||||||
|
// key was ever written.
|
||||||
|
if !routes.isEmpty, let routesData = try? JSONEncoder().encode(routes) {
|
||||||
|
UserDefaults.standard.set(routesData, forKey: legacyRoutesDefaultsKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSettingsFromDefaults() {
|
private func loadSettingsFromDefaults() {
|
||||||
|
|||||||
@@ -1,16 +1,107 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Read-only activity log viewer — extracted from ContentView.
|
/// Activity log viewer — extracted from ContentView. Renders `logText`
|
||||||
|
/// (plain newline-separated lines, no timestamps — those only exist in the
|
||||||
|
/// persistent file log written by AppLogStore) as readable rows with a
|
||||||
|
/// status icon per line instead of a raw monospaced dump, plus a search
|
||||||
|
/// filter. "Clear" only empties this in-memory view; the file log on disk
|
||||||
|
/// (Reveal Log File, in the toolbar's overflow menu) is untouched.
|
||||||
struct LogSectionView: View {
|
struct LogSectionView: View {
|
||||||
let logText: String
|
@Binding var logText: String
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
private enum LineKind {
|
||||||
|
case ok, error, warning, info
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .ok: return "checkmark.circle.fill"
|
||||||
|
case .error: return "xmark.circle.fill"
|
||||||
|
case .warning: return "exclamationmark.triangle.fill"
|
||||||
|
case .info: return "circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .ok: return .green
|
||||||
|
case .error: return .red
|
||||||
|
case .warning: return .orange
|
||||||
|
case .info: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Line: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let text: String
|
||||||
|
let kind: LineKind
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lines: [Line] {
|
||||||
|
logText.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
.enumerated()
|
||||||
|
.map { idx, raw in
|
||||||
|
let text = String(raw)
|
||||||
|
let kind: LineKind
|
||||||
|
if text.hasPrefix("✓") { kind = .ok }
|
||||||
|
else if text.hasPrefix("✗") { kind = .error }
|
||||||
|
else if text.contains("SKIP") || text.contains("WARN") { kind = .warning }
|
||||||
|
else { kind = .info }
|
||||||
|
return Line(id: idx, text: text, kind: kind)
|
||||||
|
}
|
||||||
|
.filter { !$0.text.isEmpty }
|
||||||
|
.filter { searchText.isEmpty || $0.text.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TextEditor(text: Binding(get: { logText }, set: { _ in }))
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text("Activity Log")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search log", text: $searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.frame(width: 180)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||||
|
.fill(Color.primary.opacity(0.06))
|
||||||
|
)
|
||||||
|
Button("Clear") { logText = "" }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(logText.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(lines) { line in
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: line.kind.symbol)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(line.kind.color)
|
||||||
|
.padding(.top, 3)
|
||||||
|
Text(line.text)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.frame(minHeight: 180)
|
.textSelection(.enabled)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
Divider().opacity(0.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.stroke(Color.secondary.opacity(0.22))
|
.stroke(Color.secondary.opacity(0.22))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import ServiceManagement
|
|||||||
|
|
||||||
enum BusyMirrorSceneID {
|
enum BusyMirrorSceneID {
|
||||||
static let mainWindow = "main-window"
|
static let mainWindow = "main-window"
|
||||||
static let preferencesWindow = "preferences-window"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -67,11 +66,6 @@ final class BusyMirrorAppController: ObservableObject {
|
|||||||
openWindow(id: BusyMirrorSceneID.mainWindow)
|
openWindow(id: BusyMirrorSceneID.mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openPreferencesWindow(using openWindow: OpenWindowAction) {
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
openWindow(id: BusyMirrorSceneID.preferencesWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Event-driven background sync
|
// MARK: - Event-driven background sync
|
||||||
//
|
//
|
||||||
// Owned here (not by ContentView) because this controller lives for the
|
// Owned here (not by ContentView) because this controller lives for the
|
||||||
@@ -256,6 +250,7 @@ final class BusyMirrorAppController: ObservableObject {
|
|||||||
|
|
||||||
struct BusyMirrorMenuBarView: View {
|
struct BusyMirrorMenuBarView: View {
|
||||||
@Environment(\.openWindow) private var openWindow
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@Environment(\.openSettings) private var openSettings
|
||||||
@EnvironmentObject private var appController: BusyMirrorAppController
|
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -263,9 +258,14 @@ struct BusyMirrorMenuBarView: View {
|
|||||||
Text("BusyMirror")
|
Text("BusyMirror")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(appController.isSyncing ? Color.orange : (appController.lastRunFailed ? Color.red : Color.green))
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
Text(appController.isSyncing ? "Sync in progress." : appController.lastRunStatusText)
|
Text(appController.isSyncing ? "Sync in progress." : appController.lastRunStatusText)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(appController.lastRunFailed ? .red : .secondary)
|
.foregroundStyle(appController.lastRunFailed ? .red : .secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Text(appController.autoSyncArmed ? "Auto-sync: watching for calendar changes." : "Auto-sync: not active (add a saved route to enable).")
|
Text(appController.autoSyncArmed ? "Auto-sync: watching for calendar changes." : "Auto-sync: not active (add a saved route to enable).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -273,28 +273,39 @@ struct BusyMirrorMenuBarView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(appController.isSyncing ? "Syncing…" : "Sync Now") {
|
Button {
|
||||||
let shouldOpenWindow = !appController.isMainWindowVisible
|
let shouldOpenWindow = !appController.isMainWindowVisible
|
||||||
appController.requestSync()
|
appController.requestSync()
|
||||||
if shouldOpenWindow {
|
if shouldOpenWindow {
|
||||||
appController.openMainWindow(using: openWindow)
|
appController.openMainWindow(using: openWindow)
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Label(appController.isSyncing ? "Syncing…" : "Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||||
}
|
}
|
||||||
.disabled(appController.isSyncing)
|
.disabled(appController.isSyncing)
|
||||||
|
|
||||||
Button("Open BusyMirror") {
|
Button {
|
||||||
appController.openMainWindow(using: openWindow)
|
appController.openMainWindow(using: openWindow)
|
||||||
|
} label: {
|
||||||
|
Label("Open BusyMirror", systemImage: "macwindow")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Preferences…") {
|
Button {
|
||||||
appController.openPreferencesWindow(using: openWindow)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
openSettings()
|
||||||
|
} label: {
|
||||||
|
Label("Preferences…", systemImage: "gearshape")
|
||||||
}
|
}
|
||||||
|
.keyboardShortcut(",", modifiers: .command)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Quit BusyMirror") {
|
Button {
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
|
} label: {
|
||||||
|
Label("Quit BusyMirror", systemImage: "power")
|
||||||
}
|
}
|
||||||
|
.keyboardShortcut("q", modifiers: .command)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(width: 240, alignment: .leading)
|
.frame(width: 240, alignment: .leading)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct RoutesSectionView: View {
|
|||||||
let canAddRoute: Bool
|
let canAddRoute: Bool
|
||||||
let onAddRoute: () -> Void
|
let onAddRoute: () -> Void
|
||||||
|
|
||||||
|
@State private var expandedRouteID: UUID?
|
||||||
|
|
||||||
private static let intFormatter: NumberFormatter = {
|
private static let intFormatter: NumberFormatter = {
|
||||||
let f = NumberFormatter()
|
let f = NumberFormatter()
|
||||||
f.minimum = 0
|
f.minimum = 0
|
||||||
@@ -56,18 +58,29 @@ struct RoutesSectionView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func routeCard(for routeBinding: Binding<Route>) -> some View {
|
private func routeCard(for routeBinding: Binding<Route>) -> some View {
|
||||||
let route = routeBinding.wrappedValue
|
let route = routeBinding.wrappedValue
|
||||||
|
let isExpanded = expandedRouteID == route.id
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Button {
|
||||||
|
expandedRouteID = isExpanded ? nil : route.id
|
||||||
|
} label: {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||||
|
.frame(width: 12)
|
||||||
|
.padding(.top, 3)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sourceSummaryView(for: route)
|
sourceSummaryView(for: route)
|
||||||
targetSummaryView(for: route)
|
targetSummaryView(for: route)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
Button(role: .destructive) {
|
|
||||||
routes.removeAll { $0.id == route.id }
|
|
||||||
} label: { Text("Remove") }
|
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Toggle("Private", isOn: routeBinding.privacy)
|
Toggle("Private", isOn: routeBinding.privacy)
|
||||||
@@ -86,6 +99,10 @@ struct RoutesSectionView: View {
|
|||||||
mergeGapField(for: routeBinding)
|
mergeGapField(for: routeBinding)
|
||||||
overlapPicker(for: routeBinding)
|
overlapPicker(for: routeBinding)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
routes.removeAll { $0.id == route.id }
|
||||||
|
} label: { Text("Remove Route") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
|||||||
@@ -2,6 +2,37 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
All notable changes to BusyMirror will be documented in this file.
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-08-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Sync Now" toolbar button showed only its icon**, no text — confirmed via screenshot: the Dry Run/Write toggle and toolbar layout were actually fine (1.9.1's fix worked, sidebar clicks too), but the circular-arrows icon next to it was unlabeled, easy to miss as "Sync Now" entirely. Forced `.labelStyle(.titleAndIcon)` so the text always shows regardless of toolbar width. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-08-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Sidebar not clickable.** `sidebarView` used `List(SidebarSection.allCases, selection:)` (the data-driven initializer), which ties selection to `Identifiable`'s `id` — a `String` for `SidebarSection` — while the binding was typed `SidebarSection?`. Rebuilt with the standard `List(selection:) { ForEach(...) { ... .tag(section) } }` pattern, which binds selection directly to the (`Hashable`) section value with no ambiguity. Also added `.listStyle(.sidebar)`, the standard modifier for a `NavigationSplitView` sidebar (vibrancy, selection color) that was missing.
|
||||||
|
- **"Write to calendars" hard to find.** It was still there as the Dry Run/Write segmented control in the toolbar, but likely lost to toolbar overflow alongside a status readout, Sync Now, and the overflow menu all crammed into one `ToolbarItemGroup`. Moved the calendar-count/status text out of the toolbar entirely into `.navigationSubtitle` (always visible, native, never subject to overflow), leaving the toolbar to just Dry Run/Write, Sync Now/Cancel, and the overflow menu.
|
||||||
|
|
||||||
|
Not independently confirmed by clicking through the UI (no GUI automation available in this environment) — the `List(selection:)` fix follows SwiftUI's standard, unambiguous sidebar-selection pattern.
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-08-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Sidebar navigation redesign.** Main window is now a `NavigationSplitView`: a sidebar (Routes / Schedule / Activity Log, with a route-count badge) replaces the old 2×2 grid of panel cards. Primary actions (Dry Run/Write, sync status, Sync Now) moved to the window toolbar; Export/Import/Reveal Log/Cleanup Placeholders/Refresh Calendars/Recheck Permission moved into a toolbar overflow menu.
|
||||||
|
- **Routes** are now a real collapsed-by-default list — click a row to expand its editor (Private, Copy description, Sync reminders, Mirror all-day, merge gap, overlap, Remove) instead of every route always showing every field. The manual source/target picker lives in a collapsible "Manual Selection" section above the list.
|
||||||
|
- **Schedule** view leads with live auto-sync status (armed/watching, last sync) instead of a bare `launchd` form; the manual fixed-time schedule is still there, demoted to an explicitly optional section underneath — matches what it actually became once 1.7.0 shipped event-driven sync.
|
||||||
|
- **Activity Log** is now readable rows (status icon + text) instead of a monospaced text dump, with a search filter and a Clear button for the in-app view (the persistent file log on disk is untouched).
|
||||||
|
- Menu bar dropdown got icons on every item and real ⌘,/⌘Q keyboard shortcuts on Preferences/Quit.
|
||||||
|
- Design explored first as a mockup (light+dark) before any SwiftUI changes; direction confirmed before implementing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Routes silently dropped on most launches, masked by a legacy fallback.** `reloadCalendars()` pruned routes referencing calendars not in the current EventKit fetch — but the fetch immediately following a permission grant (a freshly-created `EKEventStore`) can under-report calendars for a moment, especially remote accounts (Exchange/CalDAV), before they finish loading. Every affected launch wrongly deleted real routes, and only kept working because of a legacy `routes.v1` backup key kept rescuing them — a backup that itself was frozen at whatever it held the first time it was ever written, since nothing updated it afterward. Any real route edit made in a session that hit this bug would have been silently lost on the next launch. Fixed by skipping the prune specifically on the post-permission-grant reload (`reloadCalendars(pruneRoutes: false)`) — the EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click, both against an already-warm store, still prune as before — and by keeping the `routes.v1` backup itself current on every save instead of frozen. Confirmed via `--status`/reading the UserDefaults plist directly on a real install: routes stopped disappearing across repeated launches. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||||
|
|
||||||
|
## [1.8.3] - 2026-08-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Preferences missing from the standard app menu.** 1.8.1's fix for Preferences (a plain `Window` opened via `openWindow`) worked around `LSUIElement` having no app menu, but 1.8.2 removed `LSUIElement` and the workaround was never swapped back — so there was still no "Preferences…" in the app's own menu or Cmd+, response, only a button buried in the main window and a menu-bar-dropdown item. Restored a real `Settings { }` scene now that the app menu exists to host it; both the menu bar dropdown and the main window's button now call the standard `openSettings()` action (via `SettingsLink` in the main window) instead of a custom `openWindow(id:)`. ([BusyMirrorApp.swift](BusyMirror/BusyMirrorApp.swift), [MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift), [ContentView.swift](BusyMirror/ContentView.swift))
|
||||||
|
|
||||||
## [1.8.2] - 2026-08-27
|
## [1.8.2] - 2026-08-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
- 1.8.0: **V2 UI polish.** `ContentView.swift` split from ~2000 lines into focused view files — `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (state stays owned by `ContentView`/`@AppStorage`; these are view-layer extractions, not a full MVVM rewrite — the settings-persistence model didn't need touching and touching it is exactly how the 1.6.0/1.6.1 data-loss bug happened). A preferences window hosting the pure `@AppStorage`-backed defaults (time window, privacy/mirroring defaults, work hours, skip filters) that used to live in the main window; this surfaced a latent bug — moving fields out of `ContentView` meant they stopped re-triggering `saveSettingsToDefaults()`, so on next launch `applySnapshot` would have silently reverted a preference changed in the new window using the stale blob. Fixed by splitting launch-time restore (`restoreLaunchState`, routes/selections only, since @AppStorage fields already self-restore) from Import's full restore (`applySnapshot`, unchanged). Menu bar icon now reflects idle/syncing/error state, and the dropdown shows last-sync time/result and whether auto-sync is armed.
|
- 1.8.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.1: the preferences window (originally a `Settings{}` scene) didn't actually open — `LSUIElement` apps get no standard app menu, so Cmd+,/`SettingsLink` had no menu to hook into. Replaced with a plain `Window` opened via `openWindow(id:)`, plus a "Preferences…" menu bar item as a second entry point.
|
||||||
- 1.8.2: **standard app, not menu-bar-only.** Removed `LSUIElement` — Dock icon, Cmd+Tab, standard app menu (Cmd+Q) are back. The accessory-app design (no Dock icon) turned out to make the app hard to quit reliably, which blocked replacing the bundle during updates. Menu bar extra stays as a secondary quick-access point.
|
- 1.8.2: **standard app, not menu-bar-only.** Removed `LSUIElement` — Dock icon, Cmd+Tab, standard app menu (Cmd+Q) are back. The accessory-app design (no Dock icon) turned out to make the app hard to quit reliably, which blocked replacing the bundle during updates. Menu bar extra stays as a secondary quick-access point.
|
||||||
|
- 1.8.3: restored the real `Settings{}` scene for Preferences now that the app menu exists to host it (1.8.1's `Window`+`openWindow` workaround was never swapped back after 1.8.2 removed `LSUIElement`) — Cmd+, and the app's own "Preferences…" menu item work again, not just a button and a menu-bar-dropdown item.
|
||||||
|
- 1.9.0: **sidebar-navigation redesign** (Routes / Schedule / Activity Log via `NavigationSplitView`, toolbar actions, collapsed-by-default route list, structured log view, refreshed menu bar) — explored as a mockup first, direction confirmed before implementing. Also fixed a real bug the redesign work surfaced: routes were being silently dropped on most launches (a freshly-created `EKEventStore` right after a permission grant can under-report calendars for a moment, especially remote accounts, so pruning against it wrongly deleted routes referencing calendars that were actually still there) — only ever recovered by a legacy backup key that was itself frozen at its first-ever value. Fixed the prune timing and made the backup stay current.
|
||||||
|
|
||||||
## Next
|
## 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.
|
1. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently.
|
||||||
|
|||||||
Reference in New Issue
Block a user