diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a06e4b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.xcuserdatad/ diff --git a/BrewBar.xcodeproj/project.pbxproj b/BrewBar.xcodeproj/project.pbxproj index 5c73827..64db0c2 100644 --- a/BrewBar.xcodeproj/project.pbxproj +++ b/BrewBar.xcodeproj/project.pbxproj @@ -10,9 +10,22 @@ 87F2AA622FEE797F0014F9D6 /* BrewBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BrewBar.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 87F2AA712FEE7A680014F9D6 /* Exceptions for "BrewBar" folder in "BrewBar" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 87F2AA612FEE797F0014F9D6 /* BrewBar */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 87F2AA642FEE797F0014F9D6 /* BrewBar */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 87F2AA712FEE7A680014F9D6 /* Exceptions for "BrewBar" folder in "BrewBar" target */, + ); path = BrewBar; sourceTree = ""; }; @@ -251,17 +264,20 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BrewBar/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BrewBar; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = BrewBar.AppDelegate; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = MaxSoch.BrewBar; + PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -281,17 +297,20 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BrewBar/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BrewBar; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = BrewBar.AppDelegate; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = MaxSoch.BrewBar; + PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/BrewBar/Assets.xcassets/AppIcon.appiconset/Contents.json b/BrewBar/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..8f8ecdb 100644 --- a/BrewBar/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/BrewBar/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -46,6 +46,7 @@ "size" : "512x512" }, { + "filename" : "GPT_image-2026-06-30-16-40-01-removebg-preview.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/BrewBar/Assets.xcassets/AppIcon.appiconset/GPT_image-2026-06-30-16-40-01-removebg-preview.png b/BrewBar/Assets.xcassets/AppIcon.appiconset/GPT_image-2026-06-30-16-40-01-removebg-preview.png new file mode 100644 index 0000000..8b46f0c Binary files /dev/null and b/BrewBar/Assets.xcassets/AppIcon.appiconset/GPT_image-2026-06-30-16-40-01-removebg-preview.png differ diff --git a/BrewBar/Assets.xcassets/brewbar-outdated.imageset/Contents.json b/BrewBar/Assets.xcassets/brewbar-outdated.imageset/Contents.json new file mode 100644 index 0000000..cee00f3 --- /dev/null +++ b/BrewBar/Assets.xcassets/brewbar-outdated.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "outdated-removebg-preview.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BrewBar/Assets.xcassets/brewbar-outdated.imageset/outdated-removebg-preview.png b/BrewBar/Assets.xcassets/brewbar-outdated.imageset/outdated-removebg-preview.png new file mode 100644 index 0000000..7972067 Binary files /dev/null and b/BrewBar/Assets.xcassets/brewbar-outdated.imageset/outdated-removebg-preview.png differ diff --git a/BrewBar/Assets.xcassets/brewbar-updating.imageset/Contents.json b/BrewBar/Assets.xcassets/brewbar-updating.imageset/Contents.json new file mode 100644 index 0000000..3e2a4f3 --- /dev/null +++ b/BrewBar/Assets.xcassets/brewbar-updating.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "updating-removebg-preview.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BrewBar/Assets.xcassets/brewbar-updating.imageset/updating-removebg-preview.png b/BrewBar/Assets.xcassets/brewbar-updating.imageset/updating-removebg-preview.png new file mode 100644 index 0000000..c05998d Binary files /dev/null and b/BrewBar/Assets.xcassets/brewbar-updating.imageset/updating-removebg-preview.png differ diff --git a/BrewBar/Assets.xcassets/brewbar-uptodate.imageset/Contents.json b/BrewBar/Assets.xcassets/brewbar-uptodate.imageset/Contents.json new file mode 100644 index 0000000..8ee889e --- /dev/null +++ b/BrewBar/Assets.xcassets/brewbar-uptodate.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "test_-removebg-preview.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BrewBar/Assets.xcassets/brewbar-uptodate.imageset/test_-removebg-preview.png b/BrewBar/Assets.xcassets/brewbar-uptodate.imageset/test_-removebg-preview.png new file mode 100644 index 0000000..aef5a65 Binary files /dev/null and b/BrewBar/Assets.xcassets/brewbar-uptodate.imageset/test_-removebg-preview.png differ diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index 22818a3..1c2df1e 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -1,17 +1,343 @@ -// -// BrewBarApp.swift -// BrewBar -// -// Created by Maxence Socheleau on 26.06.2026. -// - -import SwiftUI +import Cocoa @main -struct BrewBarApp: App { - var body: some Scene { - WindowGroup { - ContentView() +class AppDelegate: NSObject, NSApplicationDelegate { + + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.setActivationPolicy(.accessory) + app.run() + } + + // MARK: - Varaibles + + var statusItem: NSStatusItem! + var statusMenuItem: NSMenuItem! + var outdatedSubmenu: NSMenu! + var versionItem: NSMenuItem! + + var logWindow: LogWindowController? + var logBuffer: String = "" + + var brewPath: String? + + var refreshTimer: Timer? + //var refreshInterval: TimeInterval = 3600 + var refreshInterval: TimeInterval { + get { + let saved = UserDefaults.standard.double(forKey: "refreshInterval") + return saved > 0 ? saved : 3600 // fallback 1h si jamais dΓ©fini + } + set { + UserDefaults.standard.set(newValue, forKey: "refreshInterval") + UserDefaults.standard.synchronize() } } + + // βœ… cache to prevent duplicate outdated calls + var cachedOutdated: [String] = [] + var lastOutdatedFetch: Date? + + // MARK: - Menu + + func applicationDidFinishLaunching(_ notification: Notification) { + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + setMenuBarIcon("brewbar-uptodate") + + let menu = NSMenu() + + statusMenuItem = NSMenuItem(title: "Checking...", action: nil, keyEquivalent: "") + menu.addItem(statusMenuItem) + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(NSMenuItem(title: "πŸ”„ Refresh", action: #selector(refreshAction), keyEquivalent: "r")) + menu.addItem(NSMenuItem(title: "πŸ“¦ Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u")) + let outdatedItem = NSMenuItem(title: "πŸ“‹ Outdated", action: nil, keyEquivalent: "") + outdatedSubmenu = NSMenu() + outdatedSubmenu.addItem(NSMenuItem(title: "Refresh List", action: #selector(forceRefreshOutdated), keyEquivalent: "")) + outdatedItem.submenu = outdatedSubmenu + menu.addItem(outdatedItem) + menu.addItem(NSMenuItem(title: "πŸ“œ Logs", action: #selector(showLogs), keyEquivalent: "l")) + + menu.addItem(NSMenuItem.separator()) + + + + let intervalItem = NSMenuItem(title: "⏱ Refresh Interval", action: nil, keyEquivalent: "") + let intervalSubmenu = NSMenu() + + let options: [(String, TimeInterval)] = [ + ("1 heure", 3600), + ("6 heures", 21600), + ] + + for (label, interval) in options { + let item = NSMenuItem(title: label, action: #selector(setRefreshInterval(_:)), keyEquivalent: "") + item.representedObject = interval + intervalSubmenu.addItem(item) + } + + let customMenuItem = NSMenuItem() + let customView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + + let textField = NSTextField(frame: NSRect(x: 10, y: 4, width: 120, height: 22)) + textField.placeholderString = "Secondes..." + textField.stringValue = "\(Int(refreshInterval))" + + let confirmButton = NSButton(frame: NSRect(x: 138, y: 4, width: 52, height: 22)) + confirmButton.title = "βœ“ Set" + confirmButton.bezelStyle = .rounded + confirmButton.target = self + confirmButton.action = #selector(applyCustomInterval(_:)) + + customView.addSubview(textField) + customView.addSubview(confirmButton) + customMenuItem.view = customView + intervalSubmenu.addItem(customMenuItem) + + intervalItem.submenu = intervalSubmenu + menu.addItem(intervalItem) + + + + + versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "") + menu.addItem(versionItem) + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + + statusItem.menu = menu + + refreshAll() + refreshBrewVersion() + + refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in + self.refreshAll() + } // pour arrΓͺter : refreshTimer?.invalidate() + } + + // MARK: - Env func + + func setMenuBarIcon(_ name: String) { + DispatchQueue.main.async { + if let button = self.statusItem.button { + if let icon = NSImage(named: name) { + icon.size = NSSize(width: 22, height: 22) + icon.isTemplate = true + button.image = icon + } + } + } + } + + func restartTimer() { + refreshTimer?.invalidate() // stop running timer + refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in + self.refreshAll() + } + } + + @objc func setRefreshInterval(_ sender: NSMenuItem) { + guard let interval = sender.representedObject as? TimeInterval else { return } + refreshInterval = interval // ← le set sauvegarde dans UserDefaults + restartTimer() + } + + @objc func applyCustomInterval(_ sender: NSButton) { + guard let view = sender.superview, + let textField = view.subviews.first(where: { $0 is NSTextField }) as? NSTextField, + let seconds = Int(textField.stringValue), seconds > 0 else { return } + + refreshInterval = TimeInterval(seconds) + restartTimer() + statusItem.menu?.cancelTracking() + } + + func resolveBrewPath() -> String { + if let cached = brewPath { + return cached + } + + let paths = [ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew" + ] + + for path in paths { + if FileManager.default.isExecutableFile(atPath: path) { + brewPath = path + return path + } + } + + //fallback + brewPath = "brew" + return "brew" + } + + func runBrew(_ command: String, completion: @escaping (String) -> Void = { _ in }) { + + DispatchQueue.global().async { + + let brew = self.resolveBrewPath() + let cleaned = command.replacingOccurrences(of: "brew ", with: "") + let fullCommand = "\(brew) \(cleaned)" + + self.log("β†’ \(fullCommand)\n") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-c", fullCommand] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + } catch { + self.log("ERROR: \(error)\n") + completion("") + return + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + let output = String(data: data, encoding: .utf8) ?? "" + + self.log(output) + completion(output) + } + } + + // MARK: - Actions + + @objc func refreshAction() { + refreshAll() + } + + @objc func upgradeAll() { + statusMenuItem.title = "Upgrading..." + + runBrew("update") { _ in + self.runBrew("upgrade") { _ in + self.fetchOutdated() + } + } + } + + // βœ… SMART refresh (uses cache) + @objc func refreshOutdatedList() { + + // if fetched in last 5s β†’ uses cache + if let last = lastOutdatedFetch, Date().timeIntervalSince(last) < 5 { + updateOutdatedMenu(with: cachedOutdated) + return + } + + fetchOutdated() + } + + // βœ… FORCE refresh (menu button) + @objc func forceRefreshOutdated() { + fetchOutdated() + } + + func fetchOutdated() { + setMenuBarIcon("brewbar-updating") + + runBrew("outdated") { output in + + let lines = output.split(separator: "\n").map { String($0) } + + self.cachedOutdated = lines + self.lastOutdatedFetch = Date() + + DispatchQueue.main.async { + self.updateOutdatedMenu(with: lines) + self.updateStatus(count: lines.count) + if lines.isEmpty { + self.setMenuBarIcon("brewbar-uptodate") + } else { + self.setMenuBarIcon("brewbar-outdated") + } + } + } + } + + func updateOutdatedMenu(with lines: [String]) { + + outdatedSubmenu.removeAllItems() + outdatedSubmenu.addItem(NSMenuItem(title: "Refresh List", action: #selector(forceRefreshOutdated), keyEquivalent: "")) + + if lines.isEmpty { + outdatedSubmenu.addItem(NSMenuItem(title: "βœ… All up to date", action: nil, keyEquivalent: "")) + return + } + + for formula in lines { + let item = NSMenuItem(title: formula, action: #selector(self.upgradeSingle(_:)), keyEquivalent: "") + item.representedObject = formula + outdatedSubmenu.addItem(item) + } + } + + func updateStatus(count: Int) { + if count == 0 { + statusMenuItem.title = "βœ… All up to date" + } else { + statusMenuItem.title = "⚠️ \(count) outdated" + } + } + + @objc func upgradeSingle(_ sender: NSMenuItem) { + guard let formula = sender.representedObject as? String else { return } + + runBrew("upgrade \(formula)") { _ in + self.refreshAll() + } + } + + func refreshAll() { + statusMenuItem.title = "Updating..." + + runBrew("update") { _ in + self.fetchOutdated() // βœ… ONLY place calling outdated + } + } + + // βœ… Brew version (correct) + func refreshBrewVersion() { + runBrew("--version") { output in + let firstLine = output.split(separator: "\n").first.map(String.init) ?? "Unknown" + + DispatchQueue.main.async { + self.versionItem.title = "🏷 \(firstLine)" + } + } + } + + // MARK: - Logs + + func log(_ text: String) { + DispatchQueue.main.async { + self.logBuffer += text + self.logWindow?.update(text: self.logBuffer) + } + } + + @objc func showLogs() { + if logWindow == nil { + logWindow = LogWindowController() + } + logWindow?.showWindow(nil) + logWindow?.update(text: logBuffer) + } } diff --git a/BrewBar/ContentView.swift b/BrewBar/ContentView.swift deleted file mode 100644 index b3d2134..0000000 --- a/BrewBar/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// BrewBar -// -// Created by Maxence Socheleau on 26.06.2026. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/BrewBar/Info.plist b/BrewBar/Info.plist new file mode 100644 index 0000000..4fc7a2f --- /dev/null +++ b/BrewBar/Info.plist @@ -0,0 +1,8 @@ + + + + + CFBundleGetInfoString + + + diff --git a/BrewBar/LogWindowController.swift b/BrewBar/LogWindowController.swift new file mode 100644 index 0000000..b9008ad --- /dev/null +++ b/BrewBar/LogWindowController.swift @@ -0,0 +1,36 @@ +import Cocoa + +class LogWindowController: NSWindowController { + + var textView: NSTextView! + + convenience init() { + + let contentRect = NSRect(x: 0, y: 0, width: 600, height: 400) + let styleMask: NSWindow.StyleMask = [.titled, .closable, .resizable] + + let window = NSWindow(contentRect: contentRect, + styleMask: styleMask, + backing: .buffered, + defer: false) + + self.init(window: window) + + window.title = "Logs" + + let scrollView = NSScrollView(frame: window.contentView!.bounds) + scrollView.autoresizingMask = [.width, .height] + + textView = NSTextView(frame: scrollView.bounds) + textView.isEditable = false + textView.autoresizingMask = [.width, .height] + + scrollView.documentView = textView + window.contentView?.addSubview(scrollView) + } + + func update(text: String) { + textView.string = text + textView.scrollToEndOfDocument(nil) + } +}