Release 1.3.6
@@ -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 {
|
||||
|
||||