krz/octosentry

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

Commit 8629bbb211

8629bbb2115614fe07ecf9fc3783231a1d4114bb

parent: 3690864493

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 22:12 UTC

Add mark-seen action, critical-alert badge, and a dedicated window

Closes #8-#10 (milestone 0.5.0).

- Mark-seen: each row gets a checkmark button (separate from the
  click-to-open button) that persists the event into seenEventIDs and
  removes it from the active stream immediately, no API write.
- Menu bar icon switches to a filled exclamation-shield with a red badge
  showing the count of unseen critical alerts when any exist.
- Added a dedicated Window (singleton scene, not WindowGroup — avoids
  spawning duplicates) opened via a header button, sharing the exact
  same store/authStore instances as the popover. The button hides itself
  when already viewing the standalone window.

Layout: unified · split

octosentry/SecurityEventListView.swift +16 −2
@@ -9,7 +9,9 @@ import SwiftUI
99struct SecurityEventListView: View {
1010 var store: SecurityEventStore
1111 var authStore: AuthStore
12 var isStandaloneWindow: Bool = false
1213 @State private var showingRepoManager = false
14 @Environment(\.openWindow) private var openWindow
1315
1416 var body: some View {
1517 VStack(alignment: .leading, spacing: 0) {
@@ -23,7 +25,6 @@ struct SecurityEventListView: View {
2325 content
2426 }
2527 }
26 .frame(width: 380, height: 420)
2728 .task(id: authStore.isSignedIn) {
2829 guard authStore.isSignedIn else { return }
2930 await store.refresh()
@@ -66,6 +67,16 @@ struct SecurityEventListView: View {
6667 }
6768
6869 if authStore.isSignedIn {
70 if !isStandaloneWindow {
71 Button {
72 openWindow(id: SecurityEventWindow.id)
73 } label: {
74 Image(systemName: "macwindow")
75 }
76 .buttonStyle(.plain)
77 .help("Open in Window")
78 }
79
6980 Button {
7081 showingRepoManager.toggle()
7182 } label: {
@@ -116,7 +127,9 @@ struct SecurityEventListView: View {
116127 Divider()
117128 }
118129 ForEach(store.events) { event in
119 SecurityEventRow(event: event)
130 SecurityEventRow(event: event) {
131 Task { await store.markSeen(event.id) }
132 }
120133 Divider()
121134 }
122135 }
@@ -250,4 +263,5 @@ private struct StatusView: View {
250263
251264#Preview {
252265 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore())
266 .frame(width: 380, height: 420)
253267}
octosentry/SecurityEventRow.swift +57 −42
@@ -8,6 +8,7 @@ import SwiftUI
88
99struct SecurityEventRow: View {
1010 let event: SecurityEvent
11 var onMarkSeen: () -> Void
1112
1213 private static let relativeFormatter: RelativeDateTimeFormatter = {
1314 let formatter = RelativeDateTimeFormatter()
@@ -16,58 +17,72 @@ struct SecurityEventRow: View {
1617 }()
1718
1819 var body: some View {
19 Button {
20 NSWorkspace.shared.open(event.detailURL)
21 } label: {
22 VStack(alignment: .leading, spacing: 3) {
23 HStack(spacing: 6) {
24 Text(event.nativeSeverityLabel.uppercased())
25 .font(.caption2.weight(.bold))
26 .foregroundStyle(event.severity.color)
27 .padding(.horizontal, 6)
28 .padding(.vertical, 2)
29 .background(event.severity.color.opacity(0.18), in: Capsule())
20 HStack(alignment: .top, spacing: 0) {
21 Button {
22 NSWorkspace.shared.open(event.detailURL)
23 } label: {
24 VStack(alignment: .leading, spacing: 3) {
25 HStack(spacing: 6) {
26 Text(event.nativeSeverityLabel.uppercased())
27 .font(.caption2.weight(.bold))
28 .foregroundStyle(event.severity.color)
29 .padding(.horizontal, 6)
30 .padding(.vertical, 2)
31 .background(event.severity.color.opacity(0.18), in: Capsule())
3032
31 Text(event.source.displayName)
32 .font(.caption2.weight(.semibold))
33 .padding(.horizontal, 6)
34 .padding(.vertical, 2)
35 .background(.secondary.opacity(0.15), in: Capsule())
33 Text(event.source.displayName)
34 .font(.caption2.weight(.semibold))
35 .padding(.horizontal, 6)
36 .padding(.vertical, 2)
37 .background(.secondary.opacity(0.15), in: Capsule())
3638
37 Text(event.repoFullName)
38 .font(.caption)
39 .foregroundStyle(.secondary)
39 Text(event.repoFullName)
40 .font(.caption)
41 .foregroundStyle(.secondary)
4042
41 Spacer()
43 Spacer()
4244
43 Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now))
44 .font(.caption2)
45 .foregroundStyle(.secondary)
45 Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now))
46 .font(.caption2)
47 .foregroundStyle(.secondary)
48 }
49
50 Text(event.summary)
51 .font(.callout)
52 .lineLimit(1)
53 .foregroundStyle(.primary)
4654 }
55 .padding(10)
56 .contentShape(Rectangle())
57 }
58 .buttonStyle(.plain)
4759
48 Text(event.summary)
49 .font(.callout)
50 .lineLimit(1)
51 .foregroundStyle(.primary)
60 Button(action: onMarkSeen) {
61 Image(systemName: "checkmark.circle")
5262 }
53 .padding(10)
54 .contentShape(Rectangle())
63 .buttonStyle(.plain)
64 .foregroundStyle(.secondary)
65 .help("Mark as seen")
66 .padding(.top, 12)
67 .padding(.trailing, 10)
5568 }
56 .buttonStyle(.plain)
5769 }
5870}
5971
6072#Preview {
61 SecurityEventRow(event: SecurityEvent(
62 id: "preview-1",
63 source: .dependabot,
64 repoFullName: "zerolabsco/octosentry",
65 severity: .critical,
66 nativeSeverityLabel: "Critical",
67 summary: "Denial of service in some-package",
68 detailURL: URL(string: "https://github.com")!,
69 createdAt: .now.addingTimeInterval(-3600 * 26),
70 updatedAt: .now,
71 seenLocally: false
72 ))
73 SecurityEventRow(
74 event: SecurityEvent(
75 id: "preview-1",
76 source: .dependabot,
77 repoFullName: "zerolabsco/octosentry",
78 severity: .critical,
79 nativeSeverityLabel: "Critical",
80 summary: "Denial of service in some-package",
81 detailURL: URL(string: "https://github.com")!,
82 createdAt: .now.addingTimeInterval(-3600 * 26),
83 updatedAt: .now,
84 seenLocally: false
85 ),
86 onMarkSeen: {}
87 )
7388}
octosentry/SecurityEventStore.swift +16
@@ -28,6 +28,10 @@ final class SecurityEventStore {
2828 private(set) var watchedRepos: [String] = []
2929 private(set) var watchListErrorMessage: String?
3030
31 var unseenCriticalCount: Int {
32 rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count
33 }
34
3135 private let persistenceStore = PersistenceStore()
3236 private var rawEvents: [SecurityEvent] = []
3337 private var pollingTask: Task<Void, Never>?
@@ -130,6 +134,18 @@ final class SecurityEventStore {
130134 await refresh()
131135 }
132136
137 /// Local-only triage state (spec §11) — no API write, no scope beyond
138 /// read needed. Removes the event from the active stream.
139 func markSeen(_ eventID: String) async {
140 var state = await persistenceStore.load()
141 state.seenEventIDs.insert(eventID)
142 await persistenceStore.save(state)
143
144 rawEvents.removeAll { $0.id == eventID }
145 totalFetchedCount = rawEvents.count
146 applyMinimumSeverityFilter()
147 }
148
133149 func removeRepo(_ repoFullName: String) async {
134150 var state = await persistenceStore.load()
135151 state.watchedRepos.removeAll { $0 == repoFullName }
octosentry/octosentryApp.swift +32 −1
@@ -7,15 +7,46 @@
77
88import SwiftUI
99
10enum SecurityEventWindow {
11 static let id = "security-events-window"
12}
13
1014@main
1115struct octosentryApp: App {
1216 @State private var store = SecurityEventStore()
1317 @State private var authStore = AuthStore()
1418
1519 var body: some Scene {
16 MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") {
20 MenuBarExtra {
1721 SecurityEventListView(store: store, authStore: authStore)
22 .frame(width: 380, height: 420)
23 } label: {
24 MenuBarIconView(criticalCount: store.unseenCriticalCount)
1825 }
1926 .menuBarExtraStyle(.window)
27
28 Window("Security Events", id: SecurityEventWindow.id) {
29 SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true)
30 .frame(minWidth: 420, minHeight: 480)
31 }
32 }
33}
34
35private struct MenuBarIconView: View {
36 let criticalCount: Int
37
38 var body: some View {
39 ZStack(alignment: .topTrailing) {
40 Image(systemName: criticalCount > 0 ? "exclamationmark.shield.fill" : "shield.lefthalf.filled")
41
42 if criticalCount > 0 {
43 Text(criticalCount > 9 ? "9+" : "\(criticalCount)")
44 .font(.system(size: 8, weight: .bold))
45 .foregroundStyle(.white)
46 .padding(2)
47 .background(Circle().fill(.red))
48 .offset(x: 8, y: -6)
49 }
50 }
2051 }
2152}