Skip to content
Merged
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
48 changes: 48 additions & 0 deletions PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions PluginUpdater/PluginUpdater/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class AppState {
var errorMessage: String?
var manifestEntries: [String: UpdateManifestEntry] = [:]
var updatesAvailableCount = 0
var availableAppUpdate: AppUpdateChecker.AppUpdate?

private(set) var modelContainer: ModelContainer
private var fileMonitor: FileSystemMonitor?
Expand All @@ -20,6 +21,7 @@ final class AppState {
private let manifestManager = ManifestManager()
private let versionChecker = VersionChecker()
private let vendorURLResolver = VendorURLResolver()
private let appUpdateChecker = AppUpdateChecker()
private var prefetchTask: Task<Void, Never>?

/// Plist fields from most recent scan, keyed by bundleID.
Expand Down Expand Up @@ -85,6 +87,13 @@ final class AppState {
AppLogger.shared.info("Update check complete — \(updatesAvailableCount) updates available", category: "updates")
}

/// Checks the GitHub Releases API for a newer version of PluginUpdater.
func checkForAppUpdate() async {
availableAppUpdate = await appUpdateChecker.checkForUpdate(
currentVersion: AppVersion.version
)
}

/// Uses VendorURLResolver to find URLs for plugins without download links.
/// Tries: hardcoded overrides → plist URLs → reverse-domain → search fallback.
/// Deduplicates by vendor prefix and resolves in parallel batches.
Expand Down
6 changes: 6 additions & 0 deletions PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ struct PluginUpdaterApp: App {
Constants.UserDefaultsKeys.notifyNewPlugins: true,
Constants.UserDefaultsKeys.notifyUpdatedPlugins: true,
Constants.UserDefaultsKeys.notifyRemovedPlugins: true,
Constants.UserDefaultsKeys.checkForAppUpdates: true,
])

AppLogger.shared.info(
Expand All @@ -114,6 +115,11 @@ struct PluginUpdaterApp: App {
await appState.loadManifest()
await appState.performScan()

// Check for app updates
if UserDefaults.standard.bool(forKey: Constants.UserDefaultsKeys.checkForAppUpdates) {
await appState.checkForAppUpdate()
}

// Start auto-scan timer
appState.startAutoScanTimer()
}
Expand Down
107 changes: 107 additions & 0 deletions PluginUpdater/PluginUpdater/Services/Updates/AppUpdateChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation

/// Checks for new releases of PluginUpdater itself via the GitHub Releases API.
actor AppUpdateChecker {

struct GitHubRelease: Codable {
let tagName: String
let htmlUrl: String
let body: String?
let publishedAt: String?
let assets: [Asset]

struct Asset: Codable {
let name: String
let browserDownloadUrl: String

enum CodingKeys: String, CodingKey {
case name
case browserDownloadUrl = "browser_download_url"
}
}

enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlUrl = "html_url"
case body
case publishedAt = "published_at"
case assets
}
}

struct AppUpdate {
let version: String
let releaseNotes: String?
let releasePageURL: URL
let downloadURL: URL?
let publishedAt: String?
}

/// Abstraction over URLSession for testability.
protocol URLSessionProtocol: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

private let session: URLSessionProtocol
private let apiBaseURL: String

init(session: URLSessionProtocol = URLSession.shared, apiBaseURL: String? = nil) {
self.session = session
self.apiBaseURL = apiBaseURL ?? Constants.AppUpdateConfig.githubAPIBase
}

/// Queries GitHub for the latest release and returns an `AppUpdate` if a newer version is available.
func checkForUpdate(currentVersion: String) async -> AppUpdate? {
let urlString = "\(apiBaseURL)/repos/\(Constants.AppUpdateConfig.repoOwner)/\(Constants.AppUpdateConfig.repoName)/releases/latest"

guard let url = URL(string: urlString) else {
AppLogger.shared.error("Invalid GitHub API URL", category: "appUpdate")
return nil
}

var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 15

do {
let (data, response) = try await session.data(for: request)

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
AppLogger.shared.error("GitHub API returned status \(code)", category: "appUpdate")
return nil
}

let decoder = JSONDecoder()
let release = try decoder.decode(GitHubRelease.self, from: data)

// Strip "v" prefix from tag name for version comparison
let remoteVersion = release.tagName.normalizedVersion

guard remoteVersion.isNewerVersion(than: currentVersion) else {
AppLogger.shared.info("App is up to date (current: \(currentVersion), latest: \(remoteVersion))", category: "appUpdate")
return nil
}

let releasePageURL = URL(string: release.htmlUrl)!

Check warning on line 87 in PluginUpdater/PluginUpdater/Services/Updates/AppUpdateChecker.swift

View workflow job for this annotation

GitHub Actions / build-and-test

Force unwrapping should be avoided (force_unwrapping)
let pkgAsset = release.assets.first { $0.name.hasSuffix(".pkg") }
let downloadURL = pkgAsset.flatMap { URL(string: $0.browserDownloadUrl) }

AppLogger.shared.info("App update available: \(remoteVersion) (current: \(currentVersion))", category: "appUpdate")

return AppUpdate(
version: remoteVersion,
releaseNotes: release.body,
releasePageURL: releasePageURL,
downloadURL: downloadURL,
publishedAt: release.publishedAt
)
} catch {
AppLogger.shared.error("Failed to check for app update: \(error.localizedDescription)", category: "appUpdate")
return nil
}
}
}

