Commit 3690864493
Unsigned
Layout: unified · split
README.md +14 −15
| @@ -14,33 +14,32 @@ just a fast triage view that deep-links out to github.com to act. | |||
| 14 | - Click an alert to open it directly on github.com | 14 | - Click an alert to open it directly on github.com |
| 15 | - Errors and unavailable sources (e.g. an alert type disabled for a repo) | 15 | - Errors and unavailable sources (e.g. an alert type disabled for a repo) |
| 16 | are surfaced in the popover instead of failing silently | 16 | are surfaced in the popover instead of failing silently |
| 17 | - Add or remove watched repos from the popover; a background poll keeps | ||
| 18 | the feed fresh even while it's closed | ||
| 19 | - Sign in with GitHub via device authorization — no password or manually | ||
| 20 | generated token needed, and nothing is ever typed into the app itself | ||
| 17 | - Zero third-party dependencies — pure SwiftUI and URLSession | 21 | - Zero third-party dependencies — pure SwiftUI and URLSession |
| 18 | 22 | ||
| 19 | ## Requirements | 23 | ## Requirements |
| 20 | 24 | ||
| 21 | - macOS 14 or later | 25 | - macOS 14 or later |
| 22 | - A GitHub personal access token (fine-grained or classic) with read | 26 | - A GitHub account with access to whatever repos you want to watch |
| 23 | access to Dependabot alerts, code scanning alerts, and secret scanning | ||
| 24 | alerts for the repo you want to watch | ||
| 25 | 27 | ||
| 26 | ## Usage | 28 | ## Usage |
| 27 | 29 | ||
| 28 | octosentry currently watches a single, hardcoded repo and reads its | ||
| 29 | GitHub token from the `GITHUB_TOKEN` environment variable — this is a | ||
| 30 | development-only shortcut ahead of a proper device authorization flow. | ||
| 31 | |||
| 32 | 1. Build and run the app (see Building, below). | 30 | 1. Build and run the app (see Building, below). |
| 33 | 2. Set `GITHUB_TOKEN` in your **personal, non-shared** Xcode scheme | 31 | 2. Click the shield icon in the menu bar, then **Sign in with GitHub**. |
| 34 | (Product → Scheme → Edit Scheme… → Run → Arguments → | 32 | You'll get a short code — click **Open GitHub**, enter the code there, |
| 35 | Environment Variables). Don't add it to a shared scheme — that would | 33 | and authorize. The popover updates automatically once that completes. |
| 36 | commit the token to git. | 34 | 3. Click the gear icon to add or remove watched repos (`owner/repo`). |
| 37 | 3. Click the shield icon in the menu bar to open the popover. It fetches | ||
| 38 | automatically on open, or use the refresh button. | ||
| 39 | 4. Click any alert to open it on github.com. | 35 | 4. Click any alert to open it on github.com. |
| 40 | 36 | ||
| 41 | If a source shows as unavailable, it usually means that alert type is | 37 | If a source shows as unavailable, it usually means that alert type is |
| 42 | disabled for the repo, or the token is missing that one permission — not | 38 | disabled for the repo, or your account lacks permission for it — not |
| 43 | that something is broken. | 39 | that something is broken. Classic OAuth's `security_events` scope |
| 40 | (what device flow grants) may not be sufficient for Dependabot alerts on | ||
| 41 | private repos — if you hit that, it needs verifying against a real | ||
| 42 | private repo case by case. | ||
| 44 | 43 | ||
| 45 | ## Building | 44 | ## Building |
| 46 | 45 | ||
octosentry/AuthState.swift added +12
| @@ -0,0 +1,12 @@ | |||
| 1 | // | ||
| 2 | // AuthState.swift | ||
| 3 | // octosentry | ||
| 4 | // | ||
| 5 | |||
| 6 | import Foundation | ||
| 7 | |||
| 8 | nonisolated enum AuthState { | ||
| 9 | case signedOut | ||
| 10 | case awaitingAuthorization(userCode: String, verificationURL: URL) | ||
| 11 | case signedIn | ||
| 12 | } | ||
octosentry/AuthStore.swift added +60
| @@ -0,0 +1,60 @@ | |||
| 1 | // | ||
| 2 | // AuthStore.swift | ||
| 3 | // octosentry | ||
| 4 | // | ||
| 5 | // Drives the device authorization flow and mirrors whether a token is | ||
| 6 | // currently in the Keychain. Replaces the GITHUB_TOKEN env var dev | ||
| 7 | // shortcut (spec §13) with the real v1 auth flow (spec §6). | ||
| 8 | // | ||
| 9 | |||
| 10 | import Foundation | ||
| 11 | import Observation | ||
| 12 | |||
| 13 | @Observable | ||
| 14 | final class AuthStore { | ||
| 15 | private(set) var state: AuthState | ||
| 16 | private(set) var errorMessage: String? | ||
| 17 | |||
| 18 | private let client = GitHubDeviceAuthClient() | ||
| 19 | private var authorizationTask: Task<Void, Never>? | ||
| 20 | |||
| 21 | init() { | ||
| 22 | state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut | ||
| 23 | } | ||
| 24 | |||
| 25 | var isSignedIn: Bool { | ||
| 26 | if case .signedIn = state { return true } | ||
| 27 | return false | ||
| 28 | } | ||
| 29 | |||
| 30 | func signIn() { | ||
| 31 | guard authorizationTask == nil else { return } | ||
| 32 | errorMessage = nil | ||
| 33 | |||
| 34 | authorizationTask = Task { | ||
| 35 | defer { authorizationTask = nil } | ||
| 36 | do { | ||
| 37 | let deviceCode = try await client.requestDeviceCode() | ||
| 38 | state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) | ||
| 39 | |||
| 40 | let token = try await client.pollForToken( | ||
| 41 | deviceCode: deviceCode.deviceCode, | ||
| 42 | interval: deviceCode.interval, | ||
| 43 | expiresIn: deviceCode.expiresIn | ||
| 44 | ) | ||
| 45 | try KeychainTokenStore.save(token) | ||
| 46 | state = .signedIn | ||
| 47 | } catch { | ||
| 48 | errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription | ||
| 49 | state = .signedOut | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | func signOut() { | ||
| 55 | authorizationTask?.cancel() | ||
| 56 | authorizationTask = nil | ||
| 57 | KeychainTokenStore.delete() | ||
| 58 | state = .signedOut | ||
| 59 | } | ||
| 60 | } | ||
octosentry/DeviceAuthModels.swift added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | // | ||
| 2 | // DeviceAuthModels.swift | ||
| 3 | // octosentry | ||
| 4 | // | ||
| 5 | // Wire types for GitHub's OAuth 2.0 Device Authorization Grant | ||
| 6 | // (RFC 8628): github.com/login/device/code and | ||
| 7 | // github.com/login/oauth/access_token. | ||
| 8 | // | ||
| 9 | |||
| 10 | import Foundation | ||
| 11 | |||
| 12 | nonisolated struct DeviceCodeResponse: Decodable { | ||
| 13 | let deviceCode: String | ||
| 14 | let userCode: String | ||
| 15 | let verificationUri: URL | ||
| 16 | let expiresIn: Int | ||
| 17 | let interval: Int | ||
| 18 | |||
| 19 | enum CodingKeys: String, CodingKey { | ||
| 20 | case deviceCode = "device_code" | ||
| 21 | case userCode = "user_code" | ||
| 22 | case verificationUri = "verification_uri" | ||
| 23 | case expiresIn = "expires_in" | ||
| 24 | case interval | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | nonisolated struct AccessTokenResponse: Decodable { | ||
| 29 | let accessToken: String? | ||
| 30 | let error: String? | ||
| 31 | let interval: Int? | ||
| 32 | |||
| 33 | enum CodingKeys: String, CodingKey { | ||
| 34 | case accessToken = "access_token" | ||
| 35 | case error | ||
| 36 | case interval | ||
| 37 | } | ||
| 38 | } | ||
octosentry/GitHubAPIError.swift +1 −1
| @@ -19,7 +19,7 @@ enum GitHubAPIError: Error, LocalizedError, Sendable { | |||
| 19 | var errorDescription: String? { | 19 | var errorDescription: String? { |
| 20 | switch self { | 20 | switch self { |
| 21 | case .missingToken: | 21 | case .missingToken: |
| 22 | "No GitHub token found in the GITHUB_TOKEN environment variable." | 22 | "Not signed in to GitHub." |
| 23 | case .network(let message): | 23 | case .network(let message): |
| 24 | "Network error: \(message)" | 24 | "Network error: \(message)" |
| 25 | case .invalidResponse: | 25 | case .invalidResponse: |
octosentry/GitHubDeviceAuthClient.swift added +135
| @@ -0,0 +1,135 @@ | |||
| 1 | // | ||
| 2 | // GitHubDeviceAuthClient.swift | ||
| 3 | // octosentry | ||
| 4 | // | ||
| 5 | // Implements the GitHub device authorization flow (spec §6): request a | ||
| 6 | // device/user code pair, show the user code, then poll until they've | ||
| 7 | // authorized it on github.com/login/device. No client secret involved — | ||
| 8 | // device flow for native apps doesn't use one. | ||
| 9 | // | ||
| 10 | |||
| 11 | import Foundation | ||
| 12 | |||
| 13 | actor GitHubDeviceAuthClient { | ||
| 14 | // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). | ||
| 15 | // Not a secret — safe to embed in source. | ||
| 16 | private let clientID = "Ov23li6tqaTghDc4IJYv" | ||
| 17 | |||
| 18 | // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth | ||
| 19 | // scopes have no read-only variant (unlike fine-grained PATs); this is the | ||
| 20 | // narrowest scope GitHub offers for these three endpoints via OAuth Apps. | ||
| 21 | private let scope = "security_events" | ||
| 22 | |||
| 23 | private let session: URLSession | ||
| 24 | |||
| 25 | init(session: URLSession = .shared) { | ||
| 26 | self.session = session | ||
| 27 | } | ||
| 28 | |||
| 29 | func requestDeviceCode() async throws -> DeviceCodeResponse { | ||
| 30 | let data = try await post( | ||
| 31 | url: URL(string: "https://github.com/login/device/code")!, | ||
| 32 | parameters: ["client_id": clientID, "scope": scope] | ||
| 33 | ) | ||
| 34 | do { | ||
| 35 | return try JSONDecoder().decode(DeviceCodeResponse.self, from: data) | ||
| 36 | } catch { | ||
| 37 | throw DeviceAuthError.decodingFailed(error.localizedDescription) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /// Polls until the user authorizes, denies, or the device code expires. | ||
| 42 | func pollForToken(deviceCode: String, interval: Int, expiresIn: Int) async throws -> String { | ||
| 43 | var currentInterval = interval | ||
| 44 | let deadline = Date().addingTimeInterval(TimeInterval(expiresIn)) | ||
| 45 | |||
| 46 | while Date() < deadline { | ||
| 47 | try await Task.sleep(for: .seconds(currentInterval)) | ||
| 48 | try Task.checkCancellation() | ||
| 49 | |||
| 50 | let data = try await post( | ||
| 51 | url: URL(string: "https://github.com/login/oauth/access_token")!, | ||
| 52 | parameters: [ | ||
| 53 | "client_id": clientID, | ||
| 54 | "device_code": deviceCode, | ||
| 55 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", | ||
| 56 | ] | ||
| 57 | ) | ||
| 58 | |||
| 59 | let response: AccessTokenResponse | ||
| 60 | do { | ||
| 61 | response = try JSONDecoder().decode(AccessTokenResponse.self, from: data) | ||
| 62 | } catch { | ||
| 63 | throw DeviceAuthError.decodingFailed(error.localizedDescription) | ||
| 64 | } | ||
| 65 | |||
| 66 | if let token = response.accessToken { | ||
| 67 | return token | ||
| 68 | } | ||
| 69 | |||
| 70 | switch response.error { | ||
| 71 | case "authorization_pending": | ||
| 72 | continue | ||
| 73 | case "slow_down": | ||
| 74 | currentInterval = response.interval ?? (currentInterval + 5) | ||
| 75 | case "expired_token": | ||
| 76 | throw DeviceAuthError.expired | ||
| 77 | case "access_denied": | ||
| 78 | throw DeviceAuthError.denied | ||
| 79 | default: | ||
| 80 | throw DeviceAuthError.unknown(response.error ?? "unrecognized response") | ||
| 81 | } | ||
| 82 | } | ||
| 83 | throw DeviceAuthError.expired | ||
| 84 | } | ||
| 85 | |||
| 86 | private func post(url: URL, parameters: [String: String]) async throws -> Data { | ||
| 87 | var components = URLComponents() | ||
| 88 | components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } | ||
| 89 | |||
| 90 | var request = URLRequest(url: url) | ||
| 91 | request.httpMethod = "POST" | ||
| 92 | request.setValue("application/json", forHTTPHeaderField: "Accept") | ||
| 93 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") | ||
| 94 | request.httpBody = Data((components.percentEncodedQuery ?? "").utf8) | ||
| 95 | |||
| 96 | let data: Data | ||
| 97 | let response: URLResponse | ||
| 98 | do { | ||
| 99 | (data, response) = try await session.data(for: request) | ||
| 100 | } catch { | ||
| 101 | throw DeviceAuthError.network(error.localizedDescription) | ||
| 102 | } | ||
| 103 | |||
| 104 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { | ||
| 105 | throw DeviceAuthError.requestFailed | ||
| 106 | } | ||
| 107 | return data | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | nonisolated enum DeviceAuthError: Error, LocalizedError { | ||
| 112 | case network(String) | ||
| 113 | case requestFailed | ||
| 114 | case decodingFailed(String) | ||
| 115 | case expired | ||
| 116 | case denied | ||
| 117 | case unknown(String) | ||
| 118 | |||
| 119 | var errorDescription: String? { | ||
| 120 | switch self { | ||
| 121 | case .network(let message): | ||
| 122 | "Network error: \(message)" | ||
| 123 | case .requestFailed: | ||
| 124 | "Failed to reach GitHub." | ||
| 125 | case .decodingFailed(let message): | ||
| 126 | "Unexpected response from GitHub: \(message)" | ||
| 127 | case .expired: | ||
| 128 | "The sign-in code expired before it was used. Try again." | ||
| 129 | case .denied: | ||
| 130 | "Sign-in was denied on GitHub." | ||
| 131 | case .unknown(let message): | ||
| 132 | "GitHub sign-in failed: \(message)" | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
octosentry/KeychainTokenStore.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | // | ||
| 2 | // KeychainTokenStore.swift | ||
| 3 | // octosentry | ||
| 4 | // | ||
| 5 | // Stores the GitHub OAuth token in the app's own Keychain item. Not | ||
| 6 | // synced to iCloud Keychain by default (spec §6) — deliberate given the | ||
| 7 | // token's access scope. No keychain-access-groups entitlement needed: | ||
| 8 | // that's only required to share an item across multiple apps/extensions, | ||
| 9 | // not for an app reading/writing its own item. | ||
| 10 | // | ||
| 11 | |||
| 12 | import Foundation | ||
| 13 | import Security | ||
| 14 | |||
| 15 | nonisolated enum KeychainTokenStore { | ||
| 16 | private static let service = "net.cleberg.octosentry.github-token" | ||
| 17 | private static let account = "github-oauth-token" | ||
| 18 | |||
| 19 | static func save(_ token: String) throws { | ||
| 20 | let query: [String: Any] = [ | ||
| 21 | kSecClass as String: kSecClassGenericPassword, | ||
| 22 | kSecAttrService as String: service, | ||
| 23 | kSecAttrAccount as String: account, | ||
| 24 | ] | ||
| 25 | SecItemDelete(query as CFDictionary) | ||
| 26 | |||
| 27 | var attributes = query | ||
| 28 | attributes[kSecValueData as String] = Data(token.utf8) | ||
| 29 | attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock | ||
| 30 | attributes[kSecAttrSynchronizable as String] = false | ||
| 31 | |||
| 32 | let status = SecItemAdd(attributes as CFDictionary, nil) | ||
| 33 | guard status == errSecSuccess else { | ||
| 34 | throw KeychainError.unhandled(status) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | static func load() -> String? { | ||
| 39 | let query: [String: Any] = [ | ||
| 40 | kSecClass as String: kSecClassGenericPassword, | ||
| 41 | kSecAttrService as String: service, | ||
| 42 | kSecAttrAccount as String: account, | ||
| 43 | kSecReturnData as String: true, | ||
| 44 | kSecMatchLimit as String: kSecMatchLimitOne, | ||
| 45 | ] | ||
| 46 | |||
| 47 | var result: AnyObject? | ||
| 48 | let status = SecItemCopyMatching(query as CFDictionary, &result) | ||
| 49 | guard status == errSecSuccess, let data = result as? Data else { return nil } | ||
| 50 | return String(data: data, encoding: .utf8) | ||
| 51 | } | ||
| 52 | |||
| 53 | static func delete() { | ||
| 54 | let query: [String: Any] = [ | ||
| 55 | kSecClass as String: kSecClassGenericPassword, | ||
| 56 | kSecAttrService as String: service, | ||
| 57 | kSecAttrAccount as String: account, | ||
| 58 | ] | ||
| 59 | SecItemDelete(query as CFDictionary) | ||
| 60 | } | ||
| 61 | |||
| 62 | enum KeychainError: Error, LocalizedError { | ||
| 63 | case unhandled(OSStatus) | ||
| 64 | |||
| 65 | var errorDescription: String? { | ||
| 66 | switch self { | ||
| 67 | case .unhandled(let status): | ||
| 68 | let message = SecCopyErrorMessageString(status, nil) as String? ?? "unknown" | ||
| 69 | return "Keychain error \(status): \(message)" | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
octosentry/SecurityEventListView.swift +25 −10
| @@ -8,20 +8,24 @@ import SwiftUI | |||
| 8 | 8 | ||
| 9 | struct SecurityEventListView: View { | 9 | struct SecurityEventListView: View { |
| 10 | var store: SecurityEventStore | 10 | var store: SecurityEventStore |
| 11 | var authStore: AuthStore | ||
| 11 | @State private var showingRepoManager = false | 12 | @State private var showingRepoManager = false |
| 12 | 13 | ||
| 13 | var body: some View { | 14 | var body: some View { |
| 14 | VStack(alignment: .leading, spacing: 0) { | 15 | VStack(alignment: .leading, spacing: 0) { |
| 15 | header | 16 | header |
| 16 | Divider() | 17 | Divider() |
| 17 | if showingRepoManager { | 18 | if !authStore.isSignedIn { |
| 18 | RepoManagerView(store: store) | 19 | SignInView(authStore: authStore) |
| 20 | } else if showingRepoManager { | ||
| 21 | RepoManagerView(store: store, authStore: authStore) | ||
| 19 | } else { | 22 | } else { |
| 20 | content | 23 | content |
| 21 | } | 24 | } |
| 22 | } | 25 | } |
| 23 | .frame(width: 380, height: 420) | 26 | .frame(width: 380, height: 420) |
| 24 | .task { | 27 | .task(id: authStore.isSignedIn) { |
| 28 | guard authStore.isSignedIn else { return } | ||
| 25 | await store.refresh() | 29 | await store.refresh() |
| 26 | store.startPolling() | 30 | store.startPolling() |
| 27 | } | 31 | } |
| @@ -39,7 +43,7 @@ struct SecurityEventListView: View { | |||
| 39 | 43 | ||
| 40 | Spacer() | 44 | Spacer() |
| 41 | 45 | ||
| 42 | if !showingRepoManager { | 46 | if authStore.isSignedIn && !showingRepoManager { |
| 43 | Picker("Minimum severity", selection: Binding( | 47 | Picker("Minimum severity", selection: Binding( |
| 44 | get: { store.minimumSeverity }, | 48 | get: { store.minimumSeverity }, |
| 45 | set: { newValue in Task { await store.setMinimumSeverity(newValue) } } | 49 | set: { newValue in Task { await store.setMinimumSeverity(newValue) } } |
| @@ -61,12 +65,14 @@ struct SecurityEventListView: View { | |||
| 61 | .disabled(store.isLoading) | 65 | .disabled(store.isLoading) |
| 62 | } | 66 | } |
| 63 | 67 | ||
| 64 | Button { | 68 | if authStore.isSignedIn { |
| 65 | showingRepoManager.toggle() | 69 | Button { |
| 66 | } label: { | 70 | showingRepoManager.toggle() |
| 67 | Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") | 71 | } label: { |
| 72 | Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") | ||
| 73 | } | ||
| 74 | .buttonStyle(.plain) | ||
| 68 | } | 75 | } |
| 69 | .buttonStyle(.plain) | ||
| 70 | 76 | ||
| 71 | Button("Quit") { | 77 | Button("Quit") { |
| 72 | NSApplication.shared.terminate(nil) | 78 | NSApplication.shared.terminate(nil) |
| @@ -121,6 +127,7 @@ struct SecurityEventListView: View { | |||
| 121 | 127 | ||
| 122 | private struct RepoManagerView: View { | 128 | private struct RepoManagerView: View { |
| 123 | var store: SecurityEventStore | 129 | var store: SecurityEventStore |
| 130 | var authStore: AuthStore | ||
| 124 | @State private var newRepoText = "" | 131 | @State private var newRepoText = "" |
| 125 | 132 | ||
| 126 | var body: some View { | 133 | var body: some View { |
| @@ -167,6 +174,14 @@ private struct RepoManagerView: View { | |||
| 167 | } | 174 | } |
| 168 | 175 | ||
| 169 | Spacer() | 176 | Spacer() |
| 177 | |||
| 178 | Divider() | ||
| 179 | |||
| 180 | Button("Sign Out") { | ||
| 181 | authStore.signOut() | ||
| 182 | } | ||
| 183 | .buttonStyle(.plain) | ||
| 184 | .foregroundStyle(.red) | ||
| 170 | } | 185 | } |
| 171 | .padding(12) | 186 | .padding(12) |
| 172 | .frame(maxWidth: .infinity, alignment: .leading) | 187 | .frame(maxWidth: .infinity, alignment: .leading) |
| @@ -234,5 +249,5 @@ private struct StatusView: View { | |||
| 234 | } | 249 | } |
| 235 | 250 | ||
| 236 | #Preview { | 251 | #Preview { |
| 237 | SecurityEventListView(store: SecurityEventStore()) | 252 | SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) |
| 238 | } | 253 | } |
octosentry/SecurityEventStore.swift +4 −4
| @@ -3,8 +3,8 @@ | |||
| 3 | // octosentry | 3 | // octosentry |
| 4 | // | 4 | // |
| 5 | // Holds the fetched event stream for the popover. Watch list, seen-state, | 5 | // Holds the fetched event stream for the popover. Watch list, seen-state, |
| 6 | // and last-fetch timestamps are persisted (see PersistedState); the PAT | 6 | // and last-fetch timestamps are persisted (see PersistedState); the token |
| 7 | // is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13). | 7 | // comes from Keychain, put there by the device authorization flow (spec §6). |
| 8 | // | 8 | // |
| 9 | // Each alert source is fetched independently, per repo, so a problem | 9 | // Each alert source is fetched independently, per repo, so a problem |
| 10 | // with one endpoint (or one repo) doesn't blank out the rest. A 403/404 | 10 | // with one endpoint (or one repo) doesn't blank out the rest. A 403/404 |
| @@ -42,8 +42,8 @@ final class SecurityEventStore { | |||
| 42 | minimumSeverity = state.minimumSeverity | 42 | minimumSeverity = state.minimumSeverity |
| 43 | watchedRepos = state.watchedRepos | 43 | watchedRepos = state.watchedRepos |
| 44 | 44 | ||
| 45 | guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { | 45 | guard let token = KeychainTokenStore.load() else { |
| 46 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] | 46 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] |
| 47 | return | 47 | return |
| 48 | } | 48 | } |
| 49 | 49 | ||
octosentry/SignInView.swift added +86
| @@ -0,0 +1,86 @@ | |||
| 1 | // | ||
| 2 | // SignInView.swift | ||
| 3 | // octosentry | ||
| 4 | // | ||
| 5 | |||
| 6 | import AppKit | ||
| 7 | import SwiftUI | ||
| 8 | |||
| 9 | struct SignInView: View { | ||
| 10 | var authStore: AuthStore | ||
| 11 | |||
| 12 | var body: some View { | ||
| 13 | VStack(spacing: 16) { | ||
| 14 | Spacer() | ||
| 15 | |||
| 16 | switch authStore.state { | ||
| 17 | case .signedOut: | ||
| 18 | signedOutContent | ||
| 19 | case .awaitingAuthorization(let userCode, let verificationURL): | ||
| 20 | awaitingAuthorizationContent(userCode: userCode, verificationURL: verificationURL) | ||
| 21 | case .signedIn: | ||
| 22 | EmptyView() | ||
| 23 | } | ||
| 24 | |||
| 25 | if let errorMessage = authStore.errorMessage { | ||
| 26 | Text(errorMessage) | ||
| 27 | .font(.caption) | ||
| 28 | .foregroundStyle(.red) | ||
| 29 | .multilineTextAlignment(.center) | ||
| 30 | .padding(.horizontal) | ||
| 31 | } | ||
| 32 | |||
| 33 | Spacer() | ||
| 34 | } | ||
| 35 | .frame(maxWidth: .infinity, maxHeight: .infinity) | ||
| 36 | .padding() | ||
| 37 | } | ||
| 38 | |||
| 39 | private var signedOutContent: some View { | ||
| 40 | VStack(spacing: 16) { | ||
| 41 | Image(systemName: "shield.lefthalf.filled") | ||
| 42 | .font(.system(size: 40)) | ||
| 43 | .foregroundStyle(.secondary) | ||
| 44 | Text("Sign in with GitHub to see your security alerts.") | ||
| 45 | .font(.callout) | ||
| 46 | .multilineTextAlignment(.center) | ||
| 47 | .foregroundStyle(.secondary) | ||
| 48 | .padding(.horizontal) | ||
| 49 | Button("Sign in with GitHub") { | ||
| 50 | authStore.signIn() | ||
| 51 | } | ||
| 52 | .buttonStyle(.borderedProminent) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | private func awaitingAuthorizationContent(userCode: String, verificationURL: URL) -> some View { | ||
| 57 | VStack(spacing: 12) { | ||
| 58 | Text("Enter this code on GitHub") | ||
| 59 | .font(.callout) | ||
| 60 | .foregroundStyle(.secondary) | ||
| 61 | |||
| 62 | Text(userCode) | ||
| 63 | .font(.system(.title, design: .monospaced).weight(.bold)) | ||
| 64 | .textSelection(.enabled) | ||
| 65 | |||
| 66 | HStack(spacing: 8) { | ||
| 67 | Button("Copy Code") { | ||
| 68 | NSPasteboard.general.clearContents() | ||
| 69 | NSPasteboard.general.setString(userCode, forType: .string) | ||
| 70 | } | ||
| 71 | Button("Open GitHub") { | ||
| 72 | NSWorkspace.shared.open(verificationURL) | ||
| 73 | } | ||
| 74 | .buttonStyle(.borderedProminent) | ||
| 75 | } | ||
| 76 | |||
| 77 | ProgressView() | ||
| 78 | .controlSize(.small) | ||
| 79 | .padding(.top, 4) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | #Preview { | ||
| 85 | SignInView(authStore: AuthStore()) | ||
| 86 | } | ||
octosentry/octosentryApp.swift +2 −1
| @@ -10,10 +10,11 @@ import SwiftUI | |||
| 10 | @main | 10 | @main |
| 11 | struct octosentryApp: App { | 11 | struct octosentryApp: App { |
| 12 | @State private var store = SecurityEventStore() | 12 | @State private var store = SecurityEventStore() |
| 13 | @State private var authStore = AuthStore() | ||
| 13 | 14 | ||
| 14 | var body: some Scene { | 15 | var body: some Scene { |
| 15 | MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { | 16 | MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { |
| 16 | SecurityEventListView(store: store) | 17 | SecurityEventListView(store: store, authStore: authStore) |
| 17 | } | 18 | } |
| 18 | .menuBarExtraStyle(.window) | 19 | .menuBarExtraStyle(.window) |
| 19 | } | 20 | } |