From fe9e81358354f4274d376ee58522d342a71d6349 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Thu, 9 Apr 2026 15:55:09 +0200 Subject: [PATCH] Release 1.3.9 --- BusyMirror.xcodeproj/project.pbxproj | 8 +-- BusyMirror/BusyMirrorApp.swift | 11 +++- BusyMirror/ContentView.swift | 37 ++++++++++++++ BusyMirror/Info.plist | 2 + BusyMirror/MenuBarSupport.swift | 75 ++++++++++++++++++++++++++++ CHANGELOG.md | 6 +++ README.md | 3 ++ ReleaseNotes-1.3.9.md | 9 ++++ 8 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 BusyMirror/MenuBarSupport.swift create mode 100644 ReleaseNotes-1.3.9.md diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 297080c..e2f62c7 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 = 16; + CURRENT_PROJECT_VERSION = 17; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.3.8; + MARKETING_VERSION = 1.3.9; 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 = 16; + CURRENT_PROJECT_VERSION = 17; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.3.8; + MARKETING_VERSION = 1.3.9; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/BusyMirrorApp.swift b/BusyMirror/BusyMirrorApp.swift index 84b8daa..ddaa125 100644 --- a/BusyMirror/BusyMirrorApp.swift +++ b/BusyMirror/BusyMirrorApp.swift @@ -2,10 +2,19 @@ import SwiftUI @main struct BusyMirrorApp: App { + @StateObject private var appController = BusyMirrorAppController() + var body: some Scene { - WindowGroup { + Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) { ContentView() + .environmentObject(appController) .frame(minWidth: 720, minHeight: 520) } + .defaultSize(width: 1120, height: 760) + + MenuBarExtra("BusyMirror", systemImage: appController.isSyncing ? "arrow.triangle.2.circlepath.circle.fill" : "calendar.badge.clock") { + BusyMirrorMenuBarView() + .environmentObject(appController) + } } } diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index deae948..f85f665 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -284,6 +284,7 @@ struct Route: Identifiable, Hashable, Codable { } struct ContentView: View { + @EnvironmentObject private var appController: BusyMirrorAppController @State private var store = EKEventStore() @State private var hasAccess = false @State private var calendars: [EKCalendar] = [] @@ -986,7 +987,14 @@ struct ContentView: View { } private func startMirrorNow() { + guard !appController.isSyncing else { return } + appController.setSyncing(true) Task { + defer { + Task { @MainActor in + appController.setSyncing(false) + } + } // New click -> reset the guard so we don't re-process sessionGuard.removeAll() if routes.isEmpty { @@ -997,6 +1005,20 @@ struct ContentView: View { } } + private func handlePendingMenuBarSyncIfNeeded() { + guard appController.hasPendingSyncRequest else { return } + guard !isRunning else { return } + guard hasAccess else { return } + guard !calendars.isEmpty else { return } + guard canRunMirrorNow else { + appController.clearPendingSyncRequest() + log("Menu bar sync requested, but no valid manual targets or saved routes are available.") + return + } + appController.clearPendingSyncRequest() + startMirrorNow() + } + @ViewBuilder private func optionsSection() -> some View { VStack(alignment: .leading, spacing: 14) { @@ -1449,6 +1471,7 @@ struct ContentView: View { Text("This will remove events identified as mirrored (by URL prefix or title prefix ‘\(titlePrefix)’) within the current window (Days back/forward) from the selected target calendars.") } .onAppear { + appController.setMainWindowVisible(true) AppLogStore.append("=== BusyMirror launch ===") log("Log file: \(AppLogStore.logFileURL.path)") requestAccess() @@ -1456,8 +1479,15 @@ struct ContentView: View { mergeGapMin = max(0, mergeGapHours * 60) tryRunCLIIfPresent() enforceNoSourceInTargets() + handlePendingMenuBarSyncIfNeeded() + } + .onDisappear { + appController.setMainWindowVisible(false) } // Persist key settings whenever they change, to ensure restore between runs + .onChange(of: appController.syncRequestToken) { _ in + handlePendingMenuBarSyncIfNeeded() + } .onChange(of: daysBack) { _ in saveSettingsToDefaults() } .onChange(of: daysForward) { _ in saveSettingsToDefaults() } .onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() } @@ -1485,9 +1515,11 @@ struct ContentView: View { // If IDs contain the source’s ID, drop it enforceNoSourceInTargets() saveSettingsToDefaults() + handlePendingMenuBarSyncIfNeeded() } .onChange(of: routes) { _ in saveSettingsToDefaults() + handlePendingMenuBarSyncIfNeeded() } } @@ -1618,6 +1650,8 @@ struct ContentView: View { // Reinitialize the store after permission changes to ensure sources load store = EKEventStore() reloadCalendars() + } else { + appController.clearPendingSyncRequest() } log(granted ? "Access granted." : "Access denied.") } @@ -1630,6 +1664,8 @@ struct ContentView: View { // Reinitialize the store after permission changes to ensure sources load store = EKEventStore() reloadCalendars() + } else { + appController.clearPendingSyncRequest() } log(granted ? "Access granted." : "Access denied.") } @@ -1655,6 +1691,7 @@ struct ContentView: View { saveSettingsToDefaults() } log("Loaded \(calendars.count) calendars.") + handlePendingMenuBarSyncIfNeeded() } // MARK: - Mirror engine (EventKit) diff --git a/BusyMirror/Info.plist b/BusyMirror/Info.plist index eef3dde..3c6f3bb 100644 --- a/BusyMirror/Info.plist +++ b/BusyMirror/Info.plist @@ -2,6 +2,8 @@ + LSUIElement + NSCalendarsFullAccessUsageDescription BusyMirror needs access to your calendars to create busy placeholders. NSRemindersFullAccessUsageDescription diff --git a/BusyMirror/MenuBarSupport.swift b/BusyMirror/MenuBarSupport.swift new file mode 100644 index 0000000..a4b59db --- /dev/null +++ b/BusyMirror/MenuBarSupport.swift @@ -0,0 +1,75 @@ +import SwiftUI +import AppKit + +enum BusyMirrorSceneID { + static let mainWindow = "main-window" +} + +@MainActor +final class BusyMirrorAppController: ObservableObject { + @Published private(set) var isSyncing = false + @Published private(set) var hasPendingSyncRequest = false + @Published private(set) var syncRequestToken = UUID() + @Published private(set) var isMainWindowVisible = false + + func requestSync() { + hasPendingSyncRequest = true + syncRequestToken = UUID() + } + + func clearPendingSyncRequest() { + hasPendingSyncRequest = false + } + + func setSyncing(_ syncing: Bool) { + isSyncing = syncing + } + + func setMainWindowVisible(_ visible: Bool) { + isMainWindowVisible = visible + } + + func openMainWindow(using openWindow: OpenWindowAction) { + NSApp.activate(ignoringOtherApps: true) + openWindow(id: BusyMirrorSceneID.mainWindow) + } +} + +struct BusyMirrorMenuBarView: View { + @Environment(\.openWindow) private var openWindow + @EnvironmentObject private var appController: BusyMirrorAppController + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("BusyMirror") + .font(.headline) + + Text(appController.isSyncing ? "Sync in progress." : "Use your saved routes or current selection.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Divider() + + Button(appController.isSyncing ? "Syncing…" : "Sync Now") { + let shouldOpenWindow = !appController.isMainWindowVisible + appController.requestSync() + if shouldOpenWindow { + appController.openMainWindow(using: openWindow) + } + } + .disabled(appController.isSyncing) + + Button("Open BusyMirror") { + appController.openMainWindow(using: openWindow) + } + + Divider() + + Button("Quit BusyMirror") { + NSApp.terminate(nil) + } + } + .padding(12) + .frame(width: 240, alignment: .leading) + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d39ede4..6dec598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to BusyMirror will be documented in this file. +## [1.3.9] - 2026-04-09 +- New: add a macOS menu bar extra with `Sync Now`, `Open BusyMirror`, and `Quit BusyMirror`. +- UX: menu bar sync requests reuse the existing mirror flow and can open the main window automatically when needed. +- UX: BusyMirror now runs as a menu bar-only app and no longer appears in the Dock. +- Build: bump version to 1.3.9 (build 17). + ## [1.3.8] - 2026-04-08 - Fix: release ZIPs now package `BusyMirror.app` at the archive root instead of embedding the full build path. - Fix: release builds now apply an ad-hoc bundle signature before packaging so downloaded artifacts pass `codesign --verify --deep --strict`. diff --git a/README.md b/README.md index 1a5683f..cc0139d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices. +On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync manually or open the main window; it no longer appears in the Dock. + ## What it does (current) - Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go. - Manual selection mirroring: pick a source and targets in the UI and run. @@ -11,6 +13,7 @@ BusyMirror mirrors meetings between your calendars so your availability stays co - DRY-RUN mode: see what would be created/updated/deleted without writing. - Activity Log in the app plus persistent file logging on disk. - In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section. +- Menu bar controls: trigger `Sync Now`, open the main window, or quit without keeping a Dock icon around. - Overlap modes: `allow`, `skipCovered`, `fillGaps`. - Merge adjacent events with a configurable gap. - Time window controls (days back/forward) and Work Hours filter. diff --git a/ReleaseNotes-1.3.9.md b/ReleaseNotes-1.3.9.md new file mode 100644 index 0000000..0fa053c --- /dev/null +++ b/ReleaseNotes-1.3.9.md @@ -0,0 +1,9 @@ +BusyMirror 1.3.9 - 2026-04-09 + +Changes +- Add a menu bar extra with `Sync Now`, `Open BusyMirror`, and `Quit BusyMirror`. +- Route menu bar sync requests through the same mirroring flow as the main window, opening the window automatically when needed. +- Run BusyMirror as a menu bar-only app so it no longer appears in the Dock. + +Build +- Version bump to 1.3.9 (build 17).