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
9struct SecurityEventListView: View { 9struct SecurityEventListView: View {
10 var store: SecurityEventStore 10 var store: SecurityEventStore
11 var authStore: AuthStore 11 var authStore: AuthStore
12 var isStandaloneWindow: Bool = false
12 @State private var showingRepoManager = false 13 @State private var showingRepoManager = false
14 @Environment(\.openWindow) private var openWindow
13 15
14 var body: some View { 16 var body: some View {
15 VStack(alignment: .leading, spacing: 0) { 17 VStack(alignment: .leading, spacing: 0) {
@@ -23,7 +25,6 @@ struct SecurityEventListView: View {
23 content 25 content
24 } 26 }
25 } 27 }
26 .frame(width: 380, height: 420)
27 .task(id: authStore.isSignedIn) { 28 .task(id: authStore.isSignedIn) {
28 guard authStore.isSignedIn else { return } 29 guard authStore.isSignedIn else { return }
29 await store.refresh() 30 await store.refresh()
@@ -66,6 +67,16 @@ struct SecurityEventListView: View {
66 } 67 }
67 68
68 if authStore.isSignedIn { 69 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
69 Button { 80 Button {
70 showingRepoManager.toggle() 81 showingRepoManager.toggle()
71 } label: { 82 } label: {
@@ -116,7 +127,9 @@ struct SecurityEventListView: View {
116 Divider() 127 Divider()
117 } 128 }
118 ForEach(store.events) { event in 129 ForEach(store.events) { event in
119 SecurityEventRow(event: event) 130 SecurityEventRow(event: event) {
131 Task { await store.markSeen(event.id) }
132 }
120 Divider() 133 Divider()
121 } 134 }
122 } 135 }
@@ -250,4 +263,5 @@ private struct StatusView: View {
250 263
251#Preview { 264#Preview {
252 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) 265 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore())
266 .frame(width: 380, height: 420)
253} 267}
octosentry/SecurityEventRow.swift +57 −42
@@ -8,6 +8,7 @@ import SwiftUI
8 8
9struct SecurityEventRow: View { 9struct SecurityEventRow: View {
10 let event: SecurityEvent 10 let event: SecurityEvent
11 var onMarkSeen: () -> Void
11 12
12 private static let relativeFormatter: RelativeDateTimeFormatter = { 13 private static let relativeFormatter: RelativeDateTimeFormatter = {
13 let formatter = RelativeDateTimeFormatter() 14 let formatter = RelativeDateTimeFormatter()
@@ -16,58 +17,72 @@ struct SecurityEventRow: View {
16 }() 17 }()
17 18
18 var body: some View { 19 var body: some View {
19 Button { 20 HStack(alignment: .top, spacing: 0) {
20 NSWorkspace.shared.open(event.detailURL) 21 Button {
21 } label: { 22 NSWorkspace.shared.open(event.detailURL)
22 VStack(alignment: .leading, spacing: 3) { 23 } label: {
23 HStack(spacing: 6) { 24 VStack(alignment: .leading, spacing: 3) {
24 Text(event.nativeSeverityLabel.uppercased()) 25 HStack(spacing: 6) {
25 .font(.caption2.weight(.bold)) 26 Text(event.nativeSeverityLabel.uppercased())
26 .foregroundStyle(event.severity.color) 27 .font(.caption2.weight(.bold))
27 .padding(.horizontal, 6) 28 .foregroundStyle(event.severity.color)
28 .padding(.vertical, 2) 29 .padding(.horizontal, 6)
29 .background(event.severity.color.opacity(0.18), in: Capsule()) 30 .padding(.vertical, 2)
31 .background(event.severity.color.opacity(0.18), in: Capsule())
30 32
31 Text(event.source.displayName) 33 Text(event.source.displayName)
32 .font(.caption2.weight(.semibold)) 34 .font(.caption2.weight(.semibold))
33 .padding(.horizontal, 6) 35 .padding(.horizontal, 6)
34 .padding(.vertical, 2) 36 .padding(.vertical, 2)
35 .background(.secondary.opacity(0.15), in: Capsule()) 37 .background(.secondary.opacity(0.15), in: Capsule())
36 38
37 Text(event.repoFullName) 39 Text(event.repoFullName)
38 .font(.caption) 40 .font(.caption)
39 .foregroundStyle(.secondary) 41 .foregroundStyle(.secondary)
40 42
41 Spacer() 43 Spacer()
42 44
43 Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) 45 Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now))
44 .font(.caption2) 46 .font(.caption2)
45 .foregroundStyle(.secondary) 47 .foregroundStyle(.secondary)
48 }
49
50 Text(event.summary)
51 .font(.callout)
52 .lineLimit(1)
53 .foregroundStyle(.primary)
46 } 54 }
55 .padding(10)
56 .contentShape(Rectangle())
57 }
58 .buttonStyle(.plain)
47 59
48 Text(event.summary) 60 Button(action: onMarkSeen) {
49 .font(.callout) 61 Image(systemName: "checkmark.circle")
50 .lineLimit(1)
51 .foregroundStyle(.primary)
52 } 62 }
53 .padding(10) 63 .buttonStyle(.plain)
54 .contentShape(Rectangle()) 64 .foregroundStyle(.secondary)
65 .help("Mark as seen")
66 .padding(.top, 12)
67 .padding(.trailing, 10)
55 } 68 }
56 .buttonStyle(.plain)
57 } 69 }
58} 70}
59 71
60#Preview { 72#Preview {
61 SecurityEventRow(event: SecurityEvent( 73 SecurityEventRow(
62 id: "preview-1", 74 event: SecurityEvent(
63 source: .dependabot, 75 id: "preview-1",
64 repoFullName: "zerolabsco/octosentry", 76 source: .dependabot,
65 severity: .critical, 77 repoFullName: "zerolabsco/octosentry",
66 nativeSeverityLabel: "Critical", 78 severity: .critical,
67 summary: "Denial of service in some-package", 79 nativeSeverityLabel: "Critical",
68 detailURL: URL(string: "https://github.com")!, 80 summary: "Denial of service in some-package",
69 createdAt: .now.addingTimeInterval(-3600 * 26), 81 detailURL: URL(string: "https://github.com")!,
70 updatedAt: .now, 82 createdAt: .now.addingTimeInterval(-3600 * 26),
71 seenLocally: false 83 updatedAt: .now,
72 )) 84 seenLocally: false
85 ),
86 onMarkSeen: {}
87 )
73} 88}
octosentry/SecurityEventStore.swift +16
@@ -28,6 +28,10 @@ final class SecurityEventStore {
28 private(set) var watchedRepos: [String] = [] 28 private(set) var watchedRepos: [String] = []
29 private(set) var watchListErrorMessage: String? 29 private(set) var watchListErrorMessage: String?
30 30
31 var unseenCriticalCount: Int {
32 rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count
33 }
34
31 private let persistenceStore = PersistenceStore() 35 private let persistenceStore = PersistenceStore()
32 private var rawEvents: [SecurityEvent] = [] 36 private var rawEvents: [SecurityEvent] = []
33 private var pollingTask: Task<Void, Never>? 37 private var pollingTask: Task<Void, Never>?
@@ -130,6 +134,18 @@ final class SecurityEventStore {
130 await refresh() 134 await refresh()
131 } 135 }
132 136
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
133 func removeRepo(_ repoFullName: String) async { 149 func removeRepo(_ repoFullName: String) async {
134 var state = await persistenceStore.load() 150 var state = await persistenceStore.load()
135 state.watchedRepos.removeAll { $0 == repoFullName } 151 state.watchedRepos.removeAll { $0 == repoFullName }
octosentry/octosentryApp.swift +32 −1
@@ -7,15 +7,46 @@
7 7
8import SwiftUI 8import SwiftUI
9 9
10enum SecurityEventWindow {
11 static let id = "security-events-window"
12}
13
10@main 14@main
11struct octosentryApp: App { 15struct octosentryApp: App {
12 @State private var store = SecurityEventStore() 16 @State private var store = SecurityEventStore()
13 @State private var authStore = AuthStore() 17 @State private var authStore = AuthStore()
14 18
15 var body: some Scene { 19 var body: some Scene {
16 MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { 20 MenuBarExtra {
17 SecurityEventListView(store: store, authStore: authStore) 21 SecurityEventListView(store: store, authStore: authStore)
22 .frame(width: 380, height: 420)
23 } label: {
24 MenuBarIconView(criticalCount: store.unseenCriticalCount)
18 } 25 }
19 .menuBarExtraStyle(.window) 26 .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 }
20 } 51 }
21} 52}