Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions bin/sparkdock.macos
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ check_for_updates() {
REMOTE=$(git rev-parse origin/${DEFAULT_BRANCH})

if [ "$LOCAL" != "$REMOTE" ]; then
echo "Updates available:"
git --no-pager log --oneline HEAD..origin/${DEFAULT_BRANCH}
DIFF=$(git --no-pager log --oneline HEAD..origin/${DEFAULT_BRANCH})
if [ -n "$DIFF" ]; then
echo "Updates available:"
echo "$DIFF"
fi
return 0
fi
return 1
Expand Down
183 changes: 176 additions & 7 deletions src/menubar-app/Sources/SparkdockManager/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ fileprivate struct MenuItem: Codable {
private enum MenuItemTag: Int {
case updateNow = 1
case loginItem = 2
case upgradeBrew = 3
}

// MARK: - Async Utilities
Expand Down Expand Up @@ -75,8 +76,10 @@ class SparkdockMenubarApp: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var menu: NSMenu?
var hasUpdates = false
var outdatedBrewCount = 0
var statusMenuItem: NSMenuItem?
var updateNowMenuItem: NSMenuItem?
var upgradeBrewMenuItem: NSMenuItem?
private var pathMonitor: NWPathMonitor?
fileprivate var menuConfig: MenuConfig?
// Cache icons to avoid recreating them
Expand Down Expand Up @@ -203,6 +206,15 @@ class SparkdockMenubarApp: NSObject, NSApplicationDelegate {
menu.addItem(updateItem)
updateNowMenuItem = updateItem

let upgradeBrewItem = NSMenuItem(title: "", action: #selector(upgradeBrew), keyEquivalent: "")
upgradeBrewItem.target = self
upgradeBrewItem.tag = MenuItemTag.upgradeBrew.rawValue
upgradeBrewItem.attributedTitle = NSAttributedString(string: "Upgrade Brew Packages", attributes: [
.font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
])
menu.addItem(upgradeBrewItem)
upgradeBrewMenuItem = upgradeBrewItem

menu.addItem(.separator())

// Add dynamic menu sections from configuration
Expand Down Expand Up @@ -294,9 +306,126 @@ class SparkdockMenubarApp: NSObject, NSApplicationDelegate {
private func checkForUpdates() {
Task(priority: .background) {
let hasUpdates = await runSparkdockCheck()
let outdatedCount = await runBrewOutdatedCheck()
await MainActor.run {
updateUI(hasUpdates: hasUpdates)
updateUI(hasUpdates: hasUpdates, outdatedBrewCount: outdatedCount)
}
}
}

private func findBrewPath() async -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
process.arguments = ["brew"]

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()

var terminationStatus: Int32 = -1
do {
try process.run()

// Await process termination with timeout
let finished: Bool = await withTaskCancellationHandler(
operation: {
do {
terminationStatus = try await withTimeout(seconds: AppConstants.processTimeout) {
await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
}
}
}
return true
} catch {
return false
}
},
onCancel: {
process.terminate()
}
)

if !finished {
AppConstants.logger.error("Which brew process timed out after \(AppConstants.processTimeout) seconds")
return nil
}

if terminationStatus == 0 {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
AppConstants.logger.info("Found brew at: \(path ?? "unknown")")
return path
} else {
AppConstants.logger.warning("Which brew command failed with status: \(terminationStatus)")
return nil
}
} catch {
AppConstants.logger.error("Failed to run which brew: \(error)")
return nil
}
}

private func runBrewOutdatedCheck() async -> Int {
// Use 'which' to find the brew binary dynamically
guard let brewPath = await findBrewPath() else {
AppConstants.logger.warning("Homebrew not found in PATH")
return 0
}

let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "\(brewPath) outdated --quiet | wc -l"]
Comment on lines +370 to +379

Copilot AI Aug 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The findBrewPath() function is called on every update check, which involves spawning a process and executing which brew. Consider caching the brew path after the first successful lookup to avoid repeated process execution overhead.

Copilot uses AI. Check for mistakes.

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()

var terminationStatus: Int32 = -1
do {
try process.run()

// Await process termination with timeout
let finished: Bool = await withTaskCancellationHandler(
operation: {
do {
terminationStatus = try await withTimeout(seconds: AppConstants.processTimeout) {
await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
}
}
}
return true
} catch {
return false
}
},
onCancel: {
// If cancelled, terminate the process
process.terminate()
}
)

if !finished {
AppConstants.logger.error("Brew outdated check process timed out after \(AppConstants.processTimeout) seconds")
return 0
}

if terminationStatus == 0 {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0"
let count = Int(output) ?? 0
AppConstants.logger.info("Found \(count) outdated brew packages")
return count
} else {
AppConstants.logger.warning("Brew outdated check failed with exit code \(terminationStatus)")
return 0
}
} catch {
AppConstants.logger.error("Failed to run brew outdated check: \(error.localizedDescription)")
return 0
}
}

Expand Down Expand Up @@ -347,15 +476,39 @@ class SparkdockMenubarApp: NSObject, NSApplicationDelegate {
}
}

