native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Wire up self-hosted Sentry + PostHog credentials via Info.plist

Replace upstream's unreplaced placeholder strings (__SENTRY_DSN__ etc.)
with Makefile-driven Info.plist injection from Config/Secrets.env
(gitignored). The archive target now forwards PROWL_SENTRY_DSN,
PROWL_POSTHOG_API_KEY, and PROWL_POSTHOG_HOST to xcodebuild; the app
reads them at startup and skips SDK init when a value is empty or still
contains an unsubstituted $(VAR) placeholder.

Swap the hardware UUID identifier for an install UUID persisted in
UserDefaults so opt-out can actually forget the user. AnalyticsClient
gains reset() which clears both the PostHog identity and the install ID;
SettingsFeature triggers it whenever analyticsEnabled transitions from
true to false.

Tune Sentry options for production on the free tier: tracesSampleRate
0.05, enableAppHangTracking true, environment and releaseName set.
Watchdog termination tracking is intentionally not added because it is
unsupported on native macOS.

onevcat 3267a883 9042d32c

+150 -41
+3
.gitignore
··· 74 74 .env 75 75 build/ 76 76 .DS_Store 77 + 78 + # Release-only credentials (analytics, crash reporting) 79 + Config/Secrets.env
+16
Config/Secrets.env.template
··· 1 + # Analytics and crash reporting credentials for Release builds. 2 + # 3 + # Setup: 4 + # 1. Copy this file: cp Config/Secrets.env.template Config/Secrets.env 5 + # 2. Fill in the real values below. 6 + # 3. Config/Secrets.env is gitignored. 7 + # 8 + # These values are injected into Info.plist at build time by the Makefile's 9 + # `archive` target and read at runtime from Bundle.main. 10 + # 11 + # Debug builds never initialize the SDKs, so empty values are fine during 12 + # local development. 13 + 14 + PROWL_SENTRY_DSN= 15 + PROWL_POSTHOG_API_KEY= 16 + PROWL_POSTHOG_HOST=https://us.i.posthog.com
+9 -1
Makefile
··· 19 19 VERSION ?= 20 20 BUILD ?= 21 21 XCODEBUILD_FLAGS ?= 22 + 23 + # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, 24 + # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. 25 + -include Config/Secrets.env 26 + PROWL_SENTRY_DSN ?= 27 + PROWL_POSTHOG_API_KEY ?= 28 + PROWL_POSTHOG_HOST ?= 29 + 22 30 .DEFAULT_GOAL := help 23 31 .PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream 24 32 ··· 262 270 echo "installed $$DST (Release build, locally signed)" 263 271 264 272 archive: build-ghostty-xcframework embed-cli # Archive Release build for distribution 265 - bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Release -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' 273 + bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Release -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' 266 274 267 275 export-archive: # Export xarchive 268 276 bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcsift -qw --format toon'
+30 -17
supacode/App/supacodeApp.swift
··· 99 99 private static func cliLaunchOpenPath() -> String? { 100 100 let args = ProcessInfo.processInfo.arguments 101 101 guard let flagIndex = args.firstIndex(of: ProwlSocket.cliOpenPathArgument), 102 - args.indices.contains(flagIndex + 1) 102 + args.indices.contains(flagIndex + 1) 103 103 else { 104 104 return nil 105 105 } ··· 107 107 return path.isEmpty ? nil : path 108 108 } 109 109 110 + /// Reads a secret from Info.plist, returning nil when the value is empty or 111 + /// still contains an unsubstituted `$(VAR)` placeholder (the Makefile did not 112 + /// inject a value for that key). 113 + private static func infoPlistSecret(_ dictionary: [String: Any], key: String) -> String? { 114 + guard let value = dictionary[key] as? String else { return nil } 115 + guard !value.isEmpty, !value.hasPrefix("$(") else { return nil } 116 + return value 117 + } 118 + 110 119 @MainActor init() { 111 120 NSWindow.allowsAutomaticWindowTabbing = false 112 121 UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") ··· 117 126 userOverrides: initialSettings.keybindingUserOverrides 118 127 ) 119 128 #if !DEBUG 120 - if initialSettings.crashReportsEnabled { 129 + let infoDictionary = Bundle.main.infoDictionary ?? [:] 130 + let releaseName = (infoDictionary["CFBundleShortVersionString"] as? String).map { "prowl@\($0)" } 131 + 132 + if initialSettings.crashReportsEnabled, let dsn = Self.infoPlistSecret(infoDictionary, key: "ProwlSentryDSN") { 121 133 SentrySDK.start { options in 122 - options.dsn = "__SENTRY_DSN__" 123 - options.tracesSampleRate = 1.0 124 - options.enableAppHangTracking = false 134 + options.dsn = dsn 135 + options.environment = "production" 136 + if let releaseName { options.releaseName = releaseName } 137 + options.tracesSampleRate = 0.05 138 + options.enableAppHangTracking = true 125 139 } 126 140 } 127 - if initialSettings.analyticsEnabled { 128 - let posthogAPIKey = "__POSTHOG_API_KEY__" 129 - let posthogHost = "__POSTHOG_HOST__" 130 - let config = PostHogConfig(apiKey: posthogAPIKey, host: posthogHost) 141 + if initialSettings.analyticsEnabled, 142 + let apiKey = Self.infoPlistSecret(infoDictionary, key: "ProwlPostHogAPIKey"), 143 + let host = Self.infoPlistSecret(infoDictionary, key: "ProwlPostHogHost") 144 + { 145 + let config = PostHogConfig(apiKey: apiKey, host: host) 131 146 config.enableSwizzling = false 132 147 PostHogSDK.shared.setup(config) 133 - if let hardwareUUID = HardwareInfo.uuid { 134 - PostHogSDK.shared.identify(hardwareUUID) 135 - } 148 + PostHogSDK.shared.identify(InstallIdentifier.current) 136 149 } 137 150 #endif 138 151 if let resourceURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") { ··· 451 464 let repoRoot = repository.rootURL 452 465 .standardizedFileURL.path(percentEncoded: false) 453 466 if repoRoot == normalized, 454 - !repository.capabilities.supportsWorktrees, 455 - repository.capabilities.supportsRunnableFolderActions 467 + !repository.capabilities.supportsWorktrees, 468 + repository.capabilities.supportsRunnableFolderActions 456 469 { 457 470 return OpenResolverResult( 458 471 resolution: .exactRoot, worktreeID: repository.id, ··· 479 492 } 480 493 } 481 494 if !repository.capabilities.supportsWorktrees, 482 - repository.capabilities.supportsRunnableFolderActions 495 + repository.capabilities.supportsRunnableFolderActions 483 496 { 484 497 let repoRoot = repository.rootURL 485 498 .standardizedFileURL.path(percentEncoded: false) ··· 509 522 return worktree 510 523 } 511 524 if repository.id == id, 512 - repository.capabilities.supportsRunnableFolderActions, 513 - !repository.capabilities.supportsWorktrees 525 + repository.capabilities.supportsRunnableFolderActions, 526 + !repository.capabilities.supportsWorktrees 514 527 { 515 528 return Worktree( 516 529 id: repository.id,
+9 -1
supacode/Clients/Analytics/AnalyticsClient.swift
··· 5 5 struct AnalyticsClient: Sendable { 6 6 var capture: @Sendable (_ event: String, _ properties: [String: Any]?) -> Void 7 7 var identify: @Sendable (_ distinctId: String) -> Void 8 + var reset: @Sendable () -> Void 8 9 } 9 10 10 11 extension AnalyticsClient: DependencyKey { ··· 22 23 guard settingsFile.global.analyticsEnabled else { return } 23 24 PostHogSDK.shared.identify(distinctId) 24 25 #endif 26 + }, 27 + reset: { 28 + #if !DEBUG 29 + PostHogSDK.shared.reset() 30 + #endif 31 + InstallIdentifier.reset() 25 32 } 26 33 ) 27 34 28 35 static let testValue = AnalyticsClient( 29 36 capture: { _, _ in }, 30 - identify: { _ in } 37 + identify: { _ in }, 38 + reset: {} 31 39 ) 32 40 } 33 41
+4
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 363 363 ) -> Effect<Action> { 364 364 let settings = state.globalSettings 365 365 @Shared(.settingsFile) var settingsFile 366 + let previouslyAnalyticsEnabled = settingsFile.global.analyticsEnabled 366 367 $settingsFile.withLock { $0.global = settings } 367 368 if captureAnalytics, settings.analyticsEnabled { 368 369 analyticsClient.capture("settings_changed", nil) 370 + } 371 + if previouslyAnalyticsEnabled, !settings.analyticsEnabled { 372 + analyticsClient.reset() 369 373 } 370 374 if emitSettingsChanged { 371 375 return .send(.delegate(.settingsChanged(settings)))
+6
supacode/Info.plist
··· 36 36 <false/> 37 37 <key>SUAutomaticallyUpdate</key> 38 38 <false/> 39 + <key>ProwlSentryDSN</key> 40 + <string>$(PROWL_SENTRY_DSN)</string> 41 + <key>ProwlPostHogAPIKey</key> 42 + <string>$(PROWL_POSTHOG_API_KEY)</string> 43 + <key>ProwlPostHogHost</key> 44 + <string>$(PROWL_POSTHOG_HOST)</string> 39 45 <key>UTExportedTypeDeclarations</key> 40 46 <array> 41 47 <dict>
-22
supacode/Support/HardwareInfo.swift
··· 1 - import IOKit 2 - 3 - nonisolated enum HardwareInfo { 4 - static var uuid: String? { 5 - let platformExpert = IOServiceGetMatchingService( 6 - kIOMainPortDefault, 7 - IOServiceMatching("IOPlatformExpertDevice") 8 - ) 9 - 10 - guard platformExpert != 0 else { return nil } 11 - defer { IOObjectRelease(platformExpert) } 12 - 13 - let uuid = IORegistryEntryCreateCFProperty( 14 - platformExpert, 15 - kIOPlatformUUIDKey as CFString, 16 - kCFAllocatorDefault, 17 - 0 18 - ) 19 - 20 - return uuid?.takeRetainedValue() as? String 21 - } 22 - }
+21
supacode/Support/InstallIdentifier.swift
··· 1 + import Foundation 2 + 3 + /// A per-install UUID persisted in UserDefaults. Regenerates after reset or 4 + /// on a fresh install — does not leak a stable hardware identifier. 5 + nonisolated enum InstallIdentifier { 6 + private static let userDefaultsKey = "com.onevcat.prowl.installIdentifier" 7 + 8 + static var current: String { 9 + let defaults = UserDefaults.standard 10 + if let existing = defaults.string(forKey: userDefaultsKey), !existing.isEmpty { 11 + return existing 12 + } 13 + let generated = UUID().uuidString 14 + defaults.set(generated, forKey: userDefaultsKey) 15 + return generated 16 + } 17 + 18 + static func reset() { 19 + UserDefaults.standard.removeObject(forKey: userDefaultsKey) 20 + } 21 + }
+52
supacodeTests/SettingsFeatureTests.swift
··· 364 364 #expect(settingsFile.global.keybindingUserOverrides == overrides) 365 365 } 366 366 367 + @Test(.dependencies) func disablingAnalyticsResetsClient() async { 368 + var initialSettings = GlobalSettings.default 369 + initialSettings.analyticsEnabled = true 370 + @Shared(.settingsFile) var settingsFile 371 + $settingsFile.withLock { $0.global = initialSettings } 372 + let resetCount = LockIsolated(0) 373 + 374 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 375 + SettingsFeature() 376 + } withDependencies: { 377 + $0.analyticsClient.capture = { _, _ in } 378 + $0.analyticsClient.reset = { 379 + resetCount.withValue { $0 += 1 } 380 + } 381 + } 382 + 383 + await store.send(.binding(.set(\.analyticsEnabled, false))) { 384 + $0.analyticsEnabled = false 385 + } 386 + await store.receive(\.delegate.settingsChanged) 387 + await store.finish() 388 + 389 + #expect(resetCount.value == 1) 390 + #expect(settingsFile.global.analyticsEnabled == false) 391 + } 392 + 393 + @Test(.dependencies) func togglingOtherSettingWhileAnalyticsOffDoesNotReset() async { 394 + var initialSettings = GlobalSettings.default 395 + initialSettings.analyticsEnabled = false 396 + initialSettings.confirmBeforeQuit = true 397 + @Shared(.settingsFile) var settingsFile 398 + $settingsFile.withLock { $0.global = initialSettings } 399 + let resetCount = LockIsolated(0) 400 + 401 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 402 + SettingsFeature() 403 + } withDependencies: { 404 + $0.analyticsClient.capture = { _, _ in } 405 + $0.analyticsClient.reset = { 406 + resetCount.withValue { $0 += 1 } 407 + } 408 + } 409 + 410 + await store.send(.binding(.set(\.confirmBeforeQuit, false))) { 411 + $0.confirmBeforeQuit = false 412 + } 413 + await store.receive(\.delegate.settingsChanged) 414 + await store.finish() 415 + 416 + #expect(resetCount.value == 0) 417 + } 418 + 367 419 @Test(.dependencies) func clearTerminalLayoutSnapshotSendsDelegate() async { 368 420 let store = TestStore(initialState: SettingsFeature.State()) { 369 421 SettingsFeature()