Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77e3eeaf7b | ||
|
|
4e29b3716c | ||
|
|
d40bc986c5 | ||
|
|
39c5cba237 | ||
|
|
edddf65288 | ||
|
|
08e9fe5325 | ||
|
|
61cea918a6 | ||
|
|
ad6ae396da |
@@ -4,9 +4,9 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
**BusyMirror** is a macOS menu-bar utility that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
|
||||
**BusyMirror** is a macOS utility (standard app + menu bar extra) that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
|
||||
|
||||
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a menu-bar-only app (`LSUIElement`) with no Dock icon.
|
||||
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a standard app (Dock icon, ⌘Q) and also has a `MenuBarExtra` for quick sync/status.
|
||||
|
||||
Key capabilities:
|
||||
- Manual or route-driven multi-source mirroring
|
||||
@@ -40,16 +40,17 @@ BusyMirror/
|
||||
├── MirrorEngine.swift # EventKit mirror engine (read, deduplicate, merge, create/update/delete)
|
||||
├── MirrorConfig.swift # Configuration struct passed to the engine
|
||||
├── MirrorUtils.swift # URL builders, mirror detection, calendar labels
|
||||
├── BlockMath.swift # Block merging, gap calculation, overlap logic
|
||||
├── BlockMath.swift # Block merging, gap calculation, overlap logic (Block.span factory)
|
||||
├── EventFilters.swift # Work-hours, title, and organizer filters
|
||||
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
||||
├── Info.plist # LSUIElement, calendar usage descriptions
|
||||
├── AppLogStore.swift # File-backed log store with rotation (AppLogStore enum)
|
||||
├── Info.plist # calendar/reminders usage descriptions
|
||||
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
||||
└── Assets.xcassets/ # AppIcon set and accent color
|
||||
|
||||
BusyMirror.xcodeproj/ # Xcode project
|
||||
BusyMirrorTests/ # Empty (no tests implemented)
|
||||
BusyMirrorUITests/ # Empty (no tests implemented)
|
||||
BusyMirror.xcodeproj/ # Xcode project (PBXFileSystemSynchronizedRootGroup — new .swift files are auto-included)
|
||||
BusyMirrorTests/ # Unit tests: BlockMathTests, EventFiltersTests, MirrorUtilsTests (45 tests)
|
||||
BusyMirrorUITests/ # UI tests (empty)
|
||||
```
|
||||
|
||||
**Architecture note:** `ContentView.swift` handles the SwiftUI view hierarchy, settings serialization, CLI argument parsing, `launchd` scheduling, and logging. The EventKit mirror engine lives in `MirrorEngine.swift` and is invoked from `ContentView` via `makeEngine()`. Pure helper logic (block math, filters, URL utilities) has been extracted into standalone files for testability.
|
||||
@@ -126,7 +127,9 @@ BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit
|
||||
BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3" --write 1 --exit
|
||||
```
|
||||
|
||||
Relevant flags: `--privacy`, `--copy-notes`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
|
||||
Relevant flags: `--privacy`, `--copy-notes`, `--sync-reminders`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
|
||||
|
||||
Diagnostic/query flags (no calendar write, exit immediately): `--help`/`-h`, `--list-calendars [--json]`, `--status [--json]`. `--status` reads `lastRunAtISO`/`lastRunOK`/`lastRunSummary` in `UserDefaults`, written by `recordRunResult(ok:summary:)` at the end of every `--routes`/`--run-saved-routes` invocation. Exit codes: `2` = no calendar access, `3` = `--run-saved-routes` with no saved routes.
|
||||
|
||||
Scheduled runs are implemented by generating a `launchd` plist in `~/Library/LaunchAgents/com.cqrenet.BusyMirror.saved-routes.plist` and bootstrapping it with `launchctl`. The app removes and re-bootstraps the agent on every "Install Schedule" click.
|
||||
|
||||
@@ -142,7 +145,8 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau
|
||||
| `BusyMirror/EventFilters.swift` | Work-hours, title, and organizer filters |
|
||||
| `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
|
||||
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
||||
| `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions |
|
||||
| `BusyMirror/AppLogStore.swift` | File-backed log with rotation (`~/Library/Logs/BusyMirror/`) |
|
||||
| `BusyMirror/Info.plist` | calendar/reminders usage descriptions |
|
||||
| `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement |
|
||||
| `Makefile` | Reproducible build, sign, and package targets |
|
||||
| `CHANGELOG.md` | Release notes (human-readable) |
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
9fd864e05f5091cbc23864ff226e7d909119a22e019584279a95d206b935cf15
|
||||
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -451,7 +451,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
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) else { return }
|
||||
// Use the throwing initialiser so we don't silently swallow
|
||||
// an inaccessible file — the outer catch handles it.
|
||||
let handle = try FileHandle(forWritingTo: logFileURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: data)
|
||||
} catch {
|
||||
// Logging must never break the app's main behavior.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-10
@@ -7,6 +7,32 @@ struct Block: Hashable {
|
||||
let label: String? // source title (for dry-run / non-private)
|
||||
let notes: String? // source notes (for optional copy)
|
||||
let occurrence: Date? // occurrenceDate for recurring instances
|
||||
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
|
||||
|
||||
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
|
||||
static func span(start: Date, end: Date) -> Block {
|
||||
Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: nil)
|
||||
}
|
||||
|
||||
// Alarms are carried along for mirroring but do not affect time-based
|
||||
// deduplication, merging, or overlap calculations.
|
||||
static func == (lhs: Block, rhs: Block) -> Bool {
|
||||
lhs.start == rhs.start &&
|
||||
lhs.end == rhs.end &&
|
||||
lhs.srcStableID == rhs.srcStableID &&
|
||||
lhs.label == rhs.label &&
|
||||
lhs.notes == rhs.notes &&
|
||||
lhs.occurrence == rhs.occurrence
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(start)
|
||||
hasher.combine(end)
|
||||
hasher.combine(srcStableID)
|
||||
hasher.combine(label)
|
||||
hasher.combine(notes)
|
||||
hasher.combine(occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
// De-dup blocks by occurrence (preferred) or by time range
|
||||
@@ -30,17 +56,19 @@ func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] {
|
||||
guard !blocks.isEmpty else { return [] }
|
||||
let sorted = blocks.sorted { $0.start < $1.start }
|
||||
var out: [Block] = []
|
||||
var cur = Block(start: sorted[0].start, end: sorted[0].end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||
var cur = Block.span(start: sorted[0].start, end: sorted[0].end)
|
||||
var curAlarms = sorted[0].alarmOffsets
|
||||
for b in sorted.dropFirst() {
|
||||
let gap = b.start.timeIntervalSince(cur.end) / 60.0
|
||||
if gap <= Double(gapMinutes) {
|
||||
if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) }
|
||||
if b.end > cur.end { cur = Block.span(start: cur.start, end: b.end) }
|
||||
} else {
|
||||
out.append(cur)
|
||||
cur = Block(start: b.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||
out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
|
||||
cur = Block.span(start: b.start, end: b.end)
|
||||
curAlarms = b.alarmOffsets
|
||||
}
|
||||
}
|
||||
out.append(cur)
|
||||
out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -55,21 +83,21 @@ func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool {
|
||||
}
|
||||
|
||||
func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] {
|
||||
if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
||||
if mergedSegs.isEmpty { return [Block.span(start: block.start, end: block.end)] }
|
||||
var segs: [Block] = []
|
||||
for s in mergedSegs where s.end > block.start && s.start < block.end {
|
||||
let ss = max(s.start, block.start)
|
||||
let ee = min(s.end, block.end)
|
||||
if ee > ss { segs.append(Block(start: ss, end: ee, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||
if ee > ss { segs.append(Block.span(start: ss, end: ee)) }
|
||||
}
|
||||
if segs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
||||
if segs.isEmpty { return [Block.span(start: block.start, end: block.end)] }
|
||||
let merged = coalesce(segs)
|
||||
var gaps: [Block] = []
|
||||
var prevEnd = block.start
|
||||
for s in merged {
|
||||
if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||
if s.start > prevEnd { gaps.append(Block.span(start: prevEnd, end: s.start)) }
|
||||
if s.end > prevEnd { prevEnd = s.end }
|
||||
}
|
||||
if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||
if prevEnd < block.end { gaps.append(Block.span(start: prevEnd, end: block.end)) }
|
||||
return gaps
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import SwiftUI
|
||||
struct BusyMirrorApp: App {
|
||||
@StateObject private var appController = BusyMirrorAppController()
|
||||
|
||||
private var menuBarIcon: String {
|
||||
if appController.isSyncing { return "arrow.triangle.2.circlepath.circle.fill" }
|
||||
if appController.lastRunFailed { return "exclamationmark.triangle.fill" }
|
||||
return "calendar.badge.clock"
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) {
|
||||
ContentView()
|
||||
@@ -12,9 +18,19 @@ struct BusyMirrorApp: App {
|
||||
}
|
||||
.defaultSize(width: 1120, height: 760)
|
||||
|
||||
MenuBarExtra("BusyMirror", systemImage: appController.isSyncing ? "arrow.triangle.2.circlepath.circle.fill" : "calendar.badge.clock") {
|
||||
MenuBarExtra("BusyMirror", systemImage: menuBarIcon) {
|
||||
BusyMirrorMenuBarView()
|
||||
.environmentObject(appController)
|
||||
}
|
||||
|
||||
// A real Settings scene: now that the app is standard (not
|
||||
// LSUIElement), this gets the conventional Cmd+, and a "Preferences…"
|
||||
// item in the app's own menu for free — the location people actually
|
||||
// look, unlike a plain Window which only opens from wherever we
|
||||
// explicitly put a button for it.
|
||||
Settings {
|
||||
PreferencesView()
|
||||
.environmentObject(appController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
import EventKit
|
||||
|
||||
// Small calendar display helpers shared by ContentView, RoutesSectionView,
|
||||
// and CalendarsSectionView.
|
||||
|
||||
func calColor(_ cal: EKCalendar) -> Color {
|
||||
#if os(macOS)
|
||||
return Color(cal.cgColor ?? NSColor.systemGray.cgColor)
|
||||
#else
|
||||
return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func calChip(_ cal: EKCalendar) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(calColor(cal)).frame(width: 10, height: 10)
|
||||
Text(calLabel(cal))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
import EventKit
|
||||
|
||||
/// The manual source/target calendar picker — extracted from ContentView.
|
||||
/// Bindings mutate ContentView's own @State directly.
|
||||
struct CalendarsSectionView: View {
|
||||
let calendars: [EKCalendar]
|
||||
@Binding var sourceIndex: Int
|
||||
@Binding var targetSelections: Set<Int>
|
||||
@Binding var targetIDs: Set<String>
|
||||
let isRunning: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Source calendar")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Source", selection: $sourceIndex) {
|
||||
ForEach(Array(calendars.indices), id: \.self) { i in
|
||||
Text("\(i + 1): \(calLabel(calendars[i]))").tag(i)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.disabled(isRunning || calendars.isEmpty)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("Target calendars")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("\(targetIDs.count) selected")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(calendars.indices), id: \.self) { i in
|
||||
let isSource = (i == sourceIndex)
|
||||
let binding = Binding<Bool>(
|
||||
get: { !isSource && targetSelections.contains(i) },
|
||||
set: { newValue in
|
||||
// Never allow selecting the source as a target
|
||||
if isSource { return }
|
||||
if newValue {
|
||||
targetSelections.insert(i)
|
||||
targetIDs.insert(calendars[i].calendarIdentifier)
|
||||
} else {
|
||||
targetSelections.remove(i)
|
||||
targetIDs.remove(calendars[i].calendarIdentifier)
|
||||
}
|
||||
}
|
||||
)
|
||||
Toggle(isOn: binding) {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(i + 1).")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
calChip(calendars[i])
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
.disabled(isRunning || isSource)
|
||||
.opacity(isSource ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 170, maxHeight: 260)
|
||||
}
|
||||
}
|
||||
}
|
||||
+602
-783
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>15.5</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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 {
|
||||
@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 {
|
||||
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))
|
||||
.textSelection(.enabled)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
Divider().opacity(0.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.22))
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import EventKit
|
||||
import ServiceManagement
|
||||
|
||||
enum BusyMirrorSceneID {
|
||||
static let mainWindow = "main-window"
|
||||
@@ -11,6 +13,36 @@ final class BusyMirrorAppController: ObservableObject {
|
||||
@Published private(set) var hasPendingSyncRequest = false
|
||||
@Published private(set) var syncRequestToken = UUID()
|
||||
@Published private(set) var isMainWindowVisible = false
|
||||
@Published private(set) var lastRunFailed = false
|
||||
@Published private(set) var autoSyncArmed = false
|
||||
|
||||
init() {
|
||||
refreshLastRunStatus()
|
||||
}
|
||||
|
||||
/// Re-reads the shared lastRun* UserDefaults keys. Call after any run —
|
||||
/// interactive, CLI, or auto-sync — writes them, so the menu bar icon and
|
||||
/// dropdown reflect the outcome regardless of which path produced it.
|
||||
func refreshLastRunStatus() {
|
||||
guard let atISO = UserDefaults.standard.string(forKey: "lastRunAtISO"), !atISO.isEmpty else {
|
||||
lastRunFailed = false
|
||||
return
|
||||
}
|
||||
lastRunFailed = (UserDefaults.standard.object(forKey: "lastRunOK") as? Bool) == false
|
||||
}
|
||||
|
||||
/// Human-readable "last sync" line for the menu bar dropdown. Computed on
|
||||
/// demand (not published) since the dropdown's content is rebuilt each
|
||||
/// time it's opened.
|
||||
var lastRunStatusText: String {
|
||||
guard let atISO = UserDefaults.standard.string(forKey: "lastRunAtISO"), !atISO.isEmpty,
|
||||
let date = ISO8601DateFormatter().date(from: atISO) else {
|
||||
return "No sync yet."
|
||||
}
|
||||
let relative = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
|
||||
let ok = (UserDefaults.standard.object(forKey: "lastRunOK") as? Bool) != false
|
||||
return ok ? "Last sync: \(relative)" : "Last sync failed: \(relative)"
|
||||
}
|
||||
|
||||
func requestSync() {
|
||||
hasPendingSyncRequest = true
|
||||
@@ -33,10 +65,192 @@ final class BusyMirrorAppController: ObservableObject {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
openWindow(id: BusyMirrorSceneID.mainWindow)
|
||||
}
|
||||
|
||||
// MARK: - Event-driven background sync
|
||||
//
|
||||
// Owned here (not by ContentView) because this controller lives for the
|
||||
// whole process, independent of the main window's lifecycle. ContentView's
|
||||
// own EKEventStoreChanged observer is torn down in .onDisappear when the
|
||||
// window closes — fine for keeping the UI's calendar list fresh while
|
||||
// open, but useless for background operation, which is the entire point
|
||||
// of replacing the old hourly launchd poll. This runs regardless of
|
||||
// whether the window is open, closed, or never opened this session.
|
||||
|
||||
private let backgroundStore = EKEventStore()
|
||||
private var storeObserver: NSObjectProtocol?
|
||||
private var wakeObserver: NSObjectProtocol?
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
private var fallbackTask: Task<Void, Never>?
|
||||
|
||||
private let settingsDefaultsKey = "settings.v2"
|
||||
private let legacyRoutesDefaultsKey = "routes.v1"
|
||||
private let launchAgentLabel = "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)
|
||||
}
|
||||
|
||||
/// Call once at app launch. Only activates if calendar access has already
|
||||
/// been granted in a prior session — never prompts on its own, since a
|
||||
/// permission dialog with no window open would be confusing. First-run
|
||||
/// consent stays ContentView's job.
|
||||
func bootstrapBackgroundSync() {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let hasAccess: Bool
|
||||
if #available(macOS 14.0, *) {
|
||||
hasAccess = status == .fullAccess
|
||||
} else {
|
||||
hasAccess = status == .authorized
|
||||
}
|
||||
guard hasAccess else { return }
|
||||
armAutoSyncIfPossible()
|
||||
}
|
||||
|
||||
/// Re-check whether auto-sync should activate. Safe to call repeatedly
|
||||
/// (e.g. from ContentView whenever the saved routes list changes) — it
|
||||
/// only does anything the first time routes go from empty to non-empty.
|
||||
func armAutoSyncIfPossible() {
|
||||
guard !autoSyncArmed else { return }
|
||||
guard !loadRoutesFromDefaults().isEmpty else { return }
|
||||
autoSyncArmed = true
|
||||
|
||||
if SMAppService.mainApp.status != .enabled {
|
||||
try? SMAppService.mainApp.register()
|
||||
AppLogStore.append("[auto-sync] Registered as login item.")
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
|
||||
removeLegacyLaunchdSchedule()
|
||||
}
|
||||
if storeObserver == nil {
|
||||
storeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .EKEventStoreChanged, object: backgroundStore, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.scheduleAutoSync(reason: "calendar changed")
|
||||
}
|
||||
}
|
||||
if wakeObserver == nil {
|
||||
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.scheduleAutoSync(reason: "system woke from sleep")
|
||||
}
|
||||
}
|
||||
if fallbackTask == nil {
|
||||
fallbackTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 30 * 60 * 1_000_000_000)
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.scheduleAutoSync(reason: "periodic fallback check")
|
||||
}
|
||||
}
|
||||
}
|
||||
AppLogStore.append("[auto-sync] Armed: watching for calendar changes.")
|
||||
}
|
||||
|
||||
private func removeLegacyLaunchdSchedule() {
|
||||
let domain = "gui/\(getuid())"
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||
proc.arguments = ["bootout", domain, launchAgentURL.path]
|
||||
proc.standardOutput = Pipe()
|
||||
proc.standardError = Pipe()
|
||||
try? proc.run()
|
||||
proc.waitUntilExit()
|
||||
try? FileManager.default.removeItem(at: launchAgentURL)
|
||||
AppLogStore.append("[auto-sync] Removed launchd schedule (superseded by change-driven sync).")
|
||||
}
|
||||
|
||||
/// Debounces bursts of EKEventStoreChanged notifications into one sync.
|
||||
private func scheduleAutoSync(reason: String) {
|
||||
debounceTask?.cancel()
|
||||
debounceTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.runAutoSync(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRoutesFromDefaults() -> [Route] {
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = defaults.data(forKey: settingsDefaultsKey),
|
||||
let snap = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: data) {
|
||||
return snap.routes
|
||||
}
|
||||
if let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey),
|
||||
let routes = try? JSONDecoder().decode([Route].self, from: legacyData) {
|
||||
return routes
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func runAutoSync(reason: String) async {
|
||||
guard !isSyncing else { return }
|
||||
let routes = loadRoutesFromDefaults()
|
||||
guard !routes.isEmpty else { return }
|
||||
|
||||
guard let snap = UserDefaults.standard.data(forKey: settingsDefaultsKey),
|
||||
let settings = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: snap) else {
|
||||
return
|
||||
}
|
||||
|
||||
setSyncing(true)
|
||||
AppLogStore.append("[auto-sync] Triggered by \(reason).")
|
||||
var errorCount = 0
|
||||
let engine = MirrorEngine(log: { s in
|
||||
AppLogStore.append("[auto-sync] \(s)")
|
||||
let lower = s.lowercased()
|
||||
if lower.contains("error") || lower.contains("fail") { errorCount += 1 }
|
||||
})
|
||||
|
||||
let calendars = backgroundStore.calendars(for: .event)
|
||||
func calendar(id: String) -> EKCalendar? { calendars.first { $0.calendarIdentifier == id } }
|
||||
|
||||
var ranAnyRoute = false
|
||||
var sessionGuard = Set<String>()
|
||||
for route in routes {
|
||||
guard let sourceCal = calendar(id: route.sourceID) else { continue }
|
||||
let validTargetIDs = route.targetIDs.filter { $0 != route.sourceID }
|
||||
let targets = validTargetIDs.compactMap(calendar(id:))
|
||||
guard !targets.isEmpty else { continue }
|
||||
ranAnyRoute = true
|
||||
let config = MirrorConfig(
|
||||
daysBack: settings.daysBack,
|
||||
daysForward: settings.daysForward,
|
||||
mergeGapMin: max(0, route.mergeGapHours * 60),
|
||||
hideDetails: route.privacy,
|
||||
copyDescription: route.copyNotes,
|
||||
mirrorAllDay: route.allDay,
|
||||
overlapMode: route.overlap,
|
||||
titlePrefix: settings.titlePrefix,
|
||||
placeholderTitle: settings.placeholderTitle,
|
||||
filterByWorkHours: settings.filterByWorkHours,
|
||||
workHoursStart: settings.workHoursStart,
|
||||
workHoursEnd: settings.workHoursEnd,
|
||||
excludedTitleFilterTerms: settings.excludedTitleFilters.map { $0.lowercased() },
|
||||
excludedOrganizerFilterTerms: settings.excludedOrganizerFilters.map { $0.lowercased() },
|
||||
mirrorAcceptedOnly: settings.mirrorAcceptedOnly,
|
||||
autoDeleteMissing: settings.autoDeleteMissing,
|
||||
writeEnabled: true,
|
||||
syncReminders: route.syncReminders
|
||||
)
|
||||
await engine.runMirror(store: backgroundStore, config: config, sourceCalendar: sourceCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true)
|
||||
}
|
||||
|
||||
let summary = ranAnyRoute ? "auto-sync (\(reason)): ran \(routes.count) saved route(s)" : "auto-sync (\(reason)): no route had a valid source+target"
|
||||
AppLogStore.append("[auto-sync] \(summary)")
|
||||
UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: "lastRunAtISO")
|
||||
UserDefaults.standard.set(errorCount == 0, forKey: "lastRunOK")
|
||||
UserDefaults.standard.set(summary, forKey: "lastRunSummary")
|
||||
refreshLastRunStatus()
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct BusyMirrorMenuBarView: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||
|
||||
var body: some View {
|
||||
@@ -44,30 +258,54 @@ struct BusyMirrorMenuBarView: View {
|
||||
Text("BusyMirror")
|
||||
.font(.headline)
|
||||
|
||||
Text(appController.isSyncing ? "Sync in progress." : "Use your saved routes or current selection.")
|
||||
.font(.subheadline)
|
||||
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)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(appController.lastRunFailed ? .red : .secondary)
|
||||
}
|
||||
|
||||
Text(appController.autoSyncArmed ? "Auto-sync: watching for calendar changes." : "Auto-sync: not active (add a saved route to enable).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
Button(appController.isSyncing ? "Syncing…" : "Sync Now") {
|
||||
Button {
|
||||
let shouldOpenWindow = !appController.isMainWindowVisible
|
||||
appController.requestSync()
|
||||
if shouldOpenWindow {
|
||||
appController.openMainWindow(using: openWindow)
|
||||
}
|
||||
} label: {
|
||||
Label(appController.isSyncing ? "Syncing…" : "Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.disabled(appController.isSyncing)
|
||||
|
||||
Button("Open BusyMirror") {
|
||||
Button {
|
||||
appController.openMainWindow(using: openWindow)
|
||||
} label: {
|
||||
Label("Open BusyMirror", systemImage: "macwindow")
|
||||
}
|
||||
|
||||
Button {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
openSettings()
|
||||
} label: {
|
||||
Label("Preferences…", systemImage: "gearshape")
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit BusyMirror") {
|
||||
Button {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
Label("Quit BusyMirror", systemImage: "power")
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: .command)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 240, alignment: .leading)
|
||||
|
||||
@@ -19,4 +19,5 @@ struct MirrorConfig {
|
||||
let mirrorAcceptedOnly: Bool
|
||||
let autoDeleteMissing: Bool
|
||||
let writeEnabled: Bool
|
||||
let syncReminders: Bool
|
||||
}
|
||||
|
||||
@@ -3,6 +3,24 @@ import EventKit
|
||||
|
||||
private let SAME_TIME_TOL_MIN: Double = 5
|
||||
|
||||
private func alarmOffsets(for event: EKEvent) -> [TimeInterval]? {
|
||||
guard let alarms = event.alarms, !alarms.isEmpty else { return nil }
|
||||
let offsets = alarms.compactMap { alarm -> TimeInterval? in
|
||||
if alarm.relativeOffset != 0 {
|
||||
return alarm.relativeOffset
|
||||
}
|
||||
if let absoluteDate = alarm.absoluteDate, let start = event.startDate {
|
||||
return absoluteDate.timeIntervalSince(start)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return offsets.isEmpty ? nil : offsets
|
||||
}
|
||||
|
||||
private func alarmsFromOffsets(_ offsets: [TimeInterval]) -> [EKAlarm] {
|
||||
offsets.map { EKAlarm(relativeOffset: $0) }
|
||||
}
|
||||
|
||||
struct MirrorRecord: Hashable, Codable {
|
||||
var targetCalendarID: String
|
||||
var sourceCalendarID: String
|
||||
@@ -13,6 +31,29 @@ struct MirrorRecord: Hashable, Codable {
|
||||
var lastKnownEndTimestamp: TimeInterval
|
||||
var updatedAt: Date = Date()
|
||||
|
||||
// updatedAt is intentionally excluded from equality and hashing: it is a
|
||||
// bookkeeping timestamp that changes on every write and should not cause
|
||||
// the mirror index to be marked dirty when the meaningful fields are equal.
|
||||
static func == (lhs: MirrorRecord, rhs: MirrorRecord) -> Bool {
|
||||
lhs.targetCalendarID == rhs.targetCalendarID &&
|
||||
lhs.sourceCalendarID == rhs.sourceCalendarID &&
|
||||
lhs.sourceStableID == rhs.sourceStableID &&
|
||||
lhs.occurrenceTimestamp == rhs.occurrenceTimestamp &&
|
||||
lhs.targetEventIdentifier == rhs.targetEventIdentifier &&
|
||||
lhs.lastKnownStartTimestamp == rhs.lastKnownStartTimestamp &&
|
||||
lhs.lastKnownEndTimestamp == rhs.lastKnownEndTimestamp
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(targetCalendarID)
|
||||
hasher.combine(sourceCalendarID)
|
||||
hasher.combine(sourceStableID)
|
||||
hasher.combine(occurrenceTimestamp)
|
||||
hasher.combine(targetEventIdentifier)
|
||||
hasher.combine(lastKnownStartTimestamp)
|
||||
hasher.combine(lastKnownEndTimestamp)
|
||||
}
|
||||
|
||||
var sourceKey: String {
|
||||
sourceOccurrenceKey(
|
||||
sourceCalID: sourceCalendarID,
|
||||
@@ -137,7 +178,7 @@ final class MirrorEngine {
|
||||
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||
let srcID = stableSourceIdentifier(for: ev)
|
||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate))
|
||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
|
||||
}
|
||||
if skippedMirrors > 0 {
|
||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||
@@ -229,7 +270,7 @@ final class MirrorEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil))
|
||||
occupied.append(Block.span(start: ts, end: te))
|
||||
}
|
||||
}
|
||||
occupied = coalesce(occupied)
|
||||
@@ -301,6 +342,17 @@ final class MirrorEngine {
|
||||
upsertMirrorRecord(for: blk, event: event)
|
||||
}
|
||||
|
||||
func desiredAlarms(for blk: Block) -> [EKAlarm] {
|
||||
guard config.syncReminders, let offsets = blk.alarmOffsets else { return [] }
|
||||
return alarmsFromOffsets(offsets)
|
||||
}
|
||||
|
||||
func alarmsEqual(_ a: [EKAlarm], _ b: [EKAlarm]) -> Bool {
|
||||
let offsetsA = a.compactMap { $0.relativeOffset }.sorted()
|
||||
let offsetsB = b.compactMap { $0.relativeOffset }.sorted()
|
||||
return offsetsA == offsetsB
|
||||
}
|
||||
|
||||
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
|
||||
let curS = existing.startDate ?? blk.start
|
||||
let curE = existing.endDate ?? blk.end
|
||||
@@ -310,6 +362,9 @@ final class MirrorEngine {
|
||||
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
|
||||
if existing.isAllDay { return true }
|
||||
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
|
||||
let newAlarms = desiredAlarms(for: blk)
|
||||
let existingAlarms = existing.alarms ?? []
|
||||
if !alarmsEqual(newAlarms, existingAlarms) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -359,13 +414,12 @@ final class MirrorEngine {
|
||||
existing.isAllDay = false
|
||||
existing.notes = notes
|
||||
existing.url = desiredURL
|
||||
existing.alarms = desiredAlarms(for: blk)
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try store.save(existing, span: .thisEvent, commit: true)
|
||||
}
|
||||
try store.save(existing, span: .thisEvent, commit: true)
|
||||
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
|
||||
rememberMirrorEvent(existing, for: blk)
|
||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
|
||||
occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)])
|
||||
sessionGuard.insert(gKey)
|
||||
updated += 1
|
||||
} catch {
|
||||
@@ -412,15 +466,14 @@ final class MirrorEngine {
|
||||
newEv.isAllDay = false
|
||||
newEv.notes = notes
|
||||
newEv.url = desiredURL
|
||||
newEv.alarms = desiredAlarms(for: blk)
|
||||
newEv.availability = .busy
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try store.save(newEv, span: .thisEvent, commit: true)
|
||||
}
|
||||
try store.save(newEv, span: .thisEvent, commit: true)
|
||||
created += 1
|
||||
log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
||||
rememberMirrorEvent(newEv, for: blk)
|
||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
|
||||
occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)])
|
||||
sessionGuard.insert(gKey)
|
||||
} catch {
|
||||
log("Save failed: \(error.localizedDescription)")
|
||||
@@ -479,9 +532,7 @@ final class MirrorEngine {
|
||||
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
|
||||
} else {
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try store.remove(candidate, span: .thisEvent, commit: true)
|
||||
}
|
||||
try store.remove(candidate, span: .thisEvent, commit: true)
|
||||
removed += 1
|
||||
} catch {
|
||||
log("Delete failed: \(error.localizedDescription)")
|
||||
@@ -543,9 +594,7 @@ final class MirrorEngine {
|
||||
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
|
||||
} else {
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try store.remove(ev, span: .thisEvent, commit: true)
|
||||
}
|
||||
try store.remove(ev, span: .thisEvent, commit: true)
|
||||
removed += 1
|
||||
} catch {
|
||||
log("Delete failed: \(error.localizedDescription)")
|
||||
@@ -598,12 +647,11 @@ final class MirrorEngine {
|
||||
log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)")
|
||||
} else {
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try store.remove(ev, span: .thisEvent, commit: true)
|
||||
}
|
||||
try store.remove(ev, span: .thisEvent, commit: true)
|
||||
delCount += 1
|
||||
} catch {
|
||||
log("Delete failed: \(error.localizedDescription)")
|
||||
}
|
||||
catch { log("Delete failed: \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
log("[Cleanup \(tgt.title)] deleted=\(delCount)")
|
||||
|
||||
@@ -57,10 +57,12 @@ func mirrorTimeKey(start: Date, end: Date) -> String {
|
||||
func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: String?, occurrence: Date?, start: Date, end: Date) -> URL? {
|
||||
let sourceID = sourceStableID ?? ""
|
||||
let occ = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||
// Percent-encode IDs so that any embedded ";" doesn't corrupt the
|
||||
// semicolon-delimited path when the URL is later parsed.
|
||||
let parts = [
|
||||
targetCalID,
|
||||
sourceCalID,
|
||||
sourceID,
|
||||
mirrorURLComponentEncode(targetCalID),
|
||||
mirrorURLComponentEncode(sourceCalID),
|
||||
mirrorURLComponentEncode(sourceID),
|
||||
occ,
|
||||
String(start.timeIntervalSince1970),
|
||||
String(end.timeIntervalSince1970)
|
||||
@@ -68,7 +70,9 @@ func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: St
|
||||
var components = URLComponents()
|
||||
components.scheme = "mirror"
|
||||
components.host = "x"
|
||||
components.path = "/" + parts.joined(separator: ";")
|
||||
// Use percentEncodedPath so URLComponents does not re-encode the already
|
||||
// percent-encoded IDs (double-encoding would break round-trip parsing).
|
||||
components.percentEncodedPath = "/" + parts.joined(separator: ";")
|
||||
return components.url
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Real macOS Settings (⌘,) window. Holds only the pure, globally-persisted
|
||||
/// defaults (all @AppStorage, shared automatically with ContentView's copies
|
||||
/// of the same keys) — anything tied to live session state (routes, the
|
||||
/// dry-run toggle, calendar access) stays in the main window since it has no
|
||||
/// meaning outside that window's lifecycle.
|
||||
struct PreferencesView: View {
|
||||
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||
|
||||
@AppStorage("daysForward") private var daysForward: Int = 7
|
||||
@AppStorage("daysBack") private var daysBack: Int = 1
|
||||
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
||||
|
||||
@AppStorage("hideDetails") private var hideDetails: Bool = true
|
||||
@AppStorage("copyDescription") private var copyDescription: Bool = false
|
||||
@AppStorage("syncReminders") private var syncReminders: Bool = false
|
||||
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
|
||||
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
|
||||
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
|
||||
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
|
||||
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
|
||||
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
|
||||
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
|
||||
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
|
||||
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 "
|
||||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy"
|
||||
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true
|
||||
|
||||
private static let intFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.minimum = 0
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let hourFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.minimum = 0
|
||||
f.maximum = 24
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}()
|
||||
|
||||
private var disabled: Bool { appController.isSyncing }
|
||||
|
||||
private func clampWorkHours() {
|
||||
let clampedStart = min(max(workHoursStart, 0), 23)
|
||||
if clampedStart != workHoursStart { workHoursStart = clampedStart }
|
||||
let clampedEnd = min(max(workHoursEnd, 1), 24)
|
||||
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
|
||||
if workHoursEnd <= workHoursStart {
|
||||
let adjustedEnd = min(workHoursStart + 1, 24)
|
||||
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Default time window") {
|
||||
HStack {
|
||||
Text("Days back")
|
||||
TextField("1", value: $daysBack, formatter: Self.intFormatter)
|
||||
.frame(width: 64)
|
||||
}
|
||||
.disabled(disabled)
|
||||
HStack {
|
||||
Text("Days forward")
|
||||
TextField("7", value: $daysForward, formatter: Self.intFormatter)
|
||||
.frame(width: 64)
|
||||
}
|
||||
.disabled(disabled)
|
||||
HStack {
|
||||
Text("Default merge gap (hours)")
|
||||
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
|
||||
.frame(width: 64)
|
||||
}
|
||||
.disabled(disabled)
|
||||
}
|
||||
.onChange(of: daysBack) { v in daysBack = max(0, v) }
|
||||
.onChange(of: daysForward) { v in daysForward = max(0, v) }
|
||||
.onChange(of: mergeGapHours) { v in mergeGapHours = max(0, v) }
|
||||
|
||||
Section("Mirroring defaults") {
|
||||
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
|
||||
Toggle("Copy description when mirroring", isOn: $copyDescription)
|
||||
.disabled(hideDetails)
|
||||
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
|
||||
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
|
||||
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
|
||||
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
|
||||
|
||||
Picker("Overlap mode", selection: $overlapModeRaw) {
|
||||
ForEach(OverlapMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(disabled)
|
||||
|
||||
Section("Placeholder title") {
|
||||
HStack {
|
||||
Text("Title prefix")
|
||||
TextField("🪞 ", text: $titlePrefix)
|
||||
.frame(width: 90)
|
||||
}
|
||||
HStack {
|
||||
Text("Placeholder title")
|
||||
TextField("Busy", text: $placeholderTitle)
|
||||
.frame(width: 170)
|
||||
}
|
||||
}
|
||||
.disabled(disabled)
|
||||
|
||||
Section("Work hours") {
|
||||
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
|
||||
if filterByWorkHours {
|
||||
HStack {
|
||||
Text("Start hour")
|
||||
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
|
||||
.frame(width: 56)
|
||||
Text("End hour")
|
||||
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
|
||||
.frame(width: 56)
|
||||
Text("(local time, end exclusive)").foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: workHoursStart) { _ in clampWorkHours() }
|
||||
.onChange(of: workHoursEnd) { _ in clampWorkHours() }
|
||||
}
|
||||
}
|
||||
.disabled(disabled)
|
||||
|
||||
Section("Skip filters") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Skip source titles (one per line)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $excludedTitleFiltersRaw)
|
||||
.font(.body)
|
||||
.frame(minHeight: 70)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.22))
|
||||
)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Skip organizers (name or email, one per line)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $excludedOrganizerFiltersRaw)
|
||||
.font(.body)
|
||||
.frame(minHeight: 70)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.22))
|
||||
)
|
||||
}
|
||||
Text("Matches are case-insensitive and apply before mirroring.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
}
|
||||
.disabled(disabled)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 480, height: 560)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import SwiftUI
|
||||
import EventKit
|
||||
|
||||
/// The "Routes (multi-source)" panel — extracted from ContentView so the
|
||||
/// route list/editor is its own small, native-feeling view instead of one
|
||||
/// piece of a 2000-line file. Owns no persisted state itself: `routes` is a
|
||||
/// binding into ContentView's own @State (which still owns saving it to
|
||||
/// UserDefaults), and everything else here is read-only context passed down.
|
||||
struct RoutesSectionView: View {
|
||||
@Binding var routes: [Route]
|
||||
let calendars: [EKCalendar]
|
||||
let isRunning: Bool
|
||||
let titlePrefix: String
|
||||
let placeholderTitle: String
|
||||
let canAddRoute: Bool
|
||||
let onAddRoute: () -> Void
|
||||
|
||||
@State private var expandedRouteID: UUID?
|
||||
|
||||
private static let intFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.minimum = 0
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}()
|
||||
|
||||
private func labelForCalendar(id: String) -> String {
|
||||
calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Routes (multi-source)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Add from current selection", action: onAddRoute)
|
||||
.disabled(isRunning || !canAddRoute)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Clear") { routes.removeAll() }
|
||||
.disabled(isRunning || routes.isEmpty)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if routes.isEmpty {
|
||||
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach($routes, id: \.id) { routeBinding in
|
||||
routeCard(for: routeBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func routeCard(for routeBinding: Binding<Route>) -> some View {
|
||||
let route = routeBinding.wrappedValue
|
||||
let isExpanded = expandedRouteID == route.id
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
expandedRouteID = isExpanded ? nil : route.id
|
||||
} label: {
|
||||
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) {
|
||||
sourceSummaryView(for: route)
|
||||
targetSummaryView(for: route)
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider()
|
||||
|
||||
Toggle("Private", isOn: routeBinding.privacy)
|
||||
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
|
||||
Toggle("Copy description", isOn: routeBinding.copyNotes)
|
||||
.disabled(isRunning || route.privacy)
|
||||
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
|
||||
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
|
||||
.disabled(isRunning)
|
||||
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
|
||||
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
|
||||
.disabled(isRunning)
|
||||
.help("Mirror all-day events for this source.")
|
||||
|
||||
HStack(spacing: 16) {
|
||||
mergeGapField(for: routeBinding)
|
||||
overlapPicker(for: routeBinding)
|
||||
Spacer(minLength: 0)
|
||||
Button(role: .destructive) {
|
||||
routes.removeAll { $0.id == route.id }
|
||||
} label: { Text("Remove Route") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceSummaryView(for route: Route) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Source")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
|
||||
Text(calLabel(sCal))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
} else {
|
||||
Text(labelForCalendar(id: route.sourceID))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func targetSummaryView(for route: Route) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Targets")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
|
||||
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
|
||||
Text(calLabel(tCal))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 999, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.1))
|
||||
)
|
||||
} else {
|
||||
Text(labelForCalendar(id: tid))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 999, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Merge gap")
|
||||
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
|
||||
.frame(width: 56)
|
||||
.disabled(isRunning)
|
||||
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
|
||||
Text("h").foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Overlap")
|
||||
Picker("Overlap", selection: routeBinding.overlap) {
|
||||
ForEach(OverlapMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.frame(width: 170)
|
||||
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Manual/fixed-time `launchd` scheduling — extracted from ContentView. Since
|
||||
/// 1.7.0 this is a fallback/override; the primary reliability mechanism is
|
||||
/// the event-driven auto-sync in `BusyMirrorAppController`, which arms itself
|
||||
/// automatically once routes exist and needs no UI.
|
||||
struct ScheduleSectionView: View {
|
||||
@Binding var scheduleMode: ScheduleMode
|
||||
@Binding var scheduleIntervalHours: Int
|
||||
@Binding var scheduleHour: Int
|
||||
@Binding var scheduleMinute: Int
|
||||
let isRunning: Bool
|
||||
let routesEmpty: Bool
|
||||
let hasInstalledSchedule: Bool
|
||||
let scheduleSummary: String
|
||||
let onInstall: () -> Void
|
||||
let onRemove: () -> Void
|
||||
let onRevealLaunchAgent: () -> Void
|
||||
let onScheduleTimeChanged: () -> Void
|
||||
|
||||
private static let intFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.minimum = 0
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let hourFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.minimum = 0
|
||||
f.maximum = 24
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let smallIntFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.minimum = 1
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Scheduled runs (manual override)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Picker("Mode", selection: $scheduleMode) {
|
||||
ForEach(ScheduleMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(isRunning)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if scheduleMode == .hourly {
|
||||
HStack(spacing: 8) {
|
||||
Text("Every")
|
||||
TextField("1", value: $scheduleIntervalHours, formatter: Self.smallIntFormatter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.disabled(isRunning)
|
||||
Text(scheduleIntervalHours == 1 ? "hour" : "hours")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Text("Time")
|
||||
TextField("8", value: $scheduleHour, formatter: Self.hourFormatter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.disabled(isRunning)
|
||||
Text(":")
|
||||
TextField("0", value: $scheduleMinute, formatter: Self.intFormatter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.disabled(isRunning)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
Text("Creates a LaunchAgent that runs the installed app with saved routes in write mode.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
Text(hasInstalledSchedule ? "Installed: \(scheduleSummary)" : "Not installed")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button("Install Schedule", action: onInstall)
|
||||
.disabled(isRunning || routesEmpty)
|
||||
Button("Remove Schedule", action: onRemove)
|
||||
.disabled(isRunning || !hasInstalledSchedule)
|
||||
Button("Reveal LaunchAgent", action: onRevealLaunchAgent)
|
||||
.disabled(!hasInstalledSchedule)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.onChange(of: scheduleHour) { _ in onScheduleTimeChanged() }
|
||||
.onChange(of: scheduleMinute) { _ in onScheduleTimeChanged() }
|
||||
.onChange(of: scheduleIntervalHours) { _ in onScheduleTimeChanged() }
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ final class BlockMathTests: XCTestCase {
|
||||
|
||||
private let d = Date(timeIntervalSince1970: 0)
|
||||
|
||||
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block {
|
||||
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil, alarmOffsets: [TimeInterval]? = nil) -> Block {
|
||||
Block(
|
||||
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
|
||||
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
|
||||
srcStableID: id,
|
||||
label: nil,
|
||||
notes: nil,
|
||||
occurrence: nil
|
||||
occurrence: nil,
|
||||
alarmOffsets: alarmOffsets
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +45,21 @@ final class BlockMathTests: XCTestCase {
|
||||
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
|
||||
}
|
||||
|
||||
func testMergeBlocksPreservesFirstAlarms() {
|
||||
let b1 = block(0, 10, alarmOffsets: [-900, -3600])
|
||||
let b2 = block(10, 20, alarmOffsets: [-600])
|
||||
let merged = mergeBlocks([b1, b2], gapMinutes: 0)
|
||||
XCTAssertEqual(merged.count, 1)
|
||||
XCTAssertEqual(merged[0].alarmOffsets, [-900, -3600])
|
||||
}
|
||||
|
||||
func testBlockEqualityIgnoresAlarms() {
|
||||
let b1 = block(0, 10, alarmOffsets: [-900])
|
||||
let b2 = block(0, 10, alarmOffsets: [-1800])
|
||||
XCTAssertEqual(b1, b2)
|
||||
XCTAssertEqual(Set([b1, b2]).count, 1)
|
||||
}
|
||||
|
||||
func testMergeBlocksUnsortedInput() {
|
||||
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
|
||||
let merged = mergeBlocks(blocks, gapMinutes: 0)
|
||||
@@ -150,8 +166,8 @@ final class BlockMathTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testUniqueBlocksByIDDifferentOccurrence() {
|
||||
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d)
|
||||
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600))
|
||||
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
|
||||
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
|
||||
let result = uniqueBlocks([b1, b2], trackByID: true)
|
||||
XCTAssertEqual(result.count, 2)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import XCTest
|
||||
@testable import BusyMirror
|
||||
|
||||
final class SettingsPayloadTests: XCTestCase {
|
||||
|
||||
// Only the fields present in the very first schema. Every key added since
|
||||
// (syncReminders, filterByWorkHours, workHoursStart/End, excludedTitleFilters,
|
||||
// excludedOrganizerFilters, mirrorAcceptedOnly, selectedSourceID/TargetIDs,
|
||||
// appVersion, exportedAt) is deliberately missing here, simulating a
|
||||
// settings.v2 blob written by an older build.
|
||||
private let oldSchemaJSON = """
|
||||
{
|
||||
"daysBack": 3,
|
||||
"daysForward": 10,
|
||||
"mergeGapHours": 1,
|
||||
"hideDetails": false,
|
||||
"copyDescription": true,
|
||||
"mirrorAllDay": true,
|
||||
"overlapMode": "skipCovered",
|
||||
"titlePrefix": "🪞 ",
|
||||
"placeholderTitle": "Busy",
|
||||
"autoDeleteMissing": true,
|
||||
"routes": []
|
||||
}
|
||||
"""
|
||||
|
||||
func testDecodeOldSchemaMissingNewerKeysDoesNotThrow() throws {
|
||||
let data = oldSchemaJSON.data(using: .utf8)!
|
||||
let payload = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
|
||||
|
||||
// Fields present in the old blob are preserved.
|
||||
XCTAssertEqual(payload.daysBack, 3)
|
||||
XCTAssertEqual(payload.daysForward, 10)
|
||||
XCTAssertEqual(payload.overlapMode, "skipCovered")
|
||||
|
||||
// Fields missing from the old blob fall back to their defaults instead
|
||||
// of failing the whole decode.
|
||||
XCTAssertEqual(payload.syncReminders, false)
|
||||
XCTAssertEqual(payload.filterByWorkHours, false)
|
||||
XCTAssertEqual(payload.workHoursStart, 9)
|
||||
XCTAssertEqual(payload.workHoursEnd, 17)
|
||||
XCTAssertEqual(payload.excludedTitleFilters, [])
|
||||
XCTAssertEqual(payload.mirrorAcceptedOnly, false)
|
||||
XCTAssertNil(payload.selectedSourceID)
|
||||
XCTAssertNil(payload.selectedTargetIDs)
|
||||
}
|
||||
|
||||
func testEncodeDecodeRoundTrip() throws {
|
||||
let route = Route(sourceID: "a", targetIDs: ["b"], privacy: true, copyNotes: false, syncReminders: true, mergeGapHours: 1, overlap: .allow, allDay: false)
|
||||
let original = ContentView.SettingsPayload(
|
||||
daysBack: 2, daysForward: 5, mergeGapHours: 0, hideDetails: true, copyDescription: false,
|
||||
mirrorAllDay: false, filterByWorkHours: true, workHoursStart: 8, workHoursEnd: 18,
|
||||
excludedTitleFilters: ["standup"], excludedOrganizerFilters: [], mirrorAcceptedOnly: true,
|
||||
overlapMode: "allow", titlePrefix: "🪞 ", placeholderTitle: "Busy", autoDeleteMissing: true,
|
||||
routes: [route]
|
||||
)
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
|
||||
XCTAssertEqual(decoded.daysBack, original.daysBack)
|
||||
XCTAssertEqual(decoded.excludedTitleFilters, original.excludedTitleFilters)
|
||||
XCTAssertEqual(decoded.routes.count, 1)
|
||||
XCTAssertEqual(decoded.routes[0].sourceID, "a")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,83 @@
|
||||
|
||||
All notable changes to BusyMirror will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
- **Standard app instead of menu-bar-only.** Removed `LSUIElement` from `Info.plist`: BusyMirror now shows a Dock icon, appears in Cmd+Tab, and gets the standard app menu (Cmd+Q to quit, among others). The menu bar extra stays as a secondary quick-access point. This also fixes the practical problem it was causing: with no Dock icon and no accessible app menu, there was no reliable way to quit the app to let an update replace the bundle — you had to know the menu bar dropdown's "Quit BusyMirror" existed and use exactly that. Verified via `lsappinfo` (`type="Foreground"`, previously `UIElement`) since this app type change isn't something a screenshot would catch either.
|
||||
|
||||
## [1.8.1] - 2026-08-26
|
||||
|
||||
### Fixed
|
||||
- **Preferences window unreachable**: 1.8.0's Settings scene (⌘,) and its `SettingsLink` button didn't work because BusyMirror is an `LSUIElement` (accessory) app — those don't get the standard app menu, so there's no menu for the automatic "Settings…" command or Cmd+, to live in. Replaced the `Settings { }` scene with a plain `Window(id: "preferences-window")`, opened via `openWindow(id:)` — the same mechanism that already reliably opens the main window from the menu bar. Also added a "Preferences…" item to the menu bar dropdown itself, so there are two direct routes in instead of one that depended on OS menu plumbing this app type doesn't get. ([BusyMirrorApp.swift](BusyMirror/BusyMirrorApp.swift), [MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift), [ContentView.swift](BusyMirror/ContentView.swift))
|
||||
|
||||
## [1.8.0] - 2026-08-26
|
||||
|
||||
### Added
|
||||
- **Real Settings (⌘,) window**: `PreferencesView` now hosts the pure-defaults controls (time window, privacy/mirroring defaults, title prefix/placeholder, work hours, skip filters) that used to live in the main window's "General Settings" panel. They're `@AppStorage`-backed so both windows always see the same live values. Main window keeps everything with session/live state: routes, calendar picker, dry-run toggle, Export/Import, manual scheduling, cleanup.
|
||||
- **Menu bar icon reflects state**: idle (`calendar.badge.clock`), syncing (`arrow.triangle.2.circlepath.circle.fill`), or last run failed (`exclamationmark.triangle.fill`).
|
||||
- **Menu bar dropdown diagnostics**: last-sync time and result, and whether auto-sync is currently armed — sourced from the same `lastRunAtISO`/`lastRunOK`/`lastRunSummary` keys `--status` reads, so CLI/interactive/auto-sync runs all feed the same indicator.
|
||||
- **`ContentView.swift` split** from ~2000 lines into `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (plus a small shared `CalendarDisplay.swift`). View-layer extraction — state ownership and settings persistence were deliberately left untouched, since that's exactly the area the 1.6.0/1.6.1 release had to fix a real data-loss bug in.
|
||||
|
||||
### Fixed
|
||||
- **Latent revert-on-relaunch bug**, caught while building the Settings window: moving the `@AppStorage`-backed preference controls out of `ContentView` meant changing them no longer re-triggered `saveSettingsToDefaults()`, so the `settings.v2` snapshot blob could go stale relative to the individual UserDefaults keys. Since `loadSettingsFromDefaults()` used the same full `applySnapshot` as Import, the next launch would silently revert a preference you'd just changed in Preferences back to whatever the stale blob had. Fixed by splitting launch-time restore into a narrow `restoreLaunchState` (routes + manual selection only — the only state that isn't already self-restoring via `@AppStorage`) separate from `applySnapshot` (unchanged, still used by Import where overwriting everything is the point). ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||
|
||||
## [1.7.0] - 2026-08-26
|
||||
|
||||
### Added
|
||||
- **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll (which only fired if the Mac happened to be awake at that instant, and got throttled/coalesced by macOS). Once saved routes exist, the app registers as a login item (`SMAppService.mainApp`), removes any previously-installed `launchd` schedule, and reacts to `EKEventStoreChanged` (debounced ~3s), `NSWorkspace.didWakeNotification` (resync after sleep), and a 30-minute fallback timer as a safety net for a missed notification. Auto-sync always writes (`writeEnabled: true`) regardless of the interactive dry-run toggle, matching what the old scheduled `--write 1` runs did.
|
||||
- This logic lives in `BusyMirrorAppController`, not `ContentView` — the controller is owned by the `App` struct for the whole process lifetime, whereas `ContentView`'s own `EKEventStoreChanged` observer is torn down in `.onDisappear` when the main window closes (confirmed: `MenuBarSupport.swift`'s "Sync Now" already reopens the window before syncing, which only makes sense if the window's state is discarded on close). Auto-sync needs to work with the window closed, so it can't live there. ([MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift))
|
||||
|
||||
## [1.6.1] - 2026-08-26
|
||||
|
||||
### Added
|
||||
- **Sync reminders**: new "Sync reminders when mirroring" option (global and per-route) copies source event alarms/relative offsets into mirrored placeholders. This lets calendars that are synced to a phone ring for mirrored events. When merging is enabled, only the first event's alarms are preserved for a merged block. ([ContentView.swift](BusyMirror/ContentView.swift), [MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift))
|
||||
- CLI flag `--sync-reminders` to enable reminder syncing from scripted/headless runs.
|
||||
- CLI polish: `--help`/`-h`, `--list-calendars`, and `--status` (all support `--json` for machine-readable output). Scheduled/headless runs now record last-run time, success/failure, and a summary so `--status` can report real diagnostics instead of just log-file grepping. Failure paths (`no calendar access`, `no saved routes`) now exit with distinct nonzero codes instead of always exiting 0. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||
|
||||
### Fixed
|
||||
- **Settings silently wiped on upgrade**: `SettingsPayload` used fully auto-synthesized `Codable`, so decoding a settings blob written by an older build (missing a field added since, e.g. `syncReminders`) threw `keyNotFound` and failed the *entire* decode — not just that one field. The app then ran with in-code defaults (empty routes, filters, etc.), and the next autosave persisted that empty state back over the real data. `SettingsPayload` now has a custom `init(from:)` that reads every field added after the first release with `decodeIfPresent` + its existing default, matching the pattern `Route` already used. `loadSettingsFromDefaults()` also now recovers routes from the legacy `routes.v1` backup key if `settings.v2` decodes successfully but with an empty `routes` array — repairing installs that already hit this bug before upgrading. ([ContentView.swift](BusyMirror/ContentView.swift), [SettingsPayloadTests.swift](BusyMirrorTests/SettingsPayloadTests.swift))
|
||||
|
||||
## [1.5.1] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
- **Mirror index dirtied on every sync**: `MirrorRecord` used synthesized `Equatable` which included `updatedAt: Date = Date()`. Because `updatedAt` is set to the current time whenever a record is constructed, the comparison used to detect changes always returned "not equal", causing `UserDefaults` to be written on every sync run even when nothing changed. A custom `==` / `hash(into:)` now excludes `updatedAt`. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift))
|
||||
- **Mirror URL corruption with special characters in calendar IDs**: `buildMirrorURL` placed raw calendar and source IDs into the URL path without percent-encoding them. If any ID contained the `;` separator character the resulting URL would be mis-parsed on the next sync. `mirrorURLComponentEncode` (which already existed and was tested) is now called on all ID fields before they are joined. The path is set via `percentEncodedPath` to prevent `URLComponents` from double-encoding the already-encoded values. ([MirrorUtils.swift](BusyMirror/MirrorUtils.swift))
|
||||
- **Dead constant**: removed unused `SKIP_ALL_DAY_DEFAULT = true` from `ContentView.swift`.
|
||||
- **Deprecated `FileHandle` API**: replaced `FileHandle(forWritingAtPath:)` + `handle.closeFile()` with the modern throwing `FileHandle(forWritingTo:)`, `handle.seekToEnd()`, and `handle.write(contentsOf:)` in `AppLogStore`. ([AppLogStore.swift](BusyMirror/AppLogStore.swift))
|
||||
- **Cleanup jumps calendar picker**: `runCleanupForRoute` was mutating `sourceIndex`, `sourceID`, and `targetIDs` during route cleanup, visibly shifting the picker in the UI. Cleanup does not need to update the UI selection; those mutations are removed.
|
||||
- **`--exit` flag redundancy**: `NSApp.terminate` was called whenever `isCLIRun` was true, making `--exit` a no-op. The app now exits only when `--exit` is explicitly passed, so `--routes` / `--run-saved-routes` can be used without forcing termination.
|
||||
|
||||
### Added
|
||||
- **Live calendar refresh**: the calendar list now updates automatically when the system calendar database changes (`EKEventStoreChanged` notification), removing the need to press "Refresh Calendars" after adding or removing a calendar. The observer is unregistered on view disappear and re-registered when the `EKEventStore` is recreated. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||
|
||||
### Changed
|
||||
- **`AppLogStore` extracted**: moved from an inline private enum in `ContentView.swift` to its own file `AppLogStore.swift` for easier navigation. ([AppLogStore.swift](BusyMirror/AppLogStore.swift))
|
||||
- **`Block.span` factory**: added `Block.span(start:end:)` to replace the repetitive `Block(start:end:srcStableID:nil:label:nil:notes:nil:occurrence:nil)` construction pattern throughout `BlockMath.swift` and `MirrorEngine.swift`. ([BlockMath.swift](BusyMirror/BlockMath.swift))
|
||||
- **Removed redundant `MainActor.run` wrappers**: `MirrorEngine` is `@MainActor`; wrapping `store.save` / `store.remove` in `try await MainActor.run { }` was unnecessary and added overhead. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift))
|
||||
- **`SettingsPayload` indentation**: the nested struct was de-dented to column 0 inside `ContentView`, making it look like a top-level type. Indentation is now consistent with the surrounding members.
|
||||
|
||||
### Build
|
||||
- Bump version to **1.5.1** (build **20**).
|
||||
|
||||
## [1.5.0] - 2026-05-27
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
|
||||
|
||||
On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync manually or open the main window; it no longer appears in the Dock.
|
||||
On macOS, BusyMirror runs as a standard app (Dock icon, ⌘Q to quit) and also has a menu bar icon for quick sync/status without opening the main window.
|
||||
|
||||
## What it does (current)
|
||||
- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
|
||||
@@ -13,7 +13,7 @@ On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync m
|
||||
- DRY-RUN mode: see what would be created/updated/deleted without writing.
|
||||
- Activity Log in the app plus persistent file logging on disk.
|
||||
- In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section.
|
||||
- Menu bar controls: trigger `Sync Now`, open the main window, or quit without keeping a Dock icon around.
|
||||
- Menu bar controls: trigger `Sync Now`, open the main window, open Preferences, or quit.
|
||||
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
|
||||
- Merge adjacent events with a configurable gap.
|
||||
- Time window controls (days back/forward) and Work Hours filter.
|
||||
@@ -45,11 +45,15 @@ See `CHANGELOG.md` for notable changes.
|
||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit`
|
||||
- Run the routes already saved in the app settings:
|
||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
||||
- `--help` prints full flag documentation.
|
||||
- `--list-calendars [--json]` prints available calendars (index, id, title, source/type) and exits.
|
||||
- `--status [--json]` prints last-run time/result, schedule state, and saved-route count and exits.
|
||||
- Flags exist for privacy, all-day, merge gap, days window, overlap mode, cleanup, and filters.
|
||||
- Filters:
|
||||
- `--exclude-titles "token1, token2"`
|
||||
- `--exclude-organizers "alice@example.com, Example Org"`
|
||||
- Tokens are comma or newline separated; matching is case-insensitive.
|
||||
- Exit codes: `0` success, `2` no calendar access, `3` no saved routes (with `--run-saved-routes`).
|
||||
|
||||
## Logs
|
||||
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
|
||||
|
||||
+16
-12
@@ -12,19 +12,23 @@
|
||||
- 1.3.6: in-app scheduling via `launchd` with hourly/daily/weekday modes
|
||||
- 1.3.6: generated macOS app icon set and packaged release assets
|
||||
- 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig`
|
||||
- CLI diagnostics: `--help`, `--list-calendars [--json]`, `--status [--json]`, real exit codes (2 = no access, 3 = no saved routes), last-run tracking (time/ok/summary)
|
||||
- 1.7.0: **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll. Auto-sync logic lives in `BusyMirrorAppController` (an app-lifetime object, not tied to `ContentView`'s window lifecycle — closing the main window used to tear down the `EKEventStoreChanged` observer along with it, which would have made auto-sync a no-op whenever the window was closed). Once saved routes exist: registers as a login item (`SMAppService.mainApp`), removes any old `launchd` schedule, watches `EKEventStoreChanged` (debounced ~3s) and `NSWorkspace.didWakeNotification`, plus a 30-min fallback timer as a safety net. Auto-sync always writes (`writeEnabled: true`), independent of the interactive dry-run toggle.
|
||||
- 1.8.0: **V2 UI polish.** `ContentView.swift` split from ~2000 lines into focused view files — `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (state stays owned by `ContentView`/`@AppStorage`; these are view-layer extractions, not a full MVVM rewrite — the settings-persistence model didn't need touching and touching it is exactly how the 1.6.0/1.6.1 data-loss bug happened). A preferences window hosting the pure `@AppStorage`-backed defaults (time window, privacy/mirroring defaults, work hours, skip filters) that used to live in the main window; this surfaced a latent bug — moving fields out of `ContentView` meant they stopped re-triggering `saveSettingsToDefaults()`, so on next launch `applySnapshot` would have silently reverted a preference changed in the new window using the stale blob. Fixed by splitting launch-time restore (`restoreLaunchState`, routes/selections only, since @AppStorage fields already self-restore) from Import's full restore (`applySnapshot`, unchanged). Menu bar icon now reflects idle/syncing/error state, and the dropdown shows last-sync time/result and whether auto-sync is armed.
|
||||
- 1.8.1: the preferences window (originally a `Settings{}` scene) didn't actually open — `LSUIElement` apps get no standard app menu, so Cmd+,/`SettingsLink` had no menu to hook into. Replaced with a plain `Window` opened via `openWindow(id:)`, plus a "Preferences…" menu bar item as a second entry point.
|
||||
- 1.8.2: **standard app, not menu-bar-only.** Removed `LSUIElement` — Dock icon, Cmd+Tab, standard app menu (Cmd+Q) are back. The accessory-app design (no Dock icon) turned out to make the app hard to quit reliably, which blocked replacing the bundle during updates. Menu bar extra stays as a secondary quick-access point.
|
||||
- 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
|
||||
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
||||
- Better scheduled-run diagnostics in the UI (last run / last error / next run)
|
||||
- Better server-side privacy mapping (per-provider heuristics)
|
||||
|
||||
## Then
|
||||
- Signed/notarized binaries and release pipeline
|
||||
- CLI quality: friendlier `--routes` parsing and help flag
|
||||
- “Dry-run by default” preference
|
||||
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.
|
||||
2. **Better server-side privacy mapping (per-provider heuristics).**
|
||||
|
||||
## Later
|
||||
- Background monitoring (macOS)
|
||||
- Smarter cleanup & conflict resolution
|
||||
- iOS/iPadOS helper (Shortcuts integration)
|
||||
- Profiles & MDM/Managed Config support
|
||||
- Signed/notarized binaries and release pipeline.
|
||||
- Smarter cleanup & conflict resolution.
|
||||
- iOS/iPadOS helper (Shortcuts integration).
|
||||
- Profiles & MDM/Managed Config support.
|
||||
|
||||
## Decided against
|
||||
- **Direct Google/CalDAV/Exchange API integration** (OAuth flows, token storage, per-provider clients so BusyMirror can mirror a non-local calendar without it being added to macOS). EventKit already surfaces any calendar added via System Settings → Internet Accounts — macOS does the sync, auth, and refresh. Building a parallel integration would duplicate the OS for the narrow case of an account that can't or won't be added system-wide (e.g. MDM-restricted work accounts). Not worth the OAuth/Keychain/per-provider-quirk surface for that.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# BusyMirror 1.5.1
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- **Mirror index written on every sync** — `MirrorRecord`'s synthesized equality check included an `updatedAt` timestamp that is always set to the current date when a record is constructed. This meant every sync run marked the index as dirty and rewrote it to `UserDefaults`, even when no mirror events changed. Fixed with a custom `==` that ignores `updatedAt`.
|
||||
|
||||
- **Mirror URLs corrupted by special characters in calendar IDs** — Calendar and source IDs placed into the `mirror://` URL were not percent-encoded before joining with `;`. An ID containing `;` would cause the URL to be mis-parsed on the next sync, potentially losing the link between a placeholder and its source event. IDs are now encoded with `mirrorURLComponentEncode` (already present and tested since 1.4.0) and the URL path is assigned via `percentEncodedPath` to prevent double-encoding.
|
||||
|
||||
- **Calendar picker jumped during route cleanup** — Running "Cleanup Placeholders" over saved routes changed the source/target picker selection for each route. Cleanup no longer mutates the UI selection.
|
||||
|
||||
- **`--exit` flag was always implied** — Using `--routes` or `--run-saved-routes` always terminated the app, making `--exit` redundant. The app now exits only when `--exit` is explicitly passed.
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Live calendar refresh** — The calendar list now updates automatically when the system calendar database changes (new account added, calendar renamed, etc.), without requiring a manual "Refresh Calendars" press.
|
||||
|
||||
- `AppLogStore` extracted into its own file; deprecated `FileHandle` API replaced with the modern throwing variant.
|
||||
|
||||
- `Block.span(start:end:)` convenience factory added to `BlockMath`, eliminating repetitive nil-field construction.
|
||||
|
||||
- Redundant `MainActor.run {}` wrappers removed from `MirrorEngine` (already running on `@MainActor`).
|
||||
Reference in New Issue
Block a user