4 Commits

7 changed files with 302 additions and 10 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ ExportOptions.plist
*.swp *.swp
*.zip *.zip
*.sha256 *.sha256
dist/

View File

@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 5;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist; INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 5;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist; INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -465,7 +465,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5; MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -482,7 +482,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5; MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -498,7 +498,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;
@@ -513,7 +513,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;

View File

@@ -128,6 +128,11 @@ struct ContentView: View {
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false @AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue @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("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
var overlapMode: OverlapMode { var overlapMode: OverlapMode {
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow } get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
nonmutating set { overlapModeRaw = newValue.rawValue } nonmutating set { overlapModeRaw = newValue.rawValue }
@@ -153,6 +158,14 @@ struct ContentView: View {
return f return f
}() }()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .none
f.minimum = 0
f.maximum = 24
return f
}()
// Deterministic ordering to keep indices stable across runs // Deterministic ordering to keep indices stable across runs
private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] { private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] {
return cals.sorted { a, b in return cals.sorted { a, b in
@@ -162,6 +175,17 @@ struct ContentView: View {
} }
} }
private var excludedTitleFilterList: [String] {
excludedTitleFiltersRaw
.split { $0 == "\n" || $0 == "," }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private var excludedTitleFilterTerms: [String] {
excludedTitleFilterList.map { $0.lowercased() }
}
private func rebuildSelectionsFromIDs() { private func rebuildSelectionsFromIDs() {
// Map IDs -> indices in current calendars // Map IDs -> indices in current calendars
var idToIndex: [String:Int] = [:] var idToIndex: [String:Int] = [:]
@@ -395,6 +419,8 @@ struct ContentView: View {
.disabled(isRunning || hideDetails) .disabled(isRunning || hideDetails)
Toggle("Mirror all-day events", isOn: $mirrorAllDay) Toggle("Mirror all-day events", isOn: $mirrorAllDay)
.disabled(isRunning) .disabled(isRunning)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
.disabled(isRunning)
Picker("Overlap mode", selection: $overlapModeRaw) { Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode.rawValue) Text(mode.rawValue).tag(mode.rawValue)
@@ -418,6 +444,53 @@ struct ContentView: View {
.disabled(isRunning) .disabled(isRunning)
} }
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
.disabled(isRunning)
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
if filterByWorkHours {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text("Start hour:")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.frame(width: 48)
.disabled(isRunning)
Text("End hour:")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.frame(width: 48)
.disabled(isRunning)
Text("(local time)").foregroundStyle(.secondary)
}
Text("Events starting outside this range are skipped; end hour is exclusive.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: workHoursStart) { _ in
clampWorkHours()
saveSettingsToDefaults()
}
.onChange(of: workHoursEnd) { _ in
clampWorkHours()
saveSettingsToDefaults()
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 80)
.disabled(isRunning)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3))
)
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled) Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled)
.disabled(isRunning) .disabled(isRunning)
@@ -583,18 +656,33 @@ struct ContentView: View {
tryRunCLIIfPresent() tryRunCLIIfPresent()
enforceNoSourceInTargets() enforceNoSourceInTargets()
} }
// Persist key settings whenever they change, to ensure restore between runs
.onChange(of: daysBack) { _ in saveSettingsToDefaults() }
.onChange(of: daysForward) { _ in saveSettingsToDefaults() }
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
.onChange(of: hideDetails) { _ in saveSettingsToDefaults() }
.onChange(of: copyDescription) { _ in saveSettingsToDefaults() }
.onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() }
.onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() }
.onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() }
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
.onChange(of: sourceIndex) { newValue in .onChange(of: sourceIndex) { newValue in
// Track selected source by persistent ID and ensure it is not a target // Track selected source by persistent ID and ensure it is not a target
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier } if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
enforceNoSourceInTargets() enforceNoSourceInTargets()
saveSettingsToDefaults()
} }
.onChange(of: targetSelections) { _ in .onChange(of: targetSelections) { _ in
// If the new source is accidentally included, drop it // If the new source is accidentally included, drop it
enforceNoSourceInTargets() enforceNoSourceInTargets()
saveSettingsToDefaults()
} }
.onChange(of: targetIDs) { _ in .onChange(of: targetIDs) { _ in
// If IDs contain the sources ID, drop it // If IDs contain the sources ID, drop it
enforceNoSourceInTargets() enforceNoSourceInTargets()
saveSettingsToDefaults()
} }
.onChange(of: routes) { _ in .onChange(of: routes) { _ in
saveSettingsToDefaults() saveSettingsToDefaults()
@@ -685,13 +773,18 @@ struct ContentView: View {
} }
// MARK: - Permissions & Calendars // MARK: - Permissions & Calendars
@MainActor
func requestAccess() { func requestAccess() {
log("Requesting calendar access…") log("Requesting calendar access…")
if #available(macOS 14.0, *) { if #available(macOS 14.0, *) {
store.requestFullAccessToEvents { granted, _ in store.requestFullAccessToEvents { granted, _ in
DispatchQueue.main.async { DispatchQueue.main.async {
hasAccess = granted hasAccess = granted
if granted { reloadCalendars() } if granted {
// Reinitialize the store after permission changes to ensure sources load
store = EKEventStore()
reloadCalendars()
}
log(granted ? "Access granted." : "Access denied.") log(granted ? "Access granted." : "Access denied.")
} }
} }
@@ -699,13 +792,18 @@ struct ContentView: View {
store.requestAccess(to: .event) { granted, _ in store.requestAccess(to: .event) { granted, _ in
DispatchQueue.main.async { DispatchQueue.main.async {
hasAccess = granted hasAccess = granted
if granted { reloadCalendars() } if granted {
// Reinitialize the store after permission changes to ensure sources load
store = EKEventStore()
reloadCalendars()
}
log(granted ? "Access granted." : "Access denied.") log(granted ? "Access granted." : "Access denied.")
} }
} }
} }
} }
@MainActor
func reloadCalendars() { func reloadCalendars() {
let fetched = store.calendars(for: .event) let fetched = store.calendars(for: .event)
calendars = sortedCalendars(fetched) calendars = sortedCalendars(fetched)
@@ -759,7 +857,37 @@ struct ContentView: View {
var srcBlocks: [Block] = [] var srcBlocks: [Block] = []
var skippedMirrors = 0 var skippedMirrors = 0
let titleFilters = excludedTitleFilterTerms
let enforceWorkHours = filterByWorkHours && workHoursEnd > workHoursStart
let allowedStartMinutes = workHoursStart * 60
let allowedEndMinutes = workHoursEnd * 60
var skippedWorkHours = 0
var skippedTitles = 0
var skippedStatus = 0
for ev in srcEvents { for ev in srcEvents {
if mirrorAcceptedOnly, ev.hasAttendees {
// Only include events where the current user's attendee status is Accepted
let attendees = ev.attendees ?? []
if let me = attendees.first(where: { $0.isCurrentUser }) {
if me.participantStatus != .accepted {
skippedStatus += 1
continue
}
} else {
// If we cannot determine a self attendee, treat as not accepted
skippedStatus += 1
continue
}
}
if enforceWorkHours, !ev.isAllDay, let start = ev.startDate,
isOutsideWorkHours(start, calendar: cal, startMinutes: allowedStartMinutes, endMinutes: allowedEndMinutes) {
skippedWorkHours += 1
continue
}
if shouldSkip(event: ev, filters: titleFilters) {
skippedTitles += 1
continue
}
if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue } if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue }
if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) { if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) {
// Aggregate skip count for mirrored-on-source // Aggregate skip count for mirrored-on-source
@@ -775,6 +903,15 @@ struct ContentView: View {
if skippedMirrors > 0 { if skippedMirrors > 0 {
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
} }
if skippedWorkHours > 0 {
log("- SKIP outside work hours: \(skippedWorkHours) event(s)")
}
if skippedTitles > 0 {
log("- SKIP title filter: \(skippedTitles) event(s)")
}
if skippedStatus > 0 {
log("- SKIP non-accepted status: \(skippedStatus) event(s)")
}
// Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races) // Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races)
srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0) srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0)
@@ -1033,11 +1170,19 @@ private struct SettingsPayload: Codable {
var hideDetails: Bool var hideDetails: Bool
var copyDescription: Bool var copyDescription: Bool
var mirrorAllDay: Bool var mirrorAllDay: Bool
var filterByWorkHours: Bool = false
var workHoursStart: Int = 9
var workHoursEnd: Int = 17
var excludedTitleFilters: [String] = []
var mirrorAcceptedOnly: Bool = false
var overlapMode: String var overlapMode: String
var titlePrefix: String var titlePrefix: String
var placeholderTitle: String var placeholderTitle: String
var autoDeleteMissing: Bool var autoDeleteMissing: Bool
var routes: [Route] var routes: [Route]
// UI selections (optional for backward compatibility)
var selectedSourceID: String? = nil
var selectedTargetIDs: [String]? = nil
// optional metadata // optional metadata
var appVersion: String? var appVersion: String?
var exportedAt: Date = Date() var exportedAt: Date = Date()
@@ -1051,11 +1196,18 @@ private struct SettingsPayload: Codable {
hideDetails: hideDetails, hideDetails: hideDetails,
copyDescription: copyDescription, copyDescription: copyDescription,
mirrorAllDay: mirrorAllDay, mirrorAllDay: mirrorAllDay,
filterByWorkHours: filterByWorkHours,
workHoursStart: workHoursStart,
workHoursEnd: workHoursEnd,
excludedTitleFilters: excludedTitleFilterList,
mirrorAcceptedOnly: mirrorAcceptedOnly,
overlapMode: overlapMode.rawValue, overlapMode: overlapMode.rawValue,
titlePrefix: titlePrefix, titlePrefix: titlePrefix,
placeholderTitle: placeholderTitle, placeholderTitle: placeholderTitle,
autoDeleteMissing: autoDeleteMissing, autoDeleteMissing: autoDeleteMissing,
routes: routes, routes: routes,
selectedSourceID: sourceID,
selectedTargetIDs: Array(targetIDs).sorted(),
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
exportedAt: Date() exportedAt: Date()
) )
@@ -1069,11 +1221,22 @@ private struct SettingsPayload: Codable {
hideDetails = s.hideDetails hideDetails = s.hideDetails
copyDescription = s.copyDescription copyDescription = s.copyDescription
mirrorAllDay = s.mirrorAllDay mirrorAllDay = s.mirrorAllDay
filterByWorkHours = s.filterByWorkHours
workHoursStart = s.workHoursStart
workHoursEnd = s.workHoursEnd
excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n")
mirrorAcceptedOnly = s.mirrorAcceptedOnly
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
titlePrefix = s.titlePrefix titlePrefix = s.titlePrefix
placeholderTitle = s.placeholderTitle placeholderTitle = s.placeholderTitle
autoDeleteMissing = s.autoDeleteMissing autoDeleteMissing = s.autoDeleteMissing
routes = s.routes routes = s.routes
// Restore UI selections if provided
if let selSrc = s.selectedSourceID { sourceID = selSrc }
if let selTgts = s.selectedTargetIDs { targetIDs = Set(selTgts) }
clampWorkHours()
// Rebuild indices from IDs after restoring selections
rebuildSelectionsFromIDs()
} }
private func exportSettings() { private func exportSettings() {
@@ -1143,12 +1306,44 @@ private struct SettingsPayload: Codable {
do { do {
let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData) let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData)
routes = decodedRoutes routes = decodedRoutes
clampWorkHours()
saveSettingsToDefaults() // upgrade stored format saveSettingsToDefaults() // upgrade stored format
} catch { } catch {
log("✗ Failed to load routes: \(error.localizedDescription)") log("✗ Failed to load routes: \(error.localizedDescription)")
} }
} }
// MARK: - Filters
private func isOutsideWorkHours(_ startDate: Date, calendar: Calendar, startMinutes: Int, endMinutes: Int) -> Bool {
guard endMinutes > startMinutes else { return false }
let comps = calendar.dateComponents([.hour, .minute], from: startDate)
guard let hour = comps.hour else { return false }
let minute = comps.minute ?? 0
let start = hour * 60 + minute
return start < startMinutes || start >= endMinutes
}
private func shouldSkip(event: EKEvent, filters: [String]) -> Bool {
guard !filters.isEmpty else { return false }
let rawTitle = (event.title ?? "").lowercased()
let strippedTitle = stripPrefix(event.title, prefix: titlePrefix).lowercased()
return filters.contains { token in
rawTitle.contains(token) || strippedTitle.contains(token)
}
}
private func clampWorkHours() {
let clampedStart = min(max(workHoursStart, 0), 23)
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min(max(workHoursEnd, 1), 24)
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min(workHoursStart + 1, 24)
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
// MARK: - Logging // MARK: - Logging
func log(_ s: String) { func log(_ s: String) {
logText.append("\n" + s) logText.append("\n" + s)

18
CHANGELOG.md Normal file
View File

@@ -0,0 +1,18 @@
# Changelog
All notable changes to BusyMirror will be documented in this file.
## [1.2.3] - 2025-10-10
- Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs.
- UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs.
- Build: bump version to 1.2.3 (build 5).
## [1.2.1] - 2025-10-10
- Fix: reinitialize EKEventStore after permission grant to avoid “Loaded 0 calendars” right after approval.
- Fix: attendee status filter uses current users attendee `participantStatus == .accepted` instead of unavailable APIs.
- Concurrency: mark `requestAccess()` and `reloadCalendars()` as `@MainActor` to satisfy strict concurrency checks.
- Dev: add Makefile with `build-debug`, `build-release`, and `package` targets; produce versioned ZIP + SHA-256.
## [1.2.0] - 2024-09-29
- Feature: multi-route mirroring, overlap modes, merge gaps, work hours filter, CLI support, export/import settings.

46
Makefile Normal file
View File

@@ -0,0 +1,46 @@
# Simple build and package helpers for BusyMirror
SCHEME ?= BusyMirror
PROJECT ?= BusyMirror.xcodeproj
DERIVED ?= build/DerivedData
DEST := platform=macOS
# Extract marketing version from project settings
VERSION := $(shell sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' $(PROJECT)/project.pbxproj | head -n1)
.PHONY: all clean build-debug build-release open app package
all: build-release
clean:
@echo "Cleaning derived data…"
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -derivedDataPath $(DERIVED) -destination '$(DEST)' CODE_SIGNING_ALLOWED=NO clean >/dev/null
@echo "Done."
build-debug:
@echo "Building Debug…"
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -configuration Debug -destination '$(DEST)' -derivedDataPath $(DERIVED) CODE_SIGNING_ALLOWED=NO build
build-release:
@echo "Building Release…"
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -configuration Release -destination '$(DEST)' -derivedDataPath $(DERIVED) CODE_SIGNING_ALLOWED=NO build
# Convenience to open the built app in Finder
open: app
@open "$<"
# Path to built app (Release)
APP_PATH := $(DERIVED)/Build/Products/Release/BusyMirror.app
app: build-release
@# Ensure the app exists
@test -d "$(APP_PATH)" && echo "Built: $(APP_PATH)" || (echo "App not found at $(APP_PATH)" && exit 1)
@echo "Version: $(VERSION)"
@echo "OK"
package: app
@echo "Packaging BusyMirror $(VERSION)"
@zip -qry "BusyMirror-$(VERSION)-macOS.zip" "$(APP_PATH)"
@shasum -a 256 "BusyMirror-$(VERSION)-macOS.zip" | awk '{print $$1}' > "BusyMirror-$(VERSION)-macOS.zip.sha256"
@echo "Created BusyMirror-$(VERSION)-macOS.zip and .sha256"

View File

@@ -14,11 +14,19 @@ BusyMirror mirrors meetings between your calendars so your availability stays co
Use one calendars confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices). Use one calendars confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
## Build (macOS) ## Build (macOS)
Option A — Xcode
1. Open `BusyMirror.xcodeproj` in Xcode. 1. Open `BusyMirror.xcodeproj` in Xcode.
2. Select the BusyMirror scheme → My Mac. 2. Select the BusyMirror scheme → My Mac.
3. Product → Build. 3. Product → Build.
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing). 4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing).
Option B — Makefile (reproducible)
- Build Release: `make build-release`
- Package ZIP: `make package` (creates `BusyMirror-<version>-macOS.zip` + `.sha256`)
- Built app: `build/DerivedData/Build/Products/Release/BusyMirror.app`
See `CHANGELOG.md` for notable changes.
## Roadmap ## Roadmap
See [ROADMAP.md](ROADMAP.md) See [ROADMAP.md](ROADMAP.md)

24
ReleaseNotes-1.2.3.md Normal file
View File

@@ -0,0 +1,24 @@
# BusyMirror 1.2.3 — 2025-10-10
This release focuses on reliable settings persistence and quality-of-life fixes from the 1.2.1 hotfix.
Highlights
- Settings persist between runs: autosave key options on change; restore on launch.
- Source/Target selection is remembered using calendar IDs and rehydrated into UI indices.
Fixes and improvements
- Save on change for: days back/forward, default merge gap, privacy/copy notes, all-day, accepted-only, overlap mode, title/placeholder prefixes, auto-delete.
- Restore saved `selectedSourceID` and `selectedTargetIDs` and rebuild index selections.
- Keep backward compatibility with older saved payloads.
- Version bump to 1.2.3 (build 5).
Included from 1.2.1
- Reinitialize `EKEventStore` after permission grant to avoid “Loaded 0 calendars”.
- Use attendee `participantStatus == .accepted` for accepted-only filter.
- Mark `requestAccess()` and `reloadCalendars()` as `@MainActor`.
- Makefile for reproducible builds and packaging.
Build
- `make build-release`
- `make package` → BusyMirror-1.2.3-macOS.zip and .sha256