diff --git a/JavaDaemonWatcher/JavaDaemonWatcher.xcodeproj/project.pbxproj b/JavaDaemonWatcher/JavaDaemonWatcher.xcodeproj/project.pbxproj index 29277f8..0a6e34b 100644 --- a/JavaDaemonWatcher/JavaDaemonWatcher.xcodeproj/project.pbxproj +++ b/JavaDaemonWatcher/JavaDaemonWatcher.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ A10000015 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000015; }; A10000016 /* ShellExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000016; }; A10000017 /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000017; }; + A10000019 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000019; }; A10000018 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000018; }; /* End PBXBuildFile section */ @@ -46,6 +47,7 @@ B10000015 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B10000016 /* ShellExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellExecutor.swift; sourceTree = ""; }; B10000017 /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = ""; }; + B10000019 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; D10000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D10000002 /* JavaDaemonWatcher.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = JavaDaemonWatcher.entitlements; sourceTree = ""; }; B10000018 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -90,6 +92,7 @@ F10000003 /* App */ = { isa = PBXGroup; children = ( + B10000019 /* main.swift */, B10000001 /* JavaDaemonWatcherApp.swift */, ); path = App; @@ -247,6 +250,7 @@ A10000015 /* SettingsView.swift in Sources */, A10000016 /* ShellExecutor.swift in Sources */, A10000017 /* Formatters.swift in Sources */, + A10000019 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/JavaDaemonWatcher/JavaDaemonWatcher/App/JavaDaemonWatcherApp.swift b/JavaDaemonWatcher/JavaDaemonWatcher/App/JavaDaemonWatcherApp.swift index 63cdcf1..60ca37f 100644 --- a/JavaDaemonWatcher/JavaDaemonWatcher/App/JavaDaemonWatcherApp.swift +++ b/JavaDaemonWatcher/JavaDaemonWatcher/App/JavaDaemonWatcherApp.swift @@ -1,27 +1,111 @@ +import AppKit import SwiftUI -@main -struct JavaDaemonWatcherApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate - @State private var viewModel = DaemonWatcherViewModel() - - var body: some Scene { - WindowGroup { - MenuBarView(viewModel: viewModel) - .onAppear { - viewModel.start() - } - } - .defaultSize(width: 400, height: 500) - } -} +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let popoverSize = NSSize(width: 360, height: 280) + private let windowSize = NSSize(width: 420, height: 340) + private let viewModel = DaemonWatcherViewModel() + private let popover = NSPopover() + private var statusItem: NSStatusItem? + private var mainWindow: NSWindow? -final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - // Ensure the window is visible on launch - if let window = NSApplication.shared.windows.first { - window.makeKeyAndOrderFront(nil) + NSApplication.shared.setActivationPolicy(.accessory) + + viewModel.start() + configurePopover() + configureStatusItem() + showMainWindow() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showMainWindow() + return true + } + + private func configurePopover() { + let rootView = MenuBarView( + viewModel: viewModel, + onOpenWindow: { [weak self] in + self?.showMainWindow() + self?.popover.performClose(nil) + } + ) + .frame(width: popoverSize.width, height: popoverSize.height, alignment: .top) + + popover.behavior = .transient + popover.contentSize = popoverSize + popover.contentViewController = NSHostingController(rootView: rootView) + } + + private func configureStatusItem() { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + guard let button = statusItem.button else { return } + + button.image = MenuBarIconProvider.icon() + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyUpOrDown + button.toolTip = "Java Daemon Watcher" + button.target = self + button.action = #selector(togglePopover(_:)) + + self.statusItem = statusItem + } + + private func showMainWindow() { + if mainWindow == nil { + createMainWindow() } + + guard let mainWindow else { return } + NSApplication.shared.setActivationPolicy(.regular) + mainWindow.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) } + + private func createMainWindow() { + let rootView = MenuBarView(viewModel: viewModel) + let hostingController = NSHostingController(rootView: rootView) + let window = NSWindow(contentViewController: hostingController) + + window.title = "Java Daemon Watcher" + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.setContentSize(windowSize) + window.minSize = NSSize(width: 360, height: 300) + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + + mainWindow = window + } + + func windowWillClose(_ notification: Notification) { + NSApplication.shared.setActivationPolicy(.accessory) + } + + @objc + private func togglePopover(_ sender: Any?) { + guard let button = statusItem?.button else { return } + + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApplication.shared.activate(ignoringOtherApps: true) + } + } +} + +private enum MenuBarIconProvider { + @MainActor + static func icon() -> NSImage { + let icon = NSApp.applicationIconImage.copy() as? NSImage ?? NSImage() + icon.size = NSSize(width: 18, height: 18) + return icon + } } diff --git a/JavaDaemonWatcher/JavaDaemonWatcher/App/main.swift b/JavaDaemonWatcher/JavaDaemonWatcher/App/main.swift new file mode 100644 index 0000000..b57e54a --- /dev/null +++ b/JavaDaemonWatcher/JavaDaemonWatcher/App/main.swift @@ -0,0 +1,6 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/JavaDaemonWatcher/JavaDaemonWatcher/Info.plist b/JavaDaemonWatcher/JavaDaemonWatcher/Info.plist index a723b35..a62fa36 100644 --- a/JavaDaemonWatcher/JavaDaemonWatcher/Info.plist +++ b/JavaDaemonWatcher/JavaDaemonWatcher/Info.plist @@ -4,5 +4,7 @@ CFBundleDisplayName Java Daemon Watcher + LSUIElement + diff --git a/JavaDaemonWatcher/JavaDaemonWatcher/Views/MenuBarView.swift b/JavaDaemonWatcher/JavaDaemonWatcher/Views/MenuBarView.swift index 005cbf7..197dfee 100644 --- a/JavaDaemonWatcher/JavaDaemonWatcher/Views/MenuBarView.swift +++ b/JavaDaemonWatcher/JavaDaemonWatcher/Views/MenuBarView.swift @@ -2,6 +2,7 @@ import SwiftUI struct MenuBarView: View { @Bindable var viewModel: DaemonWatcherViewModel + var onOpenWindow: (() -> Void)? = nil @State private var showSettings = false @State private var errorDismissTask: Task? @@ -17,7 +18,7 @@ struct MenuBarView: View { mainContent } } - .frame(minWidth: 320) + .frame(minWidth: 320, maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onChange(of: viewModel.errorMessage) { _, newValue in errorDismissTask?.cancel() if newValue != nil { @@ -56,6 +57,7 @@ struct MenuBarView: View { Divider() footer } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } // MARK: - Header @@ -153,7 +155,7 @@ struct MenuBarView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) - .padding(.vertical, 28) + .padding(.vertical, 18) } // MARK: - Daemon List @@ -194,7 +196,7 @@ struct MenuBarView: View { freed: viewModel.statistics.allTimeFreedBytes ) } - .padding(.vertical, 8) + .padding(.vertical, 6) } private func statColumn(label: String, clears: Int, freed: UInt64) -> some View { @@ -228,6 +230,15 @@ struct MenuBarView: View { .foregroundStyle(.red) } + if let onOpenWindow { + Button("Open window") { + onOpenWindow() + } + .buttonStyle(.borderless) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() Button("Quit") { @@ -238,6 +249,6 @@ struct MenuBarView: View { .foregroundStyle(.secondary) } .padding(.horizontal, 14) - .padding(.vertical, 8) + .padding(.vertical, 6) } } diff --git a/JavaDaemonWatcher/JavaDaemonWatcher/Views/SettingsView.swift b/JavaDaemonWatcher/JavaDaemonWatcher/Views/SettingsView.swift index c7f1430..f346830 100644 --- a/JavaDaemonWatcher/JavaDaemonWatcher/Views/SettingsView.swift +++ b/JavaDaemonWatcher/JavaDaemonWatcher/Views/SettingsView.swift @@ -84,6 +84,8 @@ struct SettingsView: View { .padding(.horizontal, 14) .padding(.vertical, 10) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } diff --git a/assets/pr/menubar-icon.png b/assets/pr/menubar-icon.png new file mode 100644 index 0000000..f059b71 Binary files /dev/null and b/assets/pr/menubar-icon.png differ diff --git a/assets/pr/menubar-popover.png b/assets/pr/menubar-popover.png new file mode 100644 index 0000000..41b7f63 Binary files /dev/null and b/assets/pr/menubar-popover.png differ diff --git a/assets/pr/window.png b/assets/pr/window.png new file mode 100644 index 0000000..cae2397 Binary files /dev/null and b/assets/pr/window.png differ