extension URLSession: AppUpdateChecker.URLSessionProtocol {}
7 changes: 7 additions & 0 deletions PluginUpdater/PluginUpdater/Utilities/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ enum Constants {
static let notifyNewPlugins = "notifyNewPlugins"
static let notifyUpdatedPlugins = "notifyUpdatedPlugins"
static let notifyRemovedPlugins = "notifyRemovedPlugins"
static let checkForAppUpdates = "checkForAppUpdates"
}

enum AppUpdateConfig {
static let repoOwner = "bounceconnection"
static let repoName = "plugin_updater"
static let githubAPIBase = "https://api.github.com"
}

enum NotificationIdentifiers {
Expand Down
18 changes: 18 additions & 0 deletions PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ struct MenuBarPopoverView: View {
.font(.headline)
Divider()

// App update banner
if let update = appState.availableAppUpdate {
HStack(spacing: 6) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.blue)
Text("Update Available: v\(update.version)")
.font(.subheadline.bold())
Spacer()
Button("View Release") {
NSWorkspace.shared.open(update.releasePageURL)
}
.controlSize(.small)
}
.padding(8)
.background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
Divider()
}

// Stats
HStack {
Label("\(appState.totalPluginCount) plugins", systemImage: "puzzlepiece.extension")
Expand Down
42 changes: 42 additions & 0 deletions PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ struct SettingsView: View {
@Environment(AppState.self) private var appState
@AppStorage(Constants.UserDefaultsKeys.manifestURL) private var manifestURL = ""
@AppStorage(Constants.UserDefaultsKeys.scanFrequency) private var scanFrequencyMinutes = Constants.Defaults.scanFrequencyMinutes
@AppStorage(Constants.UserDefaultsKeys.checkForAppUpdates) private var checkForAppUpdates = true
@State private var launchAtLogin = false
@State private var didClearImageCache = false
@State private var isCheckingForAppUpdate = false
@State private var didCheckForAppUpdate = false

private let frequencyOptions: [(label: String, minutes: Int)] = [
("Every 15 minutes", 15),
Expand Down Expand Up @@ -80,6 +83,45 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
}

Section("App Updates") {
Toggle("Automatically check for app updates", isOn: $checkForAppUpdates)

HStack {
Text("Current version:")
.foregroundStyle(.secondary)
Text(AppVersion.displayVersion)
.font(.body.monospaced())
}

HStack {
Button(isCheckingForAppUpdate ? "Checking…" : "Check Now") {
isCheckingForAppUpdate = true
didCheckForAppUpdate = false
Task {
await appState.checkForAppUpdate()
isCheckingForAppUpdate = false
didCheckForAppUpdate = true
}
}
.disabled(isCheckingForAppUpdate)

if let update = appState.availableAppUpdate {
Spacer()
Text("v\(update.version) available")
.foregroundStyle(.blue)
Button("View Release") {
NSWorkspace.shared.open(update.releasePageURL)
}
.controlSize(.small)
} else if didCheckForAppUpdate {
Spacer()
Label("Up to date", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline)
}
}
}
}
.tabItem { Label("General", systemImage: "gearshape") }
}
Expand Down
Loading
Loading