summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 17:34:22 -0500
committerChristian Cleberg <[email protected]>2026-07-17 17:34:57 -0500
commit705c6029ab30adf094e6324006b9fb682d8189f2 (patch)
treea7325e1d8900a4c7c5b05182cd95dbf6b7f374ce
parent29b01b0220968dd9ea70b6c84ca72603bc02042c (diff)
downloadoctosentry-0.7.0.tar.gz
octosentry-0.7.0.tar.bz2
octosentry-0.7.0.zip
Add repo picker, update checker, and distribution tooling1.0.00.7.00.6.0
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.
-rw-r--r--Casks/octosentry.rb27
-rw-r--r--DISTRIBUTION.md63
-rw-r--r--octosentry.xcodeproj/project.pbxproj12
-rw-r--r--octosentry/AuthStore.swift50
-rw-r--r--octosentry/GitHubAPIModels.swift8
-rw-r--r--octosentry/GitHubDeviceAuthClient.swift14
-rw-r--r--octosentry/GitHubSecurityAPIClient.swift20
-rw-r--r--octosentry/PersistedState.swift40
-rw-r--r--octosentry/PrivacyInfo.xcprivacy14
-rw-r--r--octosentry/SecurityEventListView.swift123
-rw-r--r--octosentry/SecurityEventStore.swift10
-rw-r--r--octosentry/UpdateChecker.swift85
-rw-r--r--octosentry/UpdateStore.swift55
-rw-r--r--octosentry/octosentryApp.swift5
-rwxr-xr-xscripts/build-dmg.sh76
15 files changed, 566 insertions, 36 deletions
diff --git a/Casks/octosentry.rb b/Casks/octosentry.rb
new file mode 100644
index 0000000..becc4fd
--- /dev/null
+++ b/Casks/octosentry.rb
@@ -0,0 +1,27 @@
+# Homebrew Cask for octosentry (spec §9: DMG/Homebrew distribution channel).
+#
+# This file lives here as a template — Homebrew taps must be their own repo
+# named "homebrew-<tapname>" for `brew tap` to find them. To actually publish:
+# 1. Create github.com/zerolabsco/homebrew-tap (or similar)
+# 2. Copy this file there as Casks/octosentry.rb
+# 3. Fill in sha256 below with the real checksum of the released DMG:
+# shasum -a 256 octosentry-<version>.dmg
+# 4. Users install via: brew tap zerolabsco/tap && brew install --cask octosentry
+
+cask "octosentry" do
+ version "1.0.0"
+ sha256 "REPLACE_WITH_REAL_SHA256_OF_RELEASED_DMG"
+
+ url "https://github.com/zerolabsco/octosentry/releases/download/#{version}/octosentry-#{version}.dmg"
+ name "octosentry"
+ desc "Menu bar app aggregating GitHub security alerts into one feed"
+ homepage "https://github.com/zerolabsco/octosentry"
+
+ depends_on macos: ">= :sonoma"
+
+ app "octosentry.app"
+
+ zap trash: [
+ "~/Library/Application Support/octosentry",
+ ]
+end
diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md
new file mode 100644
index 0000000..f81fb21
--- /dev/null
+++ b/DISTRIBUTION.md
@@ -0,0 +1,63 @@
+# Distribution
+
+octosentry ships on two channels with a single codebase and identical
+entitlements (spec §9) — the only divergence is signing method at export
+time and whether the update checker runs.
+
+## App Store
+
+1. Requires an active Apple Developer Program membership and an **Apple
+ Distribution** certificate (Xcode > Settings > Accounts > Manage
+ Certificates).
+2. Create the app record in [App Store Connect](https://appstoreconnect.apple.com)
+ with bundle ID `net.cleberg.octosentry`.
+3. Archive: Product > Archive in Xcode (Release configuration).
+4. In the Organizer, Distribute App > App Store Connect > Upload.
+5. Complete the app listing (screenshots, description, privacy nutrition
+ label — [PrivacyInfo.xcprivacy](octosentry/PrivacyInfo.xcprivacy) already
+ declares no tracking and no collected data) and submit for review.
+
+The update checker (`UpdateStore`) detects the App Store receipt at
+runtime and never runs on this build — no code changes needed per release.
+
+## DMG (direct distribution)
+
+Requires a **Developer ID Application** certificate and notarization
+credentials stored once locally:
+
+```bash
+xcrun notarytool store-credentials "octosentry-notary" \
+ --apple-id "[email protected]" \
+ --team-id "YOUR_TEAM_ID" \
+ --password "an-app-specific-password"
+```
+
+(App-specific password from [appleid.apple.com](https://appleid.apple.com),
+not your main Apple ID password.)
+
+Then, per release:
+
+```bash
+scripts/build-dmg.sh 1.0.0
+```
+
+This archives, exports with Developer ID signing, notarizes, staples the
+ticket, and produces `build/octosentry-1.0.0.dmg`. Attach that file to
+the corresponding GitHub Release (`gh release create 1.0.0 build/octosentry-1.0.0.dmg`)
+— the update checker links there.
+
+## Homebrew
+
+Not published yet. [Casks/octosentry.rb](Casks/octosentry.rb) is a
+template — to actually publish it:
+
+1. Create a `zerolabsco/homebrew-tap` repo.
+2. Copy the cask there, filling in the real `sha256` of the released DMG
+ (`shasum -a 256 octosentry-1.0.0.dmg`).
+3. Users install via `brew tap zerolabsco/tap && brew install --cask octosentry`.
+
+## Version bumps
+
+`MARKETING_VERSION` in the Xcode project must match the git tag for each
+release — the update checker compares `CFBundleShortVersionString`
+against the latest GitHub Release's tag name.
diff --git a/octosentry.xcodeproj/project.pbxproj b/octosentry.xcodeproj/project.pbxproj
index a6c2fcc..d8edb63 100644
--- a/octosentry.xcodeproj/project.pbxproj
+++ b/octosentry.xcodeproj/project.pbxproj
@@ -412,7 +412,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -448,7 +448,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -470,7 +470,7 @@
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -491,7 +491,7 @@
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -510,7 +510,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -529,7 +529,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
diff --git a/octosentry/AuthStore.swift b/octosentry/AuthStore.swift
index 4386e38..4194bba 100644
--- a/octosentry/AuthStore.swift
+++ b/octosentry/AuthStore.swift
@@ -6,6 +6,11 @@
// currently in the Keychain. Replaces the GITHUB_TOKEN env var dev
// shortcut (spec §13) with the real v1 auth flow (spec §6).
//
+// Sign-in requests the minimal security_events scope by default.
+// Broader "repo" scope (needed to list repos for the picker, #15) is
+// only ever requested on demand via requestRepoAccess(), never by
+// default — a deliberate choice to keep the default blast radius small.
+//
import Foundation
import Observation
@@ -14,12 +19,17 @@ import Observation
final class AuthStore {
private(set) var state: AuthState
private(set) var errorMessage: String?
+ private(set) var hasRepoAccess = false
private let client = GitHubDeviceAuthClient()
+ private let persistenceStore = PersistenceStore()
private var authorizationTask: Task<Void, Never>?
init() {
state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
+ Task {
+ hasRepoAccess = await persistenceStore.load().hasRepoScope
+ }
}
var isSignedIn: Bool {
@@ -28,13 +38,32 @@ final class AuthStore {
}
func signIn() {
+ beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
+ }
+
+ /// Re-runs device auth with broader scope so the repo picker can list
+ /// repos. Only called explicitly from the repo picker UI, never on
+ /// the default sign-in path.
+ func requestRepoAccess() {
+ beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope)
+ }
+
+ func signOut() {
+ authorizationTask?.cancel()
+ authorizationTask = nil
+ KeychainTokenStore.delete()
+ state = .signedOut
+ hasRepoAccess = false
+ }
+
+ private func beginAuthorization(scope: String) {
guard authorizationTask == nil else { return }
errorMessage = nil
authorizationTask = Task {
defer { authorizationTask = nil }
do {
- let deviceCode = try await client.requestDeviceCode()
+ let deviceCode = try await client.requestDeviceCode(scope: scope)
state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
let token = try await client.pollForToken(
@@ -43,18 +72,21 @@ final class AuthStore {
expiresIn: deviceCode.expiresIn
)
try KeychainTokenStore.save(token)
+
+ let grantedRepoScope = scope.contains("repo")
+ var persisted = await persistenceStore.load()
+ persisted.hasRepoScope = grantedRepoScope
+ await persistenceStore.save(persisted)
+ hasRepoAccess = grantedRepoScope
+
state = .signedIn
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
- state = .signedOut
+ // A failed re-auth (e.g. requestRepoAccess while already
+ // signed in) shouldn't sign the user out of their existing
+ // valid token — only reflect reality from the Keychain.
+ state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
}
}
}
-
- func signOut() {
- authorizationTask?.cancel()
- authorizationTask = nil
- KeychainTokenStore.delete()
- state = .signedOut
- }
}
diff --git a/octosentry/GitHubAPIModels.swift b/octosentry/GitHubAPIModels.swift
index 3a84aa8..2b95b70 100644
--- a/octosentry/GitHubAPIModels.swift
+++ b/octosentry/GitHubAPIModels.swift
@@ -86,3 +86,11 @@ nonisolated struct SecretScanningAlertDTO: Decodable {
case validity
}
}
+
+nonisolated struct GitHubRepoDTO: Decodable {
+ let fullName: String
+
+ enum CodingKeys: String, CodingKey {
+ case fullName = "full_name"
+ }
+}
diff --git a/octosentry/GitHubDeviceAuthClient.swift b/octosentry/GitHubDeviceAuthClient.swift
index 63799ff..3105733 100644
--- a/octosentry/GitHubDeviceAuthClient.swift
+++ b/octosentry/GitHubDeviceAuthClient.swift
@@ -15,10 +15,14 @@ actor GitHubDeviceAuthClient {
// Not a secret — safe to embed in source.
private let clientID = "Ov23li6tqaTghDc4IJYv"
- // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth
- // scopes have no read-only variant (unlike fine-grained PATs); this is the
- // narrowest scope GitHub offers for these three endpoints via OAuth Apps.
- private let scope = "security_events"
+ // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert
+ // access. Classic OAuth scopes have no read-only variant (unlike fine-grained
+ // PATs); this is the narrowest scope GitHub offers for these three endpoints.
+ static let defaultScope = "security_events"
+
+ // Broader scope requested only on demand (repo picker, #15) — never the
+ // default, since it's a real increase in blast radius over defaultScope alone.
+ static let repoAccessScope = "security_events repo"
private let session: URLSession
@@ -26,7 +30,7 @@ actor GitHubDeviceAuthClient {
self.session = session
}
- func requestDeviceCode() async throws -> DeviceCodeResponse {
+ func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse {
let data = try await post(
url: URL(string: "https://github.com/login/device/code")!,
parameters: ["client_id": clientID, "scope": scope]
diff --git a/octosentry/GitHubSecurityAPIClient.swift b/octosentry/GitHubSecurityAPIClient.swift
index 629699a..2e480bd 100644
--- a/octosentry/GitHubSecurityAPIClient.swift
+++ b/octosentry/GitHubSecurityAPIClient.swift
@@ -3,9 +3,9 @@
// octosentry
//
// Fetches Dependabot, code scanning, and secret scanning alerts for a
-// single repo and normalizes them into SecurityEvent. Auth is a PAT read
-// by the caller from the GITHUB_TOKEN environment variable — a dev-only
-// shortcut ahead of the device authorization flow (spec §6, §13).
+// repo and normalizes them into SecurityEvent, plus (with broader scope)
+// listing repos the token can see for the repo picker. The token itself
+// comes from Keychain via the device authorization flow (spec §6).
//
import Foundation
@@ -89,6 +89,20 @@ actor GitHubSecurityAPIClient {
}
}
+ /// Lists repos the token can see (requires the broader repo-access
+ /// scope granted via AuthStore.requestRepoAccess(), not the default
+ /// sign-in scope). Used by the repo picker (#15).
+ func fetchAccessibleRepos() async throws -> [String] {
+ var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
+ components.path = "/user/repos"
+ components.queryItems = [
+ URLQueryItem(name: "per_page", value: "100"),
+ URLQueryItem(name: "sort", value: "full_name"),
+ ]
+ let dtos: [GitHubRepoDTO] = try await fetchAllPages(url: components.url!)
+ return dtos.map(\.fullName)
+ }
+
private func alertsURL(owner: String, repo: String, path: String) -> URL {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
components.path = "/repos/\(owner)/\(repo)/\(path)"
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
index 3ef4234..c8c1bae 100644
--- a/octosentry/PersistedState.swift
+++ b/octosentry/PersistedState.swift
@@ -3,10 +3,12 @@
// 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.
+// local-only seen-state per event, last-fetch timestamp per repo, the
+// minimum severity filter, and whether the current token has the
+// broader "repo" scope needed to list repos. 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
@@ -16,6 +18,36 @@ nonisolated struct PersistedState: Codable {
var seenEventIDs: Set<String>
var lastFetchByRepo: [String: Date]
var minimumSeverity: SecurityEventSeverity
+ var hasRepoScope: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope
+ }
+
+ init(
+ watchedRepos: [String],
+ seenEventIDs: Set<String>,
+ lastFetchByRepo: [String: Date],
+ minimumSeverity: SecurityEventSeverity,
+ hasRepoScope: Bool = false
+ ) {
+ self.watchedRepos = watchedRepos
+ self.seenEventIDs = seenEventIDs
+ self.lastFetchByRepo = lastFetchByRepo
+ self.minimumSeverity = minimumSeverity
+ self.hasRepoScope = hasRepoScope
+ }
+
+ // Custom decode so existing state.json files saved before hasRepoScope
+ // existed still load instead of falling back to .placeholder.
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ watchedRepos = try container.decode([String].self, forKey: .watchedRepos)
+ seenEventIDs = try container.decode(Set<String>.self, forKey: .seenEventIDs)
+ lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo)
+ minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
+ hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
+ }
static let placeholder = PersistedState(
watchedRepos: ["ccleberg/cleberg.net"],
diff --git a/octosentry/PrivacyInfo.xcprivacy b/octosentry/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..e08a130
--- /dev/null
+++ b/octosentry/PrivacyInfo.xcprivacy
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>NSPrivacyTracking</key>
+ <false/>
+ <key>NSPrivacyTrackingDomains</key>
+ <array/>
+ <key>NSPrivacyCollectedDataTypes</key>
+ <array/>
+ <key>NSPrivacyAccessedAPITypes</key>
+ <array/>
+</dict>
+</plist>
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index d817370..84e6638 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -9,6 +9,7 @@ import SwiftUI
struct SecurityEventListView: View {
var store: SecurityEventStore
var authStore: AuthStore
+ var updateStore: UpdateStore
var isStandaloneWindow: Bool = false
@State private var showingRepoManager = false
@Environment(\.openWindow) private var openWindow
@@ -16,6 +17,9 @@ struct SecurityEventListView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
+ if let release = updateStore.availableRelease {
+ UpdateBanner(release: release)
+ }
Divider()
if !authStore.isSignedIn {
SignInView(authStore: authStore)
@@ -30,6 +34,9 @@ struct SecurityEventListView: View {
await store.refresh()
store.startPolling()
}
+ .task {
+ await updateStore.checkForUpdate()
+ }
}
private var header: some View {
@@ -142,6 +149,10 @@ private struct RepoManagerView: View {
var store: SecurityEventStore
var authStore: AuthStore
@State private var newRepoText = ""
+ @State private var isBrowsingRepos = false
+ @State private var availableRepos: [String] = []
+ @State private var isLoadingRepos = false
+ @State private var browseErrorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
@@ -171,13 +182,24 @@ private struct RepoManagerView: View {
Divider()
- HStack {
- TextField("owner/repo", text: $newRepoText)
- .textFieldStyle(.roundedBorder)
- .onSubmit(addRepo)
+ if isBrowsingRepos {
+ browsingContent
+ } else {
+ HStack {
+ TextField("owner/repo", text: $newRepoText)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit(addRepo)
+
+ Button("Add", action: addRepo)
+ .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
- Button("Add", action: addRepo)
- .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ Button(action: startBrowsing) {
+ Label("Browse your repos", systemImage: "list.bullet")
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(Color.accentColor)
}
if let errorMessage = store.watchListErrorMessage {
@@ -200,6 +222,75 @@ private struct RepoManagerView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
+ @ViewBuilder
+ private var browsingContent: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text("Your Repositories")
+ .font(.caption.weight(.semibold))
+ Spacer()
+ Button {
+ isBrowsingRepos = false
+ } label: {
+ Image(systemName: "xmark.circle")
+ }
+ .buttonStyle(.plain)
+ }
+
+ if isLoadingRepos {
+ ProgressView()
+ .controlSize(.small)
+ .frame(maxWidth: .infinity)
+ } else if let browseErrorMessage {
+ Text(browseErrorMessage)
+ .font(.caption2)
+ .foregroundStyle(.red)
+ } else {
+ let selectableRepos = availableRepos.filter { !store.watchedRepos.contains($0) }
+ if selectableRepos.isEmpty {
+ Text("All visible repos are already watched.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 4) {
+ ForEach(selectableRepos, id: \.self) { repo in
+ Button {
+ Task { await store.addRepo(repo) }
+ isBrowsingRepos = false
+ } label: {
+ Text(repo)
+ .font(.callout)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ .frame(maxHeight: 160)
+ }
+ }
+ }
+ }
+
+ private func startBrowsing() {
+ guard authStore.hasRepoAccess else {
+ authStore.requestRepoAccess()
+ return
+ }
+ isBrowsingRepos = true
+ isLoadingRepos = true
+ browseErrorMessage = nil
+ Task {
+ do {
+ availableRepos = try await store.fetchAccessibleRepos()
+ } catch {
+ browseErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+ isLoadingRepos = false
+ }
+ }
+
private func addRepo() {
let text = newRepoText
newRepoText = ""
@@ -207,6 +298,24 @@ private struct RepoManagerView: View {
}
}
+private struct UpdateBanner: View {
+ let release: UpdateChecker.LatestRelease
+
+ var body: some View {
+ Button {
+ NSWorkspace.shared.open(release.htmlURL)
+ } label: {
+ Label("Update available: \(release.version)", systemImage: "arrow.down.circle.fill")
+ .font(.caption)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.blue)
+ .padding(8)
+ .background(.blue.opacity(0.1))
+ }
+}
+
private struct ErrorBanner: View {
let messages: [String]
@@ -262,6 +371,6 @@ private struct StatusView: View {
}
#Preview {
- SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore())
+ SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore(), updateStore: UpdateStore())
.frame(width: 380, height: 420)
}
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 26dcc16..8f3d0f5 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -134,6 +134,16 @@ final class SecurityEventStore {
await refresh()
}
+ /// Lists repos the current token can see, for the repo picker (#15).
+ /// Requires broader repo-access scope — throws if the token only has
+ /// the default security_events scope.
+ func fetchAccessibleRepos() async throws -> [String] {
+ guard let token = KeychainTokenStore.load() else {
+ throw GitHubAPIError.missingToken
+ }
+ return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos()
+ }
+
/// Local-only triage state (spec §11) — no API write, no scope beyond
/// read needed. Removes the event from the active stream.
func markSeen(_ eventID: String) async {
diff --git a/octosentry/UpdateChecker.swift b/octosentry/UpdateChecker.swift
new file mode 100644
index 0000000..e90cab1
--- /dev/null
+++ b/octosentry/UpdateChecker.swift
@@ -0,0 +1,85 @@
+//
+// UpdateChecker.swift
+// octosentry
+//
+// Polls this repo's own GitHub Releases API (spec §9) — no auto-install,
+// no Sparkle, just a link to the release page. Skipped entirely on the
+// Mac App Store build, detected at runtime via the presence of an App
+// Store receipt rather than a separate build configuration: same
+// outcome (this code never runs there) with far less project surface
+// than maintaining a second Xcode configuration/scheme just for this.
+//
+
+import Foundation
+
+actor UpdateChecker {
+ private let session: URLSession
+ private let repoOwner = "zerolabsco"
+ private let repoName = "octosentry"
+
+ init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ struct LatestRelease: Sendable {
+ let version: String
+ let htmlURL: URL
+ }
+
+ func fetchLatestRelease() async throws -> LatestRelease {
+ var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repoOwner)/\(repoName)/releases/latest")!)
+ request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
+ request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
+
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw UpdateCheckError.network(error.localizedDescription)
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw UpdateCheckError.requestFailed
+ }
+
+ let dto: GitHubReleaseDTO
+ do {
+ dto = try JSONDecoder().decode(GitHubReleaseDTO.self, from: data)
+ } catch {
+ throw UpdateCheckError.decodingFailed(error.localizedDescription)
+ }
+
+ guard let url = URL(string: dto.htmlUrl) else {
+ throw UpdateCheckError.decodingFailed("Malformed release URL.")
+ }
+ return LatestRelease(version: dto.tagName, htmlURL: url)
+ }
+}
+
+nonisolated struct GitHubReleaseDTO: Decodable {
+ let tagName: String
+ let htmlUrl: String
+
+ enum CodingKeys: String, CodingKey {
+ case tagName = "tag_name"
+ case htmlUrl = "html_url"
+ }
+}
+
+nonisolated enum UpdateCheckError: Error, LocalizedError {
+ case network(String)
+ case requestFailed
+ case decodingFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .network(let message):
+ "Network error checking for updates: \(message)"
+ case .requestFailed:
+ "Failed to check for updates."
+ case .decodingFailed(let message):
+ "Unexpected response checking for updates: \(message)"
+ }
+ }
+}
diff --git a/octosentry/UpdateStore.swift b/octosentry/UpdateStore.swift
new file mode 100644
index 0000000..1ed463f
--- /dev/null
+++ b/octosentry/UpdateStore.swift
@@ -0,0 +1,55 @@
+//
+// UpdateStore.swift
+// octosentry
+//
+
+import Foundation
+import Observation
+
+@Observable
+final class UpdateStore {
+ private(set) var availableRelease: UpdateChecker.LatestRelease?
+
+ private let checker = UpdateChecker()
+
+ /// True for a Mac App Store build (has an App Store receipt), false for
+ /// a direct DMG/Homebrew build. Runtime check rather than a build flag —
+ /// see UpdateChecker.swift for why.
+ var isMacAppStoreBuild: Bool {
+ guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
+ return FileManager.default.fileExists(atPath: receiptURL.path)
+ }
+
+ func checkForUpdate() async {
+ guard !isMacAppStoreBuild else { return }
+ guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
+ guard let latest = try? await checker.fetchLatestRelease() else { return }
+
+ if Self.isNewer(latest.version, than: currentVersion) {
+ availableRelease = latest
+ }
+ }
+
+ static func isNewer(_ candidate: String, than current: String) -> Bool {
+ let candidateParts = versionComponents(candidate)
+ let currentParts = versionComponents(current)
+ let count = max(candidateParts.count, currentParts.count)
+
+ for i in 0..<count {
+ let candidatePart = i < candidateParts.count ? candidateParts[i] : 0
+ let currentPart = i < currentParts.count ? currentParts[i] : 0
+ if candidatePart != currentPart {
+ return candidatePart > currentPart
+ }
+ }
+ return false
+ }
+
+ private static func versionComponents(_ version: String) -> [Int] {
+ var trimmed = version
+ if trimmed.hasPrefix("v") {
+ trimmed.removeFirst()
+ }
+ return trimmed.split(separator: ".").map { Int($0) ?? 0 }
+ }
+}
diff --git a/octosentry/octosentryApp.swift b/octosentry/octosentryApp.swift
index e80c4a0..d9f388e 100644
--- a/octosentry/octosentryApp.swift
+++ b/octosentry/octosentryApp.swift
@@ -15,10 +15,11 @@ enum SecurityEventWindow {
struct octosentryApp: App {
@State private var store = SecurityEventStore()
@State private var authStore = AuthStore()
+ @State private var updateStore = UpdateStore()
var body: some Scene {
MenuBarExtra {
- SecurityEventListView(store: store, authStore: authStore)
+ SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore)
.frame(width: 380, height: 420)
} label: {
MenuBarIconView(criticalCount: store.unseenCriticalCount)
@@ -26,7 +27,7 @@ struct octosentryApp: App {
.menuBarExtraStyle(.window)
Window("Security Events", id: SecurityEventWindow.id) {
- SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true)
+ SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore, isStandaloneWindow: true)
.frame(minWidth: 420, minHeight: 480)
}
}
diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh
new file mode 100755
index 0000000..14bd608
--- /dev/null
+++ b/scripts/build-dmg.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+#
+# build-dmg.sh
+#
+# Builds a notarized, Developer-ID-signed DMG for direct distribution
+# (spec §9: DMG/Homebrew channel). Requires local one-time setup this
+# script does NOT do for you:
+#
+# 1. A "Developer ID Application" certificate in your keychain, tied to
+# an active Apple Developer Program membership. Xcode > Settings >
+# Accounts > Manage Certificates > + > Developer ID Application.
+# 2. Notarization credentials stored once via:
+# xcrun notarytool store-credentials "octosentry-notary" \
+# --apple-id "[email protected]" \
+# --team-id "YOUR_TEAM_ID" \
+# --password "an-app-specific-password"
+# (App-specific password from appleid.apple.com, not your main
+# Apple ID password.)
+#
+# Usage: scripts/build-dmg.sh [version]
+# Output: build/octosentry-<version>.dmg
+
+set -euo pipefail
+
+VERSION="${1:-dev}"
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BUILD_DIR="$PROJECT_DIR/build"
+ARCHIVE_PATH="$BUILD_DIR/octosentry.xcarchive"
+EXPORT_PATH="$BUILD_DIR/export"
+EXPORT_OPTIONS_PLIST="$BUILD_DIR/export-options.plist"
+DMG_PATH="$BUILD_DIR/octosentry-$VERSION.dmg"
+NOTARY_PROFILE="octosentry-notary"
+
+rm -rf "$BUILD_DIR"
+mkdir -p "$BUILD_DIR"
+
+echo "==> Archiving (Release configuration)"
+xcodebuild archive \
+ -project "$PROJECT_DIR/octosentry.xcodeproj" \
+ -scheme octosentry \
+ -configuration Release \
+ -archivePath "$ARCHIVE_PATH"
+
+cat > "$EXPORT_OPTIONS_PLIST" <<PLIST
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>method</key>
+ <string>developer-id</string>
+</dict>
+</plist>
+PLIST
+
+echo "==> Exporting (Developer ID)"
+xcodebuild -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_PATH" \
+ -exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
+
+APP_PATH="$EXPORT_PATH/octosentry.app"
+
+echo "==> Notarizing"
+DMG_STAGING="$BUILD_DIR/staging"
+mkdir -p "$DMG_STAGING"
+cp -R "$APP_PATH" "$DMG_STAGING/"
+ln -s /Applications "$DMG_STAGING/Applications"
+
+hdiutil create -volname "octosentry" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_PATH"
+
+xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
+
+echo "==> Stapling notarization ticket"
+xcrun stapler staple "$DMG_PATH"
+
+echo "==> Done: $DMG_PATH"