diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48f3533 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# PostHog public client configuration +POSTHOG_PROJECT_TOKEN=phc_your_project_token +POSTHOG_HOST=https://your-posthog-host diff --git a/.gitignore b/.gitignore index 9eb667f..43d22b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Environment +.env +.env.xcconfig + # Xcode MicStatusAI.xcodeproj/ DerivedData/ diff --git a/MicStatusAI/App/MicStatusAIApp.swift b/MicStatusAI/App/MicStatusAIApp.swift index 84ecb29..9d1a6ea 100644 --- a/MicStatusAI/App/MicStatusAIApp.swift +++ b/MicStatusAI/App/MicStatusAIApp.swift @@ -1,7 +1,42 @@ +import PostHog import SwiftUI @main struct MicStatusAIApp: App { + init() { + let environment = ProcessInfo.processInfo.environment + let projectToken = environment["POSTHOG_PROJECT_TOKEN"] + ?? Bundle.main.object(forInfoDictionaryKey: "POSTHOG_PROJECT_TOKEN") as? String + guard let projectToken, !projectToken.isEmpty else { + #if DEBUG + fatalError( + "POSTHOG_PROJECT_TOKEN variable required by PostHog is missing or un-configured, " + + "this causes events to be silently missed. This error stops appearing once " + + "POSTHOG_PROJECT_TOKEN is configured" + ) + #else + return + #endif + } + + let host = environment["POSTHOG_HOST"] + ?? Bundle.main.object(forInfoDictionaryKey: "POSTHOG_HOST") as? String + guard let host, !host.isEmpty else { + #if DEBUG + fatalError( + "POSTHOG_HOST variable required by PostHog is missing or un-configured, this causes " + + "events to be silently missed. This error stops appearing once POSTHOG_HOST is configured" + ) + #else + return + #endif + } + + let config = PostHogConfig(apiKey: projectToken, host: host) + config.errorTrackingConfig.autoCapture = true + PostHogSDK.shared.setup(config) + } + @AppStorage("statusOverlayEnabled") private var statusOverlayEnabled = true @AppStorage("statusOverlayDuration") diff --git a/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift b/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift index 77b8bd2..7f08689 100644 --- a/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift +++ b/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift @@ -1,5 +1,6 @@ import AppKit import Carbon.HIToolbox +import PostHog @MainActor final class HotKeyRecorderCoordinator: NSObject { @@ -47,6 +48,10 @@ final class HotKeyRecorderCoordinator: NSObject { parent.onValidationError(nil) parent.onChange(configuration) + PostHogSDK.shared.capture( + "hotkey_configured", + properties: ["modifier_count": configuration.modifierCount] + ) button.finishRecording(with: configuration) button.window?.makeFirstResponder(nil) } diff --git a/MicStatusAI/Info.plist b/MicStatusAI/Info.plist index 36a532d..5b77f74 100644 --- a/MicStatusAI/Info.plist +++ b/MicStatusAI/Info.plist @@ -26,5 +26,9 @@ NSPrincipalClass NSApplication + POSTHOG_HOST + $(POSTHOG_HOST) + POSTHOG_PROJECT_TOKEN + $(POSTHOG_PROJECT_TOKEN) diff --git a/MicStatusAI/Models/MicrophoneStatusModel.swift b/MicStatusAI/Models/MicrophoneStatusModel.swift index b9c4936..1c4da8b 100644 --- a/MicStatusAI/Models/MicrophoneStatusModel.swift +++ b/MicStatusAI/Models/MicrophoneStatusModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import PostHog @MainActor @Observable @@ -79,6 +80,11 @@ final class MicrophoneStatusModel { } else { startMonitoring() } + + PostHogSDK.shared.capture( + "microphone_monitoring_toggled", + properties: ["is_monitoring": isMonitoring] + ) } func startMonitoring() { @@ -142,6 +148,11 @@ final class MicrophoneStatusModel { if isMonitoring { refreshStatus() } + + PostHogSDK.shared.capture( + "microphone_mute_toggled", + properties: ["is_muted": targetMuteState] + ) } catch { if isMonitoring { status = .unavailable(error.localizedDescription) diff --git a/MicStatusAI/Views/HotKeySettingsView.swift b/MicStatusAI/Views/HotKeySettingsView.swift index bda55d1..68ebb4f 100644 --- a/MicStatusAI/Views/HotKeySettingsView.swift +++ b/MicStatusAI/Views/HotKeySettingsView.swift @@ -1,3 +1,4 @@ +import PostHog import SwiftUI struct HotKeySettingsView: View { @@ -69,6 +70,7 @@ struct HotKeySettingsView: View { Button { recordingError = nil model.restoreDefaultHotKey() + PostHogSDK.shared.capture("hotkey_restored") } label: { Text(L10n.actionRestoreHotkey) } diff --git a/MicStatusAI/Views/InputLevelControl.swift b/MicStatusAI/Views/InputLevelControl.swift index a82f9b2..eaa7cb7 100644 --- a/MicStatusAI/Views/InputLevelControl.swift +++ b/MicStatusAI/Views/InputLevelControl.swift @@ -1,3 +1,4 @@ +import PostHog import SwiftUI struct InputLevelControl: View { @@ -15,10 +16,18 @@ struct InputLevelControl: View { get: { model.inputLevel }, set: { model.setInputLevel($0) } ), - in: 0 ... 1 - ) { - Text(L10n.inputAccessibility) - } + in: 0 ... 1, + onEditingChanged: { isEditing in + guard !isEditing else { return } + PostHogSDK.shared.capture( + "input_level_adjusted", + properties: ["input_level": model.inputLevel] + ) + }, + label: { + Text(L10n.inputAccessibility) + } + ) .tint(model.inputLevel > 0 ? .green : .red) .disabled(!model.canAdjustInputLevel) .accessibilityValue( diff --git a/MicStatusAI/Views/StatusOverlaySettingsView.swift b/MicStatusAI/Views/StatusOverlaySettingsView.swift index 58b044b..477daba 100644 --- a/MicStatusAI/Views/StatusOverlaySettingsView.swift +++ b/MicStatusAI/Views/StatusOverlaySettingsView.swift @@ -1,3 +1,4 @@ +import PostHog import SwiftUI struct StatusOverlaySettingsView: View { @@ -10,6 +11,9 @@ struct StatusOverlaySettingsView: View { GroupBox { VStack(alignment: .leading, spacing: 8) { Toggle(L10n.overlayEnabled, isOn: $isEnabled) + .onChange(of: isEnabled) { _, isEnabled in + captureSettingChange("enabled", value: isEnabled) + } Picker(L10n.overlayDuration, selection: $duration) { ForEach(StatusOverlayDuration.allCases) { option in @@ -19,6 +23,9 @@ struct StatusOverlaySettingsView: View { } .pickerStyle(.menu) .disabled(!isEnabled) + .onChange(of: duration) { _, duration in + captureSettingChange("duration_seconds", value: duration.rawValue) + } Picker(L10n.overlayPlacement, selection: $placement) { ForEach(StatusOverlayPlacement.allCases) { option in @@ -28,16 +35,24 @@ struct StatusOverlaySettingsView: View { } .pickerStyle(.menu) .disabled(!isEnabled) + .onChange(of: placement) { _, placement in + captureSettingChange("placement", value: placement.rawValue) + } LabeledContent { HStack(spacing: 8) { Slider( value: $transparency, in: StatusOverlayTransparency.range, - step: StatusOverlayTransparency.step - ) { - Text(L10n.overlayTransparency) - } + step: StatusOverlayTransparency.step, + onEditingChanged: { isEditing in + guard !isEditing else { return } + captureSettingChange("transparency", value: transparency) + }, + label: { + Text(L10n.overlayTransparency) + } + ) .labelsHidden() .accessibilityLabel(L10n.overlayTransparency) .accessibilityValue( @@ -70,4 +85,11 @@ struct StatusOverlaySettingsView: View { Text(L10n.overlayTitle) } } + + private func captureSettingChange(_ setting: String, value: Any) { + PostHogSDK.shared.capture( + "status_overlay_setting_changed", + properties: ["setting": setting, "value": value] + ) + } } diff --git a/PostHog.xcconfig b/PostHog.xcconfig new file mode 100644 index 0000000..cdfdae6 --- /dev/null +++ b/PostHog.xcconfig @@ -0,0 +1,8 @@ +// Default PostHog configuration. +// Real values are supplied via the gitignored .env.xcconfig for local builds, +// or injected before this file is read for CI/release builds. This file must +// stay tracked so xcodegen can always find a config file to load. +POSTHOG_PROJECT_TOKEN = +POSTHOG_HOST = + +#include? ".env.xcconfig" diff --git a/project.yml b/project.yml index 644bf08..1c5b83d 100644 --- a/project.yml +++ b/project.yml @@ -12,6 +12,15 @@ configs: Debug: debug Release: release +configFiles: + Debug: PostHog.xcconfig + Release: PostHog.xcconfig + +packages: + PostHog: + url: https://github.com/PostHog/posthog-ios.git + from: 3.59.3 + settings: base: CLANG_ENABLE_MODULES: "YES" @@ -33,6 +42,7 @@ targets: dependencies: - sdk: CoreAudio.framework - sdk: Carbon.framework + - package: PostHog settings: base: ARCHS: arm64