krz/octosentry

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

Commit 4dce33b3ae

4dce33b3ae576ca8ec412202c28ea6f5a796ac55

parent: 1efda28320

Unsigned

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

Add repo picker, update checker, and distribution tooling

Closes #11-#15 (milestones 0.6.0, 0.7.0, 1.0.0).

- Repo picker: on-demand broader OAuth scope (security_events repo),
  requested only when the "Browse your repos" action is used, never by
  default. Lists /user/repos via the existing pagination helper. Granted
  scope persisted with backward-compatible decoding for existing state
  files. Fixed a bug where a failed re-auth force-signed-out a user who
  already had a valid narrower-scope token.
- Update checker: polls this repo's GitHub Releases API, surfaces a
  banner linking to new releases. Skipped on the Mac App Store build via
  a runtime receipt check rather than a separate build configuration.
- Fixed MARKETING_VERSION, stuck at Xcode's default "1.0" this whole
  time unrelated to our git tags — now 1.0.0, matching this release.
- Added PrivacyInfo.xcprivacy (no tracking, no collected data).
- Added scripts/build-dmg.sh (archive, Developer ID export, notarize,
  staple) and Casks/octosentry.rb (Homebrew Cask template), plus
  DISTRIBUTION.md documenting both channels end to end.

Entitlements were already identical across all builds — no divergence
needed there. What remains for actual App Store submission and notarized
DMG builds is account-specific (Apple Developer Program membership,
certificates, App Store Connect submission) and can't be done from here;
documented clearly in DISTRIBUTION.md.

Layout: unified · split

