diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 6df4ddf..5bbf8b3 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -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; diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json b/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..48eb928 100644 --- a/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/BusyMirror/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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", diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png new file mode 100644 index 0000000..2cded2f Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png differ diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..f129389 Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..f2cf232 Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..7e019b8 Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..347e449 Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..467efb9 Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_64x64.png b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_64x64.png new file mode 100644 index 0000000..ff104cb Binary files /dev/null and b/BusyMirror/Assets.xcassets/AppIcon.appiconset/icon_64x64.png differ diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index 2497c46..f70f773 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -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 { diff --git a/CHANGELOG.md b/CHANGELOG.md index bfac662..6f05f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a676125..62b842a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ReleaseNotes-1.3.6.md b/ReleaseNotes-1.3.6.md new file mode 100644 index 0000000..65923bf --- /dev/null +++ b/ReleaseNotes-1.3.6.md @@ -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).