diff --git a/.gitignore b/.gitignore index f9466c47..38bbdb95 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ Resources/terminfo .env build/ .DS_Store + +# Release-only credentials (analytics, crash reporting) +Config/Secrets.env diff --git a/Config/Secrets.env.template b/Config/Secrets.env.template new file mode 100644 index 00000000..5332a853 --- /dev/null +++ b/Config/Secrets.env.template @@ -0,0 +1,16 @@ +# Analytics and crash reporting credentials for Release builds. +# +# Setup: +# 1. Copy this file: cp Config/Secrets.env.template Config/Secrets.env +# 2. Fill in the real values below. +# 3. Config/Secrets.env is gitignored. +# +# These values are injected into Info.plist at build time by the Makefile's +# `archive` target and read at runtime from Bundle.main. +# +# Debug builds never initialize the SDKs, so empty values are fine during +# local development. + +PROWL_SENTRY_DSN= +PROWL_POSTHOG_API_KEY= +PROWL_POSTHOG_HOST=https://us.i.posthog.com diff --git a/Makefile b/Makefile index 80eb24d8..407db6bb 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,14 @@ SPM_CACHE_DIR := /tmp/supacode-spm-cache/SourcePackages VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= + +# Release-only analytics/crash credentials. Included from Config/Secrets.env if present, +# or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. +-include Config/Secrets.env +PROWL_SENTRY_DSN ?= +PROWL_POSTHOG_API_KEY ?= +PROWL_POSTHOG_HOST ?= + .DEFAULT_GOAL := help .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 @@ -262,7 +270,7 @@ install-release: build-ghostty-xcframework # Build Release, sign locally, instal echo "installed $$DST (Release build, locally signed)" archive: build-ghostty-xcframework embed-cli # Archive Release build for distribution - 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' + 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' export-archive: # Export xarchive 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' diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 2a528ed6..627cc6f6 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -99,7 +99,7 @@ struct SupacodeApp: App { private static func cliLaunchOpenPath() -> String? { let args = ProcessInfo.processInfo.arguments guard let flagIndex = args.firstIndex(of: ProwlSocket.cliOpenPathArgument), - args.indices.contains(flagIndex + 1) + args.indices.contains(flagIndex + 1) else { return nil } @@ -107,6 +107,15 @@ struct SupacodeApp: App { return path.isEmpty ? nil : path } + /// Reads a secret from Info.plist, returning nil when the value is empty or + /// still contains an unsubstituted `$(VAR)` placeholder (the Makefile did not + /// inject a value for that key). + private static func infoPlistSecret(_ dictionary: [String: Any], key: String) -> String? { + guard let value = dictionary[key] as? String else { return nil } + guard !value.isEmpty, !value.hasPrefix("$(") else { return nil } + return value + } + @MainActor init() { NSWindow.allowsAutomaticWindowTabbing = false UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") @@ -117,22 +126,26 @@ struct SupacodeApp: App { userOverrides: initialSettings.keybindingUserOverrides ) #if !DEBUG - if initialSettings.crashReportsEnabled { + let infoDictionary = Bundle.main.infoDictionary ?? [:] + let releaseName = (infoDictionary["CFBundleShortVersionString"] as? String).map { "prowl@\($0)" } + + if initialSettings.crashReportsEnabled, let dsn = Self.infoPlistSecret(infoDictionary, key: "ProwlSentryDSN") { SentrySDK.start { options in - options.dsn = "__SENTRY_DSN__" - options.tracesSampleRate = 1.0 - options.enableAppHangTracking = false + options.dsn = dsn + options.environment = "production" + if let releaseName { options.releaseName = releaseName } + options.tracesSampleRate = 0.05 + options.enableAppHangTracking = true } } - if initialSettings.analyticsEnabled { - let posthogAPIKey = "__POSTHOG_API_KEY__" - let posthogHost = "__POSTHOG_HOST__" - let config = PostHogConfig(apiKey: posthogAPIKey, host: posthogHost) + if initialSettings.analyticsEnabled, + let apiKey = Self.infoPlistSecret(infoDictionary, key: "ProwlPostHogAPIKey"), + let host = Self.infoPlistSecret(infoDictionary, key: "ProwlPostHogHost") + { + let config = PostHogConfig(apiKey: apiKey, host: host) config.enableSwizzling = false PostHogSDK.shared.setup(config) - if let hardwareUUID = HardwareInfo.uuid { - PostHogSDK.shared.identify(hardwareUUID) - } + PostHogSDK.shared.identify(InstallIdentifier.current) } #endif if let resourceURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") { @@ -451,8 +464,8 @@ struct SupacodeApp: App { let repoRoot = repository.rootURL .standardizedFileURL.path(percentEncoded: false) if repoRoot == normalized, - !repository.capabilities.supportsWorktrees, - repository.capabilities.supportsRunnableFolderActions + !repository.capabilities.supportsWorktrees, + repository.capabilities.supportsRunnableFolderActions { return OpenResolverResult( resolution: .exactRoot, worktreeID: repository.id, @@ -479,7 +492,7 @@ struct SupacodeApp: App { } } if !repository.capabilities.supportsWorktrees, - repository.capabilities.supportsRunnableFolderActions + repository.capabilities.supportsRunnableFolderActions { let repoRoot = repository.rootURL .standardizedFileURL.path(percentEncoded: false) @@ -509,8 +522,8 @@ struct SupacodeApp: App { return worktree } if repository.id == id, - repository.capabilities.supportsRunnableFolderActions, - !repository.capabilities.supportsWorktrees + repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees { return Worktree( id: repository.id, diff --git a/supacode/Clients/Analytics/AnalyticsClient.swift b/supacode/Clients/Analytics/AnalyticsClient.swift index ae897e60..9467040c 100644 --- a/supacode/Clients/Analytics/AnalyticsClient.swift +++ b/supacode/Clients/Analytics/AnalyticsClient.swift @@ -5,6 +5,7 @@ import SwiftUI struct AnalyticsClient: Sendable { var capture: @Sendable (_ event: String, _ properties: [String: Any]?) -> Void var identify: @Sendable (_ distinctId: String) -> Void + var reset: @Sendable () -> Void } extension AnalyticsClient: DependencyKey { @@ -22,12 +23,19 @@ extension AnalyticsClient: DependencyKey { guard settingsFile.global.analyticsEnabled else { return } PostHogSDK.shared.identify(distinctId) #endif + }, + reset: { + #if !DEBUG + PostHogSDK.shared.reset() + #endif + InstallIdentifier.reset() } ) static let testValue = AnalyticsClient( capture: { _, _ in }, - identify: { _ in } + identify: { _ in }, + reset: {} ) } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 4b9faaf7..8def4a69 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -363,10 +363,14 @@ struct SettingsFeature { ) -> Effect { let settings = state.globalSettings @Shared(.settingsFile) var settingsFile + let previouslyAnalyticsEnabled = settingsFile.global.analyticsEnabled $settingsFile.withLock { $0.global = settings } if captureAnalytics, settings.analyticsEnabled { analyticsClient.capture("settings_changed", nil) } + if previouslyAnalyticsEnabled, !settings.analyticsEnabled { + analyticsClient.reset() + } if emitSettingsChanged { return .send(.delegate(.settingsChanged(settings))) } diff --git a/supacode/Info.plist b/supacode/Info.plist index 70d3a06d..ffb3ab14 100644 --- a/supacode/Info.plist +++ b/supacode/Info.plist @@ -36,6 +36,12 @@ SUAutomaticallyUpdate + ProwlSentryDSN + $(PROWL_SENTRY_DSN) + ProwlPostHogAPIKey + $(PROWL_POSTHOG_API_KEY) + ProwlPostHogHost + $(PROWL_POSTHOG_HOST) UTExportedTypeDeclarations diff --git a/supacode/Support/HardwareInfo.swift b/supacode/Support/HardwareInfo.swift deleted file mode 100644 index d9ec0932..00000000 --- a/supacode/Support/HardwareInfo.swift +++ /dev/null @@ -1,22 +0,0 @@ -import IOKit - -nonisolated enum HardwareInfo { - static var uuid: String? { - let platformExpert = IOServiceGetMatchingService( - kIOMainPortDefault, - IOServiceMatching("IOPlatformExpertDevice") - ) - - guard platformExpert != 0 else { return nil } - defer { IOObjectRelease(platformExpert) } - - let uuid = IORegistryEntryCreateCFProperty( - platformExpert, - kIOPlatformUUIDKey as CFString, - kCFAllocatorDefault, - 0 - ) - - return uuid?.takeRetainedValue() as? String - } -} diff --git a/supacode/Support/InstallIdentifier.swift b/supacode/Support/InstallIdentifier.swift new file mode 100644 index 00000000..6109e940 --- /dev/null +++ b/supacode/Support/InstallIdentifier.swift @@ -0,0 +1,21 @@ +import Foundation + +/// A per-install UUID persisted in UserDefaults. Regenerates after reset or +/// on a fresh install — does not leak a stable hardware identifier. +nonisolated enum InstallIdentifier { + private static let userDefaultsKey = "com.onevcat.prowl.installIdentifier" + + static var current: String { + let defaults = UserDefaults.standard + if let existing = defaults.string(forKey: userDefaultsKey), !existing.isEmpty { + return existing + } + let generated = UUID().uuidString + defaults.set(generated, forKey: userDefaultsKey) + return generated + } + + static func reset() { + UserDefaults.standard.removeObject(forKey: userDefaultsKey) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index c53ebfa2..aae88539 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -364,6 +364,58 @@ struct SettingsFeatureTests { #expect(settingsFile.global.keybindingUserOverrides == overrides) } + @Test(.dependencies) func disablingAnalyticsResetsClient() async { + var initialSettings = GlobalSettings.default + initialSettings.analyticsEnabled = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + let resetCount = LockIsolated(0) + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + $0.analyticsClient.reset = { + resetCount.withValue { $0 += 1 } + } + } + + await store.send(.binding(.set(\.analyticsEnabled, false))) { + $0.analyticsEnabled = false + } + await store.receive(\.delegate.settingsChanged) + await store.finish() + + #expect(resetCount.value == 1) + #expect(settingsFile.global.analyticsEnabled == false) + } + + @Test(.dependencies) func togglingOtherSettingWhileAnalyticsOffDoesNotReset() async { + var initialSettings = GlobalSettings.default + initialSettings.analyticsEnabled = false + initialSettings.confirmBeforeQuit = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + let resetCount = LockIsolated(0) + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + $0.analyticsClient.reset = { + resetCount.withValue { $0 += 1 } + } + } + + await store.send(.binding(.set(\.confirmBeforeQuit, false))) { + $0.confirmBeforeQuit = false + } + await store.receive(\.delegate.settingsChanged) + await store.finish() + + #expect(resetCount.value == 0) + } + @Test(.dependencies) func clearTerminalLayoutSnapshotSendsDelegate() async { let store = TestStore(initialState: SettingsFeature.State()) { SettingsFeature()