Release 1.3.6
@@ -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 = 12;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
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.3.4;
|
MARKETING_VERSION = 1.3.6;
|
||||||
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 = 12;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
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.3.4;
|
MARKETING_VERSION = 1.3.6;
|
||||||
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;
|
||||||
|
|||||||
@@ -1,55 +1,15 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||||
"idiom" : "mac",
|
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||||
"scale" : "1x",
|
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||||
"size" : "16x16"
|
{ "filename" : "icon_64x64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
||||||
},
|
{ "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
||||||
{
|
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
|
||||||
"idiom" : "mac",
|
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||||
"scale" : "2x",
|
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||||
"size" : "16x16"
|
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||||
},
|
{ "filename" : "icon_1024x1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "512x512"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "512x512"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
|
|||||||
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png
Normal file
|
After Width: | Height: | Size: 654 KiB |
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_64x64.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
@@ -19,6 +19,8 @@ private enum AppLogStore {
|
|||||||
|
|
||||||
static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false)
|
static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false)
|
||||||
private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.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 = {
|
private static let timestampFormatter: ISO8601DateFormatter = {
|
||||||
let f = ISO8601DateFormatter()
|
let f = ISO8601DateFormatter()
|
||||||
@@ -58,6 +60,19 @@ enum OverlapMode: String, CaseIterable, Identifiable, Codable {
|
|||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ScheduleMode: String, CaseIterable, Identifiable {
|
||||||
|
case hourly, daily, weekdays
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .hourly: return "Hourly"
|
||||||
|
case .daily: return "Daily"
|
||||||
|
case .weekdays: return "Weekdays"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar label helper to disambiguate identical names
|
// Calendar label helper to disambiguate identical names
|
||||||
private func calLabel(_ cal: EKCalendar) -> String {
|
private func calLabel(_ cal: EKCalendar) -> String {
|
||||||
let src = cal.source.title
|
let src = cal.source.title
|
||||||
@@ -209,6 +224,11 @@ struct ContentView: View {
|
|||||||
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
|
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
|
||||||
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
|
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
|
||||||
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
|
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
|
||||||
|
@AppStorage("scheduleMode") private var scheduleModeRaw: String = ScheduleMode.weekdays.rawValue
|
||||||
|
@AppStorage("scheduleHour") private var scheduleHour: Int = 8
|
||||||
|
@AppStorage("scheduleMinute") private var scheduleMinute: Int = 0
|
||||||
|
@AppStorage("scheduleWeekdaysOnly") private var scheduleWeekdaysOnly: Bool = true
|
||||||
|
@AppStorage("scheduleIntervalHours") private var scheduleIntervalHours: Int = 1
|
||||||
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
|
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
|
||||||
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
|
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
|
||||||
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
|
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
|
||||||
@@ -253,6 +273,132 @@ struct ContentView: View {
|
|||||||
return f
|
return f
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private static let smallIntFormatter: NumberFormatter = {
|
||||||
|
let f = NumberFormatter()
|
||||||
|
f.numberStyle = .none
|
||||||
|
f.minimum = 1
|
||||||
|
f.maximum = 24
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var scheduleMode: ScheduleMode {
|
||||||
|
get { ScheduleMode(rawValue: scheduleModeRaw) ?? (scheduleWeekdaysOnly ? .weekdays : .daily) }
|
||||||
|
nonmutating set { scheduleModeRaw = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var launchAgentLabel: String { "com.cqrenet.BusyMirror.saved-routes" }
|
||||||
|
|
||||||
|
private var launchAgentURL: URL {
|
||||||
|
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||||
|
return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasInstalledSchedule: Bool {
|
||||||
|
FileManager.default.fileExists(atPath: launchAgentURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scheduleSummary: String {
|
||||||
|
switch scheduleMode {
|
||||||
|
case .hourly:
|
||||||
|
return "every \(scheduleIntervalHours) hour" + (scheduleIntervalHours == 1 ? "" : "s")
|
||||||
|
case .daily:
|
||||||
|
return String(format: "%02d:%02d daily", scheduleHour, scheduleMinute)
|
||||||
|
case .weekdays:
|
||||||
|
return String(format: "%02d:%02d weekdays", scheduleHour, scheduleMinute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clampScheduleTime() {
|
||||||
|
let nextHour = min(max(scheduleHour, 0), 23)
|
||||||
|
if nextHour != scheduleHour { scheduleHour = nextHour }
|
||||||
|
let nextMinute = min(max(scheduleMinute, 0), 59)
|
||||||
|
if nextMinute != scheduleMinute { scheduleMinute = nextMinute }
|
||||||
|
let nextInterval = min(max(scheduleIntervalHours, 1), 24)
|
||||||
|
if nextInterval != scheduleIntervalHours { scheduleIntervalHours = nextInterval }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchAgentScheduleProperties() -> [String: Any] {
|
||||||
|
switch scheduleMode {
|
||||||
|
case .hourly:
|
||||||
|
return ["StartInterval": scheduleIntervalHours * 3600]
|
||||||
|
case .daily:
|
||||||
|
return ["StartCalendarInterval": ["Hour": scheduleHour, "Minute": scheduleMinute]]
|
||||||
|
case .weekdays:
|
||||||
|
let intervals: [[String: Int]] = (1...5).map { weekday in
|
||||||
|
["Hour": scheduleHour, "Minute": scheduleMinute, "Weekday": weekday]
|
||||||
|
}
|
||||||
|
return ["StartCalendarInterval": intervals]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchCtl(_ arguments: [String], allowFailure: Bool = false) throws -> String {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||||
|
proc.arguments = arguments
|
||||||
|
let output = Pipe()
|
||||||
|
proc.standardOutput = output
|
||||||
|
proc.standardError = output
|
||||||
|
try proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if proc.terminationStatus != 0 && !allowFailure {
|
||||||
|
throw NSError(
|
||||||
|
domain: "BusyMirrorLaunchCtl",
|
||||||
|
code: Int(proc.terminationStatus),
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: text.isEmpty ? "launchctl failed (\(proc.terminationStatus))" : text]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installSchedule() {
|
||||||
|
guard !routes.isEmpty else {
|
||||||
|
log("Cannot install schedule: no saved routes.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clampScheduleTime()
|
||||||
|
do {
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: launchAgentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try fm.createDirectory(at: AppLogStore.logDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let executablePath = Bundle.main.executableURL?.path
|
||||||
|
?? Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/BusyMirror").path
|
||||||
|
let plist: [String: Any] = [
|
||||||
|
"Label": launchAgentLabel,
|
||||||
|
"ProgramArguments": [executablePath, "--run-saved-routes", "--write", "1", "--exit"],
|
||||||
|
"RunAtLoad": false,
|
||||||
|
"StandardOutPath": AppLogStore.launchdStdoutURL.path,
|
||||||
|
"StandardErrorPath": AppLogStore.launchdStderrURL.path,
|
||||||
|
"WorkingDirectory": NSHomeDirectory()
|
||||||
|
].merging(launchAgentScheduleProperties()) { _, new in new }
|
||||||
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
|
try data.write(to: launchAgentURL, options: .atomic)
|
||||||
|
|
||||||
|
let domain = "gui/\(getuid())"
|
||||||
|
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
|
||||||
|
_ = try launchCtl(["bootstrap", domain, launchAgentURL.path])
|
||||||
|
log("Installed schedule at \(scheduleSummary). LaunchAgent: \(launchAgentURL.path)")
|
||||||
|
} catch {
|
||||||
|
log("Failed to install schedule: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSchedule() {
|
||||||
|
do {
|
||||||
|
let domain = "gui/\(getuid())"
|
||||||
|
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
|
||||||
|
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: launchAgentURL)
|
||||||
|
}
|
||||||
|
log("Removed schedule: \(launchAgentURL.path)")
|
||||||
|
} catch {
|
||||||
|
log("Failed to remove schedule: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deterministic ordering to keep indices stable across runs
|
// 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
|
||||||
@@ -947,6 +1093,74 @@ struct ContentView: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Scheduled runs")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Picker("Mode", selection: Binding(
|
||||||
|
get: { scheduleMode },
|
||||||
|
set: { newValue in
|
||||||
|
scheduleMode = newValue
|
||||||
|
scheduleWeekdaysOnly = (newValue == .weekdays)
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
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") { installSchedule() }
|
||||||
|
.disabled(isRunning || routes.isEmpty)
|
||||||
|
Button("Remove Schedule") { removeSchedule() }
|
||||||
|
.disabled(isRunning || !hasInstalledSchedule)
|
||||||
|
Button("Reveal LaunchAgent") {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL])
|
||||||
|
}
|
||||||
|
.disabled(!hasInstalledSchedule)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scheduleHour) { _ in clampScheduleTime() }
|
||||||
|
.onChange(of: scheduleMinute) { _ in clampScheduleTime() }
|
||||||
|
.onChange(of: scheduleIntervalHours) { _ in clampScheduleTime() }
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button("Cleanup Placeholders") {
|
Button("Cleanup Placeholders") {
|
||||||
if writeEnabled {
|
if writeEnabled {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
All notable changes to BusyMirror will be documented in this file.
|
||||||
|
|
||||||
|
## [1.3.6] - 2026-03-13
|
||||||
|
- Scheduling: add in-app `Scheduled runs` controls to install or remove a user `launchd` LaunchAgent from BusyMirror itself.
|
||||||
|
- Scheduling: support `Hourly`, `Daily`, and `Weekdays` schedules; hourly mode runs saved routes via `StartInterval`.
|
||||||
|
- UX: generate and ship a proper macOS app icon set for BusyMirror.
|
||||||
|
- Build: bump version to 1.3.6 (build 14).
|
||||||
|
|
||||||
## [1.3.4] - 2026-03-13
|
## [1.3.4] - 2026-03-13
|
||||||
- Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run.
|
- Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run.
|
||||||
- Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates `EKEventStore` for a hard reload.
|
- Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates `EKEventStore` for a hard reload.
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ See `CHANGELOG.md` for notable changes.
|
|||||||
- In the UI, use `Reveal Log File` to open the current log directly in Finder.
|
- In the UI, use `Reveal Log File` to open the current log directly in Finder.
|
||||||
|
|
||||||
## Scheduling
|
## Scheduling
|
||||||
- Yes. The recommended way is macOS `launchd` calling the built-in CLI with saved routes:
|
- BusyMirror can create its own schedule from the app UI in `Scheduled runs`.
|
||||||
|
- Choose `Hourly`, `Daily`, or `Weekdays`, then click `Install Schedule`.
|
||||||
|
- The installed LaunchAgent runs:
|
||||||
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
||||||
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
||||||
- A typical `launchd` job can run this on a daily or weekday schedule after you grant calendar access once in the app.
|
- Hourly schedules use `launchd` `StartInterval`; daily and weekday schedules use `StartCalendarInterval`.
|
||||||
|
- You can remove the job from the same UI with `Remove Schedule`.
|
||||||
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
|
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
9
ReleaseNotes-1.3.6.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
BusyMirror 1.3.6 - 2026-03-13
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- Add in-app scheduling controls so BusyMirror can install and remove its own `launchd` LaunchAgent.
|
||||||
|
- Support hourly saved-route runs in addition to daily and weekday schedules.
|
||||||
|
- Ship a generated macOS app icon set for the app bundle and exported releases.
|
||||||
|
|
||||||
|
Build
|
||||||
|
- Version bump to 1.3.6 (build 14).
|
||||||