diff --git a/ActionBar.xcodeproj/project.pbxproj b/ActionBar.xcodeproj/project.pbxproj index 9b0c3f7..8cb5b95 100644 --- a/ActionBar.xcodeproj/project.pbxproj +++ b/ActionBar.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ A10001000000000000000017 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000017 /* SettingsView.swift */; }; A10001000000000000000018 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A1000C000000000000000001 /* Sparkle */; }; A10001000000000000000019 /* UpdaterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000019 /* UpdaterService.swift */; }; + A10001000000000000000021 /* GitHubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000021 /* GitHubModels.swift */; }; + A10001000000000000000022 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000022 /* Components.swift */; }; /* End PBXBuildFile section */ @@ -52,6 +54,8 @@ A10003000000000000000001 /* ActionBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ActionBar.app; sourceTree = BUILT_PRODUCTS_DIR; }; A10002000000000000000018 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Config/Base.xcconfig; sourceTree = ""; }; A10002000000000000000019 /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; + A10002000000000000000021 /* GitHubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubModels.swift; sourceTree = ""; }; + A10002000000000000000022 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; A10002000000000000000020 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ @@ -112,6 +116,7 @@ children = ( A10002000000000000000003 /* WorkflowRun.swift */, A10002000000000000000004 /* AppState.swift */, + A10002000000000000000021 /* GitHubModels.swift */, ); path = Models; sourceTree = ""; @@ -137,6 +142,7 @@ A10002000000000000000012 /* AboutView.swift */, A10002000000000000000013 /* MenuContentView.swift */, A10002000000000000000017 /* SettingsView.swift */, + A10002000000000000000022 /* Components.swift */, ); path = Views; sourceTree = ""; @@ -248,6 +254,8 @@ A10001000000000000000014 /* RelativeTime.swift in Sources */, A10001000000000000000015 /* MenuBarIcon.swift in Sources */, A10001000000000000000017 /* SettingsView.swift in Sources */, + A10001000000000000000021 /* GitHubModels.swift in Sources */, + A10001000000000000000022 /* Components.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/README.md b/README.md index de85746..eced5a9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Requirements: macOS 14+ and a recent version of Xcode. 2. Copy `Config/Local.xcconfig.example` to `Config/Local.xcconfig` and set your Apple Developer Team ID and a bundle identifier registered to your team. 3. Open `ActionBar.xcodeproj` and run the `ActionBar` scheme, or use `make run`. -The app authenticates through the official ActionBar GitHub App. If you're shipping your own fork, register your own [GitHub App](https://docs.github.com/en/apps/creating-github-apps) with device flow enabled and **Actions (read)** permission, then update `clientID` in `Sources/ActionBar/Services/OAuthService.swift` and the app's installation URL. You'll also need your own Sparkle key pair and feed URL in `Resources/Info.plist` (see below). +The app authenticates through the official ActionBar GitHub App. If you're shipping your own fork, register your own [GitHub App](https://docs.github.com/en/apps/creating-github-apps) with device flow enabled and **Actions (read)** permission, then update `GitHubApp.clientID` and `GitHubApp.installationURL` in `Sources/ActionBar/Services/OAuthService.swift`. You'll also need your own Sparkle key pair and feed URL in `Resources/Info.plist` (see below). ## Releasing diff --git a/Sources/ActionBar/App/ActionBarApp.swift b/Sources/ActionBar/App/ActionBarApp.swift index cb0deb8..4aac3e8 100644 --- a/Sources/ActionBar/App/ActionBarApp.swift +++ b/Sources/ActionBar/App/ActionBarApp.swift @@ -2,7 +2,7 @@ import SwiftUI @main struct ActionBarApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var appState = AppState() @StateObject private var updater = UpdaterService() @@ -19,17 +19,15 @@ struct ActionBarApp: App { } } -struct MenuBarLabel: View { +private struct MenuBarLabel: View { @ObservedObject var appState: AppState var body: some View { - Image(nsImage: makeIcon()) + Image(nsImage: MenuBarIcon.image(statusDotColor: statusDotColor)) } - private func makeIcon() -> NSImage { - let dotColor: NSColor? = appState.isAuthenticated && !appState.runs.isEmpty - ? MenuBarIcon.statusColor(for: appState.overallStatus) - : nil - return MenuBarIcon.makeMenuBarIcon(statusDotColor: dotColor) + private var statusDotColor: NSColor? { + guard appState.isAuthenticated && !appState.runs.isEmpty else { return nil } + return appState.overallStatus.color } } diff --git a/Sources/ActionBar/App/AppDelegate.swift b/Sources/ActionBar/App/AppDelegate.swift index 23fcd13..9413904 100644 --- a/Sources/ActionBar/App/AppDelegate.swift +++ b/Sources/ActionBar/App/AppDelegate.swift @@ -2,8 +2,7 @@ import AppKit import UserNotifications final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { - private var windowObserver: Any? - private var configuredPanels = NSHashTable.weakObjects() + private var windowObserver: NSObjectProtocol? func applicationDidFinishLaunching(_ notification: Notification) { UNUserNotificationCenter.current().delegate = self @@ -14,14 +13,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main - ) { [weak self] notification in - guard let panel = notification.object as? NSPanel else { return } - guard !panel.isKind(of: NSOpenPanel.self) else { return } - guard let self, !self.configuredPanels.contains(panel) else { return } - panel.collectionBehavior.insert( - [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] - ) - self.configuredPanels.add(panel) + ) { notification in + let behavior: NSWindow.CollectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + guard let panel = notification.object as? NSPanel, !(panel is NSOpenPanel), + !panel.collectionBehavior.contains(behavior) else { return } + panel.collectionBehavior.insert(behavior) } } diff --git a/Sources/ActionBar/Models/AppState.swift b/Sources/ActionBar/Models/AppState.swift index 9d67f34..17df531 100644 --- a/Sources/ActionBar/Models/AppState.swift +++ b/Sources/ActionBar/Models/AppState.swift @@ -1,9 +1,11 @@ import AppKit -import Foundation -import SwiftUI +import Combine @MainActor final class AppState: ObservableObject { + static let defaultPollingInterval: TimeInterval = 30 + static let defaultMaxRunsPerRepo = 5 + @Published var isAuthenticated = KeychainService.loadToken() != nil @Published var runs: [WorkflowRun] = [] @Published var isLoading = false @@ -19,62 +21,50 @@ final class AppState: ObservableObject { @Published var showingSettings = false @Published var needsInstallation = false @Published var isCheckingInstallation = false - @Published var pollingInterval: TimeInterval = 30 + @Published var pollingInterval = AppState.defaultPollingInterval @Published var workflowFilterMode: WorkflowFilterMode = .off - @Published var workflowFilterText: String = "" - @Published var maxRunsPerRepo: Int = 5 + @Published var workflowFilterText = "" + @Published var maxRunsPerRepo = AppState.defaultMaxRunsPerRepo @Published var includeRunsFromOtherUsers = false @Published private(set) var overallStatus: RunStatus = .unknown private let apiClient = GitHubAPIClient() private let oauthService = OAuthService() + private let defaults = UserDefaults.standard private var pollTask: Task? private var installCheckTask: Task? private var oauthTask: Task? - private var previousRunStates: [Int: String] = [:] // run.id -> status/conclusion + private var previousRunStatuses: [Int: String] = [:] // run.id -> run.status private var hasLoadedOnce = false private var isFetchingRuns = false - private static let selectedReposKey = "selectedRepositories" - private static let pollingIntervalKey = "pollingInterval" - private static let workflowFilterModeKey = "workflowFilterMode" - private static let workflowFilterTextKey = "workflowFilterText" - private static let maxRunsPerRepoKey = "maxRunsPerRepo" - private static let includeRunsFromOtherUsersKey = "includeRunsFromOtherUsers" + private enum DefaultsKey { + static let selectedRepos = "selectedRepositories" + static let pollingInterval = "pollingInterval" + static let workflowFilterMode = "workflowFilterMode" + static let workflowFilterText = "workflowFilterText" + static let maxRunsPerRepo = "maxRunsPerRepo" + static let includeRunsFromOtherUsers = "includeRunsFromOtherUsers" + } - var hasRepoConfiguration: Bool { - UserDefaults.standard.object(forKey: Self.selectedReposKey) != nil + private var hasRepoConfiguration: Bool { + defaults.object(forKey: DefaultsKey.selectedRepos) != nil } func start() { - loadSelectedRepos() - loadPollingInterval() - loadWorkflowFilter() - loadMaxRunsPerRepo() - loadIncludeRunsFromOtherUsers() + loadSettings() guard let token = KeychainService.loadToken() else { return } isAuthenticated = true Task { if let user = try? await apiClient.validateToken(token) { username = user.login } - - do { - let hasInstalls = try await apiClient.hasInstallations(token: token) - if hasInstalls { - startPolling() - } else { - needsInstallation = true - startInstallCheckTimer() - } - } catch { - // API call failed — don't assume no installations. - // Start polling; fetchRuns will surface real errors. - startPolling() - } + await startMonitoring(token: token) } } + // MARK: - Authentication + func loginWithGitHub() { guard !isLoggingIn else { return } isLoggingIn = true @@ -96,35 +86,18 @@ final class AppState: ObservableObject { ) let user = try await apiClient.validateToken(token) - guard KeychainService.saveToken(token) else { - throw GitHubAPIError.networkError( - NSError(domain: "KeychainError", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to save token to Keychain"]) - ) - } + try KeychainService.saveToken(token) username = user.login isAuthenticated = true deviceCode = nil isLoggingIn = false - do { - let hasInstalls = try await apiClient.hasInstallations(token: token) - if hasInstalls { - startPolling() - } else { - needsInstallation = true - startInstallCheckTimer() - } - } catch { - // Installation check failed after login — start polling anyway - startPolling() - } - } catch is CancellationError { - deviceCode = nil - isLoggingIn = false + await startMonitoring(token: token) } catch { - errorMessage = error.localizedDescription + if !(error is CancellationError) { + errorMessage = error.localizedDescription + } deviceCode = nil isLoggingIn = false } @@ -139,9 +112,56 @@ final class AppState: ObservableObject { errorMessage = nil } - func openInstallationPage() { - if let url = URL(string: "https://github.com/apps/actionbar-macos") { - NSWorkspace.shared.open(url) + func signOut() { + cancelLogin() + stopPolling() + stopInstallCheckTimer() + Task { + await apiClient.clearConditionalCache() + } + KeychainService.deleteToken() + isAuthenticated = false + needsInstallation = false + overallStatus = .unknown + runs = [] + username = nil + previousRunStatuses = [:] + hasLoadedOnce = false + errorMessage = nil + selectedRepoNames = [] + availableRepos = [] + repoListIsCapped = false + showingSettings = false + workflowFilterMode = .off + workflowFilterText = "" + maxRunsPerRepo = Self.defaultMaxRunsPerRepo + includeRunsFromOtherUsers = false + // The polling interval is a device preference, so it survives signing out. + for key in [ + DefaultsKey.selectedRepos, + DefaultsKey.workflowFilterMode, + DefaultsKey.workflowFilterText, + DefaultsKey.maxRunsPerRepo, + DefaultsKey.includeRunsFromOtherUsers, + ] { + defaults.removeObject(forKey: key) + } + } + + // MARK: - GitHub App Installation + + /// Starts polling if the GitHub App is installed, otherwise waits for it to be installed. + private func startMonitoring(token: String) async { + do { + if try await apiClient.hasInstallations(token: token) { + startPolling() + } else { + needsInstallation = true + startInstallCheckTimer() + } + } catch { + // Don't assume there are no installations; fetchRuns will surface real errors. + startPolling() } } @@ -150,12 +170,8 @@ final class AppState: ObservableObject { isCheckingInstallation = true Task { do { - let hasInstalls = try await apiClient.hasInstallations(token: token) - if hasInstalls { - stopInstallCheckTimer() - needsInstallation = false - errorMessage = nil - startPolling() + if try await apiClient.hasInstallations(token: token) { + installationFound() } else { errorMessage = "No installation found yet. Please install the app on GitHub first. If you did, wait a bit." } @@ -166,18 +182,15 @@ final class AppState: ObservableObject { } } - func startInstallCheckTimer() { + private func startInstallCheckTimer() { stopInstallCheckTimer() installCheckTask = Task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(10)) guard !Task.isCancelled else { break } guard let token = KeychainService.loadToken() else { continue } - let hasInstalls = (try? await apiClient.hasInstallations(token: token)) ?? false - if hasInstalls { - needsInstallation = false - errorMessage = nil - startPolling() + if (try? await apiClient.hasInstallations(token: token)) == true { + installationFound() break } } @@ -189,61 +202,42 @@ final class AppState: ObservableObject { installCheckTask = nil } - func signOut() { - cancelLogin() - stopPolling() + private func installationFound() { stopInstallCheckTimer() - Task { - await apiClient.clearConditionalCache() - } - KeychainService.deleteToken() - isAuthenticated = false needsInstallation = false - overallStatus = .unknown - runs = [] - username = nil - previousRunStates = [:] - hasLoadedOnce = false errorMessage = nil - selectedRepoNames = [] - availableRepos = [] - repoListIsCapped = false - showingSettings = false - workflowFilterMode = .off - workflowFilterText = "" - maxRunsPerRepo = 5 - includeRunsFromOtherUsers = false - UserDefaults.standard.removeObject(forKey: Self.selectedReposKey) - UserDefaults.standard.removeObject(forKey: Self.workflowFilterModeKey) - UserDefaults.standard.removeObject(forKey: Self.workflowFilterTextKey) - UserDefaults.standard.removeObject(forKey: Self.maxRunsPerRepoKey) - UserDefaults.standard.removeObject(forKey: Self.includeRunsFromOtherUsersKey) + startPolling() } + // MARK: - Polling + func refreshNow() { isLoading = true Task { - let start = Date() + let start = ContinuousClock.now await fetchRuns(manageLoading: false) - let elapsed = Date().timeIntervalSince(start) - let remaining = 0.5 - elapsed - if remaining > 0 { - try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - } + // Keep the spinner up long enough to register as feedback. + try? await Task.sleep(until: start + .milliseconds(500), clock: .continuous) isLoading = false } } - // MARK: - Polling + func updatePollingInterval(_ interval: TimeInterval) { + pollingInterval = interval + defaults.set(pollingInterval, forKey: DefaultsKey.pollingInterval) + startPolling() + } private func startPolling() { stopPolling() pollTask = Task { await fetchRuns() while !Task.isCancelled { + // Wait in short steps against a wall-clock deadline, so a refresh happens + // promptly after the Mac wakes from sleep. let deadline = Date().addingTimeInterval(pollingInterval) while Date() < deadline, !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) + try? await Task.sleep(for: .seconds(1)) } guard !Task.isCancelled else { break } await fetchRuns() @@ -277,183 +271,89 @@ final class AppState: ObservableObject { let actor = includeRunsFromOtherUsers ? nil : username let newRuns = try await apiClient.fetchRecentRuns(token: token, actor: actor, repos: repos) + // With nothing selected yet, adopt the repos that have recent runs as the selection. if usingFallback && !newRuns.isEmpty { - let repoNames = Set(newRuns.map(\.repositoryFullName)) - selectedRepoNames = repoNames + selectedRepoNames = Set(newRuns.map(\.repositoryFullName)) saveSelectedRepos() } - let workflowFilteredRuns = applyWorkflowFilter(to: newRuns) - let filteredRuns = applyMaxRunsPerRepo(to: workflowFilteredRuns) + let workflowFilteredRuns = newRuns.filtered(by: workflowFilterMode, patterns: workflowFilterText) + let visibleRuns = workflowFilteredRuns.limited(toRunsPerRepo: maxRunsPerRepo) - // Detect completions for notifications if hasLoadedOnce { - detectCompletions(newRuns: filteredRuns) + notifyCompletedRuns(in: visibleRuns) } - - // Update state tracking - previousRunStates = [:] - for run in filteredRuns { - previousRunStates[run.id] = stateKey(for: run) + previousRunStatuses = [:] + for run in visibleRuns { + previousRunStatuses[run.id] = run.status } - overallStatus = menuBarStatus(for: workflowFilteredRuns) - runs = filteredRuns + overallStatus = workflowFilteredRuns.overallStatus + runs = visibleRuns hasLoadedOnce = true - if manageLoading { isLoading = false } - } catch let error as GitHubAPIError { - if case .invalidToken = error { - signOut() - } else { - errorMessage = error.localizedDescription - if manageLoading { isLoading = false } - } + } catch GitHubAPIError.invalidToken { + signOut() } catch is CancellationError { // Task was cancelled (e.g. polling restart) — don't surface to UI. - if manageLoading { isLoading = false } } catch { errorMessage = error.localizedDescription - if manageLoading { isLoading = false } } - } - // MARK: - Completion Detection + if manageLoading { isLoading = false } + } - private func detectCompletions(newRuns: [WorkflowRun]) { - for run in newRuns { - let newState = stateKey(for: run) - if let oldState = previousRunStates[run.id], oldState != newState { - let status = RunStatus(from: run) - if run.status == "completed" && (oldState.hasPrefix("in_progress") || oldState.hasPrefix("queued")) { - NotificationService.sendCompletionNotification(run: run) - _ = status // suppress unused warning - } + /// Notifies about runs that were queued or in progress on the previous fetch and have now completed. + private func notifyCompletedRuns(in newRuns: [WorkflowRun]) { + for run in newRuns where run.status == "completed" { + if let previousStatus = previousRunStatuses[run.id], ["in_progress", "queued"].contains(previousStatus) { + NotificationService.sendCompletionNotification(run: run) } } } - private func stateKey(for run: WorkflowRun) -> String { - "\(run.status ?? "unknown")/\(run.conclusion ?? "none")" - } - - // MARK: - Settings Persistence + // MARK: - Settings - private func loadSelectedRepos() { - if let saved = UserDefaults.standard.stringArray(forKey: Self.selectedReposKey) { + private func loadSettings() { + if let saved = defaults.stringArray(forKey: DefaultsKey.selectedRepos) { selectedRepoNames = Set(saved) } - } - - func saveSelectedRepos() { - UserDefaults.standard.set(Array(selectedRepoNames), forKey: Self.selectedReposKey) - } - - func snapshotSelectedRepos() { - if let saved = UserDefaults.standard.stringArray(forKey: Self.selectedReposKey) { - repoSelectionSnapshot = Set(saved) - } else { - repoSelectionSnapshot = [] - } - } - func resetReposToSnapshot() { - selectedRepoNames = repoSelectionSnapshot - } + let savedInterval = defaults.double(forKey: DefaultsKey.pollingInterval) + pollingInterval = savedInterval > 0 ? savedInterval : Self.defaultPollingInterval - private func loadWorkflowFilter() { - if let modeRaw = UserDefaults.standard.string(forKey: Self.workflowFilterModeKey), + if let modeRaw = defaults.string(forKey: DefaultsKey.workflowFilterMode), let mode = WorkflowFilterMode(rawValue: modeRaw) { workflowFilterMode = mode } - workflowFilterText = UserDefaults.standard.string(forKey: Self.workflowFilterTextKey) ?? "" - } + workflowFilterText = defaults.string(forKey: DefaultsKey.workflowFilterText) ?? "" - func saveWorkflowFilter() { - UserDefaults.standard.set(workflowFilterMode.rawValue, forKey: Self.workflowFilterModeKey) - UserDefaults.standard.set(workflowFilterText, forKey: Self.workflowFilterTextKey) - } - - private func applyWorkflowFilter(to runs: [WorkflowRun]) -> [WorkflowRun] { - guard workflowFilterMode != .off else { return runs } - - let patterns = workflowFilterText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } - .filter { !$0.isEmpty } - - guard !patterns.isEmpty else { return runs } - - return runs.filter { run in - let name = (run.name ?? "").lowercased() - let matches = patterns.contains { name.contains($0) } - return workflowFilterMode == .include ? matches : !matches - } - } - - private func menuBarStatus(for runs: [WorkflowRun]) -> RunStatus { - let hasActiveRun = runs.contains { run in - switch RunStatus(from: run) { - case .running, .queued: - return true - default: - return false - } - } - - if hasActiveRun { - return .running - } + let savedMaxRuns = defaults.integer(forKey: DefaultsKey.maxRunsPerRepo) + maxRunsPerRepo = savedMaxRuns > 0 ? savedMaxRuns : Self.defaultMaxRunsPerRepo - guard let lastFinishedRun = runs - .filter({ $0.status == "completed" }) - .max(by: { $0.updatedAt < $1.updatedAt }) else { - return .unknown - } - - return RunStatus(from: lastFinishedRun) + includeRunsFromOtherUsers = defaults.bool(forKey: DefaultsKey.includeRunsFromOtherUsers) } - private func loadMaxRunsPerRepo() { - let saved = UserDefaults.standard.integer(forKey: Self.maxRunsPerRepoKey) - maxRunsPerRepo = saved > 0 ? saved : 5 + /// Persists the settings edited in the settings panel. The polling interval is saved as soon as it changes. + func saveSettings() { + saveSelectedRepos() + defaults.set(workflowFilterMode.rawValue, forKey: DefaultsKey.workflowFilterMode) + defaults.set(workflowFilterText, forKey: DefaultsKey.workflowFilterText) + defaults.set(maxRunsPerRepo, forKey: DefaultsKey.maxRunsPerRepo) + defaults.set(includeRunsFromOtherUsers, forKey: DefaultsKey.includeRunsFromOtherUsers) } - func saveMaxRunsPerRepo() { - UserDefaults.standard.set(maxRunsPerRepo, forKey: Self.maxRunsPerRepoKey) + private func saveSelectedRepos() { + defaults.set(Array(selectedRepoNames), forKey: DefaultsKey.selectedRepos) } - private func loadIncludeRunsFromOtherUsers() { - includeRunsFromOtherUsers = UserDefaults.standard.bool(forKey: Self.includeRunsFromOtherUsersKey) - } + // MARK: - Repository Selection - func saveIncludeRunsFromOtherUsers() { - UserDefaults.standard.set(includeRunsFromOtherUsers, forKey: Self.includeRunsFromOtherUsersKey) - } - - private func applyMaxRunsPerRepo(to runs: [WorkflowRun]) -> [WorkflowRun] { - var countsByRepo: [String: Int] = [:] - return runs.filter { run in - let count = countsByRepo[run.repositoryFullName, default: 0] - guard count < maxRunsPerRepo else { return false } - countsByRepo[run.repositoryFullName] = count + 1 - return true - } - } - - private func loadPollingInterval() { - let saved = UserDefaults.standard.double(forKey: Self.pollingIntervalKey) - pollingInterval = saved > 0 ? saved : 30 - } - - private func savePollingInterval() { - UserDefaults.standard.set(pollingInterval, forKey: Self.pollingIntervalKey) + func snapshotSelectedRepos() { + repoSelectionSnapshot = Set(defaults.stringArray(forKey: DefaultsKey.selectedRepos) ?? []) } - func updatePollingInterval(_ interval: TimeInterval) { - pollingInterval = interval - savePollingInterval() - stopPolling() - startPolling() + func resetReposToSnapshot() { + selectedRepoNames = repoSelectionSnapshot } func toggleRepo(_ repoName: String) { @@ -477,12 +377,10 @@ final class AppState: ObservableObject { if !force && !availableRepos.isEmpty { return } isLoadingRepos = true Task { - do { - let repos = try await apiClient.fetchAllRepos(token: token) + // On failure, keep whatever list we had before. + if let repos = try? await apiClient.fetchAllRepos(token: token) { availableRepos = repos - repoListIsCapped = repos.count >= 200 - } catch { - // Keep whatever we had before + repoListIsCapped = repos.count >= GitHubAPIClient.maxRepositories } isLoadingRepos = false } diff --git a/Sources/ActionBar/Models/GitHubModels.swift b/Sources/ActionBar/Models/GitHubModels.swift new file mode 100644 index 0000000..add7c04 --- /dev/null +++ b/Sources/ActionBar/Models/GitHubModels.swift @@ -0,0 +1,60 @@ +import Foundation + +// Responses from the GitHub REST API and OAuth device flow. +// Decoded with `.convertFromSnakeCase`, so property names mirror the JSON keys. + +struct GitHubUser: Decodable { + let login: String +} + +struct Repository: Decodable, Hashable, Identifiable { + let id: Int + let fullName: String +} + +struct WorkflowRunsResponse: Decodable { + let workflowRuns: [WorkflowRun] +} + +// MARK: - GitHub App Installations + +/// A response from a paginated list endpoint that reports the total item count. +protocol PaginatedResponse: Decodable { + associatedtype Item + var totalCount: Int { get } + var items: [Item] { get } +} + +struct AppInstallation: Decodable { + let id: Int +} + +struct InstallationsResponse: PaginatedResponse { + let totalCount: Int + let installations: [AppInstallation] + + var items: [AppInstallation] { installations } +} + +struct InstallationRepositoriesResponse: PaginatedResponse { + let totalCount: Int + let repositories: [Repository] + + var items: [Repository] { repositories } +} + +// MARK: - OAuth Device Flow + +struct DeviceCodeResponse: Decodable { + let deviceCode: String + let userCode: String + let verificationUri: String + let interval: Int +} + +struct OAuthTokenResponse: Decodable { + let accessToken: String? + let error: String? + let errorDescription: String? + let interval: Int? +} diff --git a/Sources/ActionBar/Models/WorkflowRun.swift b/Sources/ActionBar/Models/WorkflowRun.swift index 6c9d781..7d0736c 100644 --- a/Sources/ActionBar/Models/WorkflowRun.swift +++ b/Sources/ActionBar/Models/WorkflowRun.swift @@ -1,39 +1,5 @@ import Foundation -// MARK: - API Response Models - -struct ReposResponse: Decodable { - // Used for array decoding — GitHub returns [Repository] -} - -struct Repository: Decodable, Hashable, Identifiable { - let id: Int - let fullName: String - let htmlUrl: String - - enum CodingKeys: String, CodingKey { - case id - case fullName = "full_name" - case htmlUrl = "html_url" - } - - init(id: Int, fullName: String, htmlUrl: String) { - self.id = id - self.fullName = fullName - self.htmlUrl = htmlUrl - } -} - -struct WorkflowRunsResponse: Decodable { - let totalCount: Int - let workflowRuns: [WorkflowRun] - - enum CodingKeys: String, CodingKey { - case totalCount = "total_count" - case workflowRuns = "workflow_runs" - } -} - struct WorkflowRun: Decodable, Identifiable, Hashable { let id: Int let name: String? @@ -48,24 +14,17 @@ struct WorkflowRun: Decodable, Identifiable, Hashable { let repositoryFullName: String let triggeringActorLogin: String? - enum CodingKeys: String, CodingKey { - case id - case name - case displayTitle = "display_title" - case status - case conclusion - case event - case htmlUrl = "html_url" - case headBranch = "head_branch" - case createdAt = "created_at" - case updatedAt = "updated_at" - case repository - case actor - case triggeringActor = "triggering_actor" + var title: String { + displayTitle ?? name ?? "Workflow" + } + + private enum CodingKeys: String, CodingKey { + case id, name, displayTitle, status, conclusion, event, htmlUrl, headBranch, createdAt, updatedAt + case repository, actor, triggeringActor } - enum RepositoryCodingKeys: String, CodingKey { - case fullName = "full_name" + private struct RepositoryRef: Decodable { + let fullName: String } private struct Actor: Decodable { @@ -84,8 +43,7 @@ struct WorkflowRun: Decodable, Identifiable, Hashable { headBranch = try container.decodeIfPresent(String.self, forKey: .headBranch) createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) - let repoContainer = try container.nestedContainer(keyedBy: RepositoryCodingKeys.self, forKey: .repository) - repositoryFullName = try repoContainer.decode(String.self, forKey: .fullName) + repositoryFullName = try container.decode(RepositoryRef.self, forKey: .repository).fullName let actor = try container.decodeIfPresent(Actor.self, forKey: .actor) let triggeringActor = try container.decodeIfPresent(Actor.self, forKey: .triggeringActor) triggeringActorLogin = triggeringActor?.login ?? actor?.login @@ -109,14 +67,6 @@ struct WorkflowRun: Decodable, Identifiable, Hashable { } } -// MARK: - Workflow Filter Mode - -enum WorkflowFilterMode: String, CaseIterable { - case off - case include - case exclude -} - // MARK: - Run Status enum RunStatus { @@ -159,42 +109,61 @@ enum RunStatus { case .unknown: return "Unknown" } } -} -// MARK: - GitHub App Installation Models - -struct InstallationsResponse: Decodable { - let totalCount: Int - let installations: [AppInstallation] - - enum CodingKeys: String, CodingKey { - case totalCount = "total_count" - case installations + var isActive: Bool { + self == .running || self == .queued } } -struct AppInstallation: Decodable { - let id: Int - let account: Account +// MARK: - Filtering - struct Account: Decodable { - let login: String - } +enum WorkflowFilterMode: String, CaseIterable { + case off + case include + case exclude } -struct InstallationRepositoriesResponse: Decodable { - let totalCount: Int - let repositories: [Repository] +extension Array where Element == WorkflowRun { + /// Keeps or drops runs whose workflow name contains any of the comma-separated patterns (case-insensitive). + func filtered(by mode: WorkflowFilterMode, patterns: String) -> [WorkflowRun] { + guard mode != .off else { return self } - enum CodingKeys: String, CodingKey { - case totalCount = "total_count" - case repositories + let patterns = patterns + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + .filter { !$0.isEmpty } + + guard !patterns.isEmpty else { return self } + + return filter { run in + let name = (run.name ?? "").lowercased() + let matches = patterns.contains { name.contains($0) } + return mode == .include ? matches : !matches + } + } + + /// Keeps at most `limit` runs per repository, preserving order. + func limited(toRunsPerRepo limit: Int) -> [WorkflowRun] { + var countsByRepo: [String: Int] = [:] + return filter { run in + let count = countsByRepo[run.repositoryFullName, default: 0] + guard count < limit else { return false } + countsByRepo[run.repositoryFullName] = count + 1 + return true + } } -} -// MARK: - GitHub User (for token validation) + /// Running if anything is in flight, otherwise the outcome of the most recently finished run. + var overallStatus: RunStatus { + if contains(where: { RunStatus(from: $0).isActive }) { + return .running + } -struct GitHubUser: Decodable { - let login: String - let id: Int + guard let lastFinishedRun = filter({ $0.status == "completed" }) + .max(by: { $0.updatedAt < $1.updatedAt }) else { + return .unknown + } + + return RunStatus(from: lastFinishedRun) + } } diff --git a/Sources/ActionBar/Services/GitHubAPIClient.swift b/Sources/ActionBar/Services/GitHubAPIClient.swift index 5901e4d..781db91 100644 --- a/Sources/ActionBar/Services/GitHubAPIClient.swift +++ b/Sources/ActionBar/Services/GitHubAPIClient.swift @@ -24,7 +24,17 @@ enum GitHubAPIError: Error, LocalizedError { } actor GitHubAPIClient { - private let baseURL = "https://api.github.com" + /// Upper bound on repositories listed for selection (and polled when nothing is selected). + static let maxRepositories = 200 + + private static let baseURL = "https://api.github.com" + private static let pageSize = 100 + private static let runsPerRepo = 20 + private static let maxRecentRuns = 50 + private static let maxConcurrentRequests = 4 + /// Automated runs (cron jobs, external dispatches) that aren't worth surfacing. + private static let excludedEvents: Set = ["schedule", "repository_dispatch"] + private let session: URLSession private let decoder: JSONDecoder private var workflowRunsCache: [String: CachedWorkflowRunsResponse] = [:] @@ -45,6 +55,7 @@ actor GitHubAPIClient { self.session = URLSession(configuration: config) self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder.dateDecodingStrategy = .iso8601 } @@ -55,256 +66,192 @@ actor GitHubAPIClient { // MARK: - Token Validation func validateToken(_ token: String) async throws -> GitHubUser { - let request = makeRequest(path: "/user", token: token) - let (data, response) = try await perform(request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw GitHubAPIError.invalidResponse(0) - } - - if httpResponse.statusCode == 401 { - throw GitHubAPIError.invalidToken - } - - if isRateLimited(httpResponse) { - throw GitHubAPIError.rateLimited - } - - guard httpResponse.statusCode == 200 else { - throw GitHubAPIError.invalidResponse(httpResponse.statusCode) - } - - do { - return try decoder.decode(GitHubUser.self, from: data) - } catch { - throw GitHubAPIError.decodingError(error) - } + try await get(GitHubUser.self, path: "/user", token: token) } - // MARK: - Fetch Workflow Runs + // MARK: - Workflow Runs func fetchRecentRuns(token: String, actor: String? = nil, repos repoNames: [String]? = nil) async throws -> [WorkflowRun] { let repoFullNames: [String] if let repoNames { repoFullNames = repoNames } else { - repoFullNames = try await fetchRepos(token: token).map(\.fullName) + repoFullNames = try await fetchAllRepos(token: token).map(\.fullName) } - let (allRuns, errors) = await withTaskGroup(of: (runs: [WorkflowRun], error: Error?).self) { group in - let maxConcurrentRequests = 4 - var iterator = repoFullNames.makeIterator() - - for _ in 0...self) { group in + var pendingRepos = repoFullNames.makeIterator() + + func enqueueNextRepo() { + guard let repo = pendingRepos.next() else { return } + group.addTask { await self.fetchRunsResult(repo: repo, token: token, actor: actor) } + } + + for _ in 0.. = ["schedule", "repository_dispatch"] - let filteredRuns = allRuns.filter { run in - guard let event = run.event else { return true } - return !excludedEvents.contains(event) - } + let recentRuns = allRuns + .filter { run in run.event.map { !Self.excludedEvents.contains($0) } ?? true } + .sorted { $0.createdAt > $1.createdAt } + return Array(recentRuns.prefix(Self.maxRecentRuns)) + } - let sortedRuns = filteredRuns.sorted { $0.createdAt > $1.createdAt } - return Array(sortedRuns.prefix(50)) + private func fetchRunsResult(repo: String, token: String, actor: String?) async -> Result<[WorkflowRun], Error> { + do { + return .success(try await fetchRunsForRepo(repo: repo, token: token, actor: actor)) + } catch { + return .failure(error) + } } - // MARK: - Installations + private func fetchRunsForRepo(repo: String, token: String, actor: String?) async throws -> [WorkflowRun] { + var queryItems = [URLQueryItem(name: "per_page", value: "\(Self.runsPerRepo)")] + if let actor { + queryItems.append(URLQueryItem(name: "actor", value: actor)) + } - func fetchInstallations(token: String) async throws -> [AppInstallation] { - var allInstallations: [AppInstallation] = [] - var page = 1 + var request = makeRequest(path: "/repos/\(repo)/actions/runs", queryItems: queryItems, token: token) + let cacheKey = request.url?.absoluteString - while true { - let request = makeRequest( - path: "/user/installations", - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: "\(page)"), - ], - token: token - ) + if let cacheKey, let cached = workflowRunsCache[cacheKey] { + request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match") + } - let (data, response) = try await perform(request) - try validateResponse(response) + let (data, response) = try await perform(request) + let httpResponse = try checkedResponse(response) - let result: InstallationsResponse - do { - result = try decoder.decode(InstallationsResponse.self, from: data) - } catch { - throw GitHubAPIError.decodingError(error) - } + if httpResponse.statusCode == 304, let cacheKey, let cached = workflowRunsCache[cacheKey] { + return cached.runs + } - allInstallations.append(contentsOf: result.installations) - if allInstallations.count >= result.totalCount || result.installations.count < 100 { break } - page += 1 + if httpResponse.statusCode == 404 || httpResponse.statusCode == 409 { + // Actions disabled or no runs available. + return [] + } + + guard httpResponse.statusCode == 200 else { + throw GitHubAPIError.invalidResponse(httpResponse.statusCode) + } + + guard let runsResponse = try? decoder.decode(WorkflowRunsResponse.self, from: data) else { + return [] } - return allInstallations + if let cacheKey { + if let etag = httpResponse.value(forHTTPHeaderField: "ETag") { + workflowRunsCache[cacheKey] = CachedWorkflowRunsResponse(etag: etag, runs: runsResponse.workflowRuns) + } else { + workflowRunsCache.removeValue(forKey: cacheKey) + } + } + return runsResponse.workflowRuns } + // MARK: - Installations and Repositories + func hasInstallations(token: String) async throws -> Bool { - let installations = try await fetchInstallations(token: token) - return !installations.isEmpty + try await !fetchInstallations(token: token).isEmpty } func fetchAllRepos(token: String) async throws -> [Repository] { let installations = try await fetchInstallations(token: token) var allRepos: [Repository] = [] - let maxRepos = 200 - - for installation in installations { - var page = 1 - while allRepos.count < maxRepos { - let request = makeRequest( - path: "/user/installations/\(installation.id)/repositories", - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: "\(page)"), - ], - token: token - ) - - let (data, response) = try await perform(request) - try validateResponse(response) - - let result: InstallationRepositoriesResponse - do { - result = try decoder.decode(InstallationRepositoriesResponse.self, from: data) - } catch { - throw GitHubAPIError.decodingError(error) - } - allRepos.append(contentsOf: result.repositories) - if allRepos.count >= result.totalCount || result.repositories.count < 100 { break } - page += 1 - } - - if allRepos.count >= maxRepos { break } + for installation in installations where allRepos.count < Self.maxRepositories { + allRepos += try await fetchAllPages( + InstallationRepositoriesResponse.self, + path: "/user/installations/\(installation.id)/repositories", + token: token, + limit: Self.maxRepositories - allRepos.count + ) } let sorted = allRepos.sorted { $0.fullName.localizedCaseInsensitiveCompare($1.fullName) == .orderedAscending } - return Array(sorted.prefix(maxRepos)) + return Array(sorted.prefix(Self.maxRepositories)) } - // MARK: - Private - - private func fetchRepos(token: String) async throws -> [Repository] { - return try await fetchAllRepos(token: token) + private func fetchInstallations(token: String) async throws -> [AppInstallation] { + try await fetchAllPages(InstallationsResponse.self, path: "/user/installations", token: token) } - private func fetchRunsForRepo(repo: String, token: String, actor: String? = nil) async throws -> [WorkflowRun] { - var queryItems = [URLQueryItem(name: "per_page", value: "20")] - if let actor { - queryItems.append(URLQueryItem(name: "actor", value: actor)) - } - - var request = makeRequest( - path: "/repos/\(repo)/actions/runs", - queryItems: queryItems, - token: token - ) - let cacheKey = request.url?.absoluteString - - if let cacheKey, let cached = workflowRunsCache[cacheKey] { - request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match") - } - - let (data, response) = try await perform(request) + // MARK: - Requests - guard let httpResponse = response as? HTTPURLResponse else { - throw GitHubAPIError.invalidResponse(0) - } - - if httpResponse.statusCode == 401 { - throw GitHubAPIError.invalidToken - } + /// Follows `page` numbers until every item (or at least `limit` items) has been fetched. + private func fetchAllPages( + _ type: Response.Type, + path: String, + token: String, + limit: Int = .max + ) async throws -> [Response.Item] { + var items: [Response.Item] = [] + var page = 1 - if isRateLimited(httpResponse) { - throw GitHubAPIError.rateLimited + while items.count < limit { + let response = try await get( + type, + path: path, + queryItems: [ + URLQueryItem(name: "per_page", value: "\(Self.pageSize)"), + URLQueryItem(name: "page", value: "\(page)"), + ], + token: token + ) + items += response.items + if items.count >= response.totalCount || response.items.count < Self.pageSize { break } + page += 1 } - if httpResponse.statusCode == 304, let cacheKey, let cached = workflowRunsCache[cacheKey] { - return cached.runs - } + return items + } - if httpResponse.statusCode == 404 || httpResponse.statusCode == 409 { - // Actions disabled or no runs available. - return [] - } + private func get( + _ type: T.Type, + path: String, + queryItems: [URLQueryItem] = [], + token: String + ) async throws -> T { + let request = makeRequest(path: path, queryItems: queryItems, token: token) + let (data, response) = try await perform(request) + let httpResponse = try checkedResponse(response) guard httpResponse.statusCode == 200 else { throw GitHubAPIError.invalidResponse(httpResponse.statusCode) } do { - let runsResponse = try decoder.decode(WorkflowRunsResponse.self, from: data) - if let cacheKey, let etag = httpResponse.value(forHTTPHeaderField: "ETag") { - workflowRunsCache[cacheKey] = CachedWorkflowRunsResponse( - etag: etag, - runs: runsResponse.workflowRuns - ) - } else if let cacheKey { - workflowRunsCache.removeValue(forKey: cacheKey) - } - return runsResponse.workflowRuns + return try decoder.decode(type, from: data) } catch { - return [] + throw GitHubAPIError.decodingError(error) } } private func makeRequest(path: String, queryItems: [URLQueryItem] = [], token: String) -> URLRequest { - var components = URLComponents(string: baseURL + path)! + var components = URLComponents(string: Self.baseURL + path)! if !queryItems.isEmpty { components.queryItems = queryItems } @@ -314,7 +261,16 @@ actor GitHubAPIClient { return request } - private func validateResponse(_ response: URLResponse) throws { + private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await session.data(for: request) + } catch { + throw GitHubAPIError.networkError(error) + } + } + + /// Throws for responses that are never usable: non-HTTP, unauthorized, or rate limited. + private func checkedResponse(_ response: URLResponse) throws -> HTTPURLResponse { guard let httpResponse = response as? HTTPURLResponse else { throw GitHubAPIError.invalidResponse(0) } @@ -324,26 +280,11 @@ actor GitHubAPIClient { if isRateLimited(httpResponse) { throw GitHubAPIError.rateLimited } - guard httpResponse.statusCode == 200 else { - throw GitHubAPIError.invalidResponse(httpResponse.statusCode) - } + return httpResponse } private func isRateLimited(_ response: HTTPURLResponse) -> Bool { - if response.statusCode == 429 { return true } - if response.statusCode == 403, - let remaining = response.value(forHTTPHeaderField: "x-ratelimit-remaining"), - remaining == "0" { - return true - } - return false - } - - private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) { - do { - return try await session.data(for: request) - } catch { - throw GitHubAPIError.networkError(error) - } + response.statusCode == 429 + || (response.statusCode == 403 && response.value(forHTTPHeaderField: "x-ratelimit-remaining") == "0") } } diff --git a/Sources/ActionBar/Services/KeychainService.swift b/Sources/ActionBar/Services/KeychainService.swift index 44ee307..2d32215 100644 --- a/Sources/ActionBar/Services/KeychainService.swift +++ b/Sources/ActionBar/Services/KeychainService.swift @@ -1,49 +1,54 @@ import Foundation import Security +enum KeychainError: Error, LocalizedError { + case saveFailed(OSStatus) + + var errorDescription: String? { + "Failed to save token to Keychain" + } +} + enum KeychainService { + // Predates the ActionBar name. Changing it would sign out existing users. private static let service = "com.menuapp.github-actions" private static let account = "github-token" - static func saveToken(_ token: String) -> Bool { - deleteToken() - - guard let data = token.data(using: .utf8) else { return false } - - let query: [String: Any] = [ + private static var baseQuery: [String: Any] { + [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] + } + + static func saveToken(_ token: String) throws { + deleteToken() + + let data = Data(token.utf8) + var query = baseQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly - let status = SecItemAdd(query as CFDictionary, nil) + var status = SecItemAdd(query as CFDictionary, nil) if status == errSecDuplicateItem { - let searchQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] let updateAttrs: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] - return SecItemUpdate(searchQuery as CFDictionary, updateAttrs as CFDictionary) == errSecSuccess + status = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary) } - return status == errSecSuccess + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } } static func loadToken() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -62,15 +67,7 @@ enum KeychainService { return nil } - @discardableResult - static func deleteToken() -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound + static func deleteToken() { + SecItemDelete(baseQuery as CFDictionary) } } diff --git a/Sources/ActionBar/Services/NotificationService.swift b/Sources/ActionBar/Services/NotificationService.swift index a1d9e5c..7be9204 100644 --- a/Sources/ActionBar/Services/NotificationService.swift +++ b/Sources/ActionBar/Services/NotificationService.swift @@ -7,9 +7,8 @@ enum NotificationService { } static func sendCompletionNotification(run: WorkflowRun) { - let status = RunStatus(from: run) let content = UNMutableNotificationContent() - content.title = "\(status.label): \(run.displayTitle ?? run.name ?? "Workflow")" + content.title = "\(RunStatus(from: run).label): \(run.title)" content.body = "\(run.repositoryFullName) · \(run.headBranch ?? "")" content.sound = .default content.userInfo = ["url": run.htmlUrl] diff --git a/Sources/ActionBar/Services/OAuthService.swift b/Sources/ActionBar/Services/OAuthService.swift index c027dff..334c5a1 100644 --- a/Sources/ActionBar/Services/OAuthService.swift +++ b/Sources/ActionBar/Services/OAuthService.swift @@ -1,51 +1,11 @@ import Foundation -// MARK: - Device Flow Response Models - -struct DeviceCodeResponse: Decodable { - let deviceCode: String - let userCode: String - let verificationUri: String - let expiresIn: Int - let interval: Int - - enum CodingKeys: String, CodingKey { - case deviceCode = "device_code" - case userCode = "user_code" - case verificationUri = "verification_uri" - case expiresIn = "expires_in" - case interval - } - - init(deviceCode: String, userCode: String, verificationUri: String, expiresIn: Int, interval: Int) { - self.deviceCode = deviceCode - self.userCode = userCode - self.verificationUri = verificationUri - self.expiresIn = expiresIn - self.interval = interval - } -} - -struct OAuthTokenResponse: Decodable { - let accessToken: String? - let tokenType: String? - let scope: String? - let error: String? - let errorDescription: String? - let interval: Int? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case tokenType = "token_type" - case scope - case error - case errorDescription = "error_description" - case interval - } +/// The GitHub App ActionBar signs in through. Forks must register their own app and update these values. +enum GitHubApp { + static let clientID = "Iv23liMJQvDNiRMhbNes" + static let installationURL = URL(string: "https://github.com/apps/actionbar-macos")! } -// MARK: - OAuth Errors - enum OAuthError: Error, LocalizedError { case codeRequestFailed(Int) case expired @@ -69,31 +29,21 @@ enum OAuthError: Error, LocalizedError { } } -// MARK: - OAuth Service +/// Signs in with the OAuth device flow: +/// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow +struct OAuthService { + private static let deviceCodeURL = URL(string: "https://github.com/login/device/code")! + private static let accessTokenURL = URL(string: "https://github.com/login/oauth/access_token")! -actor OAuthService { - static let clientID = "Iv23liMJQvDNiRMhbNes" + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() func requestDeviceCode() async throws -> DeviceCodeResponse { - var request = URLRequest(url: URL(string: "https://github.com/login/device/code")!) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: String] = [ - "client_id": Self.clientID, - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await perform(request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - throw OAuthError.codeRequestFailed(code) - } - - return try JSONDecoder().decode(DeviceCodeResponse.self, from: data) + let data = try await post(Self.deviceCodeURL, body: ["client_id": GitHubApp.clientID]) + return try decoder.decode(DeviceCodeResponse.self, from: data) } func pollForToken(deviceCode: String, interval: Int) async throws -> String { @@ -101,30 +51,15 @@ actor OAuthService { while true { try Task.checkCancellation() - try await Task.sleep(nanoseconds: UInt64(pollInterval) * 1_000_000_000) + try await Task.sleep(for: .seconds(pollInterval)) try Task.checkCancellation() - var request = URLRequest(url: URL(string: "https://github.com/login/oauth/access_token")!) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: String] = [ - "client_id": Self.clientID, + let data = try await post(Self.accessTokenURL, body: [ + "client_id": GitHubApp.clientID, "device_code": deviceCode, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code" - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await perform(request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - throw OAuthError.codeRequestFailed(code) - } - - let tokenResponse = try JSONDecoder().decode(OAuthTokenResponse.self, from: data) + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + ]) + let tokenResponse = try decoder.decode(OAuthTokenResponse.self, from: data) if let token = tokenResponse.accessToken { return token @@ -135,7 +70,6 @@ actor OAuthService { continue case "slow_down": pollInterval = (tokenResponse.interval ?? pollInterval) + 5 - continue case "expired_token": throw OAuthError.expired case "access_denied": @@ -148,11 +82,25 @@ actor OAuthService { } } - private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) { + private func post(_ url: URL, body: [String: String]) async throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let data: Data + let response: URLResponse do { - return try await URLSession.shared.data(for: request) + (data, response) = try await URLSession.shared.data(for: request) } catch { throw OAuthError.networkError(error) } + + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard statusCode == 200 else { + throw OAuthError.codeRequestFailed(statusCode) + } + return data } } diff --git a/Sources/ActionBar/Utilities/MenuBarIcon.swift b/Sources/ActionBar/Utilities/MenuBarIcon.swift index e9ae159..49c8c45 100644 --- a/Sources/ActionBar/Utilities/MenuBarIcon.swift +++ b/Sources/ActionBar/Utilities/MenuBarIcon.swift @@ -1,78 +1,57 @@ import AppKit enum MenuBarIcon { - static func makeMenuBarIcon(statusDotColor: NSColor? = nil) -> NSImage { + static func image(statusDotColor: NSColor? = nil) -> NSImage { let size = NSSize(width: 18, height: 18) - let symbolName = "checkmark.circle" - guard let baseSymbol = NSImage( - systemSymbolName: symbolName, - accessibilityDescription: "ActionBar" - ) else { + guard let symbol = NSImage(systemSymbolName: "checkmark.circle", accessibilityDescription: "ActionBar")? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)) else { return NSImage(size: size) } - let sizeConfig = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular) - guard let dotColor = statusDotColor else { // No dot — pure template image, macOS handles dark/light automatically - guard let image = baseSymbol.withSymbolConfiguration(sizeConfig) else { - return NSImage(size: size) - } - image.isTemplate = true - return image + symbol.isTemplate = true + return symbol } - // With dot — draw SF Symbol with appearance-adaptive color + // A colored dot can't be part of a template image, so tint the symbol for the + // current menu bar appearance ourselves. let menuBarColor = NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - ? .white : .black - } - - guard let sizedSymbol = baseSymbol.withSymbolConfiguration(sizeConfig) else { - return NSImage(size: size) + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? .white : .black } let image = NSImage(size: size, flipped: false) { rect in - // Draw the SF Symbol as template, then tint it - let symbolSize = sizedSymbol.size let symbolRect = NSRect( x: 0, - y: (rect.height - symbolSize.height) / 2, - width: symbolSize.width, - height: symbolSize.height + y: (rect.height - symbol.size.height) / 2, + width: symbol.size.width, + height: symbol.size.height ) - sizedSymbol.draw(in: symbolRect) + symbol.draw(in: symbolRect) - // Tint the drawn pixels with menuBarColor menuBarColor.setFill() symbolRect.fill(using: .sourceAtop) - // Draw the status dot let dotSize: CGFloat = 6 - let dotRect = NSRect( - x: rect.width - dotSize, - y: 0, - width: dotSize, - height: dotSize - ) dotColor.setFill() - NSBezierPath(ovalIn: dotRect).fill() + NSBezierPath(ovalIn: NSRect(x: rect.width - dotSize, y: 0, width: dotSize, height: dotSize)).fill() return true } image.isTemplate = false return image } +} - static func statusColor(for status: RunStatus) -> NSColor { - switch status { +extension RunStatus { + var color: NSColor { + switch self { case .success: return .systemGreen case .failure: return .systemRed case .running: return .systemOrange case .queued: return .systemYellow - case .cancelled: return .systemGray - case .unknown: return .systemGray + case .cancelled, .unknown: return .systemGray } } } diff --git a/Sources/ActionBar/Utilities/RelativeTime.swift b/Sources/ActionBar/Utilities/RelativeTime.swift index b733a80..7c0c609 100644 --- a/Sources/ActionBar/Utilities/RelativeTime.swift +++ b/Sources/ActionBar/Utilities/RelativeTime.swift @@ -1,30 +1,27 @@ import Foundation enum RelativeTime { - static func format(_ date: Date, relativeTo now: Date = Date()) -> String { - let interval = now.timeIntervalSince(date) + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter + }() - if interval < 0 { - return "just now" - } - - let seconds = Int(interval) - let minutes = seconds / 60 + static func format(_ date: Date, relativeTo now: Date = Date()) -> String { + let minutes = Int(now.timeIntervalSince(date)) / 60 let hours = minutes / 60 let days = hours / 24 - if seconds < 60 { + if minutes < 1 { return "just now" - } else if minutes < 60 { + } else if hours < 1 { return "\(minutes)m ago" - } else if hours < 24 { + } else if days < 1 { return "\(hours)h ago" } else if days < 30 { return "\(days)d ago" } else { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d" - return formatter.string(from: date) + return dateFormatter.string(from: date) } } } diff --git a/Sources/ActionBar/Views/AboutView.swift b/Sources/ActionBar/Views/AboutView.swift index 21395aa..6363521 100644 --- a/Sources/ActionBar/Views/AboutView.swift +++ b/Sources/ActionBar/Views/AboutView.swift @@ -1,7 +1,9 @@ import SwiftUI struct AboutView: View { - @EnvironmentObject var updater: UpdaterService + @EnvironmentObject private var updater: UpdaterService + + private static let websiteURL = URL(string: "https://ptrchm.com/actionbar/")! private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" @@ -14,15 +16,10 @@ struct AboutView: View { Text("Version \(appVersion)") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) - Button("Website") { - if let url = URL(string: "https://ptrchm.com/actionbar/") { - NSWorkspace.shared.open(url) - } - } - .font(.caption) - .buttonStyle(.link) + Link("Website", destination: Self.websiteURL) + .font(.caption) Button("Check for Updates…") { updater.checkForUpdates() diff --git a/Sources/ActionBar/Views/Components.swift b/Sources/ActionBar/Views/Components.swift new file mode 100644 index 0000000..2645429 --- /dev/null +++ b/Sources/ActionBar/Views/Components.swift @@ -0,0 +1,20 @@ +import SwiftUI + +extension View { + /// Small borderless text button, used for secondary actions throughout the menu. + func textButtonStyle(_ color: Color = .secondary, font: Font = .caption) -> some View { + self + .font(font) + .buttonStyle(.plain) + .foregroundStyle(color) + } +} + +struct QuitButton: View { + var body: some View { + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .textButtonStyle() + } +} diff --git a/Sources/ActionBar/Views/MenuContentView.swift b/Sources/ActionBar/Views/MenuContentView.swift index 77d5c31..f8d79ce 100644 --- a/Sources/ActionBar/Views/MenuContentView.swift +++ b/Sources/ActionBar/Views/MenuContentView.swift @@ -1,116 +1,115 @@ import SwiftUI struct MenuContentView: View { - @EnvironmentObject var appState: AppState - @EnvironmentObject var updater: UpdaterService + @EnvironmentObject private var appState: AppState + @EnvironmentObject private var updater: UpdaterService @State private var now = Date() @State private var showAbout = false - private let tickTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() - var body: some View { if appState.isAuthenticated && !appState.needsInstallation { authenticatedView } else { SetupView() - .environmentObject(appState) } } private var authenticatedView: some View { VStack(spacing: 0) { - if !appState.showingSettings { - // Header - HStack { - Text("GitHub Actions") - .font(.headline) - Spacer() - if appState.isLoading { - ProgressView() - .controlSize(.small) - } - Button(action: { appState.refreshNow() }) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 11)) - } - .buttonStyle(.plain) - .help("Refresh now") - Button(action: { - appState.showingSettings = true - appState.fetchAvailableRepos() - }) { - Image(systemName: "gearshape") - .font(.system(size: 11)) - } - .buttonStyle(.plain) - .help("Settings") - } - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 8) - - Divider() - } - - // Content if appState.showingSettings { SettingsView() .frame(minHeight: 520) - } else if let error = appState.errorMessage { - errorView(error) - } else if appState.runs.isEmpty && appState.isLoading { - loadingView - } else if appState.runs.isEmpty { - emptyView } else { - runsList + header + Divider() + content } Divider() - // Footer - HStack(spacing: 16) { - if let username = appState.username { - Text(username) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Button("About") { - showAbout.toggle() - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) - .popover(isPresented: $showAbout) { - AboutView() - .environmentObject(updater) - } - - Button("Sign Out") { - appState.signOut() - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) + footer } .frame(width: 340) - .onReceive(tickTimer) { now = $0 } + .task { + // Keep relative timestamps fresh while the menu is open. + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + now = Date() + } + } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in now = Date() } } + private var header: some View { + HStack { + Text("GitHub Actions") + .font(.headline) + Spacer() + if appState.isLoading { + ProgressView() + .controlSize(.small) + } + Button("Refresh now", systemImage: "arrow.clockwise") { + appState.refreshNow() + } + .help("Refresh now") + Button("Settings", systemImage: "gearshape") { + appState.showingSettings = true + appState.fetchAvailableRepos() + } + .help("Settings") + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .font(.system(size: 11)) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + } + + @ViewBuilder + private var content: some View { + if let error = appState.errorMessage { + errorView(error) + } else if appState.runs.isEmpty && appState.isLoading { + loadingView + } else if appState.runs.isEmpty { + emptyView + } else { + runsList + } + } + + private var footer: some View { + HStack(spacing: 16) { + if let username = appState.username { + Text(username) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("About") { + showAbout.toggle() + } + .textButtonStyle() + .popover(isPresented: $showAbout) { + AboutView() + .environmentObject(updater) + } + + Button("Sign Out") { + appState.signOut() + } + .textButtonStyle() + + QuitButton() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + private var runsList: some View { ScrollView { LazyVStack(spacing: 0) { @@ -136,10 +135,10 @@ struct MenuContentView: View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 20)) - .foregroundColor(.orange) + .foregroundStyle(.orange) Text(message) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button("Retry") { appState.refreshNow() @@ -154,7 +153,7 @@ struct MenuContentView: View { ProgressView() Text("Loading workflows...") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding(30) } @@ -163,11 +162,10 @@ struct MenuContentView: View { VStack(spacing: 8) { Image(systemName: "tray") .font(.system(size: 20)) - .foregroundColor(.secondary) Text("No workflow runs found") .font(.caption) - .foregroundColor(.secondary) } + .foregroundStyle(.secondary) .padding(30) } } diff --git a/Sources/ActionBar/Views/SettingsView.swift b/Sources/ActionBar/Views/SettingsView.swift index eb71744..3ddbeae 100644 --- a/Sources/ActionBar/Views/SettingsView.swift +++ b/Sources/ActionBar/Views/SettingsView.swift @@ -1,11 +1,15 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject var appState: AppState + @EnvironmentObject private var appState: AppState + @Environment(\.openURL) private var openURL @State private var searchText = "" @State private var showSelectAllWarning = false - private let intervalOptions: [(label: String, value: TimeInterval)] = [ + /// Selecting more repos than this at once asks for confirmation, since each one costs an API call per refresh. + private static let selectAllWarningThreshold = 10 + + private static let intervalOptions: [(label: String, value: TimeInterval)] = [ ("15s", 15), ("30s", 30), ("1 min", 60), @@ -21,280 +25,281 @@ struct SettingsView: View { } } - private var selectedCount: Int { - appState.selectedRepoNames.count - } - var body: some View { VStack(spacing: 0) { - // Header - HStack { - Text("Settings") - .font(.headline) - Spacer() - Button("Done") { - appState.saveSelectedRepos() - appState.saveWorkflowFilter() - appState.saveMaxRunsPerRepo() - appState.saveIncludeRunsFromOtherUsers() - appState.showingSettings = false - appState.refreshNow() - } - .buttonStyle(.plain) - .foregroundColor(.accentColor) + header + Divider() + pollingIntervalSection + Divider() + maxRunsSection + Divider() + otherUsersSection + Divider() + workflowFilterSection + Divider() + installationsSection + Divider() + repositoriesHeader + Divider() + repositoryList + } + .onAppear { + appState.snapshotSelectedRepos() + } + } + + private var header: some View { + HStack { + Text("Settings") + .font(.headline) + Spacer() + Button("Done") { + appState.saveSettings() + appState.showingSettings = false + appState.refreshNow() } - .padding(.horizontal, 16) - .padding(.vertical, 10) + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } - Divider() + private var pollingIntervalSection: some View { + HStack { + Text("Polling interval") + .font(.subheadline) + Spacer() + Picker("Polling interval", selection: Binding( + get: { appState.pollingInterval }, + set: { appState.updatePollingInterval($0) } + )) { + ForEach(Self.intervalOptions, id: \.value) { option in + Text(option.label).tag(option.value) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + } + .settingsSectionPadding() + } - // Polling interval + private var maxRunsSection: some View { + VStack(alignment: .leading, spacing: 4) { HStack { - Text("Polling interval") + Text("Max runs per repo") .font(.subheadline) Spacer() - Picker("", selection: Binding( - get: { appState.pollingInterval }, - set: { appState.updatePollingInterval($0) } - )) { - ForEach(intervalOptions, id: \.value) { option in - Text(option.label).tag(option.value) + Picker("Max runs per repo", selection: $appState.maxRunsPerRepo) { + ForEach(1...10, id: \.self) { n in + Text("\(n)").tag(n) } } + .labelsHidden() .pickerStyle(.menu) .fixedSize() } - .padding(.horizontal, 16) - .padding(.vertical, 8) + Text("Limit how many recent runs are shown for each repository.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .settingsSectionPadding() + } - Divider() + private var otherUsersSection: some View { + VStack(alignment: .leading, spacing: 4) { + Toggle("Include runs from other users", isOn: $appState.includeRunsFromOtherUsers) + .toggleStyle(.checkbox) + .font(.subheadline) + Text("Show recent workflow runs from anyone in the selected repositories.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .settingsSectionPadding() + } - // Max runs per repo - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Max runs per repo") - .font(.subheadline) - Spacer() - Picker("", selection: $appState.maxRunsPerRepo) { - ForEach(1...10, id: \.self) { n in - Text("\(n)").tag(n) - } + private var workflowFilterSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Workflow filter") + .font(.subheadline) + Spacer() + Picker("Workflow filter", selection: $appState.workflowFilterMode) { + ForEach(WorkflowFilterMode.allCases, id: \.self) { mode in + Text(mode.rawValue.capitalized).tag(mode) } - .pickerStyle(.menu) - .fixedSize() } - Text("Limit how many recent runs are shown for each repository.") - .font(.caption2) - .foregroundColor(.secondary) + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() } - .padding(.horizontal, 16) - .padding(.vertical, 8) - Divider() + if appState.workflowFilterMode != .off { + TextField("e.g. deploy, CI, build", text: $appState.workflowFilterText) + .textFieldStyle(.roundedBorder) + .font(.caption) - // Workflow run actors - VStack(alignment: .leading, spacing: 4) { - Toggle("Include runs from other users", isOn: $appState.includeRunsFromOtherUsers) - .toggleStyle(.checkbox) - .font(.subheadline) - Text("Show recent workflow runs from anyone in the selected repositories.") - .font(.caption2) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(appState.workflowFilterMode == .include + ? "Only show workflows matching these names." + : "Hide workflows matching these names.") + Text("Comma-separated, case-insensitive substring match.") + } + .font(.caption2) + .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 8) + } + .settingsSectionPadding() + } - Divider() + private var installationsSection: some View { + HStack { + Text("GitHub App") + .font(.subheadline) + Spacer() + Button("Manage installations") { + openURL(GitHubApp.installationURL) + } + .textButtonStyle(.accentColor) + } + .settingsSectionPadding() + } - // Workflow filter - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Workflow filter") - .font(.subheadline) - Spacer() - Picker("", selection: $appState.workflowFilterMode) { - ForEach(WorkflowFilterMode.allCases, id: \.self) { mode in - Text(mode.rawValue.capitalized).tag(mode) - } - } - .pickerStyle(.segmented) - .controlSize(.small) - .fixedSize() + private var repositoriesHeader: some View { + VStack(spacing: 6) { + HStack { + Text("Repositories") + .font(.subheadline) + Spacer() + Button("Refresh repository list", systemImage: "arrow.clockwise") { + appState.fetchAvailableRepos(force: true) } - - if appState.workflowFilterMode != .off { - TextField("e.g. deploy, CI, build", text: $appState.workflowFilterText) - .textFieldStyle(.roundedBorder) - .font(.caption) - - VStack(alignment: .leading, spacing: 1) { - Text(appState.workflowFilterMode == .include - ? "Only show workflows matching these names." - : "Hide workflows matching these names.") - Text("Comma-separated, case-insensitive substring match.") + .labelStyle(.iconOnly) + .textButtonStyle(.accentColor) + .help("Refresh repository list") + .opacity(appState.isLoadingRepos ? 0 : 1) + .overlay { + if appState.isLoadingRepos { + ProgressView() + .controlSize(.small) } - .font(.caption2) - .foregroundColor(.secondary) } + .disabled(appState.isLoadingRepos) } - .padding(.horizontal, 16) - .padding(.vertical, 8) - Divider() + TextField("Search repos...", text: $searchText) + .textFieldStyle(.roundedBorder) + .font(.caption) - // GitHub App installations HStack { - Text("GitHub App") - .font(.subheadline) + Text("\(appState.selectedRepoNames.count) selected") + .font(.caption) + .foregroundStyle(.secondary) Spacer() - Button("Manage installations") { - if let url = URL(string: "https://github.com/apps/actionbar-macos") { - NSWorkspace.shared.open(url) + Button("All") { + if filteredRepos.count > Self.selectAllWarningThreshold { + showSelectAllWarning = true + } else { + appState.selectAllRepos(filteredRepos.map(\.fullName)) } } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.accentColor) + .textButtonStyle(.accentColor) + Button("None") { + showSelectAllWarning = false + appState.deselectAllRepos() + } + .textButtonStyle(.accentColor) + Button("Reset") { + showSelectAllWarning = false + appState.resetReposToSnapshot() + } + .textButtonStyle(.accentColor) + .disabled(appState.selectedRepoNames == appState.repoSelectionSnapshot) } - .padding(.horizontal, 16) - .padding(.vertical, 8) - Divider() - - // Repositories section header - VStack(spacing: 6) { - HStack { - Text("Repositories") - .font(.subheadline) - Spacer() - Button(action: { appState.fetchAvailableRepos(force: true) }) { - Image(systemName: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.plain) - .foregroundColor(.accentColor) - .help("Refresh repository list") - .opacity(appState.isLoadingRepos ? 0 : 1) - .overlay { - if appState.isLoadingRepos { - ProgressView() - .controlSize(.small) - } - } - .disabled(appState.isLoadingRepos) - } + if appState.repoListIsCapped { + Text("Only the first \(GitHubAPIClient.maxRepositories) repositories are shown.") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } - TextField("Search repos...", text: $searchText) - .textFieldStyle(.roundedBorder) - .font(.caption) + if showSelectAllWarning { + selectAllWarning + } + } + .settingsSectionPadding() + } - HStack { - Text("\(selectedCount) selected") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button("All") { - if filteredRepos.count > 10 { - showSelectAllWarning = true - } else { - appState.selectAllRepos(filteredRepos.map(\.fullName)) - } - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.accentColor) - Button("None") { + private var selectAllWarning: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 6) { + Text("Each selected repo requires a separate API call on every refresh. Selecting many repos will slow things down.") + .font(.caption2) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 12) { + Button("Select All Anyway") { + appState.selectAllRepos(filteredRepos.map(\.fullName)) showSelectAllWarning = false - appState.deselectAllRepos() } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.accentColor) - Button("Reset") { + .textButtonStyle(.accentColor, font: .caption2.bold()) + Button("Cancel") { showSelectAllWarning = false - appState.resetReposToSnapshot() } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.accentColor) - .disabled(appState.selectedRepoNames == appState.repoSelectionSnapshot) - } - - if appState.repoListIsCapped { - Text("Only the first 200 repositories are shown.") - .font(.caption2) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if showSelectAllWarning { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 12)) - .foregroundColor(.orange) - VStack(alignment: .leading, spacing: 6) { - Text("Each selected repo requires a separate API call on every refresh. Selecting many repos will slow things down.") - .font(.caption2) - .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 12) { - Button("Select All Anyway") { - appState.selectAllRepos(filteredRepos.map(\.fullName)) - showSelectAllWarning = false - } - .font(.caption2.bold()) - .buttonStyle(.plain) - .foregroundColor(.accentColor) - Button("Cancel") { - showSelectAllWarning = false - } - .font(.caption2) - .buttonStyle(.plain) - .foregroundColor(.secondary) - } - } - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(RoundedRectangle(cornerRadius: 6).fill(.orange.opacity(0.1))) + .textButtonStyle(font: .caption2) } } - .padding(.horizontal, 16) - .padding(.vertical, 8) - - Divider() + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 6).fill(.orange.opacity(0.1))) + } - // Repo list - ScrollView { - LazyVStack(spacing: 0) { - ForEach(filteredRepos) { repo in - Button(action: { appState.toggleRepo(repo.fullName) }) { - HStack(spacing: 8) { - Image(systemName: appState.selectedRepoNames.contains(repo.fullName) - ? "checkmark.circle.fill" : "circle") - .foregroundColor(appState.selectedRepoNames.contains(repo.fullName) - ? .accentColor : .secondary) - .font(.system(size: 13)) - Text(repo.fullName) - .font(.caption) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - } - .contentShape(Rectangle()) - .padding(.horizontal, 16) - .padding(.vertical, 5) - } - .buttonStyle(.plain) - } + private var repositoryList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filteredRepos) { repo in + repositoryRow(repo, isSelected: appState.selectedRepoNames.contains(repo.fullName)) } } - .frame(maxHeight: 250) - .clipped() } - .onAppear { - appState.snapshotSelectedRepos() + .frame(maxHeight: 250) + .clipped() + } + + private func repositoryRow(_ repo: Repository, isSelected: Bool) -> some View { + Button { + appState.toggleRepo(repo.fullName) + } label: { + HStack(spacing: 8) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .font(.system(size: 13)) + Text(repo.fullName) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .contentShape(Rectangle()) + .padding(.horizontal, 16) + .padding(.vertical, 5) } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +private extension View { + func settingsSectionPadding() -> some View { + padding(.horizontal, 16) + .padding(.vertical, 8) } } @@ -302,9 +307,9 @@ struct SettingsView: View { let state = AppState() state.isAuthenticated = true state.availableRepos = [ - Repository(id: 1, fullName: "owner/first-repo", htmlUrl: "https://github.com/owner/first-repo"), - Repository(id: 2, fullName: "owner/second-repo", htmlUrl: "https://github.com/owner/second-repo"), - Repository(id: 3, fullName: "owner/third-repo", htmlUrl: "https://github.com/owner/third-repo"), + Repository(id: 1, fullName: "owner/first-repo"), + Repository(id: 2, fullName: "owner/second-repo"), + Repository(id: 3, fullName: "owner/third-repo"), ] state.selectedRepoNames = ["owner/first-repo", "owner/third-repo"] return SettingsView() diff --git a/Sources/ActionBar/Views/SetupView.swift b/Sources/ActionBar/Views/SetupView.swift index 2e1a371..a54297d 100644 --- a/Sources/ActionBar/Views/SetupView.swift +++ b/Sources/ActionBar/Views/SetupView.swift @@ -1,7 +1,8 @@ import SwiftUI struct SetupView: View { - @EnvironmentObject var appState: AppState + @EnvironmentObject private var appState: AppState + @Environment(\.openURL) private var openURL var body: some View { VStack(spacing: 16) { @@ -19,171 +20,143 @@ struct SetupView: View { // MARK: - Initial State + @ViewBuilder private var initialView: some View { - Group { - Image(nsImage: MenuBarIcon.makeMenuBarIcon()) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) + Image(nsImage: MenuBarIcon.image()) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) - Text("ActionBar") - .font(.headline) + Text("ActionBar") + .font(.headline) - Text("Monitor your GitHub Actions workflow runs.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + explanation("Monitor your GitHub Actions workflow runs.") - Button(action: { appState.loginWithGitHub() }) { + Button { + appState.loginWithGitHub() + } label: { + Group { if appState.isLoggingIn { ProgressView() .controlSize(.small) - .frame(maxWidth: .infinity) } else { Text("Login with GitHub") - .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) - .disabled(appState.isLoggingIn) - .keyboardShortcut(.defaultAction) - - if let error = appState.errorMessage { - Text(error) - .font(.caption) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) + .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) + .disabled(appState.isLoggingIn) + .keyboardShortcut(.defaultAction) + + errorText + + QuitButton() } // MARK: - Installation Required + @ViewBuilder private var installationView: some View { - Group { - Image(systemName: "puzzlepiece.extension") - .font(.system(size: 28)) - .foregroundColor(.accentColor) + Image(systemName: "puzzlepiece.extension") + .font(.system(size: 28)) + .foregroundStyle(Color.accentColor) - Text("Install the App") - .font(.headline) + Text("Install the App") + .font(.headline) - Text("ActionBar needs to be installed on your GitHub account to access workflow data.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + explanation("ActionBar needs to be installed on your GitHub account to access workflow data.") - Button(action: { appState.openInstallationPage() }) { - Text("Install on GitHub") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) + Button { + openURL(GitHubApp.installationURL) + } label: { + Text("Install on GitHub") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) - Text("After installing, it may take up to a minute for GitHub to reflect the change.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + explanation("After installing, it may take up to a minute for GitHub to reflect the change.") - Button(action: { appState.checkInstallationAndStart() }) { - Text("Check Again") - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.accentColor) - .disabled(appState.isCheckingInstallation) - - if let error = appState.errorMessage { - Text(error) - .font(.caption) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } + Button("Check Again") { + appState.checkInstallationAndStart() + } + .textButtonStyle(.accentColor) + .disabled(appState.isCheckingInstallation) - Button("Sign Out") { - appState.signOut() - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) + errorText - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) + Button("Sign Out") { + appState.signOut() } + .textButtonStyle() + + QuitButton() } // MARK: - Device Code State + @ViewBuilder private func deviceCodeView(_ code: DeviceCodeResponse) -> some View { - Group { - Text("Enter this code on GitHub:") - .font(.headline) - - HStack(spacing: 8) { - Text(code.userCode) - .font(.system(size: 20, weight: .bold, design: .monospaced)) - .textSelection(.enabled) - - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(code.userCode, forType: .string) - }) { - Image(systemName: "doc.on.doc") - } - .buttonStyle(.borderless) - .help("Copy code") - } + Text("Enter this code on GitHub:") + .font(.headline) - Text("A browser window has opened where you can enter the code above.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Text(code.userCode) + .font(.system(size: 20, weight: .bold, design: .monospaced)) + .textSelection(.enabled) - Button(action: { - if let url = URL(string: code.verificationUri) { - NSWorkspace.shared.open(url) - } - }) { - Text("Open GitHub") - } - .buttonStyle(.link) - - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Waiting for authorization...") - .font(.caption) - .foregroundColor(.secondary) + Button("Copy code", systemImage: "doc.on.doc") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code.userCode, forType: .string) } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help("Copy code") + } - Button("Cancel") { - appState.cancelLogin() - } - .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) + explanation("A browser window has opened where you can enter the code above.") - Button("Quit") { - NSApplication.shared.terminate(nil) + Button("Open GitHub") { + if let url = URL(string: code.verificationUri) { + openURL(url) } + } + .buttonStyle(.link) + + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Waiting for authorization...") + .font(.caption) + .foregroundStyle(.secondary) + } + + Button("Cancel") { + appState.cancelLogin() + } + .textButtonStyle() + + QuitButton() + } + + // MARK: - Shared + + private func explanation(_ text: String) -> some View { + Text(text) .font(.caption) - .buttonStyle(.plain) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private var errorText: some View { + if let error = appState.errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) } } } @@ -199,7 +172,6 @@ struct SetupView: View { deviceCode: "abc123", userCode: "ABCD-1234", verificationUri: "https://github.com/login/device", - expiresIn: 900, interval: 5 ) return SetupView() diff --git a/Sources/ActionBar/Views/StatusDotView.swift b/Sources/ActionBar/Views/StatusDotView.swift index 8f3ecc8..064f556 100644 --- a/Sources/ActionBar/Views/StatusDotView.swift +++ b/Sources/ActionBar/Views/StatusDotView.swift @@ -7,7 +7,7 @@ struct StatusDotView: View { var body: some View { Circle() - .fill(color) + .fill(Color(nsColor: status.color)) .frame(width: 8, height: 8) .opacity(isPulsing && status == .running ? 0.4 : 1.0) .animation( @@ -16,26 +16,10 @@ struct StatusDotView: View { : .default, value: isPulsing ) - .onAppear { - if status == .running { - isPulsing = true - } - } - .onChange(of: status) { + .onChange(of: status, initial: true) { isPulsing = status == .running } } - - private var color: Color { - switch status { - case .success: return .green - case .failure: return .red - case .running: return .orange - case .queued: return .yellow - case .cancelled: return .gray - case .unknown: return .gray - } - } } #Preview("Success") { diff --git a/Sources/ActionBar/Views/WorkflowRowView.swift b/Sources/ActionBar/Views/WorkflowRowView.swift index 5ddf86c..2383e6c 100644 --- a/Sources/ActionBar/Views/WorkflowRowView.swift +++ b/Sources/ActionBar/Views/WorkflowRowView.swift @@ -2,30 +2,39 @@ import SwiftUI struct WorkflowRowView: View { let run: WorkflowRun - var now: Date = Date() + var now = Date() var showsTriggeringActor = false + @Environment(\.openURL) private var openURL @State private var isHovered = false var body: some View { + Button { + if let url = URL(string: run.htmlUrl) { + openURL(url) + } + } label: { + content + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } + + private var content: some View { HStack(spacing: 8) { StatusDotView(status: RunStatus(from: run)) VStack(alignment: .leading, spacing: 2) { - Text(run.displayTitle ?? run.name ?? "Workflow") + Text(run.title) .font(.system(size: 12, weight: .medium)) .lineLimit(1) HStack(spacing: 4) { Text(repoName) - .font(.system(size: 10)) - .foregroundColor(.secondary) if showsTriggeringActor, let login = run.triggeringActorLogin { metadataSeparator Text("@\(login)") - .font(.system(size: 10)) - .foregroundColor(.secondary) .lineLimit(1) } @@ -33,17 +42,18 @@ struct WorkflowRowView: View { metadataSeparator Text(branch) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) .lineLimit(1) } } + .font(.system(size: 10)) + .foregroundStyle(.secondary) } Spacer() Text(RelativeTime.format(run.updatedAt, relativeTo: now)) .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding(.horizontal, 16) .padding(.vertical, 6) @@ -54,28 +64,15 @@ struct WorkflowRowView: View { .padding(.vertical, 4) ) .contentShape(Rectangle()) - .onHover { hovering in - isHovered = hovering - } - .onTapGesture { - if let url = URL(string: run.htmlUrl) { - NSWorkspace.shared.open(url) - } - } } private var metadataSeparator: some View { Text("·") - .font(.system(size: 10)) - .foregroundColor(.secondary) } + /// The repository name without its owner, to save space. private var repoName: String { - // Show only repo name (without owner) to save space - if let slashIndex = run.repositoryFullName.firstIndex(of: "/") { - return String(run.repositoryFullName[run.repositoryFullName.index(after: slashIndex)...]) - } - return run.repositoryFullName + run.repositoryFullName.split(separator: "/", maxSplits: 1).last.map(String.init) ?? run.repositoryFullName } }