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