krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit 1ff98b494e

1ff98b494e9a548711765767911ad7327a716326

parent: 17288c4771

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 21:41 UTC

Add persistence, multi-repo watch list, and background polling

Closes #1-#5 (milestones 0.2.0, 0.3.0).

- Flat JSON persistence (PersistedState/PersistenceStore) in Application
  Support, chosen over SwiftData since the dataset is small and the
  existing model types are plain Codable value types passed across actor
  boundaries.
- Repo watch list, per-event seen-state, and last-fetch timestamps are
  now persisted instead of living only in memory.
- Configurable minimum severity filter, applied from a cached raw fetch
  so changing it doesn't require a network round-trip.
- Multi-repo support: SecurityEventStore now loops over a persisted watch
  list instead of one hardcoded repo, with an in-popover UI (gear button)
  to add/remove repos.
- Background polling every 15 minutes, layered on top of the existing
  refresh-on-open and manual refresh, so the feed stays fresh even while
  the popover is closed.

Layout: unified · split

octosentry/PersistedState.swift added +26
@@ -0,0 +1,26 @@
1//
2// PersistedState.swift
3// octosentry
4//
5// Everything the app remembers across launches: the repo watch list,
6// local-only seen-state per event, last-fetch timestamp per repo, and the
7// minimum severity filter. Flat JSON over SwiftData (see #1) — small,
8// inspectable, and these are already plain Codable values passed across
9// actor boundaries, not reference types tied to a persistence context.
10//
11
12import Foundation
13
14nonisolated struct PersistedState: Codable {
15 var watchedRepos: [String]
16 var seenEventIDs: Set<String>
17 var lastFetchByRepo: [String: Date]
18 var minimumSeverity: SecurityEventSeverity
19
20 static let placeholder = PersistedState(
21 watchedRepos: ["ccleberg/cleberg.net"],
22 seenEventIDs: [],
23 lastFetchByRepo: [:],
24 minimumSeverity: .low
25 )
26}
octosentry/PersistenceStore.swift added +46
@@ -0,0 +1,46 @@
1//
2// PersistenceStore.swift
3// octosentry
4//
5// Loads and saves PersistedState as JSON in the app's Application Support
6// container. No entitlement needed — sandboxed apps always get a private
7// Application Support directory in their own container.
8//
9
10import Foundation
11
12actor PersistenceStore {
13 private let fileURL: URL
14
15 private static let decoder: JSONDecoder = {
16 let decoder = JSONDecoder()
17 decoder.dateDecodingStrategy = .iso8601
18 return decoder
19 }()
20
21 private static let encoder: JSONEncoder = {
22 let encoder = JSONEncoder()
23 encoder.dateEncodingStrategy = .iso8601
24 return encoder
25 }()
26
27 init() {
28 let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
29 let directory = appSupport.appendingPathComponent("octosentry", isDirectory: true)
30 try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
31 fileURL = directory.appendingPathComponent("state.json")
32 }
33
34 func load() -> PersistedState {
35 guard let data = try? Data(contentsOf: fileURL),
36 let state = try? Self.decoder.decode(PersistedState.self, from: data) else {
37 return .placeholder
38 }
39 return state
40 }
41
42 func save(_ state: PersistedState) {
43 guard let data = try? Self.encoder.encode(state) else { return }
44 try? data.write(to: fileURL, options: .atomic)
45 }
46}
octosentry/SecurityEventListView.swift +100 −5
@@ -8,16 +8,22 @@ import SwiftUI
8 8
9struct SecurityEventListView: View { 9struct SecurityEventListView: View {
10 var store: SecurityEventStore 10 var store: SecurityEventStore
11 @State private var showingRepoManager = false
11 12
12 var body: some View { 13 var body: some View {
13 VStack(alignment: .leading, spacing: 0) { 14 VStack(alignment: .leading, spacing: 0) {
14 header 15 header
15 Divider() 16 Divider()
16 content 17 if showingRepoManager {
18 RepoManagerView(store: store)
19 } else {
20 content
21 }
17 } 22 }
18 .frame(width: 380, height: 420) 23 .frame(width: 380, height: 420)
19 .task { 24 .task {
20 await store.refresh() 25 await store.refresh()
26 store.startPolling()
21 } 27 }
22 } 28 }
23 29
@@ -33,13 +39,34 @@ struct SecurityEventListView: View {
33 39
34 Spacer() 40 Spacer()
35 41
42 if !showingRepoManager {
43 Picker("Minimum severity", selection: Binding(
44 get: { store.minimumSeverity },
45 set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
46 )) {
47 ForEach(SecurityEventSeverity.allCases, id: \.self) { severity in
48 Text(severity.displayName).tag(severity)
49 }
50 }
51 .pickerStyle(.menu)
52 .labelsHidden()
53 .fixedSize()
54
55 Button {
56 Task { await store.refresh() }
57 } label: {
58 Image(systemName: "arrow.clockwise")
59 }
60 .buttonStyle(.plain)
61 .disabled(store.isLoading)
62 }
63
36 Button { 64 Button {
37 Task { await store.refresh() } 65 showingRepoManager.toggle()
38 } label: { 66 } label: {
39 Image(systemName: "arrow.clockwise") 67 Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape")
40 } 68 }
41 .buttonStyle(.plain) 69 .buttonStyle(.plain)
42 .disabled(store.isLoading)
43 70
44 Button("Quit") { 71 Button("Quit") {
45 NSApplication.shared.terminate(nil) 72 NSApplication.shared.terminate(nil)
@@ -56,7 +83,15 @@ struct SecurityEventListView: View {
56 StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n")) 83 StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
57 } else if store.events.isEmpty && !store.isLoading { 84 } else if store.events.isEmpty && !store.isLoading {
58 VStack(spacing: 8) { 85 VStack(spacing: 8) {
59 StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts") 86 if store.totalFetchedCount > 0 {
87 StatusView(
88 systemImage: "line.3.horizontal.decrease.circle",
89 tint: .secondary,
90 message: "\(store.totalFetchedCount) alert(s) are below your minimum severity filter"
91 )
92 } else {
93 StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
94 }
60 if !store.unavailableNotices.isEmpty { 95 if !store.unavailableNotices.isEmpty {
61 NoticeBanner(messages: store.unavailableNotices) 96 NoticeBanner(messages: store.unavailableNotices)
62 .padding(.horizontal) 97 .padding(.horizontal)
@@ -84,6 +119,66 @@ struct SecurityEventListView: View {
84 } 119 }
85} 120}
86 121
122private struct RepoManagerView: View {
123 var store: SecurityEventStore
124 @State private var newRepoText = ""
125
126 var body: some View {
127 VStack(alignment: .leading, spacing: 10) {
128 Text("Watched Repositories")
129 .font(.subheadline.weight(.semibold))
130
131 if store.watchedRepos.isEmpty {
132 Text("No repos watched yet.")
133 .font(.callout)
134 .foregroundStyle(.secondary)
135 } else {
136 ForEach(store.watchedRepos, id: \.self) { repo in
137 HStack {
138 Text(repo)
139 .font(.callout)
140 Spacer()
141 Button {
142 Task { await store.removeRepo(repo) }
143 } label: {
144 Image(systemName: "minus.circle.fill")
145 .foregroundStyle(.red)
146 }
147 .buttonStyle(.plain)
148 }
149 }
150 }
151
152 Divider()
153
154 HStack {
155 TextField("owner/repo", text: $newRepoText)
156 .textFieldStyle(.roundedBorder)
157 .onSubmit(addRepo)
158
159 Button("Add", action: addRepo)
160 .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
161 }
162
163 if let errorMessage = store.watchListErrorMessage {
164 Text(errorMessage)
165 .font(.caption2)
166 .foregroundStyle(.red)
167 }
168
169 Spacer()
170 }
171 .padding(12)
172 .frame(maxWidth: .infinity, alignment: .leading)
173 }
174
175 private func addRepo() {
176 let text = newRepoText
177 newRepoText = ""
178 Task { await store.addRepo(text) }
179 }
180}
181
87private struct ErrorBanner: View { 182private struct ErrorBanner: View {
88 let messages: [String] 183 let messages: [String]
89 184
octosentry/SecurityEventSeverity.swift +1 −1
@@ -5,7 +5,7 @@
5 5
6import SwiftUI 6import SwiftUI
7 7
8enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable { 8nonisolated enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable, Hashable {
9 case low 9 case low
10 case medium 10 case medium
11 case high 11 case high
octosentry/SecurityEventStore.swift +122 −31
@@ -2,14 +2,16 @@
2// SecurityEventStore.swift 2// SecurityEventStore.swift
3// octosentry 3// octosentry
4// 4//
5// Holds the fetched event stream for the popover. MVP scope: one 5// Holds the fetched event stream for the popover. Watch list, seen-state,
6// hardcoded repo, in-memory only, PAT read from GITHUB_TOKEN (spec §13). 6// and last-fetch timestamps are persisted (see PersistedState); the PAT
7// is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13).
7// 8//
8// Each alert source is fetched independently so a problem with one 9// Each alert source is fetched independently, per repo, so a problem
9// endpoint doesn't blank out the other two. A 403/404 on a single source 10// with one endpoint (or one repo) doesn't blank out the rest. A 403/404
10// usually just means that alert type is disabled for the repo (or the 11// on a single source usually just means that alert type is disabled for
11// token lacks that one permission) — not a real failure — so those are 12// the repo (or the token lacks that one permission) — not a real
12// reported as quiet "unavailable" notices rather than alarming errors. 13// failure — so those are reported as quiet "unavailable" notices rather
14// than alarming errors.
13// 15//
14 16
15import Foundation 17import Foundation
@@ -21,9 +23,14 @@ final class SecurityEventStore {
21 private(set) var isLoading = false 23 private(set) var isLoading = false
22 private(set) var errorMessages: [String] = [] 24 private(set) var errorMessages: [String] = []
23 private(set) var unavailableNotices: [String] = [] 25 private(set) var unavailableNotices: [String] = []
26 private(set) var minimumSeverity: SecurityEventSeverity = .low
27 private(set) var totalFetchedCount = 0
28 private(set) var watchedRepos: [String] = []
29 private(set) var watchListErrorMessage: String?
24 30
25 private let owner = "ccleberg" 31 private let persistenceStore = PersistenceStore()
26 private let repo = "cleberg.net" 32 private var rawEvents: [SecurityEvent] = []
33 private var pollingTask: Task<Void, Never>?
27 34
28 func refresh() async { 35 func refresh() async {
29 isLoading = true 36 isLoading = true
@@ -31,6 +38,10 @@ final class SecurityEventStore {
31 unavailableNotices = [] 38 unavailableNotices = []
32 defer { isLoading = false } 39 defer { isLoading = false }
33 40
41 var state = await persistenceStore.load()
42 minimumSeverity = state.minimumSeverity
43 watchedRepos = state.watchedRepos
44
34 guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { 45 guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else {
35 errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] 46 errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."]
36 return 47 return
@@ -38,37 +49,117 @@ final class SecurityEventStore {
38 49
39 let client = GitHubSecurityAPIClient(token: token) 50 let client = GitHubSecurityAPIClient(token: token)
40 51
41 async let dependabot = fetchSource(label: "Dependabot") {
42 try await client.fetchDependabotAlerts(owner: self.owner, repo: self.repo)
43 }
44 async let codeScanning = fetchSource(label: "Code scanning") {
45 try await client.fetchCodeScanningAlerts(owner: self.owner, repo: self.repo)
46 }
47 async let secretScanning = fetchSource(label: "Secret scanning") {
48 try await client.fetchSecretScanningAlerts(owner: self.owner, repo: self.repo)
49 }
50
51 let outcomes = await [dependabot, codeScanning, secretScanning]
52
53 var fetchedEvents: [SecurityEvent] = [] 52 var fetchedEvents: [SecurityEvent] = []
54 var errors: [String] = [] 53 var errors: [String] = []
55 var notices: [String] = [] 54 var notices: [String] = []
56 for outcome in outcomes { 55
57 switch outcome { 56 for repoFullName in state.watchedRepos {
58 case .events(let sourceEvents): 57 let parts = repoFullName.split(separator: "/", maxSplits: 1)
59 fetchedEvents += sourceEvents 58 guard parts.count == 2 else { continue }
60 case .unavailable(let label): 59 let owner = String(parts[0])
61 notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") 60 let repo = String(parts[1])
62 case .failed(let label, let message): 61
63 errors.append("\(label): \(message)") 62 async let dependabot = fetchSource(label: "\(repoFullName) · Dependabot") {
63 try await client.fetchDependabotAlerts(owner: owner, repo: repo)
64 }
65 async let codeScanning = fetchSource(label: "\(repoFullName) · Code scanning") {
66 try await client.fetchCodeScanningAlerts(owner: owner, repo: repo)
67 }
68 async let secretScanning = fetchSource(label: "\(repoFullName) · Secret scanning") {
69 try await client.fetchSecretScanningAlerts(owner: owner, repo: repo)
70 }
71
72 let outcomes = await [dependabot, codeScanning, secretScanning]
73 var repoSucceeded = false
74 for outcome in outcomes {
75 switch outcome {
76 case .events(let sourceEvents):
77 fetchedEvents += sourceEvents
78 repoSucceeded = true
79 case .unavailable(let label):
80 notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).")
81 case .failed(let label, let message):
82 errors.append("\(label): \(message)")
83 }
84 }
85 if repoSucceeded {
86 state.lastFetchByRepo[repoFullName] = Date()
64 } 87 }
65 } 88 }
66 89
67 events = fetchedEvents.sorted { lhs, rhs in 90 rawEvents = fetchedEvents.map { event in
68 lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt 91 var event = event
92 event.seenLocally = state.seenEventIDs.contains(event.id)
93 return event
69 } 94 }
95 totalFetchedCount = rawEvents.count
96 applyMinimumSeverityFilter()
97
70 errorMessages = errors 98 errorMessages = errors
71 unavailableNotices = notices 99 unavailableNotices = notices
100 await persistenceStore.save(state)
101 }
102
103 func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
104 minimumSeverity = severity
105 applyMinimumSeverityFilter()
106
107 var state = await persistenceStore.load()
108 state.minimumSeverity = severity
109 await persistenceStore.save(state)
110 }
111
112 func addRepo(_ input: String) async {
113 watchListErrorMessage = nil
114 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
115 let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true)
116 guard parts.count == 2 else {
117 watchListErrorMessage = "Enter a repo as \"owner/repo\"."
118 return
119 }
120
121 var state = await persistenceStore.load()
122 guard !state.watchedRepos.contains(trimmed) else {
123 watchListErrorMessage = "\(trimmed) is already watched."
124 return
125 }
126 state.watchedRepos.append(trimmed)
127 await persistenceStore.save(state)
128 watchedRepos = state.watchedRepos
129
130 await refresh()
131 }
132
133 func removeRepo(_ repoFullName: String) async {
134 var state = await persistenceStore.load()
135 state.watchedRepos.removeAll { $0 == repoFullName }
136 state.lastFetchByRepo.removeValue(forKey: repoFullName)
137 await persistenceStore.save(state)
138 watchedRepos = state.watchedRepos
139
140 await refresh()
141 }
142
143 /// Keeps the feed reasonably fresh even while the popover is closed,
144 /// without hammering GitHub's rate limit (5000/hr authenticated).
145 /// Idempotent — safe to call every time the popover opens.
146 func startPolling(interval: Duration = .seconds(900)) {
147 guard pollingTask == nil else { return }
148 pollingTask = Task {
149 while !Task.isCancelled {
150 try? await Task.sleep(for: interval)
151 guard !Task.isCancelled else { return }
152 await refresh()
153 }
154 }
155 }
156
157 private func applyMinimumSeverityFilter() {
158 events = rawEvents
159 .filter { $0.severity >= minimumSeverity }
160 .sorted { lhs, rhs in
161 lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
162 }
72 } 163 }
73 164
74 private enum SourceOutcome { 165 private enum SourceOutcome {