Skip to content
Open
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
18 changes: 18 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(swift build)",
"Bash(./.build/debug/AutoUp:*)",
"Bash(git checkout:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(gh pr create:*)",
"Bash(gh pr list:*)",
"Bash(gh pr view:*)",
"Read(//Users/adityasheth/**)"
],
"deny": [],
"ask": []
}
}
12 changes: 12 additions & 0 deletions .github/workflows/cursor-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: Cursor Review
on: [pull_request]

jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://cursor.sh/install-cli | bash
- run: cursor review
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
13 changes: 7 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import PackageDescription
let package = Package(
name: "AutoUp",
platforms: [
.macOS(.v13)
.macOS("13.3"),
],
products: [
.executable(
name: "AutoUp",
targets: ["AutoUp"])
targets: ["AutoUp"]
),
],
dependencies: [
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.14.1"),
.package(url: "https://github.com/ml-explore/mlx-swift", from: "0.10.0"),
.package(url: "https://github.com/PostHog/posthog-ios", from: "3.0.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.5.0")
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.5.0"),
],
targets: [
.executableTarget(
Expand All @@ -24,14 +25,14 @@ let package = Package(
.product(name: "SQLite", package: "SQLite.swift"),
.product(name: "MLX", package: "mlx-swift"),
.product(name: "PostHog", package: "posthog-ios"),
.product(name: "Sparkle", package: "Sparkle")
.product(name: "Sparkle", package: "Sparkle"),
],
path: "Sources"
),
.testTarget(
name: "AutoUpTests",
dependencies: ["AutoUp"],
path: "Tests"
)
),
]
)
)
81 changes: 52 additions & 29 deletions Sources/App/AutoUpApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SwiftUI
import AppKit
import SwiftUI

