Release 1.3.6
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
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)
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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).
|
||||