diff --git a/.claude/skills/build/SKILL.md b/.claude/skills/build/SKILL.md new file mode 100644 index 0000000..95a7577 --- /dev/null +++ b/.claude/skills/build/SKILL.md @@ -0,0 +1,25 @@ +--- +name: build +description: Build BrewBar with xcodebuild in Debug configuration and report any compiler errors or warnings. +disable-model-invocation: false +--- + +Build the BrewBar project using xcodebuild with the Debug configuration. + +Run the following command from the project root: + +``` +xcodebuild \ + -project BrewBar.xcodeproj \ + -scheme BrewBar \ + -configuration Debug \ + -destination "platform=macOS" \ + build \ + 2>&1 +``` + +After running: +1. If the build succeeds, report "Build succeeded" and note any warnings. +2. If it fails, show the relevant error lines (not the full raw output), explain what each error means, and suggest a fix if the cause is clear. + +This is a zero-dependency project (no SPM, CocoaPods, or Carthage), so no package resolution step is needed. diff --git a/.claude/skills/run/SKILL.md b/.claude/skills/run/SKILL.md new file mode 100644 index 0000000..b24cbbc --- /dev/null +++ b/.claude/skills/run/SKILL.md @@ -0,0 +1,34 @@ +--- +name: run +description: Build and launch BrewBar.app for manual testing. Use after making changes to verify the app runs and the menu bar icon appears correctly. +disable-model-invocation: false +--- + +Build and launch BrewBar for manual testing. + +Steps: +1. Build the app in Debug configuration: + ``` + xcodebuild \ + -project BrewBar.xcodeproj \ + -scheme BrewBar \ + -configuration Debug \ + -destination "platform=macOS" \ + build \ + SYMROOT=build \ + 2>&1 + ``` + If the build fails, stop here and report the errors with explanations. + +2. If the build succeeds, open the built app: + ``` + open build/Debug/BrewBar.app + ``` + +3. Tell the user: + - The app is a menu bar app (.accessory policy) β€” it will NOT appear in the Dock, only in the menu bar at the top right of the screen + - Look for the BrewBar icon in the menu bar to confirm it launched + - Clicking the icon opens the menu; the app runs Homebrew commands in the background + - To quit, use the Quit option in the app's menu + +Note: If a previous instance is already running, macOS may bring it forward instead of launching a new one. Quit the existing instance first if you need a fresh launch. diff --git a/.gitignore b/.gitignore index a06e4b3..5c60401 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ *.xcuserdatad/ +CLAUDE.local.md +.claude/settings.local.json diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..18975bf --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,35 @@ +disabled_rules: + - trailing_whitespace # swiftformat handles this + - todo # todos in comments are fine + +opt_in_rules: + - force_unwrapping + - empty_count + - closure_spacing + - contains_over_first_not_nil + +included: + - BrewBar + +excluded: + - BrewBar.xcodeproj + +line_length: + warning: 140 + error: 200 + +file_length: + warning: 500 + error: 800 + +function_body_length: + warning: 80 + error: 120 + +type_body_length: + warning: 400 + error: 600 + +cyclomatic_complexity: + warning: 15 + error: 25 diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index 1c2df1e..c90403b 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -2,7 +2,6 @@ import Cocoa @main class AppDelegate: NSObject, NSApplicationDelegate { - static func main() { let app = NSApplication.shared let delegate = AppDelegate() @@ -12,7 +11,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } // MARK: - Varaibles - + var statusItem: NSStatusItem! var statusMenuItem: NSMenuItem! var outdatedSubmenu: NSMenu! @@ -22,9 +21,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { var logBuffer: String = "" var brewPath: String? - + var refreshTimer: Timer? - //var refreshInterval: TimeInterval = 3600 + /// var refreshInterval: TimeInterval = 3600 var refreshInterval: TimeInterval { get { let saved = UserDefaults.standard.double(forKey: "refreshInterval") @@ -39,11 +38,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // βœ… cache to prevent duplicate outdated calls var cachedOutdated: [String] = [] var lastOutdatedFetch: Date? - + // MARK: - Menu - func applicationDidFinishLaunching(_ notification: Notification) { - + func applicationDidFinishLaunching(_: Notification) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) setMenuBarIcon("brewbar-uptodate") @@ -57,16 +55,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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) + 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() @@ -80,48 +76,45 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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 customMenuItem = NSMenuItem() + let customView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) - 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(_:)) + let textField = NSTextField(frame: NSRect(x: 10, y: 4, width: 120, height: 22)) + textField.placeholderString = "Secondes..." + textField.stringValue = "\(Int(refreshInterval))" - customView.addSubview(textField) - customView.addSubview(confirmButton) - customMenuItem.view = customView - intervalSubmenu.addItem(customMenuItem) + 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) { @@ -135,30 +128,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } - + 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 + 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 @@ -166,7 +159,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let paths = [ "/opt/homebrew/bin/brew", - "/usr/local/bin/brew" + "/usr/local/bin/brew", ] for path in paths { @@ -176,15 +169,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - //fallback + // 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)" @@ -233,9 +224,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // βœ… SMART refresh (uses cache) + /// βœ… 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) @@ -245,16 +235,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { fetchOutdated() } - // βœ… FORCE refresh (menu button) + /// βœ… FORCE refresh (menu button) @objc func forceRefreshOutdated() { fetchOutdated() } func fetchOutdated() { setMenuBarIcon("brewbar-updating") - - runBrew("outdated") { output in + runBrew("outdated") { output in let lines = output.split(separator: "\n").map { String($0) } self.cachedOutdated = lines @@ -273,7 +262,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func updateOutdatedMenu(with lines: [String]) { - outdatedSubmenu.removeAllItems() outdatedSubmenu.addItem(NSMenuItem(title: "Refresh List", action: #selector(forceRefreshOutdated), keyEquivalent: "")) @@ -283,7 +271,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } for formula in lines { - let item = NSMenuItem(title: formula, action: #selector(self.upgradeSingle(_:)), keyEquivalent: "") + let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "") item.representedObject = formula outdatedSubmenu.addItem(item) } @@ -313,7 +301,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // βœ… Brew version (correct) + /// βœ… Brew version (correct) func refreshBrewVersion() { runBrew("--version") { output in let firstLine = output.split(separator: "\n").first.map(String.init) ?? "Unknown" diff --git a/BrewBar/LogWindowController.swift b/BrewBar/LogWindowController.swift index b9008ad..062cd35 100644 --- a/BrewBar/LogWindowController.swift +++ b/BrewBar/LogWindowController.swift @@ -1,11 +1,9 @@ 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] @@ -31,6 +29,7 @@ class LogWindowController: NSWindowController { func update(text: String) { textView.string = text + textView.scrollToEndOfDocument(nil) } }