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