@main
struct AutoUpApp: App {
Expand All @@ -13,11 +13,11 @@ struct AutoUpApp: App {
}

class AppDelegate: NSObject, NSApplicationDelegate {
private var statusBarItem: NSStatusItem!
private var popover: NSPopover!
private var appScanner: AppScanner!
private var updateDetector: UpdateDetector!
private var installManager: InstallManager!
private var statusBarItem: NSStatusItem?
private var popover: NSPopover?
private var appScanner: AppScanner?
private var updateDetector: UpdateDetector?
private var installManager: InstallManager?

func applicationDidFinishLaunching(_ notification: Notification) {
// Hide dock icon - we're a menu bar only app
Expand All @@ -38,7 +38,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

private func setupServices() {
@MainActor private func setupServices() {
appScanner = AppScanner()
updateDetector = UpdateDetector()
installManager = InstallManager()
Expand All @@ -47,8 +47,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func setupMenuBar() {
statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

if let button = statusBarItem.button {
button.image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Auto-Up")
if let statusBarItem = statusBarItem, let button = statusBarItem.button {
button.image = NSImage(
systemSymbolName: "arrow.triangle.2.circlepath",
accessibilityDescription: "Auto-Up"
)
button.action = #selector(togglePopover)
button.target = self
}
Expand All @@ -57,41 +60,61 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

private func setupPopover() {
popover = NSPopover()
popover.contentSize = NSSize(width: 400, height: 500)
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: MainPopoverView())
let newPopover = NSPopover()
newPopover.contentSize = NSSize(width: 400, height: 500)
newPopover.behavior = .transient
newPopover.contentViewController = NSHostingController(rootView: MainPopoverView())
popover = newPopover
}

@objc private func togglePopover() {
if let button = statusBarItem.button {
if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
guard let statusBarItem = statusBarItem,
let popover = popover,
let button = statusBarItem.button else {
return
}

if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}

private func updateBadge(count: Int) {
if let button = statusBarItem.button {
if count > 0 {
button.image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath.circle.fill", accessibilityDescription: "Auto-Up - \(count) updates available")
// Add badge number overlay
button.title = " \(count)"
} else {
button.image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Auto-Up")
button.title = ""
}
guard let statusBarItem = statusBarItem,
let button = statusBarItem.button else {
return
}

if count > 0 {
button.image = NSImage(
systemSymbolName: "arrow.triangle.2.circlepath.circle.fill",
accessibilityDescription: "Auto-Up - \(count) updates available"
)
// Add badge number overlay
button.title = " \(count)"
} else {
button.image = NSImage(
systemSymbolName: "arrow.triangle.2.circlepath",
accessibilityDescription: "Auto-Up"
)
button.title = ""
}
}

private func performInitialScan() async {
guard let appScanner = appScanner,
let updateDetector = updateDetector else {
print("Services not initialized")
return
}

let apps = await appScanner.scanInstalledApps()
let updates = await updateDetector.checkForUpdates(apps: apps)

await MainActor.run {
updateBadge(count: updates.count)
}
}
}
}
56 changes: 35 additions & 21 deletions Sources/Core/AppScanner.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import AppKit
import Foundation

@MainActor
class AppScanner: ObservableObject {
Expand All @@ -9,7 +9,7 @@ class AppScanner: ObservableObject {
private let fileManager = FileManager.default
private let applicationsPaths = [
"/Applications",
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path,
]

func scanInstalledApps() async -> [AppInfo] {
Expand Down Expand Up @@ -63,17 +63,23 @@ class AppScanner: ObservableObject {

do {
let plistData = try Data(contentsOf: URL(fileURLWithPath: infoPlistPath))
guard let plist = try PropertyListSerialization.propertyList(from: plistData, options: [], format: nil) as? [String: Any] else {
guard let plist = try PropertyListSerialization.propertyList(
from: plistData,
options: [],
format: nil
) as? [String: Any] else {
return nil
}

// Extract basic info
guard let bundleID = plist["CFBundleIdentifier"] as? String,
let name = plist["CFBundleDisplayName"] as? String ?? plist["CFBundleName"] as? String else {
let name = plist["CFBundleDisplayName"] as? String ?? plist["CFBundleName"] as? String
else {
return nil
}

let version = plist["CFBundleShortVersionString"] as? String ?? plist["CFBundleVersion"] as? String ?? "Unknown"
let version = plist["CFBundleShortVersionString"] as? String ??
plist["CFBundleVersion"] as? String ?? "Unknown"

// Get file modification date
let attributes = try fileManager.attributesOfItem(atPath: path)
Expand All @@ -86,7 +92,9 @@ class AppScanner: ObservableObject {
let iconPath = findAppIcon(appPath: path, plist: plist)

// Check if it's a Homebrew app
let isHomebrew = path.contains("/opt/homebrew/") || checkIfHomebrewApp(bundleID: bundleID)
let isHomebrewByPath = path.contains("/opt/homebrew/")
let isHomebrewByCask = await checkIfHomebrewApp(bundleID: bundleID)
let isHomebrew = isHomebrewByPath || isHomebrewByCask

// Try to determine GitHub repo
let githubRepo = inferGitHubRepo(bundleID: bundleID, name: name)
Expand Down Expand Up @@ -135,24 +143,30 @@ class AppScanner: ObservableObject {
return nil
}

private func checkIfHomebrewApp(bundleID: String) -> Bool {
private func checkIfHomebrewApp(bundleID: String) async -> Bool {
// Simple heuristic - check if app is in Homebrew casks
let process = Process()
process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
process.arguments = ["list", "--cask"]
let safeProcess = SafeProcess()

do {
let pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
// Check if brew is available
guard await safeProcess.isExecutableAvailable("brew") else {
return false
}

let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
let result = try await safeProcess.execute(
executable: "brew",
arguments: ["list", "--cask"],
timeout: 30
)

guard result.isSuccess else {
return false
}

// Basic matching - could be improved
return output.lowercased().contains(bundleID.lowercased()) ||
output.lowercased().contains(bundleID.components(separatedBy: ".").last?.lowercased() ?? "")
let output = result.stdout.lowercased()
return output.contains(bundleID.lowercased()) ||
output.contains(bundleID.components(separatedBy: ".").last?.lowercased() ?? "")
} catch {
return false
}
Expand All @@ -169,7 +183,7 @@ class AppScanner: ObservableObject {
"com.raycast.macos": "raycast/raycast",
"com.runningwithcrayons.Alfred": "alfred-app/alfred",
"com.figma.Desktop": "figma/figma-api",
"com.tinyapp.TableFlip": "chockenberry/tableflip"
"com.tinyapp.TableFlip": "chockenberry/tableflip",
]

if let repo = repoMapping[bundleID] {
Expand All @@ -183,11 +197,11 @@ class AppScanner: ObservableObject {
let appName = components[2]

// Check if domain looks like a GitHub username
if domain != "com" && domain != "org" && domain != "net" {
if domain != "com", domain != "org", domain != "net" {
return "\(domain)/\(appName.lowercased())"
}
}

return nil
}
}
}
Loading
Loading