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 | 14 | - Click an alert to open it directly on github.com |
| 15 | 15 | - Errors and unavailable sources (e.g. an alert type disabled for a repo) |
| 16 | 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 | 21 | - Zero third-party dependencies — pure SwiftUI and URLSession |
| 18 | 22 | |
| 19 | 23 | ## Requirements |
| 20 | 24 | |
| 21 | 25 | - macOS 14 or later |
| 22 | - A GitHub personal access token (fine-grained or classic) with read | |
| 23 | access to Dependabot alerts, code scanning alerts, and secret scanning | |
| 24 | alerts for the repo you want to watch | |
| 26 | - A GitHub account with access to whatever repos you want to watch | |
| 25 | 27 | |
| 26 | 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 | 30 | 1. Build and run the app (see Building, below). |
| 33 | 2. Set `GITHUB_TOKEN` in your **personal, non-shared** Xcode scheme | |
| 34 | (Product → Scheme → Edit Scheme… → Run → Arguments → | |
| 35 | Environment Variables). Don't add it to a shared scheme — that would | |
| 36 | commit the token to git. | |
| 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. | |
| 31 | 2. Click the shield icon in the menu bar, then **Sign in with GitHub**. | |
| 32 | You'll get a short code — click **Open GitHub**, enter the code there, | |
| 33 | and authorize. The popover updates automatically once that completes. | |
| 34 | 3. Click the gear icon to add or remove watched repos (`owner/repo`). | |
| 39 | 35 | 4. Click any alert to open it on github.com. |
| 40 | 36 | |
| 41 | 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 | |
| 43 | that something is broken. | |
| 38 | disabled for the repo, or your account lacks permission for it — not | |
| 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 | 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 | 19 | var errorDescription: String? { |
| 20 | 20 | switch self { |
| 21 | 21 | case .missingToken: |
| 22 | "No GitHub token found in the GITHUB_TOKEN environment variable." | |
| 22 | "Not signed in to GitHub." | |
| 23 | 23 | case .network(let message): |
| 24 | 24 | "Network error: \(message)" |
| 25 | 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 | 9 | struct SecurityEventListView: View { |
| 10 | 10 | var store: SecurityEventStore |
| 11 | var authStore: AuthStore | |
| 11 | 12 | @State private var showingRepoManager = false |
| 12 | 13 | |
| 13 | 14 | var body: some View { |
| 14 | 15 | VStack(alignment: .leading, spacing: 0) { |
| 15 | 16 | header |
| 16 | 17 | Divider() |
| 17 | if showingRepoManager { | |
| 18 | RepoManagerView(store: store) | |
| 18 | if !authStore.isSignedIn { | |
| 19 | SignInView(authStore: authStore) | |
| 20 | } else if showingRepoManager { | |
| 21 | RepoManagerView(store: store, authStore: authStore) | |
| 19 | 22 | } else { |
| 20 | 23 | content |
| 21 | 24 | } |
| 22 | 25 | } |
| 23 | 26 | .frame(width: 380, height: 420) |
| 24 | .task { | |
| 27 | .task(id: authStore.isSignedIn) { | |
| 28 | guard authStore.isSignedIn else { return } | |
| 25 | 29 | await store.refresh() |
| 26 | 30 | store.startPolling() |
| 27 | 31 | } |
| @@ -39,7 +43,7 @@ struct SecurityEventListView: View { | ||
| 39 | 43 | |
| 40 | 44 | Spacer() |
| 41 | 45 | |
| 42 | if !showingRepoManager { | |
| 46 | if authStore.isSignedIn && !showingRepoManager { | |
| 43 | 47 | Picker("Minimum severity", selection: Binding( |
| 44 | 48 | get: { store.minimumSeverity }, |
| 45 | 49 | set: { newValue in Task { await store.setMinimumSeverity(newValue) } } |
| @@ -61,12 +65,14 @@ struct SecurityEventListView: View { | ||
| 61 | 65 | .disabled(store.isLoading) |
| 62 | 66 | } |
| 63 | 67 | |
| 64 | Button { | |
| 65 | showingRepoManager.toggle() | |
| 66 | } label: { | |
| 67 | Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") | |
| 68 | if authStore.isSignedIn { | |
| 69 | Button { | |
| 70 | showingRepoManager.toggle() | |
| 71 | } label: { | |
| 72 | Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") | |
| 73 | } | |
| 74 | .buttonStyle(.plain) | |
| 68 | 75 | } |
| 69 | .buttonStyle(.plain) | |
| 70 | 76 | |
| 71 | 77 | Button("Quit") { |
| 72 | 78 | NSApplication.shared.terminate(nil) |
| @@ -121,6 +127,7 @@ struct SecurityEventListView: View { | ||
| 121 | 127 | |
| 122 | 128 | private struct RepoManagerView: View { |
| 123 | 129 | var store: SecurityEventStore |
| 130 | var authStore: AuthStore | |
| 124 | 131 | @State private var newRepoText = "" |
| 125 | 132 | |
| 126 | 133 | var body: some View { |
| @@ -167,6 +174,14 @@ private struct RepoManagerView: View { | ||
| 167 | 174 | } |
| 168 | 175 | |
| 169 | 176 | Spacer() |
| 177 | ||
| 178 | Divider() | |
| 179 | ||
| 180 | Button("Sign Out") { | |
| 181 | authStore.signOut() | |
| 182 | } | |
| 183 | .buttonStyle(.plain) | |
| 184 | .foregroundStyle(.red) | |
| 170 | 185 | } |
| 171 | 186 | .padding(12) |
| 172 | 187 | .frame(maxWidth: .infinity, alignment: .leading) |
| @@ -234,5 +249,5 @@ private struct StatusView: View { | ||
| 234 | 249 | } |
| 235 | 250 | |
| 236 | 251 | #Preview { |
| 237 | SecurityEventListView(store: SecurityEventStore()) | |
| 252 | SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) | |
| 238 | 253 | } |
octosentry/SecurityEventStore.swift +4 −4
| @@ -3,8 +3,8 @@ | ||
| 3 | 3 | // octosentry |
| 4 | 4 | // |
| 5 | 5 | // Holds the fetched event stream for the popover. Watch list, seen-state, |
| 6 | // and last-fetch timestamps are persisted (see PersistedState); the PAT | |
| 7 | // is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13). | |
| 6 | // and last-fetch timestamps are persisted (see PersistedState); the token | |
| 7 | // comes from Keychain, put there by the device authorization flow (spec §6). | |
| 8 | 8 | // |
| 9 | 9 | // Each alert source is fetched independently, per repo, so a problem |
| 10 | 10 | // with one endpoint (or one repo) doesn't blank out the rest. A 403/404 |
| @@ -42,8 +42,8 @@ final class SecurityEventStore { | ||
| 42 | 42 | minimumSeverity = state.minimumSeverity |
| 43 | 43 | watchedRepos = state.watchedRepos |
| 44 | 44 | |
| 45 | guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { | |
| 46 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] | |
| 45 | guard let token = KeychainTokenStore.load() else { | |
| 46 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] | |
| 47 | 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 | 10 | @main |
| 11 | 11 | struct octosentryApp: App { |
| 12 | 12 | @State private var store = SecurityEventStore() |
| 13 | @State private var authStore = AuthStore() | |
| 13 | 14 | |
| 14 | 15 | var body: some Scene { |
| 15 | 16 | MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { |
| 16 | SecurityEventListView(store: store) | |
| 17 | SecurityEventListView(store: store, authStore: authStore) | |
| 17 | 18 | } |
| 18 | 19 | .menuBarExtraStyle(.window) |
| 19 | 20 | } |