Release 1.3.6

This commit is contained in:
2026-03-13 09:08:31 +01:00
parent 58d88e9fa5
commit f81403745c
13 changed files with 248 additions and 56 deletions

View File

@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 14;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.3.6;
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 = 12;
CURRENT_PROJECT_VERSION = 14;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.3.6;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;

View File

@@ -1,55 +1,15 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"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"
}
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
{ "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" },
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
{ "filename" : "icon_1024x1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
],
"info" : {
"author" : "xcode",

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -19,6 +19,8 @@ private enum AppLogStore {
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()
@@ -58,6 +60,19 @@ enum OverlapMode: String, CaseIterable, Identifiable, Codable {
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
private func calLabel(_ cal: EKCalendar) -> String {
let src = cal.source.title
@@ -209,6 +224,11 @@ struct ContentView: View {
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("scheduleMode") private var scheduleModeRaw: String = ScheduleMode.weekdays.rawValue
@AppStorage("scheduleHour") private var scheduleHour: Int = 8
@AppStorage("scheduleMinute") private var scheduleMinute: Int = 0
@AppStorage("scheduleWeekdaysOnly") private var scheduleWeekdaysOnly: Bool = true
@AppStorage("scheduleIntervalHours") private var scheduleIntervalHours: Int = 1
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
@@ -253,6 +273,132 @@ struct ContentView: View {
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
private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] {
return cals.sorted { a, b in
@@ -947,6 +1093,74 @@ struct ContentView: View {
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) {
Button("Cleanup Placeholders") {
if writeEnabled {

View File

@@ -2,6 +2,12 @@
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
- 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.

View File

@@ -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.
## 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`
- 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.
## Roadmap

9
ReleaseNotes-1.3.6.md Normal file
View 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).