Release 1.5.1

Bug fixes and code quality improvements:

- Fix mirror index dirtied on every sync (MirrorRecord.updatedAt in equality)
- Fix mirror URL corruption: encode calendar/source IDs before joining with ';'
  and use percentEncodedPath to prevent double-encoding
- Fix cleanup route mutating UI calendar picker selection unnecessarily
- Fix --exit flag redundancy (isCLIRun no longer implies termination)
- Remove dead SKIP_ALL_DAY_DEFAULT constant
- Replace deprecated FileHandle(forWritingAtPath:) with throwing variant
- Add EKEventStoreChanged observer for live calendar list refresh
- Extract AppLogStore into its own file (AppLogStore.swift)
- Add Block.span(start🔚) factory; replace verbose nil-field constructions
- Remove redundant MainActor.run{} wrappers inside @MainActor MirrorEngine
- Fix SettingsPayload indentation inside ContentView

All 45 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:48:08 +02:00
parent 2c319808c2
commit ad6ae396da
11 changed files with 220 additions and 120 deletions
+61 -81
View File
@@ -2,55 +2,6 @@ import SwiftUI
import EventKit
import AppKit
private let SKIP_ALL_DAY_DEFAULT = true
private enum AppLogStore {
private static let queue = DispatchQueue(label: "BusyMirror.log.store")
private static let maxLogSizeBytes: UInt64 = 1_000_000
static let logDirectoryURL: URL = {
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return base.appendingPathComponent("Logs/BusyMirror", isDirectory: true)
}()
static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false)
private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false)
static let launchdStdoutURL = logDirectoryURL.appendingPathComponent("launchd.stdout.log", isDirectory: false)
static let launchdStderrURL = logDirectoryURL.appendingPathComponent("launchd.stderr.log", isDirectory: false)
private static let timestampFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
static func append(_ message: String) {
let line = "[\(timestampFormatter.string(from: Date()))] \(message)\n"
queue.async {
let fm = FileManager.default
do {
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
if let attrs = try? fm.attributesOfItem(atPath: logFileURL.path),
let size = attrs[.size] as? NSNumber,
size.uint64Value >= maxLogSizeBytes {
try? fm.removeItem(at: archivedLogFileURL)
try? fm.moveItem(at: logFileURL, to: archivedLogFileURL)
}
if !fm.fileExists(atPath: logFileURL.path) {
fm.createFile(atPath: logFileURL.path, contents: nil)
}
guard let data = line.data(using: .utf8),
let handle = FileHandle(forWritingAtPath: logFileURL.path) else { return }
defer { handle.closeFile() }
handle.seekToEndOfFile()
handle.write(data)
} catch {
// Logging must never break the app's main behavior.
}
}
}
}
enum OverlapMode: String, CaseIterable, Identifiable, Codable {
case allow, skipCovered, fillGaps
@@ -162,6 +113,8 @@ struct ContentView: View {
@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.
@@ -1345,6 +1298,7 @@ struct ContentView: View {
}
.onDisappear {
appController.setMainWindowVisible(false)
unregisterStoreObserver()
}
// Persist key settings whenever they change, to ensure restore between runs
.onChange(of: appController.syncRequestToken) { _ in
@@ -1497,7 +1451,10 @@ struct ContentView: View {
}
}
}
if CommandLine.arguments.contains("--exit") || isCLIRun {
// 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)
}
}
@@ -1542,6 +1499,9 @@ struct ContentView: View {
func reloadCalendars(forceResetStore: Bool = false) {
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)
@@ -1556,35 +1516,57 @@ struct ContentView: View {
saveSettingsToDefaults()
}
log("Loaded \(calendars.count) calendars.")
// Register for live calendar-store changes the first time we have access,
// so the calendar list stays up-to-date without pressing "Refresh".
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
private struct SettingsPayload: Codable {
var daysBack: Int
var daysForward: Int
var mergeGapHours: Int
var hideDetails: Bool
var copyDescription: Bool
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()
}
// MARK: - Export / Import Settings
private struct SettingsPayload: Codable {
var daysBack: Int
var daysForward: Int
var mergeGapHours: Int
var hideDetails: Bool
var copyDescription: Bool
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()
}
private func makeSnapshot() -> SettingsPayload {
SettingsPayload(
@@ -1697,11 +1679,9 @@ private struct SettingsPayload: Codable {
let srcCal = calendars[sIdx]
let targetSet = route.targetIDs.subtracting([srcCal.calendarIdentifier])
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
await MainActor.run {
sourceIndex = sIdx
sourceID = route.sourceID
targetIDs = route.targetIDs
}
// 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)
}