- Sidebar-navigation redesign: NavigationSplitView with Routes / Schedule / Activity Log replaces the 2x2 panel-card grid. Primary actions moved to the toolbar (dry-run/write, sync status, Sync Now); Export/Import/Reveal Log/Cleanup/Refresh/Recheck Permission moved to a toolbar overflow menu. - Routes are now a collapsed-by-default list -- click a row to expand its editor instead of always showing every field for every route. - Schedule view leads with live auto-sync status; the manual launchd schedule is demoted to an explicit optional section underneath. - Activity Log is readable rows (status icon + text) with a search filter and a Clear button, instead of a monospaced text dump. - Menu bar dropdown got icons and real Cmd+,/Cmd+Q shortcuts. - Explored as a design mockup first (published as an Artifact, light+dark), direction confirmed before writing any SwiftUI. Fixed along the way: routes were being silently dropped on most launches. reloadCalendars() pruned routes against a freshly-created EKEventStore's calendar fetch right after a permission grant, which can under-report calendars for a moment (especially remote/Exchange accounts) before they finish loading -- wrongly concluding a route's calendar was gone. Only survived because of a routes.v1 legacy backup key that itself never got updated after its first write, so any real route edit made in an affected session would have been silently reverted on the next launch. Fixed by skipping the prune specifically on the post-grant reload (reloadCalendars(pruneRoutes: false)) and keeping the legacy backup current on every save instead of frozen. All unit tests pass. Verified on a real install: routes stopped disappearing across repeated launches (checked via --status and reading the UserDefaults plist directly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1590 lines
70 KiB
Swift
1590 lines
70 KiB
Swift
import SwiftUI
|
||
import EventKit
|
||
import AppKit
|
||
|
||
|
||
enum OverlapMode: String, CaseIterable, Identifiable, Codable {
|
||
case allow, skipCovered, fillGaps
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
enum ScheduleMode: String, CaseIterable, Identifiable {
|
||
case hourly, daily, weekdays
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .hourly: return "Hourly"
|
||
case .daily: return "Daily"
|
||
case .weekdays: return "Weekdays"
|
||
}
|
||
}
|
||
}
|
||
|
||
enum SidebarSection: String, CaseIterable, Identifiable, Hashable {
|
||
case routes, schedule, log
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .routes: return "Routes"
|
||
case .schedule: return "Schedule"
|
||
case .log: return "Activity Log"
|
||
}
|
||
}
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .routes: return "arrow.triangle.branch"
|
||
case .schedule: return "clock"
|
||
case .log: return "terminal"
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
struct Route: Identifiable, Hashable, Codable {
|
||
let id = UUID()
|
||
var sourceID: String
|
||
var targetIDs: Set<String>
|
||
var privacy: Bool // true = hide details for this source
|
||
var copyNotes: Bool // copy description when privacy is OFF
|
||
var syncReminders: Bool // copy source event alarms into placeholder
|
||
var mergeGapHours: Int // per-route merge gap (hours)
|
||
var overlap: OverlapMode // per-route overlap behavior
|
||
var allDay: Bool // per-route mirror all-day
|
||
enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, syncReminders, mergeGapHours, overlap, allDay }
|
||
|
||
init(sourceID: String, targetIDs: Set<String>, privacy: Bool, copyNotes: Bool, syncReminders: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) {
|
||
self.sourceID = sourceID
|
||
self.targetIDs = targetIDs
|
||
self.privacy = privacy
|
||
self.copyNotes = copyNotes
|
||
self.syncReminders = syncReminders
|
||
self.mergeGapHours = mergeGapHours
|
||
self.overlap = overlap
|
||
self.allDay = allDay
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
self.sourceID = try c.decode(String.self, forKey: .sourceID)
|
||
self.targetIDs = try c.decode(Set<String>.self, forKey: .targetIDs)
|
||
self.privacy = try c.decode(Bool.self, forKey: .privacy)
|
||
self.copyNotes = try c.decode(Bool.self, forKey: .copyNotes)
|
||
self.syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false
|
||
self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours)
|
||
self.overlap = try c.decode(OverlapMode.self, forKey: .overlap)
|
||
self.allDay = try c.decode(Bool.self, forKey: .allDay)
|
||
|
||
}
|
||
}
|
||
|
||
struct ContentView: View {
|
||
@EnvironmentObject private var appController: BusyMirrorAppController
|
||
@State private var store = EKEventStore()
|
||
@State private var hasAccess = false
|
||
@State private var calendars: [EKCalendar] = []
|
||
@State private var sourceIndex: Int = 0
|
||
@State private var targetSelections = Set<Int>() // indices in calendars
|
||
// Stable selection storage by persistent identifiers (survives reordering)
|
||
@State private var sourceID: String? = nil
|
||
@State private var targetIDs = Set<String>()
|
||
@State private var routes: [Route] = []
|
||
@State private var selectedSection: SidebarSection? = .routes
|
||
@State private var manualSelectionExpanded = false
|
||
@AppStorage("daysForward") private var daysForward: Int = 7
|
||
@AppStorage("daysBack") private var daysBack: Int = 1
|
||
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
||
private var mergeGapMin: Int { max(0, mergeGapHours * 60) }
|
||
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
|
||
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
||
@AppStorage("syncReminders") private var syncReminders: Bool = false // Copy source alarms into mirrored placeholders
|
||
@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("scheduleMode") private var scheduleModeRaw: String = ScheduleMode.weekdays.rawValue
|
||
@AppStorage("scheduleHour") private var scheduleHour: Int = 8
|
||
@AppStorage("scheduleMinute") private var scheduleMinute: Int = 0
|
||
@AppStorage("scheduleWeekdaysOnly") private var scheduleWeekdaysOnly: Bool = true
|
||
@AppStorage("scheduleIntervalHours") private var scheduleIntervalHours: Int = 1
|
||
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
|
||
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
|
||
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
|
||
var overlapMode: OverlapMode {
|
||
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
|
||
nonmutating set { overlapModeRaw = newValue.rawValue }
|
||
}
|
||
@State private var writeEnabled = false // dry-run unless checked
|
||
@State private var logText = "Ready."
|
||
@State private var isRunning = false
|
||
@State private var isCLIRun = false
|
||
@State private var cliRunErrorCount = 0
|
||
@AppStorage("lastRunAtISO") private var lastRunAtISO: String = ""
|
||
@AppStorage("lastRunOK") private var lastRunOK: Bool = true
|
||
@AppStorage("lastRunSummary") private var lastRunSummary: String = ""
|
||
@State private var confirmCleanup = false
|
||
@State private var mirrorTask: Task<Void, Never>? = nil
|
||
@State private var progressText: String? = nil
|
||
/// Token for the EKEventStoreChanged observer; nil until calendar access is granted.
|
||
@State private var storeObserver: NSObjectProtocol? = nil
|
||
// Run-session guard: prevents the same source event from being mirrored
|
||
// into the same target more than once across multiple routes within a
|
||
// single "Mirror Now" click.
|
||
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
||
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
||
|
||
// Mirrors can run either by manual selection (source + at least one target)
|
||
// or using predefined routes. This derived flag controls the Mirror Now button.
|
||
private var canRunMirrorNow: Bool {
|
||
let hasManualTargets = !targetIDs.isEmpty
|
||
let hasRouteTargets = routes.contains { !$0.targetIDs.isEmpty }
|
||
return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets)
|
||
}
|
||
|
||
private static let intFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.numberStyle = .none
|
||
f.minimum = 0
|
||
f.maximum = 720
|
||
return f
|
||
}()
|
||
|
||
private static let hourFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.numberStyle = .none
|
||
f.minimum = 0
|
||
f.maximum = 24
|
||
return f
|
||
}()
|
||
|
||
private static let smallIntFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.numberStyle = .none
|
||
f.minimum = 1
|
||
f.maximum = 24
|
||
return f
|
||
}()
|
||
|
||
private var scheduleMode: ScheduleMode {
|
||
get { ScheduleMode(rawValue: scheduleModeRaw) ?? (scheduleWeekdaysOnly ? .weekdays : .daily) }
|
||
nonmutating set { scheduleModeRaw = newValue.rawValue }
|
||
}
|
||
|
||
private var launchAgentLabel: String { "com.cqrenet.BusyMirror.saved-routes" }
|
||
|
||
private var launchAgentURL: URL {
|
||
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||
return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false)
|
||
}
|
||
|
||
private var hasInstalledSchedule: Bool {
|
||
FileManager.default.fileExists(atPath: launchAgentURL.path)
|
||
}
|
||
|
||
private var scheduleSummary: String {
|
||
switch scheduleMode {
|
||
case .hourly:
|
||
return "every \(scheduleIntervalHours) hour" + (scheduleIntervalHours == 1 ? "" : "s")
|
||
case .daily:
|
||
return String(format: "%02d:%02d daily", scheduleHour, scheduleMinute)
|
||
case .weekdays:
|
||
return String(format: "%02d:%02d weekdays", scheduleHour, scheduleMinute)
|
||
}
|
||
}
|
||
|
||
private func clampScheduleTime() {
|
||
let nextHour = min(max(scheduleHour, 0), 23)
|
||
if nextHour != scheduleHour { scheduleHour = nextHour }
|
||
let nextMinute = min(max(scheduleMinute, 0), 59)
|
||
if nextMinute != scheduleMinute { scheduleMinute = nextMinute }
|
||
let nextInterval = min(max(scheduleIntervalHours, 1), 24)
|
||
if nextInterval != scheduleIntervalHours { scheduleIntervalHours = nextInterval }
|
||
}
|
||
|
||
private func launchAgentScheduleProperties() -> [String: Any] {
|
||
switch scheduleMode {
|
||
case .hourly:
|
||
return ["StartInterval": scheduleIntervalHours * 3600]
|
||
case .daily:
|
||
return ["StartCalendarInterval": ["Hour": scheduleHour, "Minute": scheduleMinute]]
|
||
case .weekdays:
|
||
let intervals: [[String: Int]] = (1...5).map { weekday in
|
||
["Hour": scheduleHour, "Minute": scheduleMinute, "Weekday": weekday]
|
||
}
|
||
return ["StartCalendarInterval": intervals]
|
||
}
|
||
}
|
||
|
||
private func launchCtl(_ arguments: [String], allowFailure: Bool = false) throws -> String {
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||
proc.arguments = arguments
|
||
let stdout = Pipe()
|
||
let stderr = Pipe()
|
||
proc.standardOutput = stdout
|
||
proc.standardError = stderr
|
||
try proc.run()
|
||
proc.waitUntilExit()
|
||
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
|
||
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
|
||
let outText = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let errText = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let combined = [outText, errText].filter { !$0.isEmpty }.joined(separator: "\n")
|
||
if proc.terminationStatus != 0 && !allowFailure {
|
||
throw NSError(
|
||
domain: "BusyMirrorLaunchCtl",
|
||
code: Int(proc.terminationStatus),
|
||
userInfo: [NSLocalizedDescriptionKey: combined.isEmpty ? "launchctl failed (\(proc.terminationStatus))" : combined]
|
||
)
|
||
}
|
||
return combined
|
||
}
|
||
|
||
private func installSchedule() {
|
||
guard !routes.isEmpty else {
|
||
log("Cannot install schedule: no saved routes.")
|
||
return
|
||
}
|
||
clampScheduleTime()
|
||
do {
|
||
let fm = FileManager.default
|
||
try fm.createDirectory(at: launchAgentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
try fm.createDirectory(at: AppLogStore.logDirectoryURL, withIntermediateDirectories: true)
|
||
|
||
let executablePath = Bundle.main.executableURL?.path
|
||
?? Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/BusyMirror").path
|
||
let plist: [String: Any] = [
|
||
"Label": launchAgentLabel,
|
||
"ProgramArguments": [executablePath, "--run-saved-routes", "--write", "1", "--exit"],
|
||
"RunAtLoad": false,
|
||
"StandardOutPath": AppLogStore.launchdStdoutURL.path,
|
||
"StandardErrorPath": AppLogStore.launchdStderrURL.path,
|
||
"WorkingDirectory": NSHomeDirectory(),
|
||
"EnvironmentVariables": ["HOME": NSHomeDirectory()]
|
||
].merging(launchAgentScheduleProperties()) { _, new in new }
|
||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||
try data.write(to: launchAgentURL, options: .atomic)
|
||
|
||
let domain = "gui/\(getuid())"
|
||
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
|
||
_ = try launchCtl(["bootstrap", domain, launchAgentURL.path])
|
||
log("Installed schedule at \(scheduleSummary). LaunchAgent: \(launchAgentURL.path)")
|
||
} catch {
|
||
log("Failed to install schedule: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
private func removeSchedule() {
|
||
do {
|
||
let domain = "gui/\(getuid())"
|
||
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
|
||
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
|
||
try FileManager.default.removeItem(at: launchAgentURL)
|
||
}
|
||
log("Removed schedule: \(launchAgentURL.path)")
|
||
} catch {
|
||
log("Failed to remove schedule: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
// Deterministic ordering to keep indices stable across runs
|
||
private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] {
|
||
return cals.sorted { a, b in
|
||
if a.source.title != b.source.title { return a.source.title < b.source.title }
|
||
if a.title != b.title { return a.title < b.title }
|
||
return a.calendarIdentifier < b.calendarIdentifier
|
||
}
|
||
}
|
||
|
||
private var excludedTitleFilterList: [String] {
|
||
excludedTitleFiltersRaw
|
||
.split { $0 == "\n" || $0 == "," }
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
}
|
||
|
||
private var excludedTitleFilterTerms: [String] {
|
||
excludedTitleFilterList.map { $0.lowercased() }
|
||
}
|
||
|
||
private var excludedOrganizerFilterList: [String] {
|
||
excludedOrganizerFiltersRaw
|
||
.split { $0 == "\n" || $0 == "," }
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
}
|
||
|
||
private var excludedOrganizerFilterTerms: [String] {
|
||
excludedOrganizerFilterList.map { $0.lowercased() }
|
||
}
|
||
|
||
private func rebuildSelectionsFromIDs() {
|
||
// Map IDs -> indices in current calendars
|
||
var idToIndex: [String:Int] = [:]
|
||
for (i, c) in calendars.enumerated() { idToIndex[c.calendarIdentifier] = i }
|
||
if calendars.isEmpty {
|
||
sourceIndex = 0
|
||
sourceID = nil
|
||
targetSelections.removeAll()
|
||
targetIDs.removeAll()
|
||
return
|
||
}
|
||
// Drop selections that no longer exist in EventKit
|
||
targetIDs = Set(targetIDs.filter { idToIndex[$0] != nil })
|
||
// Restore source index from sourceID if possible
|
||
if let sid = sourceID, let idx = idToIndex[sid] { sourceIndex = idx }
|
||
else if !calendars.isEmpty { sourceIndex = min(sourceIndex, calendars.count - 1); sourceID = calendars[sourceIndex].calendarIdentifier }
|
||
// Ensure selected source is never a target
|
||
if let sid = sourceID { targetIDs.remove(sid) }
|
||
// Restore targets from IDs
|
||
let restored = targetIDs.compactMap { idToIndex[$0] }
|
||
targetSelections = Set(restored).filter { $0 != sourceIndex }
|
||
}
|
||
|
||
private func pruneStaleCalendarReferences() -> (removedTargets: Int, droppedRoutes: Int, trimmedRoutes: Int, removedSource: Bool) {
|
||
let validIDs = Set(calendars.map { $0.calendarIdentifier })
|
||
|
||
let originalTargets = targetIDs
|
||
targetIDs = Set(originalTargets.filter { validIDs.contains($0) })
|
||
let removedTargets = originalTargets.count - targetIDs.count
|
||
|
||
var removedSource = false
|
||
if let sid = sourceID, !validIDs.contains(sid) {
|
||
sourceID = nil
|
||
removedSource = true
|
||
}
|
||
|
||
var droppedRoutes = 0
|
||
var trimmedRoutes = 0
|
||
if !routes.isEmpty {
|
||
var cleaned: [Route] = []
|
||
cleaned.reserveCapacity(routes.count)
|
||
for route in routes {
|
||
guard validIDs.contains(route.sourceID) else {
|
||
droppedRoutes += 1
|
||
continue
|
||
}
|
||
|
||
var filteredTargets = Set(route.targetIDs.filter { validIDs.contains($0) })
|
||
filteredTargets.remove(route.sourceID)
|
||
guard !filteredTargets.isEmpty else {
|
||
droppedRoutes += 1
|
||
continue
|
||
}
|
||
|
||
var next = route
|
||
if filteredTargets != route.targetIDs {
|
||
next.targetIDs = filteredTargets
|
||
trimmedRoutes += 1
|
||
}
|
||
cleaned.append(next)
|
||
}
|
||
routes = cleaned
|
||
}
|
||
|
||
return (removedTargets: removedTargets, droppedRoutes: droppedRoutes, trimmedRoutes: trimmedRoutes, removedSource: removedSource)
|
||
}
|
||
|
||
private func indexForCalendar(id: String) -> Int? { calendars.firstIndex(where: { $0.calendarIdentifier == id }) }
|
||
private func labelForCalendar(id: String) -> String { calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id }
|
||
|
||
|
||
// Ensure the currently selected source calendar is not present in targets
|
||
private func enforceNoSourceInTargets() {
|
||
guard sourceIndex < calendars.count else { return }
|
||
let sid = calendars[sourceIndex].calendarIdentifier
|
||
// Remove by index
|
||
if targetSelections.contains(sourceIndex) {
|
||
targetSelections.remove(sourceIndex)
|
||
}
|
||
// Remove by ID (in case indices shifted)
|
||
targetIDs.remove(sid)
|
||
}
|
||
|
||
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 {
|
||
var ranAnyRoute = false
|
||
var skippedMissingSource = 0
|
||
var skippedNoTargets = 0
|
||
|
||
for (idx, r) in configuredRoutes.enumerated() {
|
||
if Task.isCancelled { break }
|
||
guard let sIdx = indexForCalendar(id: r.sourceID) else {
|
||
skippedMissingSource += 1
|
||
continue
|
||
}
|
||
|
||
let validTargets = Set(r.targetIDs.filter { tid in
|
||
tid != r.sourceID && indexForCalendar(id: tid) != nil
|
||
})
|
||
guard !validTargets.isEmpty else {
|
||
skippedNoTargets += 1
|
||
continue
|
||
}
|
||
|
||
ranAnyRoute = true
|
||
let config = MirrorConfig(
|
||
daysBack: daysBack,
|
||
daysForward: daysForward,
|
||
mergeGapMin: max(0, r.mergeGapHours * 60),
|
||
hideDetails: r.privacy,
|
||
copyDescription: r.copyNotes,
|
||
mirrorAllDay: r.allDay,
|
||
overlapMode: r.overlap,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
filterByWorkHours: filterByWorkHours,
|
||
workHoursStart: workHoursStart,
|
||
workHoursEnd: workHoursEnd,
|
||
excludedTitleFilterTerms: excludedTitleFilterTerms,
|
||
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||
autoDeleteMissing: autoDeleteMissing,
|
||
writeEnabled: writeEnabled,
|
||
syncReminders: r.syncReminders
|
||
)
|
||
let srcCal = calendars[sIdx]
|
||
let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier }
|
||
await MainActor.run {
|
||
sourceIndex = sIdx
|
||
sourceID = r.sourceID
|
||
targetIDs = validTargets
|
||
targetIDs.remove(r.sourceID)
|
||
progressText = "Route \(idx + 1) of \(configuredRoutes.count)"
|
||
}
|
||
let engine = makeEngine()
|
||
await engine.runMirror(store: store, config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true)
|
||
}
|
||
|
||
if skippedMissingSource > 0 {
|
||
log("- SKIP routes with missing source calendar: \(skippedMissingSource)")
|
||
}
|
||
if skippedNoTargets > 0 {
|
||
log("- SKIP routes with no valid targets: \(skippedNoTargets)")
|
||
}
|
||
if !ranAnyRoute {
|
||
log("No valid routes to run. Refresh calendars and update your route selections.")
|
||
}
|
||
}
|
||
|
||
private func makeMirrorConfig() -> MirrorConfig {
|
||
MirrorConfig(
|
||
daysBack: daysBack,
|
||
daysForward: daysForward,
|
||
mergeGapMin: mergeGapMin,
|
||
hideDetails: hideDetails,
|
||
copyDescription: copyDescription,
|
||
mirrorAllDay: mirrorAllDay,
|
||
overlapMode: overlapMode,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
filterByWorkHours: filterByWorkHours,
|
||
workHoursStart: workHoursStart,
|
||
workHoursEnd: workHoursEnd,
|
||
excludedTitleFilterTerms: excludedTitleFilterTerms,
|
||
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||
autoDeleteMissing: autoDeleteMissing,
|
||
writeEnabled: writeEnabled,
|
||
syncReminders: syncReminders
|
||
)
|
||
}
|
||
|
||
private func startMirrorNow() {
|
||
guard !appController.isSyncing else { return }
|
||
guard mirrorTask == nil else { return }
|
||
appController.setSyncing(true)
|
||
isRunning = true
|
||
progressText = nil
|
||
mirrorTask = Task {
|
||
defer {
|
||
Task { @MainActor in
|
||
appController.setSyncing(false)
|
||
isRunning = false
|
||
mirrorTask = nil
|
||
progressText = nil
|
||
}
|
||
}
|
||
var sessionGuard = Set<String>()
|
||
if routes.isEmpty {
|
||
guard calendars.indices.contains(sourceIndex) else {
|
||
log("Cannot mirror: selected source is invalid.")
|
||
return
|
||
}
|
||
let config = makeMirrorConfig()
|
||
let srcCal = calendars[sourceIndex]
|
||
await MainActor.run {
|
||
sourceID = srcCal.calendarIdentifier
|
||
enforceNoSourceInTargets()
|
||
}
|
||
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
let engine = makeEngine()
|
||
await engine.runMirror(store: store, config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
||
} else {
|
||
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelMirror() {
|
||
mirrorTask?.cancel()
|
||
mirrorTask = nil
|
||
appController.setSyncing(false)
|
||
isRunning = false
|
||
log("Cancelled.")
|
||
}
|
||
|
||
private func handlePendingMenuBarSyncIfNeeded() {
|
||
guard appController.hasPendingSyncRequest else { return }
|
||
guard !isRunning else { return }
|
||
guard hasAccess else { return }
|
||
guard !calendars.isEmpty else { return }
|
||
guard canRunMirrorNow else {
|
||
appController.clearPendingSyncRequest()
|
||
log("Menu bar sync requested, but no valid manual targets or saved routes are available.")
|
||
return
|
||
}
|
||
appController.clearPendingSyncRequest()
|
||
startMirrorNow()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var sidebarView: some View {
|
||
List(SidebarSection.allCases, selection: $selectedSection) { section in
|
||
Label {
|
||
HStack {
|
||
Text(section.title)
|
||
if section == .routes && !routes.isEmpty {
|
||
Spacer()
|
||
Text("\(routes.count)")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
} icon: {
|
||
Image(systemName: section.icon)
|
||
}
|
||
.tag(section as SidebarSection?)
|
||
}
|
||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var accessNeededView: some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "lock.fill")
|
||
.font(.system(size: 40))
|
||
.foregroundStyle(.secondary)
|
||
Text("Calendar Permission Needed")
|
||
.font(.title3.weight(.semibold))
|
||
Text("BusyMirror needs access to read and mirror your events.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
Button("Request Calendar Access") {
|
||
requestAccess()
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.navigationTitle("BusyMirror")
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var routesDetailView: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
DisclosureGroup("Manual Selection", isExpanded: $manualSelectionExpanded) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
CalendarsSectionView(
|
||
calendars: calendars,
|
||
sourceIndex: $sourceIndex,
|
||
targetSelections: $targetSelections,
|
||
targetIDs: $targetIDs,
|
||
isRunning: isRunning
|
||
)
|
||
HStack {
|
||
Spacer()
|
||
Button("Add Route from Selection", action: addRouteFromCurrentSelection)
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(isRunning || sourceID == nil || targetIDs.isEmpty)
|
||
}
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
.font(.headline)
|
||
|
||
Divider()
|
||
|
||
RoutesSectionView(
|
||
routes: $routes,
|
||
calendars: calendars,
|
||
isRunning: isRunning,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
|
||
onAddRoute: addRouteFromCurrentSelection
|
||
)
|
||
}
|
||
.padding(20)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var scheduleDetailView: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Automatic Sync")
|
||
.font(.title2.weight(.bold))
|
||
Text("Runs on its own when your calendars change — no schedule to manage.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: appController.autoSyncArmed ? "checkmark.circle.fill" : "circle.dashed")
|
||
.foregroundStyle(appController.autoSyncArmed ? .green : .secondary)
|
||
.font(.title3)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(appController.autoSyncArmed ? "Auto-sync active" : "Auto-sync not active")
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(appController.autoSyncArmed ? "Watching for calendar changes, wake, and a 30-min fallback" : "Add a saved route below to enable")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(14)
|
||
Divider()
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "clock")
|
||
.foregroundStyle(.secondary)
|
||
.font(.title3)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Last sync")
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(appController.lastRunStatusText)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(14)
|
||
}
|
||
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(nsColor: .controlBackgroundColor)))
|
||
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.primary.opacity(0.12)))
|
||
|
||
Divider()
|
||
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Manual Schedule (optional)")
|
||
.font(.subheadline.weight(.semibold))
|
||
Text("Add a fixed-time schedule on top of auto-sync — a guaranteed full resync at a specific hour regardless of what changed.")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
ScheduleSectionView(
|
||
scheduleMode: Binding(
|
||
get: { scheduleMode },
|
||
set: { newValue in
|
||
scheduleMode = newValue
|
||
scheduleWeekdaysOnly = (newValue == .weekdays)
|
||
}
|
||
),
|
||
scheduleIntervalHours: $scheduleIntervalHours,
|
||
scheduleHour: $scheduleHour,
|
||
scheduleMinute: $scheduleMinute,
|
||
isRunning: isRunning,
|
||
routesEmpty: routes.isEmpty,
|
||
hasInstalledSchedule: hasInstalledSchedule,
|
||
scheduleSummary: scheduleSummary,
|
||
onInstall: installSchedule,
|
||
onRemove: removeSchedule,
|
||
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
|
||
onScheduleTimeChanged: clampScheduleTime
|
||
)
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
}
|
||
|
||
@ToolbarContentBuilder
|
||
private var toolbarContent: some ToolbarContent {
|
||
ToolbarItemGroup {
|
||
Picker("Mode", selection: $writeEnabled) {
|
||
Text("Dry Run").tag(false)
|
||
Text("Write").tag(true)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(width: 150)
|
||
.disabled(isRunning)
|
||
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(isRunning ? Color.orange : (appController.lastRunFailed ? Color.red : Color.secondary))
|
||
.frame(width: 7, height: 7)
|
||
Text(progressText ?? (isRunning ? "Running…" : (hasAccess ? "\(calendars.count) calendars" : "No access")))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if isRunning {
|
||
Button("Cancel") { cancelMirror() }
|
||
} else {
|
||
Button {
|
||
startMirrorNow()
|
||
} label: {
|
||
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||
}
|
||
.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") {
|
||
if writeEnabled {
|
||
confirmCleanup = true
|
||
} else {
|
||
Task {
|
||
if routes.isEmpty {
|
||
await runCleanupForCurrentSelection()
|
||
} else {
|
||
for r in routes {
|
||
await runCleanupForRoute(r)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.disabled(isRunning)
|
||
Button("Refresh Calendars") {
|
||
reloadCalendars(forceResetStore: true)
|
||
}
|
||
.disabled(isRunning)
|
||
Divider()
|
||
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
|
||
requestAccess()
|
||
}
|
||
.disabled(isRunning)
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
}
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationSplitView {
|
||
sidebarView
|
||
} detail: {
|
||
Group {
|
||
if !hasAccess {
|
||
accessNeededView
|
||
} else {
|
||
switch selectedSection ?? .routes {
|
||
case .routes: routesDetailView
|
||
case .schedule: scheduleDetailView
|
||
case .log: LogSectionView(logText: $logText)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle((selectedSection ?? .routes).title)
|
||
.toolbar { toolbarContent }
|
||
}
|
||
.confirmationDialog(
|
||
"Delete mirrored placeholders?",
|
||
isPresented: $confirmCleanup,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Delete now", role: .destructive) {
|
||
Task {
|
||
if routes.isEmpty {
|
||
await runCleanupForCurrentSelection()
|
||
} else {
|
||
for r in routes {
|
||
await runCleanupForRoute(r)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
let prefixNote = titlePrefix.isEmpty ? "" : " (title prefix ‘\(titlePrefix)’)"
|
||
Text("This will remove events identified as mirrored by URL prefix\(prefixNote) within the current window (Days back/forward) from the selected target calendars.")
|
||
}
|
||
.onAppear {
|
||
appController.setMainWindowVisible(true)
|
||
AppLogStore.append("=== BusyMirror launch ===")
|
||
log("Log file: \(AppLogStore.logFileURL.path)")
|
||
requestAccess()
|
||
loadSettingsFromDefaults()
|
||
tryRunCLIIfPresent()
|
||
enforceNoSourceInTargets()
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
if !isCLIRun {
|
||
appController.bootstrapBackgroundSync()
|
||
}
|
||
}
|
||
.onDisappear {
|
||
appController.setMainWindowVisible(false)
|
||
unregisterStoreObserver()
|
||
}
|
||
// Persist key settings whenever they change, to ensure restore between runs
|
||
.onChange(of: appController.syncRequestToken) { _ in
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
}
|
||
.onChange(of: daysBack) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: daysForward) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: hideDetails) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: copyDescription) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: syncReminders) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: workHoursStart) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: workHoursEnd) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: excludedOrganizerFiltersRaw) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: sourceIndex) { newValue in
|
||
// Track selected source by persistent ID and ensure it is not a target
|
||
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
||
enforceNoSourceInTargets()
|
||
saveSettingsToDefaults()
|
||
}
|
||
.onChange(of: targetSelections) { _ in
|
||
// If the new source is accidentally included, drop it
|
||
enforceNoSourceInTargets()
|
||
saveSettingsToDefaults()
|
||
}
|
||
.onChange(of: targetIDs) { _ in
|
||
// If IDs contain the source’s ID, drop it
|
||
enforceNoSourceInTargets()
|
||
saveSettingsToDefaults()
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
}
|
||
.onChange(of: routes) { _ in
|
||
saveSettingsToDefaults()
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
appController.armAutoSyncIfPossible()
|
||
}
|
||
}
|
||
|
||
// MARK: - CLI support
|
||
private static let cliHelpText = """
|
||
BusyMirror — mirror calendar events between EventKit calendars.
|
||
|
||
Usage:
|
||
BusyMirror --run-saved-routes [--write 1] [--exit]
|
||
BusyMirror --routes "1->2,3; 4->5" [--write 1] [--exit]
|
||
BusyMirror --list-calendars [--json]
|
||
BusyMirror --status [--json]
|
||
BusyMirror --help
|
||
|
||
Run modes:
|
||
--run-saved-routes Run the routes configured in the app's saved settings.
|
||
--routes SPEC Run ad-hoc routes by 1-based calendar index, e.g. "1->2,3".
|
||
--list-calendars Print available calendars (index, id, title, source) and exit.
|
||
--status Print last-run and schedule diagnostics and exit.
|
||
--help, -h Print this help and exit.
|
||
|
||
Options:
|
||
--json Machine-readable JSON output for --list-calendars / --status.
|
||
--write 1 Actually create/update/delete events (default: dry-run).
|
||
--exit Quit the app after the run completes.
|
||
--cleanup-only Only delete stale mirrored placeholders; don't mirror.
|
||
--privacy 1|0 Hide event details behind a placeholder title.
|
||
--copy-notes 1|0 Copy the source event's notes into the mirror.
|
||
--sync-reminders 1|0 Copy source event alarms into the mirror.
|
||
--all-day 1|0 Mirror all-day events.
|
||
--mode allow|skipCovered|fillGaps
|
||
--days-back N / --days-forward N
|
||
--merge-gap-hours N
|
||
--exclude-titles "token1, token2"
|
||
--exclude-organizers "alice@example.com, Example Org"
|
||
"""
|
||
|
||
private func recordRunResult(ok: Bool, summary: String) {
|
||
lastRunAtISO = ISO8601DateFormatter().string(from: Date())
|
||
lastRunOK = ok
|
||
lastRunSummary = summary
|
||
}
|
||
|
||
private struct CLICalendarInfo: Codable {
|
||
let index: Int
|
||
let id: String
|
||
let title: String
|
||
let source: String
|
||
let sourceType: String
|
||
let allowsModify: Bool
|
||
}
|
||
|
||
private struct CLIStatusInfo: Codable {
|
||
let lastRunAt: String?
|
||
let lastRunOK: Bool?
|
||
let lastRunSummary: String?
|
||
let scheduleInstalled: Bool
|
||
let scheduleSummary: String?
|
||
let routeCount: Int
|
||
let logFilePath: String
|
||
}
|
||
|
||
private func sourceTypeLabel(_ type: EKSourceType) -> String {
|
||
switch type {
|
||
case .local: return "local"
|
||
case .exchange: return "exchange"
|
||
case .calDAV: return "calDAV"
|
||
case .mobileMe: return "iCloud"
|
||
case .subscribed: return "subscribed"
|
||
case .birthdays: return "birthdays"
|
||
@unknown default: return "unknown"
|
||
}
|
||
}
|
||
|
||
private func printCalendars(json: Bool) {
|
||
let infos = calendars.enumerated().map { idx, cal in
|
||
CLICalendarInfo(
|
||
index: idx + 1,
|
||
id: cal.calendarIdentifier,
|
||
title: cal.title,
|
||
source: cal.source.title,
|
||
sourceType: sourceTypeLabel(cal.source.sourceType),
|
||
allowsModify: cal.allowsContentModifications
|
||
)
|
||
}
|
||
if json {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||
if let data = try? encoder.encode(infos), let s = String(data: data, encoding: .utf8) {
|
||
print(s)
|
||
}
|
||
} else {
|
||
for info in infos {
|
||
print("\(info.index): \(info.title) [\(info.source), \(info.sourceType)]\(info.allowsModify ? "" : " (read-only)") id=\(info.id)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func printStatus(json: Bool) {
|
||
let info = CLIStatusInfo(
|
||
lastRunAt: lastRunAtISO.isEmpty ? nil : lastRunAtISO,
|
||
lastRunOK: lastRunAtISO.isEmpty ? nil : lastRunOK,
|
||
lastRunSummary: lastRunSummary.isEmpty ? nil : lastRunSummary,
|
||
scheduleInstalled: hasInstalledSchedule,
|
||
scheduleSummary: hasInstalledSchedule ? scheduleSummary : nil,
|
||
routeCount: routes.count,
|
||
logFilePath: AppLogStore.logFileURL.path
|
||
)
|
||
if json {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||
if let data = try? encoder.encode(info), let s = String(data: data, encoding: .utf8) {
|
||
print(s)
|
||
}
|
||
} else {
|
||
print("Last run: \(info.lastRunAt ?? "never")\(info.lastRunAt != nil ? (info.lastRunOK == true ? " (ok)" : " (error)") : "")")
|
||
if let summary = info.lastRunSummary { print(" \(summary)") }
|
||
print("Schedule: \(info.scheduleInstalled ? (info.scheduleSummary ?? "installed") : "not installed")")
|
||
print("Saved routes: \(info.routeCount)")
|
||
print("Log file: \(info.logFilePath)")
|
||
}
|
||
}
|
||
|
||
func tryRunCLIIfPresent() {
|
||
let args = CommandLine.arguments
|
||
let jsonOutput = args.contains("--json")
|
||
|
||
if args.contains("--help") || args.contains("-h") {
|
||
isCLIRun = true
|
||
print(Self.cliHelpText)
|
||
NSApp.terminate(nil)
|
||
return
|
||
}
|
||
|
||
if args.contains("--status") {
|
||
isCLIRun = true
|
||
printStatus(json: jsonOutput)
|
||
NSApp.terminate(nil)
|
||
return
|
||
}
|
||
|
||
if args.contains("--list-calendars") {
|
||
isCLIRun = true
|
||
Task {
|
||
if hasAccess { await MainActor.run { reloadCalendars() } }
|
||
for _ in 0..<50 {
|
||
if hasAccess && !calendars.isEmpty { break }
|
||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||
}
|
||
guard hasAccess else {
|
||
FileHandle.standardError.write("No calendar access.\n".data(using: .utf8)!)
|
||
exit(2)
|
||
}
|
||
await MainActor.run { printCalendars(json: jsonOutput) }
|
||
NSApp.terminate(nil)
|
||
}
|
||
return
|
||
}
|
||
|
||
let routesIdx = args.firstIndex(of: "--routes")
|
||
let runSavedRoutes = args.contains("--run-saved-routes")
|
||
guard routesIdx != nil || runSavedRoutes else { return }
|
||
isCLIRun = true
|
||
cliRunErrorCount = 0
|
||
|
||
func boolArg(_ name: String, default def: Bool) -> Bool {
|
||
if let i = args.firstIndex(of: name), i+1 < args.count {
|
||
let v = args[i+1].lowercased()
|
||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||
}
|
||
return def
|
||
}
|
||
func intArg(_ name: String, default def: Int) -> Int {
|
||
if let i = args.firstIndex(of: name), i+1 < args.count, let n = Int(args[i+1]) { return n }
|
||
return def
|
||
}
|
||
func strArg(_ name: String) -> String? {
|
||
if let i = args.firstIndex(of: name), i+1 < args.count { return args[i+1] }
|
||
return nil
|
||
}
|
||
|
||
// Configure options from CLI flags
|
||
hideDetails = boolArg("--privacy", default: hideDetails)
|
||
copyDescription = boolArg("--copy-notes", default: copyDescription)
|
||
syncReminders = boolArg("--sync-reminders", default: syncReminders)
|
||
writeEnabled = boolArg("--write", default: writeEnabled)
|
||
mirrorAllDay = boolArg("--all-day", default: mirrorAllDay)
|
||
daysForward = intArg("--days-forward", default: daysForward)
|
||
daysBack = intArg("--days-back", default: daysBack)
|
||
mergeGapHours = intArg("--merge-gap-hours", default: mergeGapHours)
|
||
if let modeStr = strArg("--mode")?.lowercased() {
|
||
switch modeStr {
|
||
case "allow": overlapModeRaw = OverlapMode.allow.rawValue
|
||
case "skipcovered", "skip": overlapModeRaw = OverlapMode.skipCovered.rawValue
|
||
case "fillgaps", "gaps": overlapModeRaw = OverlapMode.fillGaps.rawValue
|
||
default: break
|
||
}
|
||
}
|
||
// Optional filters via CLI
|
||
if let tFilters = strArg("--exclude-titles") { excludedTitleFiltersRaw = tFilters }
|
||
if let oFilters = strArg("--exclude-organizers") { excludedOrganizerFiltersRaw = oFilters }
|
||
|
||
let routesSpec = {
|
||
guard let routesIdx else { return "" }
|
||
return (routesIdx + 1 < args.count) ? args[routesIdx + 1] : ""
|
||
}()
|
||
let routeParts = routesSpec.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) }
|
||
if runSavedRoutes {
|
||
log("CLI: run saved routes")
|
||
} else {
|
||
log("CLI: routes=\(routesSpec)")
|
||
}
|
||
Task {
|
||
// If permission already granted, force a sync calendar reload so
|
||
// the CLI doesn't race the async permission callback.
|
||
if hasAccess {
|
||
await MainActor.run { reloadCalendars() }
|
||
}
|
||
// Wait up to ~10s for calendars to load
|
||
for _ in 0..<50 {
|
||
if hasAccess && !calendars.isEmpty { break }
|
||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||
}
|
||
guard hasAccess, !calendars.isEmpty else {
|
||
log("CLI: no calendar access; aborting")
|
||
recordRunResult(ok: false, summary: "no calendar access")
|
||
exit(2)
|
||
}
|
||
|
||
let cliConfig = makeMirrorConfig()
|
||
if runSavedRoutes {
|
||
if routes.isEmpty {
|
||
log("CLI: no saved routes; aborting")
|
||
recordRunResult(ok: false, summary: "no saved routes")
|
||
exit(3)
|
||
} else if boolArg("--cleanup-only", default: false) {
|
||
for r in routes {
|
||
log("CLI: cleanup saved route \(r.sourceID)")
|
||
await runCleanupForRoute(r)
|
||
}
|
||
recordRunResult(ok: cliRunErrorCount == 0, summary: "cleaned up \(routes.count) saved route(s)")
|
||
} else {
|
||
var sessionGuard = Set<String>()
|
||
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routes.count) saved route(s)")
|
||
}
|
||
} else {
|
||
for part in routeParts where !part.isEmpty {
|
||
let lr = part.split(separator: "->", maxSplits: 1).map { String($0) }
|
||
guard lr.count == 2, let s1 = Int(lr[0].trimmingCharacters(in: .whitespaces)) else { continue }
|
||
let srcIdx0 = max(0, s1 - 1)
|
||
let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 }
|
||
if srcIdx0 >= calendars.count { continue }
|
||
let srcCal = calendars[srcIdx0]
|
||
let targetSet = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil }).subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
await MainActor.run {
|
||
sourceIndex = srcIdx0
|
||
sourceID = srcCal.calendarIdentifier
|
||
targetSelections = Set(tgtIdxs0)
|
||
targetIDs = targetSet
|
||
}
|
||
|
||
if boolArg("--cleanup-only", default: false) {
|
||
log("CLI: cleanup route \(part)")
|
||
let engine = makeEngine()
|
||
await engine.runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||
} else {
|
||
log("CLI: mirror route \(part)")
|
||
let engine = makeEngine()
|
||
var sessionGuard = Set<String>()
|
||
await engine.runMirror(store: store, config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
||
}
|
||
}
|
||
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routeParts.count) route(s)")
|
||
}
|
||
// Exit only when --exit is explicitly passed. isCLIRun alone does
|
||
// not force termination so that advanced users can open the UI with
|
||
// --routes to pre-populate a run without auto-quitting.
|
||
if CommandLine.arguments.contains("--exit") {
|
||
NSApp.terminate(nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Permissions & Calendars
|
||
@MainActor
|
||
func requestAccess() {
|
||
log("Requesting calendar access…")
|
||
if #available(macOS 14.0, *) {
|
||
store.requestFullAccessToEvents { granted, _ in
|
||
DispatchQueue.main.async {
|
||
hasAccess = granted
|
||
if granted {
|
||
// Reinitialize the store after permission changes to ensure sources load
|
||
store = EKEventStore()
|
||
reloadCalendars(pruneRoutes: false)
|
||
} else {
|
||
appController.clearPendingSyncRequest()
|
||
}
|
||
log(granted ? "Access granted." : "Access denied.")
|
||
}
|
||
}
|
||
} else {
|
||
store.requestAccess(to: .event) { granted, _ in
|
||
DispatchQueue.main.async {
|
||
hasAccess = granted
|
||
if granted {
|
||
// Reinitialize the store after permission changes to ensure sources load
|
||
store = EKEventStore()
|
||
reloadCalendars(pruneRoutes: false)
|
||
} else {
|
||
appController.clearPendingSyncRequest()
|
||
}
|
||
log(granted ? "Access granted." : "Access denied.")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func reloadCalendars(forceResetStore: Bool = false, pruneRoutes: Bool = true) {
|
||
if forceResetStore {
|
||
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
|
||
// Unregister the existing EKEventStoreChanged observer first — it targets the
|
||
// old store object and would never fire again after the store is replaced.
|
||
unregisterStoreObserver()
|
||
store = EKEventStore()
|
||
}
|
||
let fetched = store.calendars(for: .event)
|
||
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()
|
||
if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource {
|
||
log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).")
|
||
saveSettingsToDefaults()
|
||
}
|
||
}
|
||
// Initialize IDs on first load
|
||
if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier }
|
||
// Rebuild index-based selections from stored IDs
|
||
rebuildSelectionsFromIDs()
|
||
log("Loaded \(calendars.count) calendars.")
|
||
// Register for live calendar-store changes the first time we have access,
|
||
// so the calendar list stays up-to-date without pressing "Refresh".
|
||
if storeObserver == nil {
|
||
storeObserver = NotificationCenter.default.addObserver(
|
||
forName: .EKEventStoreChanged,
|
||
object: store,
|
||
queue: .main
|
||
) { [self] _ in
|
||
// Skip silent background refreshes while a sync is running to
|
||
// avoid interfering with an in-progress mirror operation.
|
||
guard !isRunning else { return }
|
||
reloadCalendars()
|
||
}
|
||
}
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
}
|
||
|
||
@MainActor
|
||
private func unregisterStoreObserver() {
|
||
if let token = storeObserver {
|
||
NotificationCenter.default.removeObserver(token)
|
||
storeObserver = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Export / Import Settings
|
||
struct SettingsPayload: Codable {
|
||
var daysBack: Int
|
||
var daysForward: Int
|
||
var mergeGapHours: Int
|
||
var hideDetails: Bool
|
||
var copyDescription: Bool
|
||
var syncReminders: Bool = false
|
||
var mirrorAllDay: Bool
|
||
var filterByWorkHours: Bool = false
|
||
var workHoursStart: Int = 9
|
||
var workHoursEnd: Int = 17
|
||
var excludedTitleFilters: [String] = []
|
||
var excludedOrganizerFilters: [String] = []
|
||
var mirrorAcceptedOnly: Bool = false
|
||
var overlapMode: String
|
||
var titlePrefix: String
|
||
var placeholderTitle: String
|
||
var autoDeleteMissing: Bool
|
||
var routes: [Route]
|
||
// UI selections (optional for backward compatibility)
|
||
var selectedSourceID: String? = nil
|
||
var selectedTargetIDs: [String]? = nil
|
||
// optional metadata
|
||
var appVersion: String?
|
||
var exportedAt: Date = Date()
|
||
|
||
init(daysBack: Int, daysForward: Int, mergeGapHours: Int, hideDetails: Bool, copyDescription: Bool,
|
||
syncReminders: Bool = false, mirrorAllDay: Bool, filterByWorkHours: Bool, workHoursStart: Int,
|
||
workHoursEnd: Int, excludedTitleFilters: [String], excludedOrganizerFilters: [String],
|
||
mirrorAcceptedOnly: Bool, overlapMode: String, titlePrefix: String, placeholderTitle: String,
|
||
autoDeleteMissing: Bool, routes: [Route], selectedSourceID: String? = nil,
|
||
selectedTargetIDs: [String]? = nil, appVersion: String? = nil, exportedAt: Date = Date()) {
|
||
self.daysBack = daysBack
|
||
self.daysForward = daysForward
|
||
self.mergeGapHours = mergeGapHours
|
||
self.hideDetails = hideDetails
|
||
self.copyDescription = copyDescription
|
||
self.syncReminders = syncReminders
|
||
self.mirrorAllDay = mirrorAllDay
|
||
self.filterByWorkHours = filterByWorkHours
|
||
self.workHoursStart = workHoursStart
|
||
self.workHoursEnd = workHoursEnd
|
||
self.excludedTitleFilters = excludedTitleFilters
|
||
self.excludedOrganizerFilters = excludedOrganizerFilters
|
||
self.mirrorAcceptedOnly = mirrorAcceptedOnly
|
||
self.overlapMode = overlapMode
|
||
self.titlePrefix = titlePrefix
|
||
self.placeholderTitle = placeholderTitle
|
||
self.autoDeleteMissing = autoDeleteMissing
|
||
self.routes = routes
|
||
self.selectedSourceID = selectedSourceID
|
||
self.selectedTargetIDs = selectedTargetIDs
|
||
self.appVersion = appVersion
|
||
self.exportedAt = exportedAt
|
||
}
|
||
|
||
// Custom decode: every field added after the very first release must be
|
||
// read with decodeIfPresent so that a settings blob written by an older
|
||
// build (missing that key) doesn't fail the whole decode and silently
|
||
// wipe all saved routes/settings (see: settings.v2 losing data across
|
||
// the 1.5.1 -> 1.6.0 upgrade when `syncReminders` was added).
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
daysBack = try c.decodeIfPresent(Int.self, forKey: .daysBack) ?? 1
|
||
daysForward = try c.decodeIfPresent(Int.self, forKey: .daysForward) ?? 7
|
||
mergeGapHours = try c.decodeIfPresent(Int.self, forKey: .mergeGapHours) ?? 0
|
||
hideDetails = try c.decodeIfPresent(Bool.self, forKey: .hideDetails) ?? true
|
||
copyDescription = try c.decodeIfPresent(Bool.self, forKey: .copyDescription) ?? false
|
||
syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false
|
||
mirrorAllDay = try c.decodeIfPresent(Bool.self, forKey: .mirrorAllDay) ?? false
|
||
filterByWorkHours = try c.decodeIfPresent(Bool.self, forKey: .filterByWorkHours) ?? false
|
||
workHoursStart = try c.decodeIfPresent(Int.self, forKey: .workHoursStart) ?? 9
|
||
workHoursEnd = try c.decodeIfPresent(Int.self, forKey: .workHoursEnd) ?? 17
|
||
excludedTitleFilters = try c.decodeIfPresent([String].self, forKey: .excludedTitleFilters) ?? []
|
||
excludedOrganizerFilters = try c.decodeIfPresent([String].self, forKey: .excludedOrganizerFilters) ?? []
|
||
mirrorAcceptedOnly = try c.decodeIfPresent(Bool.self, forKey: .mirrorAcceptedOnly) ?? false
|
||
overlapMode = try c.decodeIfPresent(String.self, forKey: .overlapMode) ?? OverlapMode.allow.rawValue
|
||
titlePrefix = try c.decodeIfPresent(String.self, forKey: .titlePrefix) ?? "🪞 "
|
||
placeholderTitle = try c.decodeIfPresent(String.self, forKey: .placeholderTitle) ?? "Busy"
|
||
autoDeleteMissing = try c.decodeIfPresent(Bool.self, forKey: .autoDeleteMissing) ?? true
|
||
routes = try c.decodeIfPresent([Route].self, forKey: .routes) ?? []
|
||
selectedSourceID = try c.decodeIfPresent(String.self, forKey: .selectedSourceID)
|
||
selectedTargetIDs = try c.decodeIfPresent([String].self, forKey: .selectedTargetIDs)
|
||
appVersion = try c.decodeIfPresent(String.self, forKey: .appVersion)
|
||
exportedAt = try c.decodeIfPresent(Date.self, forKey: .exportedAt) ?? Date()
|
||
}
|
||
}
|
||
|
||
private func makeSnapshot() -> SettingsPayload {
|
||
SettingsPayload(
|
||
daysBack: daysBack,
|
||
daysForward: daysForward,
|
||
mergeGapHours: mergeGapHours,
|
||
hideDetails: hideDetails,
|
||
copyDescription: copyDescription,
|
||
mirrorAllDay: mirrorAllDay,
|
||
filterByWorkHours: filterByWorkHours,
|
||
workHoursStart: workHoursStart,
|
||
workHoursEnd: workHoursEnd,
|
||
excludedTitleFilters: excludedTitleFilterList,
|
||
excludedOrganizerFilters: excludedOrganizerFilterList,
|
||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||
overlapMode: overlapMode.rawValue,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
autoDeleteMissing: autoDeleteMissing,
|
||
routes: routes,
|
||
selectedSourceID: sourceID,
|
||
selectedTargetIDs: Array(targetIDs).sorted(),
|
||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||
exportedAt: Date()
|
||
)
|
||
}
|
||
|
||
private func applySnapshot(_ s: SettingsPayload) {
|
||
daysBack = s.daysBack
|
||
daysForward = s.daysForward
|
||
mergeGapHours = s.mergeGapHours
|
||
hideDetails = s.hideDetails
|
||
copyDescription = s.copyDescription
|
||
syncReminders = s.syncReminders
|
||
mirrorAllDay = s.mirrorAllDay
|
||
filterByWorkHours = s.filterByWorkHours
|
||
workHoursStart = s.workHoursStart
|
||
workHoursEnd = s.workHoursEnd
|
||
excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n")
|
||
excludedOrganizerFiltersRaw = s.excludedOrganizerFilters.joined(separator: "\n")
|
||
mirrorAcceptedOnly = s.mirrorAcceptedOnly
|
||
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
|
||
titlePrefix = s.titlePrefix
|
||
placeholderTitle = s.placeholderTitle
|
||
autoDeleteMissing = s.autoDeleteMissing
|
||
routes = s.routes
|
||
// Restore UI selections if provided
|
||
if let selSrc = s.selectedSourceID { sourceID = selSrc }
|
||
if let selTgts = s.selectedTargetIDs { targetIDs = Set(selTgts) }
|
||
clampWorkHours()
|
||
// Rebuild indices from IDs after restoring selections
|
||
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"]
|
||
panel.nameFieldStringValue = "BusyMirror-Settings.json"
|
||
panel.canCreateDirectories = true
|
||
panel.isExtensionHidden = false
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
do {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||
let data = try encoder.encode(makeSnapshot())
|
||
try data.write(to: url, options: Data.WritingOptions.atomic)
|
||
log("✓ Exported settings to \(url.path)")
|
||
} catch {
|
||
log("✗ Export failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func importSettings() {
|
||
let panel = NSOpenPanel()
|
||
panel.allowedFileTypes = ["json"]
|
||
panel.allowsMultipleSelection = false
|
||
panel.canChooseDirectories = false
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
do {
|
||
let data = try Data(contentsOf: url)
|
||
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
|
||
applySnapshot(snap)
|
||
saveSettingsToDefaults()
|
||
log("✓ Imported settings from \(url.lastPathComponent)")
|
||
} catch {
|
||
log("✗ Import failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Settings persistence (UserDefaults)
|
||
private func makeEngine() -> MirrorEngine {
|
||
MirrorEngine(log: log)
|
||
}
|
||
|
||
private func runCleanupForCurrentSelection() async {
|
||
guard hasAccess, !calendars.isEmpty else { return }
|
||
guard calendars.indices.contains(sourceIndex) else {
|
||
log("Cannot cleanup: selected source is invalid.")
|
||
return
|
||
}
|
||
let srcCal = calendars[sourceIndex]
|
||
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
await makeEngine().runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||
}
|
||
|
||
private func runCleanupForRoute(_ route: Route) async {
|
||
guard let sIdx = indexForCalendar(id: route.sourceID) else { return }
|
||
let srcCal = calendars[sIdx]
|
||
let targetSet = route.targetIDs.subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
// Do NOT mutate sourceIndex / sourceID / targetIDs here: cleanup does
|
||
// not need to reflect route selections in the UI and doing so causes
|
||
// jarring picker jumps when iterating over multiple routes.
|
||
await makeEngine().runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||
}
|
||
|
||
private let settingsDefaultsKey = "settings.v2"
|
||
private let legacyRoutesDefaultsKey = "routes.v1"
|
||
|
||
private func saveSettingsToDefaults() {
|
||
do {
|
||
let data = try JSONEncoder().encode(makeSnapshot())
|
||
UserDefaults.standard.set(data, forKey: settingsDefaultsKey)
|
||
} catch {
|
||
log("✗ Failed to save settings: \(error.localizedDescription)")
|
||
}
|
||
// Keep the legacy routes-only backup current (not just a frozen
|
||
// historical snapshot) so it's a real safety net: if settings.v2 ever
|
||
// comes back with an empty `routes` array again (the exact bug fixed
|
||
// by reloadCalendars(pruneRoutes:) above), recovery restores the
|
||
// actual current routes, not whatever they were the first time this
|
||
// key was ever written.
|
||
if !routes.isEmpty, let routesData = try? JSONEncoder().encode(routes) {
|
||
UserDefaults.standard.set(routesData, forKey: legacyRoutesDefaultsKey)
|
||
}
|
||
}
|
||
|
||
private func loadSettingsFromDefaults() {
|
||
let defaults = UserDefaults.standard
|
||
if let data = defaults.data(forKey: settingsDefaultsKey) {
|
||
do {
|
||
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
|
||
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
|
||
// back as empty) can still be running with `routes: []` in
|
||
// settings.v2 while the untouched legacy `routes.v1` key still
|
||
// holds the real routes. Recover them if so.
|
||
if routes.isEmpty, let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey),
|
||
let recovered = try? JSONDecoder().decode([Route].self, from: legacyData), !recovered.isEmpty {
|
||
routes = recovered
|
||
log("Recovered \(recovered.count) route(s) from legacy backup (routes.v1).")
|
||
saveSettingsToDefaults()
|
||
}
|
||
} catch {
|
||
log("✗ Failed to load settings: \(error.localizedDescription)")
|
||
}
|
||
return
|
||
}
|
||
|
||
// Legacy fallback: routes-only payload
|
||
guard let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey) else { return }
|
||
do {
|
||
let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData)
|
||
routes = decodedRoutes
|
||
clampWorkHours()
|
||
saveSettingsToDefaults() // upgrade stored format
|
||
} catch {
|
||
log("✗ Failed to load routes: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Filters
|
||
|
||
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 }
|
||
}
|
||
}
|
||
|
||
// MARK: - Logging
|
||
func log(_ s: String) {
|
||
AppLogStore.append(s)
|
||
if isCLIRun {
|
||
let lower = s.lowercased()
|
||
if lower.contains("error") || lower.contains("fail") {
|
||
cliRunErrorCount += 1
|
||
}
|
||
}
|
||
DispatchQueue.main.async {
|
||
logText.append("\n" + s)
|
||
let maxLines = 2000
|
||
let lines = logText.split(separator: "\n", omittingEmptySubsequences: false)
|
||
if lines.count > maxLines {
|
||
logText = lines.suffix(maxLines).joined(separator: "\n")
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|