diff options
Diffstat (limited to 'octosentry')
| -rw-r--r-- | octosentry/PersistedState.swift | 26 | ||||
| -rw-r--r-- | octosentry/PersistenceStore.swift | 46 | ||||
| -rw-r--r-- | octosentry/SecurityEventListView.swift | 105 | ||||
| -rw-r--r-- | octosentry/SecurityEventSeverity.swift | 2 | ||||
| -rw-r--r-- | octosentry/SecurityEventStore.swift | 153 |
5 files changed, 295 insertions, 37 deletions
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift new file mode 100644 index 0000000..3ef4234 --- /dev/null +++ b/octosentry/PersistedState.swift @@ -0,0 +1,26 @@ +// +// PersistedState.swift +// octosentry +// +// Everything the app remembers across launches: the repo watch list, +// local-only seen-state per event, last-fetch timestamp per repo, and the +// minimum severity filter. Flat JSON over SwiftData (see #1) — small, +// inspectable, and these are already plain Codable values passed across +// actor boundaries, not reference types tied to a persistence context. +// + +import Foundation + +nonisolated struct PersistedState: Codable { + var watchedRepos: [String] + var seenEventIDs: Set<String> + var lastFetchByRepo: [String: Date] + var minimumSeverity: SecurityEventSeverity + + static let placeholder = PersistedState( + watchedRepos: ["ccleberg/cleberg.net"], + seenEventIDs: [], + lastFetchByRepo: [:], + minimumSeverity: .low + ) +} diff --git a/octosentry/PersistenceStore.swift b/octosentry/PersistenceStore.swift new file mode 100644 index 0000000..ca86eec --- /dev/null +++ b/octosentry/PersistenceStore.swift @@ -0,0 +1,46 @@ +// +// PersistenceStore.swift +// octosentry +// +// Loads and saves PersistedState as JSON in the app's Application Support +// container. No entitlement needed — sandboxed apps always get a private +// Application Support directory in their own container. +// + +import Foundation + +actor PersistenceStore { + private let fileURL: URL + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + init() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let directory = appSupport.appendingPathComponent("octosentry", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + fileURL = directory.appendingPathComponent("state.json") + } + + func load() -> PersistedState { + guard let data = try? Data(contentsOf: fileURL), + let state = try? Self.decoder.decode(PersistedState.self, from: data) else { + return .placeholder + } + return state + } + + func save(_ state: PersistedState) { + guard let data = try? Self.encoder.encode(state) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift index 4542275..a5dba3e 100644 --- a/octosentry/SecurityEventListView.swift +++ b/octosentry/SecurityEventListView.swift @@ -8,16 +8,22 @@ import SwiftUI struct SecurityEventListView: View { var store: SecurityEventStore + @State private var showingRepoManager = false var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() - content + if showingRepoManager { + RepoManagerView(store: store) + } else { + content + } } .frame(width: 380, height: 420) .task { await store.refresh() + store.startPolling() } } @@ -33,13 +39,34 @@ struct SecurityEventListView: View { Spacer() + if !showingRepoManager { + Picker("Minimum severity", selection: Binding( + get: { store.minimumSeverity }, + set: { newValue in Task { await store.setMinimumSeverity(newValue) } } + )) { + ForEach(SecurityEventSeverity.allCases, id: \.self) { severity in + Text(severity.displayName).tag(severity) + } + } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() + + Button { + Task { await store.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .disabled(store.isLoading) + } + Button { - Task { await store.refresh() } + showingRepoManager.toggle() } label: { - Image(systemName: "arrow.clockwise") + Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") } .buttonStyle(.plain) - .disabled(store.isLoading) Button("Quit") { NSApplication.shared.terminate(nil) @@ -56,7 +83,15 @@ struct SecurityEventListView: View { StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n")) } else if store.events.isEmpty && !store.isLoading { VStack(spacing: 8) { - StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts") + if store.totalFetchedCount > 0 { + StatusView( + systemImage: "line.3.horizontal.decrease.circle", + tint: .secondary, + message: "\(store.totalFetchedCount) alert(s) are below your minimum severity filter" + ) + } else { + StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts") + } if !store.unavailableNotices.isEmpty { NoticeBanner(messages: store.unavailableNotices) .padding(.horizontal) @@ -84,6 +119,66 @@ struct SecurityEventListView: View { } } +private struct RepoManagerView: View { + var store: SecurityEventStore + @State private var newRepoText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Watched Repositories") + .font(.subheadline.weight(.semibold)) + + if store.watchedRepos.isEmpty { + Text("No repos watched yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(store.watchedRepos, id: \.self) { repo in + HStack { + Text(repo) + .font(.callout) + Spacer() + Button { + Task { await store.removeRepo(repo) } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + } + + Divider() + + HStack { + TextField("owner/repo", text: $newRepoText) + .textFieldStyle(.roundedBorder) + .onSubmit(addRepo) + + Button("Add", action: addRepo) + .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if let errorMessage = store.watchListErrorMessage { + Text(errorMessage) + .font(.caption2) + .foregroundStyle(.red) + } + + Spacer() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func addRepo() { + let text = newRepoText + newRepoText = "" + Task { await store.addRepo(text) } + } +} + private struct ErrorBanner: View { let messages: [String] diff --git a/octosentry/SecurityEventSeverity.swift b/octosentry/SecurityEventSeverity.swift index f975639..102c14b 100644 --- a/octosentry/SecurityEventSeverity.swift +++ b/octosentry/SecurityEventSeverity.swift @@ -5,7 +5,7 @@ import SwiftUI -enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable { +nonisolated enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable, Hashable { case low case medium case high diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift index a45d5c7..11f813c 100644 --- a/octosentry/SecurityEventStore.swift +++ b/octosentry/SecurityEventStore.swift @@ -2,14 +2,16 @@ // SecurityEventStore.swift // octosentry // -// Holds the fetched event stream for the popover. MVP scope: one -// hardcoded repo, in-memory only, PAT read from GITHUB_TOKEN (spec §13). +// Holds the fetched event stream for the popover. Watch list, seen-state, +// and last-fetch timestamps are persisted (see PersistedState); the PAT +// is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13). // -// Each alert source is fetched independently so a problem with one -// endpoint doesn't blank out the other two. A 403/404 on a single source -// usually just means that alert type is disabled for the repo (or the -// token lacks that one permission) — not a real failure — so those are -// reported as quiet "unavailable" notices rather than alarming errors. +// Each alert source is fetched independently, per repo, so a problem +// with one endpoint (or one repo) doesn't blank out the rest. A 403/404 +// on a single source usually just means that alert type is disabled for +// the repo (or the token lacks that one permission) — not a real +// failure — so those are reported as quiet "unavailable" notices rather +// than alarming errors. // import Foundation @@ -21,9 +23,14 @@ final class SecurityEventStore { private(set) var isLoading = false private(set) var errorMessages: [String] = [] private(set) var unavailableNotices: [String] = [] + private(set) var minimumSeverity: SecurityEventSeverity = .low + private(set) var totalFetchedCount = 0 + private(set) var watchedRepos: [String] = [] + private(set) var watchListErrorMessage: String? - private let owner = "ccleberg" - private let repo = "cleberg.net" + private let persistenceStore = PersistenceStore() + private var rawEvents: [SecurityEvent] = [] + private var pollingTask: Task<Void, Never>? func refresh() async { isLoading = true @@ -31,6 +38,10 @@ final class SecurityEventStore { unavailableNotices = [] defer { isLoading = false } + var state = await persistenceStore.load() + minimumSeverity = state.minimumSeverity + watchedRepos = state.watchedRepos + guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] return @@ -38,37 +49,117 @@ final class SecurityEventStore { let client = GitHubSecurityAPIClient(token: token) - async let dependabot = fetchSource(label: "Dependabot") { - try await client.fetchDependabotAlerts(owner: self.owner, repo: self.repo) - } - async let codeScanning = fetchSource(label: "Code scanning") { - try await client.fetchCodeScanningAlerts(owner: self.owner, repo: self.repo) - } - async let secretScanning = fetchSource(label: "Secret scanning") { - try await client.fetchSecretScanningAlerts(owner: self.owner, repo: self.repo) - } - - let outcomes = await [dependabot, codeScanning, secretScanning] - var fetchedEvents: [SecurityEvent] = [] var errors: [String] = [] var notices: [String] = [] - for outcome in outcomes { - switch outcome { - case .events(let sourceEvents): - fetchedEvents += sourceEvents - case .unavailable(let label): - notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") - case .failed(let label, let message): - errors.append("\(label): \(message)") + + for repoFullName in state.watchedRepos { + let parts = repoFullName.split(separator: "/", maxSplits: 1) + guard parts.count == 2 else { continue } + let owner = String(parts[0]) + let repo = String(parts[1]) + + async let dependabot = fetchSource(label: "\(repoFullName) · Dependabot") { + try await client.fetchDependabotAlerts(owner: owner, repo: repo) + } + async let codeScanning = fetchSource(label: "\(repoFullName) · Code scanning") { + try await client.fetchCodeScanningAlerts(owner: owner, repo: repo) + } + async let secretScanning = fetchSource(label: "\(repoFullName) · Secret scanning") { + try await client.fetchSecretScanningAlerts(owner: owner, repo: repo) + } + + let outcomes = await [dependabot, codeScanning, secretScanning] + var repoSucceeded = false + for outcome in outcomes { + switch outcome { + case .events(let sourceEvents): + fetchedEvents += sourceEvents + repoSucceeded = true + case .unavailable(let label): + notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") + case .failed(let label, let message): + errors.append("\(label): \(message)") + } + } + if repoSucceeded { + state.lastFetchByRepo[repoFullName] = Date() } } - events = fetchedEvents.sorted { lhs, rhs in - lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt + rawEvents = fetchedEvents.map { event in + var event = event + event.seenLocally = state.seenEventIDs.contains(event.id) + return event } + totalFetchedCount = rawEvents.count + applyMinimumSeverityFilter() + errorMessages = errors unavailableNotices = notices + await persistenceStore.save(state) + } + + func setMinimumSeverity(_ severity: SecurityEventSeverity) async { + minimumSeverity = severity + applyMinimumSeverityFilter() + + var state = await persistenceStore.load() + state.minimumSeverity = severity + await persistenceStore.save(state) + } + + func addRepo(_ input: String) async { + watchListErrorMessage = nil + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true) + guard parts.count == 2 else { + watchListErrorMessage = "Enter a repo as \"owner/repo\"." + return + } + + var state = await persistenceStore.load() + guard !state.watchedRepos.contains(trimmed) else { + watchListErrorMessage = "\(trimmed) is already watched." + return + } + state.watchedRepos.append(trimmed) + await persistenceStore.save(state) + watchedRepos = state.watchedRepos + + await refresh() + } + + func removeRepo(_ repoFullName: String) async { + var state = await persistenceStore.load() + state.watchedRepos.removeAll { $0 == repoFullName } + state.lastFetchByRepo.removeValue(forKey: repoFullName) + await persistenceStore.save(state) + watchedRepos = state.watchedRepos + + await refresh() + } + + /// Keeps the feed reasonably fresh even while the popover is closed, + /// without hammering GitHub's rate limit (5000/hr authenticated). + /// Idempotent — safe to call every time the popover opens. + func startPolling(interval: Duration = .seconds(900)) { + guard pollingTask == nil else { return } + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: interval) + guard !Task.isCancelled else { return } + await refresh() + } + } + } + + private func applyMinimumSeverityFilter() { + events = rawEvents + .filter { $0.severity >= minimumSeverity } + .sorted { lhs, rhs in + lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt + } } private enum SourceOutcome { |
