summaryrefslogtreecommitdiff
path: root/octosentry
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 /octosentry
parent29b01b0220968dd9ea70b6c84ca72603bc02042c (diff)
downloadoctosentry-0.6.0.tar.gz
octosentry-0.6.0.tar.bz2
octosentry-0.6.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.
Diffstat (limited to 'octosentry')
-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
11 files changed, 394 insertions, 30 deletions
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)
}
}