private func updateUI(hasUpdates: Bool) {
private func updateUI(hasUpdates: Bool, outdatedBrewCount: Int = 0) {
self.hasUpdates = hasUpdates
self.outdatedBrewCount = outdatedBrewCount

statusItem?.button?.image = loadIcon(hasUpdates: hasUpdates)
statusItem?.button?.toolTip = hasUpdates ? "Sparkdock - Updates available" : "Sparkdock - Up to date"
let hasAnyUpdates = hasUpdates || outdatedBrewCount > 0
statusItem?.button?.image = loadIcon(hasUpdates: hasAnyUpdates)

// Create tooltip text
var tooltipParts: [String] = []
if hasUpdates {
tooltipParts.append("Sparkdock updates available")
}
if outdatedBrewCount > 0 {
tooltipParts.append("\(outdatedBrewCount) brew packages outdated")
}
if tooltipParts.isEmpty {
statusItem?.button?.toolTip = "Sparkdock - Up to date"
} else {
statusItem?.button?.toolTip = "Sparkdock - " + tooltipParts.joined(separator: ", ")
}

let (title, color) = hasUpdates ?
("Updates Available", NSColor.systemOrange) :
("Sparkdock is up to date", NSColor.systemGreen)
// Create status text
var statusParts: [String] = []
if hasUpdates {
statusParts.append("Sparkdock updates available")
}
if outdatedBrewCount > 0 {
statusParts.append("\(outdatedBrewCount) brew packages outdated")
}

let (title, color) = statusParts.isEmpty ?
("Sparkdock is up to date", NSColor.systemGreen) :
(statusParts.joined(separator: ", "), NSColor.systemOrange)

statusMenuItem?.attributedTitle = createStatusTitle(title, color: color)

Expand All @@ -369,13 +522,29 @@ class SparkdockMenubarApp: NSObject, NSApplicationDelegate {
updateItem.isHidden = true
}
}

// Update the "Upgrade Brew Packages" menu item visibility
if let upgradeBrewItem = upgradeBrewMenuItem {
if outdatedBrewCount > 0 {
upgradeBrewItem.title = "Upgrade Brew Packages (\(outdatedBrewCount))"
upgradeBrewItem.isEnabled = true
upgradeBrewItem.isHidden = false
} else {
upgradeBrewItem.isHidden = true
}
}
}

@objc private func updateNow() {
guard hasUpdates else { return }
executeTerminalCommand("sparkdock")
}

@objc private func upgradeBrew() {
guard outdatedBrewCount > 0 else { return }
executeTerminalCommand("brew upgrade")
}


Comment on lines +545 to 548

Copilot AI Aug 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The brew upgrade command is executed without validating the brew path, potentially allowing execution of any brew binary found in PATH. Consider using the cached brew path from findBrewPath() to ensure you're executing the expected binary.

Suggested change
executeTerminalCommand("brew upgrade")
}
guard let brewPath = findBrewPath() else {
showErrorAlert("Brew Not Found", "Could not find the Homebrew binary. Please ensure Homebrew is installed.")
return
}
executeTerminalCommand("\"\(brewPath)\" upgrade")
}
// Returns the path to the Homebrew binary, or nil if not found
private func findBrewPath() -> String? {
let possiblePaths = [
"/usr/local/bin/brew",
"/opt/homebrew/bin/brew"
]
for path in possiblePaths {
if FileManager.default.isExecutableFile(atPath: path) {
return path
}
}
// Fallback: try to locate brew in PATH
let whichProcess = Process()
let pipe = Pipe()
whichProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which")
whichProcess.arguments = ["brew"]
whichProcess.standardOutput = pipe
do {
try whichProcess.run()
whichProcess.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!output.isEmpty,
FileManager.default.isExecutableFile(atPath: output) {
return output
}
} catch {
// Ignore errors, return nil below
}
return nil
}

Copilot uses AI. Check for mistakes.
private func executeTerminalCommand(_ command: String) {
let process = Process()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ final class SparkdockManagerTests: XCTestCase {
func testMenuItemTags() {
let updateTag = 1
let loginTag = 2
let upgradeBrewTag = 3
XCTAssertNotEqual(updateTag, loginTag, "Menu item tags should be unique")
XCTAssertNotEqual(updateTag, upgradeBrewTag, "Menu item tags should be unique")
XCTAssertNotEqual(loginTag, upgradeBrewTag, "Menu item tags should be unique")
}

func testTimerTolerance() {
Expand All @@ -65,4 +68,25 @@ final class SparkdockManagerTests: XCTestCase {
XCTAssertTrue(fileExists, "pgrep should exist at \(pgrepPath) on macOS systems")
}
}

func testWhichCommandValidation() {
let whichPath = "/usr/bin/which"
let fileExists = FileManager.default.fileExists(atPath: whichPath)
// Note: This may fail in some CI environments, but should pass on most Unix systems
if fileExists {
XCTAssertTrue(fileExists, "which command should exist at \(whichPath) on Unix systems")
}
}

func testBrewCommandFormat() {
let brewCommand = "brew outdated --quiet | wc -l"
XCTAssertTrue(brewCommand.contains("brew outdated"), "Command should check for outdated packages")
XCTAssertTrue(brewCommand.contains("--quiet"), "Command should use quiet mode")
XCTAssertTrue(brewCommand.contains("wc -l"), "Command should count lines for package count")
}

func testBrewUpgradeCommand() {
let upgradeCommand = "brew upgrade"
XCTAssertEqual(upgradeCommand, "brew upgrade", "Brew upgrade command should be correct")
}
}
Loading