Casks/octosentry.rb added +27
@@ -0,0 +1,27 @@
1# Homebrew Cask for octosentry (spec §9: DMG/Homebrew distribution channel).
2#
3# This file lives here as a template — Homebrew taps must be their own repo
4# named "homebrew-<tapname>" for `brew tap` to find them. To actually publish:
5# 1. Create github.com/zerolabsco/homebrew-tap (or similar)
6# 2. Copy this file there as Casks/octosentry.rb
7# 3. Fill in sha256 below with the real checksum of the released DMG:
8# shasum -a 256 octosentry-<version>.dmg
9# 4. Users install via: brew tap zerolabsco/tap && brew install --cask octosentry
10
11cask "octosentry" do
12 version "1.0.0"
13 sha256 "REPLACE_WITH_REAL_SHA256_OF_RELEASED_DMG"
14
15 url "https://github.com/zerolabsco/octosentry/releases/download/#{version}/octosentry-#{version}.dmg"
16 name "octosentry"
17 desc "Menu bar app aggregating GitHub security alerts into one feed"
18 homepage "https://github.com/zerolabsco/octosentry"
19
20 depends_on macos: ">= :sonoma"
21
22 app "octosentry.app"
23
24 zap trash: [
25 "~/Library/Application Support/octosentry",
26 ]
27end
DISTRIBUTION.md added +63
@@ -0,0 +1,63 @@
1# Distribution
2
3octosentry ships on two channels with a single codebase and identical
4entitlements (spec §9) — the only divergence is signing method at export
5time and whether the update checker runs.
6
7## App Store
8
91. Requires an active Apple Developer Program membership and an **Apple
10 Distribution** certificate (Xcode > Settings > Accounts > Manage
11 Certificates).
122. Create the app record in [App Store Connect](https://appstoreconnect.apple.com)
13 with bundle ID `net.cleberg.octosentry`.
143. Archive: Product > Archive in Xcode (Release configuration).
154. In the Organizer, Distribute App > App Store Connect > Upload.
165. Complete the app listing (screenshots, description, privacy nutrition
17 label — [PrivacyInfo.xcprivacy](octosentry/PrivacyInfo.xcprivacy) already
18 declares no tracking and no collected data) and submit for review.
19
20The update checker (`UpdateStore`) detects the App Store receipt at
21runtime and never runs on this build — no code changes needed per release.
22
23## DMG (direct distribution)
24
25Requires a **Developer ID Application** certificate and notarization
26credentials stored once locally:
27
28```bash
29xcrun notarytool store-credentials "octosentry-notary" \
30 --apple-id "you@example.com" \
31 --team-id "YOUR_TEAM_ID" \
32 --password "an-app-specific-password"
33```
34
35(App-specific password from [appleid.apple.com](https://appleid.apple.com),
36not your main Apple ID password.)
37
38Then, per release:
39
40```bash
41scripts/build-dmg.sh 1.0.0
42```
43
44This archives, exports with Developer ID signing, notarizes, staples the
45ticket, and produces `build/octosentry-1.0.0.dmg`. Attach that file to
46the corresponding GitHub Release (`gh release create 1.0.0 build/octosentry-1.0.0.dmg`)
47— the update checker links there.
48
49## Homebrew
50
51Not published yet. [Casks/octosentry.rb](Casks/octosentry.rb) is a
52template — to actually publish it:
53
541. Create a `zerolabsco/homebrew-tap` repo.
552. Copy the cask there, filling in the real `sha256` of the released DMG
56 (`shasum -a 256 octosentry-1.0.0.dmg`).
573. Users install via `brew tap zerolabsco/tap && brew install --cask octosentry`.
58
59## Version bumps
60
61`MARKETING_VERSION` in the Xcode project must match the git tag for each
62release — the update checker compares `CFBundleShortVersionString`
63against the latest GitHub Release's tag name.
octosentry.xcodeproj/project.pbxproj +6 −6
@@ -412,7 +412,7 @@
412 "$(inherited)", 412 "$(inherited)",
413 "@executable_path/../Frameworks", 413 "@executable_path/../Frameworks",
414 ); 414 );
415 MARKETING_VERSION = 1.0; 415 MARKETING_VERSION = 1.0.0;
416 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry; 416 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry;
417 PRODUCT_NAME = "$(TARGET_NAME)"; 417 PRODUCT_NAME = "$(TARGET_NAME)";
418 REGISTER_APP_GROUPS = YES; 418 REGISTER_APP_GROUPS = YES;
@@ -448,7 +448,7 @@
448 "$(inherited)", 448 "$(inherited)",
449 "@executable_path/../Frameworks", 449 "@executable_path/../Frameworks",
450 ); 450 );
451 MARKETING_VERSION = 1.0; 451 MARKETING_VERSION = 1.0.0;
452 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry; 452 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry;
453 PRODUCT_NAME = "$(TARGET_NAME)"; 453 PRODUCT_NAME = "$(TARGET_NAME)";
454 REGISTER_APP_GROUPS = YES; 454 REGISTER_APP_GROUPS = YES;
@@ -470,7 +470,7 @@
470 DEVELOPMENT_TEAM = ZCNAX3VL9D; 470 DEVELOPMENT_TEAM = ZCNAX3VL9D;
471 GENERATE_INFOPLIST_FILE = YES; 471 GENERATE_INFOPLIST_FILE = YES;
472 MACOSX_DEPLOYMENT_TARGET = 14.0; 472 MACOSX_DEPLOYMENT_TARGET = 14.0;
473 MARKETING_VERSION = 1.0; 473 MARKETING_VERSION = 1.0.0;
474 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests; 474 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests;
475 PRODUCT_NAME = "$(TARGET_NAME)"; 475 PRODUCT_NAME = "$(TARGET_NAME)";
476 STRING_CATALOG_GENERATE_SYMBOLS = NO; 476 STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -491,7 +491,7 @@
491 DEVELOPMENT_TEAM = ZCNAX3VL9D; 491 DEVELOPMENT_TEAM = ZCNAX3VL9D;
492 GENERATE_INFOPLIST_FILE = YES; 492 GENERATE_INFOPLIST_FILE = YES;
493 MACOSX_DEPLOYMENT_TARGET = 14.0; 493 MACOSX_DEPLOYMENT_TARGET = 14.0;
494 MARKETING_VERSION = 1.0; 494 MARKETING_VERSION = 1.0.0;
495 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests; 495 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests;
496 PRODUCT_NAME = "$(TARGET_NAME)"; 496 PRODUCT_NAME = "$(TARGET_NAME)";
497 STRING_CATALOG_GENERATE_SYMBOLS = NO; 497 STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -510,7 +510,7 @@
510 CURRENT_PROJECT_VERSION = 1; 510 CURRENT_PROJECT_VERSION = 1;
511 DEVELOPMENT_TEAM = ZCNAX3VL9D; 511 DEVELOPMENT_TEAM = ZCNAX3VL9D;
512 GENERATE_INFOPLIST_FILE = YES; 512 GENERATE_INFOPLIST_FILE = YES;
513 MARKETING_VERSION = 1.0; 513 MARKETING_VERSION = 1.0.0;
514 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests; 514 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests;
515 PRODUCT_NAME = "$(TARGET_NAME)"; 515 PRODUCT_NAME = "$(TARGET_NAME)";
516 STRING_CATALOG_GENERATE_SYMBOLS = NO; 516 STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -529,7 +529,7 @@
529 CURRENT_PROJECT_VERSION = 1; 529 CURRENT_PROJECT_VERSION = 1;
530 DEVELOPMENT_TEAM = ZCNAX3VL9D; 530 DEVELOPMENT_TEAM = ZCNAX3VL9D;
531 GENERATE_INFOPLIST_FILE = YES; 531 GENERATE_INFOPLIST_FILE = YES;
532 MARKETING_VERSION = 1.0; 532 MARKETING_VERSION = 1.0.0;
533 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests; 533 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests;
534 PRODUCT_NAME = "$(TARGET_NAME)"; 534 PRODUCT_NAME = "$(TARGET_NAME)";
535 STRING_CATALOG_GENERATE_SYMBOLS = NO; 535 STRING_CATALOG_GENERATE_SYMBOLS = NO;
octosentry/AuthStore.swift +41 −9
@@ -6,6 +6,11 @@
6// currently in the Keychain. Replaces the GITHUB_TOKEN env var dev 6// currently in the Keychain. Replaces the GITHUB_TOKEN env var dev
7// shortcut (spec §13) with the real v1 auth flow (spec §6). 7// shortcut (spec §13) with the real v1 auth flow (spec §6).
8// 8//
9// Sign-in requests the minimal security_events scope by default.
10// Broader "repo" scope (needed to list repos for the picker, #15) is
11// only ever requested on demand via requestRepoAccess(), never by
12// default — a deliberate choice to keep the default blast radius small.
13//
9 14
10import Foundation 15import Foundation
11import Observation 16import Observation
@@ -14,12 +19,17 @@ import Observation
14final class AuthStore { 19final class AuthStore {
15 private(set) var state: AuthState 20 private(set) var state: AuthState
16 private(set) var errorMessage: String? 21 private(set) var errorMessage: String?
22 private(set) var hasRepoAccess = false
17 23
18 private let client = GitHubDeviceAuthClient() 24 private let client = GitHubDeviceAuthClient()
25 private let persistenceStore = PersistenceStore()
19 private var authorizationTask: Task<Void, Never>? 26 private var authorizationTask: Task<Void, Never>?
20 27
21 init() { 28 init() {
22 state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut 29 state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
30 Task {
31 hasRepoAccess = await persistenceStore.load().hasRepoScope
32 }
23 } 33 }
24 34
25 var isSignedIn: Bool { 35 var isSignedIn: Bool {
@@ -28,13 +38,32 @@ final class AuthStore {
28 } 38 }
29 39
30 func signIn() { 40 func signIn() {
41 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
42 }
43
44 /// Re-runs device auth with broader scope so the repo picker can list
45 /// repos. Only called explicitly from the repo picker UI, never on
46 /// the default sign-in path.
47 func requestRepoAccess() {
48 beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope)
49 }
50
51 func signOut() {
52 authorizationTask?.cancel()
53 authorizationTask = nil
54 KeychainTokenStore.delete()
55 state = .signedOut
56 hasRepoAccess = false
57 }
58
59 private func beginAuthorization(scope: String) {
31 guard authorizationTask == nil else { return } 60 guard authorizationTask == nil else { return }
32 errorMessage = nil 61 errorMessage = nil
33 62
34 authorizationTask = Task { 63 authorizationTask = Task {
35 defer { authorizationTask = nil } 64 defer { authorizationTask = nil }
36 do { 65 do {
37 let deviceCode = try await client.requestDeviceCode() 66 let deviceCode = try await client.requestDeviceCode(scope: scope)
38 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) 67 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
39 68
40 let token = try await client.pollForToken( 69 let token = try await client.pollForToken(
@@ -43,18 +72,21 @@ final class AuthStore {
43 expiresIn: deviceCode.expiresIn 72 expiresIn: deviceCode.expiresIn
44 ) 73 )
45 try KeychainTokenStore.save(token) 74 try KeychainTokenStore.save(token)
75
76 let grantedRepoScope = scope.contains("repo")
77 var persisted = await persistenceStore.load()
78 persisted.hasRepoScope = grantedRepoScope
79 await persistenceStore.save(persisted)
80 hasRepoAccess = grantedRepoScope
81
46 state = .signedIn 82 state = .signedIn
47 } catch { 83 } catch {
48 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription 84 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
49 state = .signedOut 85 // A failed re-auth (e.g. requestRepoAccess while already
86 // signed in) shouldn't sign the user out of their existing
87 // valid token — only reflect reality from the Keychain.
88 state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
50 } 89 }
51 } 90 }
52 } 91 }
53
54 func signOut() {
55 authorizationTask?.cancel()
56 authorizationTask = nil
57 KeychainTokenStore.delete()
58 state = .signedOut
59 }
60} 92}
octosentry/GitHubAPIModels.swift +8
@@ -86,3 +86,11 @@ nonisolated struct SecretScanningAlertDTO: Decodable {
86 case validity 86 case validity
87 } 87 }
88} 88}
89
90nonisolated struct GitHubRepoDTO: Decodable {
91 let fullName: String
92
93 enum CodingKeys: String, CodingKey {
94 case fullName = "full_name"
95 }
96}
octosentry/GitHubDeviceAuthClient.swift +9 −5
@@ -15,10 +15,14 @@ actor GitHubDeviceAuthClient {
15 // Not a secret — safe to embed in source. 15 // Not a secret — safe to embed in source.
16 private let clientID = "Ov23li6tqaTghDc4IJYv" 16 private let clientID = "Ov23li6tqaTghDc4IJYv"
17 17
18 // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth 18 // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert
19 // scopes have no read-only variant (unlike fine-grained PATs); this is the 19 // access. Classic OAuth scopes have no read-only variant (unlike fine-grained
20 // narrowest scope GitHub offers for these three endpoints via OAuth Apps. 20 // PATs); this is the narrowest scope GitHub offers for these three endpoints.
21 private let scope = "security_events" 21 static let defaultScope = "security_events"
22
23 // Broader scope requested only on demand (repo picker, #15) — never the
24 // default, since it's a real increase in blast radius over defaultScope alone.
25 static let repoAccessScope = "security_events repo"
22 26
23 private let session: URLSession 27 private let session: URLSession
24 28
@@ -26,7 +30,7 @@ actor GitHubDeviceAuthClient {
26 self.session = session 30 self.session = session
27 } 31 }
28 32
29 func requestDeviceCode() async throws -> DeviceCodeResponse { 33 func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse {
30 let data = try await post( 34 let data = try await post(
31 url: URL(string: "https://github.com/login/device/code")!, 35 url: URL(string: "https://github.com/login/device/code")!,
32 parameters: ["client_id": clientID, "scope": scope] 36 parameters: ["client_id": clientID, "scope": scope]
octosentry/GitHubSecurityAPIClient.swift +17 −3
@@ -3,9 +3,9 @@
3// octosentry 3// octosentry
4// 4//
5// Fetches Dependabot, code scanning, and secret scanning alerts for a 5// Fetches Dependabot, code scanning, and secret scanning alerts for a
6// single repo and normalizes them into SecurityEvent. Auth is a PAT read 6// repo and normalizes them into SecurityEvent, plus (with broader scope)
7// by the caller from the GITHUB_TOKEN environment variable — a dev-only 7// listing repos the token can see for the repo picker. The token itself
8// shortcut ahead of the device authorization flow (spec §6, §13). 8// comes from Keychain via the device authorization flow (spec §6).
9// 9//
10 10
11import Foundation 11import Foundation
@@ -89,6 +89,20 @@ actor GitHubSecurityAPIClient {
89 } 89 }
90 } 90 }
91 91
92 /// Lists repos the token can see (requires the broader repo-access
93 /// scope granted via AuthStore.requestRepoAccess(), not the default
94 /// sign-in scope). Used by the repo picker (#15).
95 func fetchAccessibleRepos() async throws -> [String] {
96 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
97 components.path = "/user/repos"
98 components.queryItems = [
99 URLQueryItem(name: "per_page", value: "100"),
100 URLQueryItem(name: "sort", value: "full_name"),
101 ]
102 let dtos: [GitHubRepoDTO] = try await fetchAllPages(url: components.url!)
103 return dtos.map(\.fullName)
104 }
105
92 private func alertsURL(owner: String, repo: String, path: String) -> URL { 106 private func alertsURL(owner: String, repo: String, path: String) -> URL {
93 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! 107 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
94 components.path = "/repos/\(owner)/\(repo)/\(path)" 108 components.path = "/repos/\(owner)/\(repo)/\(path)"
octosentry/PersistedState.swift +36 −4
@@ -3,10 +3,12 @@
3// octosentry 3// octosentry
4// 4//
5// Everything the app remembers across launches: the repo watch list, 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 6// local-only seen-state per event, last-fetch timestamp per repo, the
7// minimum severity filter. Flat JSON over SwiftData (see #1) — small, 7// minimum severity filter, and whether the current token has the
8// inspectable, and these are already plain Codable values passed across 8// broader "repo" scope needed to list repos. Flat JSON over SwiftData
9// actor boundaries, not reference types tied to a persistence context. 9// (see #1) — small, inspectable, and these are already plain Codable
10// values passed across actor boundaries, not reference types tied to a
11// persistence context.
10// 12//
11 13
12import Foundation 14import Foundation
@@ -16,6 +18,36 @@ nonisolated struct PersistedState: Codable {
16 var seenEventIDs: Set<String> 18 var seenEventIDs: Set<String>
17 var lastFetchByRepo: [String: Date] 19 var lastFetchByRepo: [String: Date]
18 var minimumSeverity: SecurityEventSeverity 20 var minimumSeverity: SecurityEventSeverity
21 var hasRepoScope: Bool
22
23 enum CodingKeys: String, CodingKey {
24 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope
25 }
26
27 init(
28 watchedRepos: [String],
29 seenEventIDs: Set<String>,
30 lastFetchByRepo: [String: Date],
31 minimumSeverity: SecurityEventSeverity,
32 hasRepoScope: Bool = false
33 ) {
34 self.watchedRepos = watchedRepos
35 self.seenEventIDs = seenEventIDs
36 self.lastFetchByRepo = lastFetchByRepo
37 self.minimumSeverity = minimumSeverity
38 self.hasRepoScope = hasRepoScope
39 }
40
41 // Custom decode so existing state.json files saved before hasRepoScope
42 // existed still load instead of falling back to .placeholder.
43 init(from decoder: Decoder) throws {
44 let container = try decoder.container(keyedBy: CodingKeys.self)
45 watchedRepos = try container.decode([String].self, forKey: .watchedRepos)
46 seenEventIDs = try container.decode(Set<String>.self, forKey: .seenEventIDs)
47 lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo)
48 minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
49 hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
50 }
19 51
20 static let placeholder = PersistedState( 52 static let placeholder = PersistedState(
21 watchedRepos: ["ccleberg/cleberg.net"], 53 watchedRepos: ["ccleberg/cleberg.net"],
octosentry/PrivacyInfo.xcprivacy added +14
@@ -0,0 +1,14 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>NSPrivacyTracking</key>
6 <false/>
7 <key>NSPrivacyTrackingDomains</key>
8 <array/>
9 <key>NSPrivacyCollectedDataTypes</key>
10 <array/>
11 <key>NSPrivacyAccessedAPITypes</key>
12 <array/>
13</dict>
14</plist>
octosentry/SecurityEventListView.swift +116 −7
@@ -9,6 +9,7 @@ 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 updateStore: UpdateStore
12 var isStandaloneWindow: Bool = false 13 var isStandaloneWindow: Bool = false
13 @State private var showingRepoManager = false 14 @State private var showingRepoManager = false
14 @Environment(\.openWindow) private var openWindow 15 @Environment(\.openWindow) private var openWindow
@@ -16,6 +17,9 @@ struct SecurityEventListView: View {
16 var body: some View { 17 var body: some View {
17 VStack(alignment: .leading, spacing: 0) { 18 VStack(alignment: .leading, spacing: 0) {
18 header 19 header
20 if let release = updateStore.availableRelease {
21 UpdateBanner(release: release)
22 }
19 Divider() 23 Divider()
20 if !authStore.isSignedIn { 24 if !authStore.isSignedIn {
21 SignInView(authStore: authStore) 25 SignInView(authStore: authStore)
@@ -30,6 +34,9 @@ struct SecurityEventListView: View {
30 await store.refresh() 34 await store.refresh()
31 store.startPolling() 35 store.startPolling()
32 } 36 }
37 .task {
38 await updateStore.checkForUpdate()
39 }
33 } 40 }
34 41
35 private var header: some View { 42 private var header: some View {
@@ -142,6 +149,10 @@ private struct RepoManagerView: View {
142 var store: SecurityEventStore 149 var store: SecurityEventStore
143 var authStore: AuthStore 150 var authStore: AuthStore
144 @State private var newRepoText = "" 151 @State private var newRepoText = ""
152 @State private var isBrowsingRepos = false
153 @State private var availableRepos: [String] = []
154 @State private var isLoadingRepos = false
155 @State private var browseErrorMessage: String?
145 156
146 var body: some View { 157 var body: some View {
147 VStack(alignment: .leading, spacing: 10) { 158 VStack(alignment: .leading, spacing: 10) {
@@ -171,13 +182,24 @@ private struct RepoManagerView: View {
171 182
172 Divider() 183 Divider()
173 184
174 HStack { 185 if isBrowsingRepos {
175 TextField("owner/repo", text: $newRepoText) 186 browsingContent
176 .textFieldStyle(.roundedBorder) 187 } else {
177 .onSubmit(addRepo) 188 HStack {
189 TextField("owner/repo", text: $newRepoText)
190 .textFieldStyle(.roundedBorder)
191 .onSubmit(addRepo)
192
193 Button("Add", action: addRepo)
194 .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
195 }
178 196
179 Button("Add", action: addRepo) 197 Button(action: startBrowsing) {
180 .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 198 Label("Browse your repos", systemImage: "list.bullet")
199 .font(.caption)
200 }
201 .buttonStyle(.plain)
202 .foregroundStyle(Color.accentColor)
181 } 203 }
182 204
183 if let errorMessage = store.watchListErrorMessage { 205 if let errorMessage = store.watchListErrorMessage {
@@ -200,6 +222,75 @@ private struct RepoManagerView: View {
200 .frame(maxWidth: .infinity, alignment: .leading) 222 .frame(maxWidth: .infinity, alignment: .leading)
201 } 223 }
202 224
225 @ViewBuilder
226 private var browsingContent: some View {
227 VStack(alignment: .leading, spacing: 6) {
228 HStack {
229 Text("Your Repositories")
230 .font(.caption.weight(.semibold))
231 Spacer()
232 Button {
233 isBrowsingRepos = false
234 } label: {
235 Image(systemName: "xmark.circle")
236 }
237 .buttonStyle(.plain)
238 }
239
240 if isLoadingRepos {
241 ProgressView()
242 .controlSize(.small)
243 .frame(maxWidth: .infinity)
244 } else if let browseErrorMessage {
245 Text(browseErrorMessage)
246 .font(.caption2)
247 .foregroundStyle(.red)
248 } else {
249 let selectableRepos = availableRepos.filter { !store.watchedRepos.contains($0) }
250 if selectableRepos.isEmpty {
251 Text("All visible repos are already watched.")
252 .font(.caption2)
253 .foregroundStyle(.secondary)
254 } else {
255 ScrollView {
256 LazyVStack(alignment: .leading, spacing: 4) {
257 ForEach(selectableRepos, id: \.self) { repo in
258 Button {
259 Task { await store.addRepo(repo) }
260 isBrowsingRepos = false
261 } label: {
262 Text(repo)
263 .font(.callout)
264 .frame(maxWidth: .infinity, alignment: .leading)
265 }
266 .buttonStyle(.plain)
267 }
268 }
269 }
270 .frame(maxHeight: 160)
271 }
272 }
273 }
274 }
275
276 private func startBrowsing() {
277 guard authStore.hasRepoAccess else {
278 authStore.requestRepoAccess()
279 return
280 }
281 isBrowsingRepos = true
282 isLoadingRepos = true
283 browseErrorMessage = nil
284 Task {
285 do {
286 availableRepos = try await store.fetchAccessibleRepos()
287 } catch {
288 browseErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
289 }
290 isLoadingRepos = false
291 }
292 }
293
203 private func addRepo() { 294 private func addRepo() {
204 let text = newRepoText 295 let text = newRepoText
205 newRepoText = "" 296 newRepoText = ""
@@ -207,6 +298,24 @@ private struct RepoManagerView: View {
207 } 298 }
208} 299}
209 300
301private struct UpdateBanner: View {
302 let release: UpdateChecker.LatestRelease
303
304 var body: some View {
305 Button {
306 NSWorkspace.shared.open(release.htmlURL)
307 } label: {
308 Label("Update available: \(release.version)", systemImage: "arrow.down.circle.fill")
309 .font(.caption)
310 .frame(maxWidth: .infinity, alignment: .leading)
311 }
312 .buttonStyle(.plain)
313 .foregroundStyle(.blue)
314 .padding(8)
315 .background(.blue.opacity(0.1))
316 }
317}
318
210private struct ErrorBanner: View { 319private struct ErrorBanner: View {
211 let messages: [String] 320 let messages: [String]
212 321
@@ -262,6 +371,6 @@ private struct StatusView: View {
262} 371}
263 372
264#Preview { 373#Preview {
265 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) 374 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore(), updateStore: UpdateStore())
266 .frame(width: 380, height: 420) 375 .frame(width: 380, height: 420)
267} 376}
octosentry/SecurityEventStore.swift +10
@@ -134,6 +134,16 @@ final class SecurityEventStore {
134 await refresh() 134 await refresh()
135 } 135 }
136 136
137 /// Lists repos the current token can see, for the repo picker (#15).
138 /// Requires broader repo-access scope — throws if the token only has
139 /// the default security_events scope.
140 func fetchAccessibleRepos() async throws -> [String] {
141 guard let token = KeychainTokenStore.load() else {
142 throw GitHubAPIError.missingToken
143 }
144 return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos()
145 }
146
137 /// Local-only triage state (spec §11) — no API write, no scope beyond 147 /// Local-only triage state (spec §11) — no API write, no scope beyond
138 /// read needed. Removes the event from the active stream. 148 /// read needed. Removes the event from the active stream.
139 func markSeen(_ eventID: String) async { 149 func markSeen(_ eventID: String) async {
octosentry/UpdateChecker.swift added +85
@@ -0,0 +1,85 @@
1//
2// UpdateChecker.swift
3// octosentry
4//
5// Polls this repo's own GitHub Releases API (spec §9) — no auto-install,
6// no Sparkle, just a link to the release page. Skipped entirely on the
7// Mac App Store build, detected at runtime via the presence of an App
8// Store receipt rather than a separate build configuration: same
9// outcome (this code never runs there) with far less project surface
10// than maintaining a second Xcode configuration/scheme just for this.
11//
12
13import Foundation
14
15actor UpdateChecker {
16 private let session: URLSession
17 private let repoOwner = "zerolabsco"
18 private let repoName = "octosentry"
19
20 init(session: URLSession = .shared) {
21 self.session = session
22 }
23
24 struct LatestRelease: Sendable {
25 let version: String
26 let htmlURL: URL
27 }
28
29 func fetchLatestRelease() async throws -> LatestRelease {
30 var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repoOwner)/\(repoName)/releases/latest")!)
31 request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
32 request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
33
34 let data: Data
35 let response: URLResponse
36 do {
37 (data, response) = try await session.data(for: request)
38 } catch {
39 throw UpdateCheckError.network(error.localizedDescription)
40 }
41
42 guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
43 throw UpdateCheckError.requestFailed
44 }
45
46 let dto: GitHubReleaseDTO
47 do {
48 dto = try JSONDecoder().decode(GitHubReleaseDTO.self, from: data)
49 } catch {
50 throw UpdateCheckError.decodingFailed(error.localizedDescription)
51 }
52
53 guard let url = URL(string: dto.htmlUrl) else {
54 throw UpdateCheckError.decodingFailed("Malformed release URL.")
55 }
56 return LatestRelease(version: dto.tagName, htmlURL: url)
57 }
58}
59
60nonisolated struct GitHubReleaseDTO: Decodable {
61 let tagName: String
62 let htmlUrl: String
63
64 enum CodingKeys: String, CodingKey {
65 case tagName = "tag_name"
66 case htmlUrl = "html_url"
67 }
68}
69
70nonisolated enum UpdateCheckError: Error, LocalizedError {
71 case network(String)
72 case requestFailed
73 case decodingFailed(String)
74
75 var errorDescription: String? {
76 switch self {
77 case .network(let message):
78 "Network error checking for updates: \(message)"
79 case .requestFailed:
80 "Failed to check for updates."
81 case .decodingFailed(let message):
82 "Unexpected response checking for updates: \(message)"
83 }
84 }
85}
octosentry/UpdateStore.swift added +55
@@ -0,0 +1,55 @@
1//
2// UpdateStore.swift
3// octosentry
4//
5
6import Foundation
7import Observation
8
9@Observable
10final class UpdateStore {
11 private(set) var availableRelease: UpdateChecker.LatestRelease?
12
13 private let checker = UpdateChecker()
14
15 /// True for a Mac App Store build (has an App Store receipt), false for
16 /// a direct DMG/Homebrew build. Runtime check rather than a build flag —
17 /// see UpdateChecker.swift for why.
18 var isMacAppStoreBuild: Bool {
19 guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
20 return FileManager.default.fileExists(atPath: receiptURL.path)
21 }
22
23 func checkForUpdate() async {
24 guard !isMacAppStoreBuild else { return }
25 guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
26 guard let latest = try? await checker.fetchLatestRelease() else { return }
27
28 if Self.isNewer(latest.version, than: currentVersion) {
29 availableRelease = latest
30 }
31 }
32
33 static func isNewer(_ candidate: String, than current: String) -> Bool {
34 let candidateParts = versionComponents(candidate)
35 let currentParts = versionComponents(current)
36 let count = max(candidateParts.count, currentParts.count)
37
38 for i in 0..<count {
39 let candidatePart = i < candidateParts.count ? candidateParts[i] : 0
40 let currentPart = i < currentParts.count ? currentParts[i] : 0
41 if candidatePart != currentPart {
42 return candidatePart > currentPart
43 }
44 }
45 return false
46 }
47
48 private static func versionComponents(_ version: String) -> [Int] {
49 var trimmed = version
50 if trimmed.hasPrefix("v") {
51 trimmed.removeFirst()
52 }
53 return trimmed.split(separator: ".").map { Int($0) ?? 0 }
54 }
55}
octosentry/octosentryApp.swift +3 −2
@@ -15,10 +15,11 @@ enum SecurityEventWindow {
15struct octosentryApp: App { 15struct octosentryApp: App {
16 @State private var store = SecurityEventStore() 16 @State private var store = SecurityEventStore()
17 @State private var authStore = AuthStore() 17 @State private var authStore = AuthStore()
18 @State private var updateStore = UpdateStore()
18 19
19 var body: some Scene { 20 var body: some Scene {
20 MenuBarExtra { 21 MenuBarExtra {
21 SecurityEventListView(store: store, authStore: authStore) 22 SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore)
22 .frame(width: 380, height: 420) 23 .frame(width: 380, height: 420)
23 } label: { 24 } label: {
24 MenuBarIconView(criticalCount: store.unseenCriticalCount) 25 MenuBarIconView(criticalCount: store.unseenCriticalCount)
@@ -26,7 +27,7 @@ struct octosentryApp: App {
26 .menuBarExtraStyle(.window) 27 .menuBarExtraStyle(.window)
27 28
28 Window("Security Events", id: SecurityEventWindow.id) { 29 Window("Security Events", id: SecurityEventWindow.id) {
29 SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true) 30 SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore, isStandaloneWindow: true)
30 .frame(minWidth: 420, minHeight: 480) 31 .frame(minWidth: 420, minHeight: 480)
31 } 32 }
32 } 33 }
scripts/build-dmg.sh added +76
@@ -0,0 +1,76 @@
1#!/bin/bash
2#
3# build-dmg.sh
4#
5# Builds a notarized, Developer-ID-signed DMG for direct distribution
6# (spec §9: DMG/Homebrew channel). Requires local one-time setup this
7# script does NOT do for you:
8#
9# 1. A "Developer ID Application" certificate in your keychain, tied to
10# an active Apple Developer Program membership. Xcode > Settings >
11# Accounts > Manage Certificates > + > Developer ID Application.
12# 2. Notarization credentials stored once via:
13# xcrun notarytool store-credentials "octosentry-notary" \
14# --apple-id "you@example.com" \
15# --team-id "YOUR_TEAM_ID" \
16# --password "an-app-specific-password"
17# (App-specific password from appleid.apple.com, not your main
18# Apple ID password.)
19#
20# Usage: scripts/build-dmg.sh [version]
21# Output: build/octosentry-<version>.dmg
22
23set -euo pipefail
24
25VERSION="${1:-dev}"
26PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
27BUILD_DIR="$PROJECT_DIR/build"
28ARCHIVE_PATH="$BUILD_DIR/octosentry.xcarchive"
29EXPORT_PATH="$BUILD_DIR/export"
30EXPORT_OPTIONS_PLIST="$BUILD_DIR/export-options.plist"
31DMG_PATH="$BUILD_DIR/octosentry-$VERSION.dmg"
32NOTARY_PROFILE="octosentry-notary"
33
34rm -rf "$BUILD_DIR"
35mkdir -p "$BUILD_DIR"
36
37echo "==> Archiving (Release configuration)"
38xcodebuild archive \
39 -project "$PROJECT_DIR/octosentry.xcodeproj" \
40 -scheme octosentry \
41 -configuration Release \
42 -archivePath "$ARCHIVE_PATH"
43
44cat > "$EXPORT_OPTIONS_PLIST" <<PLIST
45<?xml version="1.0" encoding="UTF-8"?>
46<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
47<plist version="1.0">
48<dict>
49 <key>method</key>
50 <string>developer-id</string>
51</dict>
52</plist>
53PLIST
54
55echo "==> Exporting (Developer ID)"
56xcodebuild -exportArchive \
57 -archivePath "$ARCHIVE_PATH" \
58 -exportPath "$EXPORT_PATH" \
59 -exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
60
61APP_PATH="$EXPORT_PATH/octosentry.app"
62
63echo "==> Notarizing"
64DMG_STAGING="$BUILD_DIR/staging"
65mkdir -p "$DMG_STAGING"
66cp -R "$APP_PATH" "$DMG_STAGING/"
67ln -s /Applications "$DMG_STAGING/Applications"
68
69hdiutil create -volname "octosentry" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_PATH"
70
71xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
72
73echo "==> Stapling notarization ticket"
74xcrun stapler staple "$DMG_PATH"
75
76echo "==> Done: $DMG_PATH"