diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc4bbe9..ac7b09e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,10 +49,16 @@ jobs: - name: Build arm64 release app env: + APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} VERSION: ${{ steps.metadata.outputs.version }} shell: bash run: | set -euo pipefail + if [[ -z "${APTABASE_APP_KEY:-}" ]]; then + echo "APTABASE_APP_KEY secret is required for release builds." >&2 + exit 1 + fi + xcodebuild \ -project MicStatusAI.xcodeproj \ -scheme MicStatusAI \ @@ -62,12 +68,19 @@ jobs: CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="$VERSION" \ CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" \ + APTABASE_APP_KEY="$APTABASE_APP_KEY" \ ARCHS=arm64 \ ONLY_ACTIVE_ARCH=NO \ clean build app="$RUNNER_TEMP/DerivedData/Build/Products/Release/MicStatusAI.app" test -d "$app" + embedded_key="$(/usr/libexec/PlistBuddy \ + -c 'Print :AptabaseAppKey' \ + "$app/Contents/Info.plist")" + test "$embedded_key" = "$APTABASE_APP_KEY" + unset embedded_key + architectures="$(lipo -archs "$app/Contents/MacOS/MicStatusAI")" test "$architectures" = "arm64" echo "Architectures: $architectures" diff --git a/MicStatusAI/Analytics/AnalyticsEvent.swift b/MicStatusAI/Analytics/AnalyticsEvent.swift new file mode 100644 index 0000000..81a2452 --- /dev/null +++ b/MicStatusAI/Analytics/AnalyticsEvent.swift @@ -0,0 +1,104 @@ +struct AnalyticsEvent { + enum Property { + case string(String) + case bool(Bool) + case int(Int) + } + + enum MuteSource: String { + case button + case hotKey = "hotkey" + } + + enum MicrophoneOperation: String { + case monitoring + case mute + case inputLevel = "input_level" + } + + let name: String + let properties: [String: Property] + + static let appLaunched = Self(name: "app_launched") + static let settingsOpened = Self(name: "settings_opened") + static let analyticsEnabled = Self(name: "analytics_enabled") + static let overlayPreviewed = Self(name: "overlay_previewed") + static let overlayShown = Self(name: "overlay_shown") + + static func monitoringChanged(enabled: Bool) -> Self { + Self( + name: "monitoring_changed", + properties: ["enabled": .bool(enabled)] + ) + } + + static func muteChanged(isMuted: Bool, source: MuteSource) -> Self { + Self( + name: "mute_changed", + properties: [ + "is_muted": .bool(isMuted), + "source": .string(source.rawValue), + ] + ) + } + + static func inputLevelChanged(percentBucket: Int) -> Self { + Self( + name: "input_level_changed", + properties: ["percent_bucket": .int(percentBucket)] + ) + } + + static func overlayEnabledChanged(enabled: Bool) -> Self { + Self( + name: "overlay_enabled_changed", + properties: ["enabled": .bool(enabled)] + ) + } + + static func overlayDurationChanged(seconds: Int) -> Self { + Self( + name: "overlay_duration_changed", + properties: ["seconds": .int(seconds)] + ) + } + + static func overlayPlacementChanged(placement: String) -> Self { + Self( + name: "overlay_placement_changed", + properties: ["placement": .string(placement)] + ) + } + + static func overlayTransparencyChanged(percentBucket: Int) -> Self { + Self( + name: "overlay_transparency_changed", + properties: ["percent_bucket": .int(percentBucket)] + ) + } + + static func hotKeyChanged(success: Bool) -> Self { + Self( + name: "hotkey_changed", + properties: ["success": .bool(success)] + ) + } + + static func microphoneError( + operation: MicrophoneOperation, + category: String + ) -> Self { + Self( + name: "microphone_error", + properties: [ + "operation": .string(operation.rawValue), + "category": .string(category), + ] + ) + } + + private init(name: String, properties: [String: Property] = [:]) { + self.name = name + self.properties = properties + } +} diff --git a/MicStatusAI/App/MicStatusAIApp.swift b/MicStatusAI/App/MicStatusAIApp.swift index 84ecb29..09f9d9f 100644 --- a/MicStatusAI/App/MicStatusAIApp.swift +++ b/MicStatusAI/App/MicStatusAIApp.swift @@ -1,6 +1,7 @@ import SwiftUI @main +@MainActor struct MicStatusAIApp: App { @AppStorage("statusOverlayEnabled") private var statusOverlayEnabled = true @@ -10,9 +11,28 @@ struct MicStatusAIApp: App { private var statusOverlayPlacement: StatusOverlayPlacement = .center @AppStorage("statusOverlayTransparency") private var statusOverlayTransparency = StatusOverlayTransparency.defaultValue - @State private var model = MicrophoneStatusModel() + @AppStorage("analyticsEnabled") + private var analyticsEnabled = true + + @State private var model: MicrophoneStatusModel @State private var statusOverlayPresenter = StatusOverlayPresenter() + private let analytics: AptabaseAnalytics + + init() { + let isAnalyticsEnabled = UserDefaults.standard.object( + forKey: "analyticsEnabled" + ) as? Bool ?? true + let analyticsClient = AptabaseAnalytics( + appKey: AptabaseAnalytics.configuredAppKey(), + isEnabled: isAnalyticsEnabled + ) + + analytics = analyticsClient + _model = State(initialValue: MicrophoneStatusModel(analytics: analyticsClient)) + analyticsClient.track(.appLaunched) + } + var body: some Scene { MenuBarExtra { StatusPanel(model: model) @@ -31,6 +51,7 @@ struct MicStatusAIApp: App { placement: statusOverlayPlacement, transparency: statusOverlayTransparency ) + analytics.track(.overlayShown) } .onChange(of: statusOverlayEnabled) { _, isEnabled in if !isEnabled { @@ -46,8 +67,22 @@ struct MicStatusAIApp: App { statusOverlayEnabled: $statusOverlayEnabled, statusOverlayDuration: $statusOverlayDuration, statusOverlayPlacement: $statusOverlayPlacement, - statusOverlayTransparency: $statusOverlayTransparency + statusOverlayTransparency: $statusOverlayTransparency, + analyticsEnabled: $analyticsEnabled, + analytics: analytics, + onShowOverlayPreview: showOverlayPreview ) } + .windowResizability(.contentSize) + } + + private func showOverlayPreview() { + statusOverlayPresenter.show( + status: model.status, + duration: statusOverlayDuration.seconds, + placement: statusOverlayPlacement, + transparency: statusOverlayTransparency + ) + analytics.track(.overlayPreviewed) } } diff --git a/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift b/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift index 77b8bd2..6a2c557 100644 --- a/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift +++ b/MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift @@ -16,6 +16,10 @@ final class HotKeyRecorderCoordinator: NSObject { button.window?.makeFirstResponder(button) } + func recordingDidChange(_ isRecording: Bool) { + parent.isRecording = isRecording + } + func record(_ event: NSEvent, in button: HotKeyRecorderButton) { guard !event.isARepeat else { return } diff --git a/MicStatusAI/Errors/MicrophoneError.swift b/MicStatusAI/Errors/MicrophoneError.swift index 8f66791..84679ea 100644 --- a/MicStatusAI/Errors/MicrophoneError.swift +++ b/MicStatusAI/Errors/MicrophoneError.swift @@ -7,6 +7,21 @@ enum MicrophoneError: LocalizedError { case muteStateVerificationFailed case coreAudio(OSStatus) + var analyticsCategory: String { + switch self { + case .noDefaultInputDevice: + "no_default_input" + case .volumeControlUnavailable: + "volume_control_unavailable" + case .muteControlUnavailable: + "mute_control_unavailable" + case .muteStateVerificationFailed: + "mute_verification_failed" + case .coreAudio: + "core_audio" + } + } + var errorDescription: String? { switch self { case .noDefaultInputDevice: diff --git a/MicStatusAI/Generated/L10n.swift b/MicStatusAI/Generated/L10n.swift index d1d472d..a60b155 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -5,11 +5,21 @@ import Foundation public enum L10n { + /// Cancel + public static var actionCancel: String { + return tr(key: "action.cancel") + } + /// Mute public static var actionMute: String { return tr(key: "action.mute") } + /// Mute or unmute the default microphone. Monitoring must be on. + public static var actionMuteHelp: String { + return tr(key: "action.muteHelp") + } + /// Quit public static var actionQuit: String { return tr(key: "action.quit") @@ -20,14 +30,9 @@ public enum L10n { return tr(key: "action.restoreHotkey") } - /// Start Monitoring - public static var actionStart: String { - return tr(key: "action.start") - } - - /// Stop Monitoring - public static var actionStop: String { - return tr(key: "action.stop") + /// Retry + public static var actionRetry: String { + return tr(key: "action.retry") } /// Unmute @@ -35,6 +40,21 @@ public enum L10n { return tr(key: "action.unmute") } + /// Share Anonymous Analytics + public static var analyticsEnabled: String { + return tr(key: "analytics.enabled") + } + + /// Anonymous usage events are sent through Aptabase. Audio, microphone names, and shortcut keys are never collected. + public static var analyticsHelp: String { + return tr(key: "analytics.help") + } + + /// Privacy + public static var analyticsTitle: String { + return tr(key: "analytics.title") + } + /// CoreAudio error %d. public static func errorCoreAudio(_ p1: Int) -> String { return tr(key: "error.coreAudio", p1) @@ -110,6 +130,11 @@ public enum L10n { return tr(key: "hotkey.recording") } + /// Recording… + public static var hotkeyRecordingStatus: String { + return tr(key: "hotkey.recordingStatus") + } + /// Mute / Unmute Hotkey public static var hotkeyTitle: String { return tr(key: "hotkey.title") @@ -135,6 +160,11 @@ public enum L10n { return tr(key: "monitoring.active") } + /// Monitor Microphone + public static var monitoringEnabled: String { + return tr(key: "monitoring.enabled") + } + /// Monitoring Off public static var monitoringOff: String { return tr(key: "monitoring.off") @@ -145,6 +175,11 @@ public enum L10n { return tr(key: "monitoring.paused") } + /// Start monitoring to use microphone controls. + public static var monitoringRequired: String { + return tr(key: "monitoring.required") + } + /// Display Duration public static var overlayDuration: String { return tr(key: "overlay.duration") @@ -185,6 +220,11 @@ public enum L10n { return tr(key: "overlay.placement.center") } + /// Show Preview + public static var overlayPreview: String { + return tr(key: "overlay.preview") + } + /// Status Overlay public static var overlayTitle: String { return tr(key: "overlay.title") @@ -195,6 +235,16 @@ public enum L10n { return tr(key: "overlay.transparency") } + /// About + public static var settingsAbout: String { + return tr(key: "settings.about") + } + + /// GitHub + public static var settingsGithub: String { + return tr(key: "settings.github") + } + /// Settings… public static var settingsOpen: String { return tr(key: "settings.open") @@ -205,6 +255,16 @@ public enum L10n { return tr(key: "settings.title") } + /// Version %@ + public static func settingsVersion(_ p1: String) -> String { + return tr(key: "settings.version", p1) + } + + /// X + public static var settingsXProfile: String { + return tr(key: "settings.xProfile") + } + /// Microphone Muted public static var statusMuted: String { return tr(key: "status.muted") diff --git a/MicStatusAI/Info.plist b/MicStatusAI/Info.plist index 36a532d..6f133d0 100644 --- a/MicStatusAI/Info.plist +++ b/MicStatusAI/Info.plist @@ -2,6 +2,8 @@ + AptabaseAppKey + $(APTABASE_APP_KEY) CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/MicStatusAI/Interfaces/AnalyticsTracking.swift b/MicStatusAI/Interfaces/AnalyticsTracking.swift new file mode 100644 index 0000000..44a89c4 --- /dev/null +++ b/MicStatusAI/Interfaces/AnalyticsTracking.swift @@ -0,0 +1,5 @@ +@MainActor +protocol AnalyticsTracking: AnyObject { + func setEnabled(_ isEnabled: Bool) + func track(_ event: AnalyticsEvent) +} diff --git a/MicStatusAI/Models/MicrophoneStatusModel.swift b/MicStatusAI/Models/MicrophoneStatusModel.swift index b9c4936..4973662 100644 --- a/MicStatusAI/Models/MicrophoneStatusModel.swift +++ b/MicStatusAI/Models/MicrophoneStatusModel.swift @@ -13,7 +13,8 @@ final class MicrophoneStatusModel { didSet { guard hotKey != oldValue else { return } saveHotKey() - registerHotKey() + let didRegister = registerHotKey() + analytics.track(.hotKeyChanged(success: didRegister)) } } @@ -37,9 +38,11 @@ final class MicrophoneStatusModel { @ObservationIgnored private let microphone: any MicrophoneVolumeControlling @ObservationIgnored private let hotKeyManager: any HotKeyManaging + @ObservationIgnored private let analytics: any AnalyticsTracking @ObservationIgnored private var pollingTimer: Timer? @ObservationIgnored private var lastNonzeroVolume: Float32 @ObservationIgnored private var currentInputDeviceID: UInt32? + @ObservationIgnored private var lastTrackedErrorSignature: String? @ObservationIgnored private var desiredMuteState: MicrophoneMuteState = .indeterminate private static let hotKeyDefaultsKey = "muteHotKey" @@ -47,10 +50,12 @@ final class MicrophoneStatusModel { init( microphone: any MicrophoneVolumeControlling = CoreAudioMicrophone(), - hotKeyManager: any HotKeyManaging = HotKeyManager() + hotKeyManager: any HotKeyManaging = HotKeyManager(), + analytics: any AnalyticsTracking = NoopAnalytics() ) { self.microphone = microphone self.hotKeyManager = hotKeyManager + self.analytics = analytics let savedHotKey = UserDefaults.standard.data(forKey: Self.hotKeyDefaultsKey).flatMap { try? JSONDecoder().decode(HotKeyConfiguration.self, from: $0) @@ -66,11 +71,11 @@ final class MicrophoneStatusModel { inputLevel = savedVolume > 0 ? savedVolume : Double(lastNonzeroVolume) self.hotKeyManager.onPressed = { [weak self] in - self?.toggleMute() + self?.toggleMute(source: .hotKey) } - registerHotKey() - startMonitoring() + _ = registerHotKey() + startMonitoring(shouldTrack: false) } func toggleMonitoring() { @@ -82,24 +87,16 @@ final class MicrophoneStatusModel { } func startMonitoring() { - guard !isMonitoring else { return } - isMonitoring = true - refreshStatus() - - let timer = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.refreshStatus() - } - } - pollingTimer = timer - RunLoop.main.add(timer, forMode: .common) + startMonitoring(shouldTrack: true) } func stopMonitoring() { + guard isMonitoring else { return } pollingTimer?.invalidate() pollingTimer = nil isMonitoring = false status = .stopped + analytics.track(.monitoringChanged(enabled: false)) } func setInputLevel(_ level: Double) { @@ -123,10 +120,16 @@ final class MicrophoneStatusModel { refreshStatus() } catch { status = .unavailable(error.localizedDescription) + trackMicrophoneError(error, operation: .inputLevel) } } - func toggleMute() { + func trackInputLevelCommit() { + let bucket = Int((inputLevel * 4).rounded()) * 25 + analytics.track(.inputLevelChanged(percentBucket: bucket)) + } + + func toggleMute(source: AnalyticsEvent.MuteSource = .button) { do { let deviceID = try microphone.defaultInputDeviceID() let currentlyMuted = try intendedMuteState(for: deviceID) @@ -138,6 +141,8 @@ final class MicrophoneStatusModel { try microphone.setMuted(targetMuteState, for: deviceID) currentInputDeviceID = deviceID desiredMuteState = targetMuteState ? .muted : .active + lastTrackedErrorSignature = nil + analytics.track(.muteChanged(isMuted: targetMuteState, source: source)) if isMonitoring { refreshStatus() @@ -146,6 +151,15 @@ final class MicrophoneStatusModel { if isMonitoring { status = .unavailable(error.localizedDescription) } + trackMicrophoneError(error, operation: .mute) + } + } + + func retryMonitoring() { + if isMonitoring { + refreshStatus() + } else { + startMonitoring() } } @@ -173,9 +187,41 @@ final class MicrophoneStatusModel { UserDefaults.standard.set(Double(volume), forKey: Self.lastVolumeDefaultsKey) } status = muted ? .muted : .active(volume) + lastTrackedErrorSignature = nil } catch { status = .unavailable(error.localizedDescription) + trackMicrophoneError(error, operation: .monitoring) + } + } + + private func startMonitoring(shouldTrack: Bool) { + guard !isMonitoring else { return } + isMonitoring = true + lastTrackedErrorSignature = nil + if shouldTrack { + analytics.track(.monitoringChanged(enabled: true)) } + refreshStatus() + + let timer = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refreshStatus() + } + } + pollingTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func trackMicrophoneError( + _ error: Error, + operation: AnalyticsEvent.MicrophoneOperation + ) { + let category = (error as? MicrophoneError)?.analyticsCategory ?? "unknown" + let signature = "\(operation.rawValue):\(category)" + guard signature != lastTrackedErrorSignature else { return } + + lastTrackedErrorSignature = signature + analytics.track(.microphoneError(operation: operation, category: category)) } private func intendedMuteState(for deviceID: UInt32) throws -> Bool { @@ -205,12 +251,14 @@ final class MicrophoneStatusModel { UserDefaults.standard.set(data, forKey: Self.hotKeyDefaultsKey) } - private func registerHotKey() { + private func registerHotKey() -> Bool { do { try hotKeyManager.register(hotKey) hotKeyRegistrationError = nil + return true } catch { hotKeyRegistrationError = error.localizedDescription + return false } } } diff --git a/MicStatusAI/Resources/Localizable.xcstrings b/MicStatusAI/Resources/Localizable.xcstrings index 449d4c3..6f09978 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -1,6 +1,54 @@ { "sourceLanguage" : "en", "strings" : { + "analytics.enabled" : { + "comment" : "Toggle that enables or disables anonymous product analytics.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share Anonymous Analytics" + } + } + } + }, + "analytics.help" : { + "comment" : "Privacy explanation shown below the anonymous analytics setting.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anonymous usage events are sent through Aptabase. Audio, microphone names, and shortcut keys are never collected." + } + } + } + }, + "analytics.title" : { + "comment" : "Heading for the analytics privacy setting.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy" + } + } + } + }, + "action.cancel" : { + "comment" : "Button that cancels global hotkey recording.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, "action.mute" : { "comment" : "Button that enables the default microphone's CoreAudio input mute property.", "extractionState" : "manual", @@ -13,50 +61,50 @@ } } }, - "action.quit" : { - "comment" : "Button that quits the application.", + "action.muteHelp" : { + "comment" : "Help text for the microphone mute and unmute button.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Quit" + "value" : "Mute or unmute the default microphone. Monitoring must be on." } } } }, - "action.restoreHotkey" : { - "comment" : "Button that restores the default global hotkey.", + "action.quit" : { + "comment" : "Button that quits the application.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Restore Default" + "value" : "Quit" } } } }, - "action.start" : { - "comment" : "Button that starts microphone input monitoring.", + "action.retry" : { + "comment" : "Button that retries reading microphone status after an error.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Start Monitoring" + "value" : "Retry" } } } }, - "action.stop" : { - "comment" : "Button that stops microphone input monitoring.", + "action.restoreHotkey" : { + "comment" : "Button that restores the default global hotkey.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Stop Monitoring" + "value" : "Restore Default" } } } @@ -253,6 +301,18 @@ } } }, + "hotkey.recordingStatus" : { + "comment" : "Visible status shown while recording a global hotkey.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording…" + } + } + } + }, "hotkey.title" : { "comment" : "Heading for global hotkey settings.", "extractionState" : "manual", @@ -313,6 +373,18 @@ } } }, + "monitoring.enabled" : { + "comment" : "Toggle that enables or disables microphone monitoring.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitor Microphone" + } + } + } + }, "monitoring.off" : { "comment" : "Primary status shown when microphone monitoring is disabled.", "extractionState" : "manual", @@ -337,6 +409,18 @@ } } }, + "monitoring.required" : { + "comment" : "Help text shown when microphone controls require monitoring to be enabled.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start monitoring to use microphone controls." + } + } + } + }, "overlay.duration" : { "comment" : "Label for choosing how long the microphone status overlay remains visible.", "extractionState" : "manual", @@ -433,6 +517,18 @@ } } }, + "overlay.preview" : { + "comment" : "Button that displays a preview of the configured microphone status overlay.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Preview" + } + } + } + }, "overlay.title" : { "comment" : "Heading for microphone status overlay settings.", "extractionState" : "manual", @@ -457,6 +553,30 @@ } } }, + "settings.about" : { + "comment" : "Heading for application version and project links in settings.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + } + } + }, + "settings.github" : { + "comment" : "Button that opens the application's GitHub repository.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + } + } + }, "settings.open" : { "comment" : "Button that opens application settings.", "extractionState" : "manual", @@ -481,6 +601,30 @@ } } }, + "settings.version" : { + "comment" : "Application version label. Argument is the short version number.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@" + } + } + } + }, + "settings.xProfile" : { + "comment" : "Button that opens the developer's X profile.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "X" + } + } + } + }, "status.muted" : { "comment" : "Primary status shown when the CoreAudio input mute property is enabled.", "extractionState" : "manual", diff --git a/MicStatusAI/Services/AptabaseAnalytics.swift b/MicStatusAI/Services/AptabaseAnalytics.swift new file mode 100644 index 0000000..cc686ce --- /dev/null +++ b/MicStatusAI/Services/AptabaseAnalytics.swift @@ -0,0 +1,63 @@ +@preconcurrency import Aptabase +import Foundation + +@MainActor +final class AptabaseAnalytics: AnalyticsTracking { + private let appKey: String? + private var isEnabled: Bool + private var isInitialized = false + + init(appKey: String?, isEnabled: Bool) { + self.appKey = Self.validAppKey(from: appKey) + self.isEnabled = isEnabled + initializeIfNeeded() + } + + func setEnabled(_ isEnabled: Bool) { + self.isEnabled = isEnabled + initializeIfNeeded() + } + + func track(_ event: AnalyticsEvent) { + guard isEnabled else { return } + initializeIfNeeded() + guard isInitialized else { return } + + var properties: [String: any Value] = [:] + for (key, property) in event.properties { + switch property { + case let .string(value): + properties[key] = value + case let .bool(value): + properties[key] = value + case let .int(value): + properties[key] = value + } + } + + Aptabase.shared.trackEvent(event.name, with: properties) + } + + static func configuredAppKey(bundle: Bundle = .main) -> String? { + let bundledValue = bundle.object(forInfoDictionaryKey: "AptabaseAppKey") as? String + if validAppKey(from: bundledValue) != nil { + return bundledValue + } + return ProcessInfo.processInfo.environment["APTABASE_APP_KEY"] + } + + private func initializeIfNeeded() { + guard isEnabled, !isInitialized, let appKey else { return } + Aptabase.shared.initialize(appKey: appKey) + isInitialized = true + } + + private static func validAppKey(from value: String?) -> String? { + guard let value else { return nil } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: "-", maxSplits: 2) + guard parts.count == 3, parts.first == "A" else { return nil } + return trimmed + } +} diff --git a/MicStatusAI/Services/NoopAnalytics.swift b/MicStatusAI/Services/NoopAnalytics.swift new file mode 100644 index 0000000..2b893a2 --- /dev/null +++ b/MicStatusAI/Services/NoopAnalytics.swift @@ -0,0 +1,10 @@ +@MainActor +final class NoopAnalytics: AnalyticsTracking { + func setEnabled(_: Bool) { + // Intentionally disabled. + } + + func track(_: AnalyticsEvent) { + // Intentionally disabled. + } +} diff --git a/MicStatusAI/Services/StatusOverlayPresenter.swift b/MicStatusAI/Services/StatusOverlayPresenter.swift index 176d0bc..fdab368 100644 --- a/MicStatusAI/Services/StatusOverlayPresenter.swift +++ b/MicStatusAI/Services/StatusOverlayPresenter.swift @@ -36,10 +36,7 @@ final class StatusOverlayPresenter { currentPanel.alphaValue = 0 currentPanel.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = fadeDuration - currentPanel.animator().alphaValue = visibleAlpha - } + animateAlpha(visibleAlpha, for: currentPanel) announce(status.accessibilityLabel) scheduleDismissal(after: duration) @@ -112,13 +109,28 @@ final class StatusOverlayPresenter { ) } - private func fadeOut() { + private func animateAlpha(_ alpha: CGFloat, for panel: NSPanel) { + let duration = animationDuration + guard duration > 0 else { + panel.alphaValue = alpha + return + } + NSAnimationContext.runAnimationGroup { context in - context.duration = fadeDuration - panel?.animator().alphaValue = 0 + context.duration = duration + panel.animator().alphaValue = alpha } } + private func fadeOut() { + guard let panel else { return } + animateAlpha(0, for: panel) + } + + private var animationDuration: TimeInterval { + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : fadeDuration + } + private func scheduleDismissal(after duration: TimeInterval) { dismissalTask = Task { @MainActor [weak self] in guard let self else { return } @@ -129,12 +141,15 @@ final class StatusOverlayPresenter { return } + let fadeOutDuration = animationDuration fadeOut() - do { - try await Task.sleep(for: .seconds(fadeDuration)) - } catch { - return + if fadeOutDuration > 0 { + do { + try await Task.sleep(for: .seconds(fadeOutDuration)) + } catch { + return + } } guard !Task.isCancelled else { return } diff --git a/MicStatusAI/Views/HotKeyRecorderButton.swift b/MicStatusAI/Views/HotKeyRecorderButton.swift index b2b5463..42a8c77 100644 --- a/MicStatusAI/Views/HotKeyRecorderButton.swift +++ b/MicStatusAI/Views/HotKeyRecorderButton.swift @@ -3,6 +3,8 @@ import AppKit @MainActor final class HotKeyRecorderButton: NSButton { var onKeyEvent: ((NSEvent) -> Void)? + var onRecordingChange: ((Bool) -> Void)? + private(set) var isRecording = false private var currentConfiguration = HotKeyConfiguration.defaultValue @@ -14,22 +16,26 @@ final class HotKeyRecorderButton: NSButton { currentConfiguration = configuration title = configuration.displayName toolTip = L10n.hotkeyTooltip + contentTintColor = nil setAccessibilityLabel(L10n.hotkeyAccessibility) setAccessibilityValue(configuration.displayName) setAccessibilityHelp(L10n.hotkeyTooltip) } func beginRecording() { - isRecording = true + setRecording(true) title = L10n.hotkeyPrompt toolTip = L10n.hotkeyCancel + contentTintColor = .systemRed setAccessibilityValue(L10n.hotkeyRecording) setAccessibilityHelp(L10n.hotkeyCancel) + NSAccessibility.post(element: self, notification: .valueChanged) } func finishRecording(with configuration: HotKeyConfiguration) { - isRecording = false + setRecording(false) show(configuration) + NSAccessibility.post(element: self, notification: .valueChanged) } override func keyDown(with event: NSEvent) { @@ -42,9 +48,14 @@ final class HotKeyRecorderButton: NSButton { override func resignFirstResponder() -> Bool { if isRecording { - isRecording = false - show(currentConfiguration) + finishRecording(with: currentConfiguration) } return super.resignFirstResponder() } + + private func setRecording(_ newValue: Bool) { + guard isRecording != newValue else { return } + isRecording = newValue + onRecordingChange?(newValue) + } } diff --git a/MicStatusAI/Views/HotKeyRecorderView.swift b/MicStatusAI/Views/HotKeyRecorderView.swift index d332b15..83afef2 100644 --- a/MicStatusAI/Views/HotKeyRecorderView.swift +++ b/MicStatusAI/Views/HotKeyRecorderView.swift @@ -3,6 +3,7 @@ import SwiftUI struct HotKeyRecorderView: NSViewRepresentable { let configuration: HotKeyConfiguration + @Binding var isRecording: Bool let onChange: (HotKeyConfiguration) -> Void let onValidationError: (String?) -> Void @@ -13,6 +14,7 @@ struct HotKeyRecorderView: NSViewRepresentable { func makeNSView(context: Context) -> HotKeyRecorderButton { let button = HotKeyRecorderButton() button.bezelStyle = .rounded + button.focusRingType = .exterior button.setButtonType(.momentaryPushIn) button.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .medium) button.target = context.coordinator @@ -21,12 +23,22 @@ struct HotKeyRecorderView: NSViewRepresentable { guard let coordinator, let button else { return } coordinator.record(event, in: button) } + button.onRecordingChange = { [weak coordinator = context.coordinator] isRecording in + coordinator?.recordingDidChange(isRecording) + } button.show(configuration) return button } func updateNSView(_ button: HotKeyRecorderButton, context: Context) { context.coordinator.parent = self + + if button.isRecording, !isRecording { + button.finishRecording(with: configuration) + button.window?.makeFirstResponder(nil) + return + } + guard !button.isRecording else { return } button.show(configuration) } diff --git a/MicStatusAI/Views/HotKeySettingsView.swift b/MicStatusAI/Views/HotKeySettingsView.swift index bda55d1..194619e 100644 --- a/MicStatusAI/Views/HotKeySettingsView.swift +++ b/MicStatusAI/Views/HotKeySettingsView.swift @@ -6,10 +6,21 @@ struct HotKeySettingsView: View { @Binding var statusOverlayDuration: StatusOverlayDuration @Binding var statusOverlayPlacement: StatusOverlayPlacement @Binding var statusOverlayTransparency: Double + @Binding var analyticsEnabled: Bool + let analytics: any AnalyticsTracking + let onShowOverlayPreview: () -> Void + @State private var recordingError: String? + @State private var isRecordingHotKey = false + + private static let appVersion = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "—" + private static let xProfileURL = URL(string: "https://x.com/disconnecter")! + private static let gitHubURL = URL(string: "https://github.com/Disconnecter/MicStatusAI")! var body: some View { - VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 12) { Label { Text(L10n.settingsTitle) } icon: { @@ -17,64 +28,190 @@ struct HotKeySettingsView: View { } .font(.title2.bold()) - Text(L10n.hotkeyInstructions) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Form { + Section { + Text(L10n.hotkeyInstructions) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + shortcutControl + hotKeyStatus + shortcutActions + } header: { + Text(L10n.hotkeyTitle) + } + + StatusOverlaySettingsView( + isEnabled: $statusOverlayEnabled, + duration: $statusOverlayDuration, + placement: $statusOverlayPlacement, + transparency: $statusOverlayTransparency, + analytics: analytics, + onShowPreview: onShowOverlayPreview + ) + + Section { + Toggle(L10n.analyticsEnabled, isOn: $analyticsEnabled) + .onChange(of: analyticsEnabled) { _, isEnabled in + analytics.setEnabled(isEnabled) + if isEnabled { + analytics.track(.analyticsEnabled) + } + } + } header: { + Text(L10n.analyticsTitle) + } footer: { + Text(L10n.analyticsHelp) + } - GroupBox { - LabeledContent { - HotKeyRecorderView( - configuration: model.hotKey, - onChange: { model.hotKey = $0 }, - onValidationError: { recordingError = $0 } - ) - .frame(width: 170, height: 28) - } label: { - Text(L10n.hotkeyLabel) + Section { + aboutContent + } header: { + Text(L10n.settingsAbout) } - .padding(.vertical, 4) + } + .formStyle(.grouped) + } + .scenePadding() + .frame(minWidth: 320, idealWidth: 340, maxWidth: 360) + .onAppear { + analytics.track(.settingsOpened) + } + } + + private var shortcutControl: some View { + ViewThatFits(in: .horizontal) { + LabeledContent { + hotKeyRecorder } label: { - Text(L10n.hotkeyTitle) + Text(L10n.hotkeyLabel) } - if let error = recordingError ?? model.hotKeyRegistrationError { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } else { - Label { - Text(L10n.hotkeyActive(model.hotKey.displayName)) - } icon: { - Image(systemName: "checkmark.circle.fill") - } + VStack(alignment: .leading, spacing: 8) { + Text(L10n.hotkeyLabel) + hotKeyRecorder + } + } + } + + private var hotKeyRecorder: some View { + HotKeyRecorderView( + configuration: model.hotKey, + isRecording: $isRecordingHotKey, + onChange: { model.hotKey = $0 }, + onValidationError: { recordingError = $0 } + ) + .frame(width: 170, height: 28) + } + + @ViewBuilder private var hotKeyStatus: some View { + if isRecordingHotKey { + Label { + Text(L10n.hotkeyRecordingStatus) + } icon: { + Image(systemName: "record.circle.fill") + } + .font(.callout) + .foregroundStyle(.red) + .accessibilityLabel(L10n.hotkeyRecording) + } else if let error = recordingError ?? model.hotKeyRegistrationError { + Label(error, systemImage: "exclamationmark.triangle.fill") .font(.callout) - .foregroundStyle(.green) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } else { + Label { + Text(L10n.hotkeyActive(model.hotKey.displayName)) + } icon: { + Image(systemName: "checkmark.circle.fill") + } + .font(.callout) + .foregroundStyle(.green) + } + } + + @ViewBuilder private var shortcutActions: some View { + if isRecordingHotKey { + ViewThatFits(in: .horizontal) { + HStack { + cancelHelp + Spacer() + cancelRecordingButton + } + + VStack(alignment: .leading, spacing: 8) { + cancelHelp + cancelRecordingButton + } } + } else { + restoreHotKeyButton + } + } - StatusOverlaySettingsView( - isEnabled: $statusOverlayEnabled, - duration: $statusOverlayDuration, - placement: $statusOverlayPlacement, - transparency: $statusOverlayTransparency - ) + private var cancelHelp: some View { + Text(L10n.hotkeyCancelHelp) + .font(.caption) + .foregroundStyle(.secondary) + } - Divider() + private var cancelRecordingButton: some View { + Button { + recordingError = nil + isRecordingHotKey = false + } label: { + Text(L10n.actionCancel) + } + } + private var restoreHotKeyButton: some View { + Button { + recordingError = nil + model.restoreDefaultHotKey() + } label: { + Text(L10n.actionRestoreHotkey) + } + } + + private var aboutContent: some View { + ViewThatFits(in: .horizontal) { HStack { - Text(L10n.hotkeyCancelHelp) - .font(.caption) - .foregroundStyle(.secondary) + versionLabel Spacer() - Button { - recordingError = nil - model.restoreDefaultHotKey() - } label: { - Text(L10n.actionRestoreHotkey) + projectLinks + } + + VStack(alignment: .leading, spacing: 8) { + versionLabel + projectLinks + } + } + } + + private var versionLabel: some View { + Text(L10n.settingsVersion(Self.appVersion)) + .foregroundStyle(.secondary) + } + + private var projectLinks: some View { + HStack { + Link(destination: Self.xProfileURL) { + Label { + Text(L10n.settingsXProfile) + } icon: { + Image(systemName: "at") } } + .buttonStyle(.bordered) + + Link(destination: Self.gitHubURL) { + Label { + Text(L10n.settingsGithub) + } icon: { + Image(systemName: "chevron.left.forwardslash.chevron.right") + } + } + .buttonStyle(.bordered) } - .scenePadding() - .frame(minWidth: 480, idealWidth: 480, minHeight: 450) } } diff --git a/MicStatusAI/Views/InputLevelControl.swift b/MicStatusAI/Views/InputLevelControl.swift index a82f9b2..d437018 100644 --- a/MicStatusAI/Views/InputLevelControl.swift +++ b/MicStatusAI/Views/InputLevelControl.swift @@ -5,34 +5,52 @@ struct InputLevelControl: View { var body: some View { GroupBox { - HStack(spacing: 10) { - Image(systemName: "mic.slash.fill") - .foregroundStyle(.secondary) - .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: inputIconName) + .foregroundStyle(model.canAdjustInputLevel ? .secondary : .tertiary) + .accessibilityHidden(true) + + Slider( + value: Binding( + get: { model.inputLevel }, + set: { model.setInputLevel($0) } + ), + in: 0 ... 1, + onEditingChanged: { isEditing in + if !isEditing { + model.trackInputLevelCommit() + } + }, + label: { + Text(L10n.inputAccessibility) + } + ) + .disabled(!model.canAdjustInputLevel) + .accessibilityValue( + Text(model.inputLevel, format: .percent.precision(.fractionLength(0))) + ) - Slider( - value: Binding( - get: { model.inputLevel }, - set: { model.setInputLevel($0) } - ), - in: 0 ... 1 - ) { - Text(L10n.inputAccessibility) - } - .tint(model.inputLevel > 0 ? .green : .red) - .disabled(!model.canAdjustInputLevel) - .accessibilityValue( Text(model.inputLevel, format: .percent.precision(.fractionLength(0))) - ) + .monospacedDigit() + .frame(minWidth: 38, alignment: .trailing) + .accessibilityHidden(true) + } - Text(model.inputLevel, format: .percent.precision(.fractionLength(0))) - .monospacedDigit() - .frame(minWidth: 38, alignment: .trailing) - .accessibilityHidden(true) + if !model.canAdjustInputLevel { + Text(L10n.monitoringRequired) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } .padding(.vertical, 4) } label: { Text(L10n.inputTitle) } } + + private var inputIconName: String { + model.isMuted || model.inputLevel == 0 ? "mic.slash.fill" : "mic.fill" + } } diff --git a/MicStatusAI/Views/MicrophoneActionsView.swift b/MicStatusAI/Views/MicrophoneActionsView.swift index 81d909e..a023041 100644 --- a/MicStatusAI/Views/MicrophoneActionsView.swift +++ b/MicStatusAI/Views/MicrophoneActionsView.swift @@ -4,34 +4,33 @@ struct MicrophoneActionsView: View { let model: MicrophoneStatusModel var body: some View { - ViewThatFits(in: .horizontal) { - HStack { - monitoringButton - Spacer(minLength: 8) - muteButton - } + VStack(alignment: .leading, spacing: 10) { + Toggle(L10n.monitoringEnabled, isOn: monitoringBinding) + .toggleStyle(.switch) - VStack(alignment: .leading, spacing: 8) { - monitoringButton - muteButton + Button { + model.toggleMute(source: .button) + } label: { + Label { + Text(model.isMuted ? L10n.actionUnmute : L10n.actionMute) + } icon: { + Image(systemName: model.isMuted ? "mic.fill" : "mic.slash.fill") + } + .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) + .disabled(!model.isMonitoring || model.status.errorMessage != nil) + .help(L10n.actionMuteHelp) } } - private var monitoringButton: some View { - Button { - model.toggleMonitoring() - } label: { - Text(model.isMonitoring ? L10n.actionStop : L10n.actionStart) - } - } - - private var muteButton: some View { - Button { - model.toggleMute() - } label: { - Text(model.isMuted ? L10n.actionUnmute : L10n.actionMute) - } - .disabled(model.status.errorMessage != nil) + private var monitoringBinding: Binding { + Binding( + get: { model.isMonitoring }, + set: { shouldMonitor in + guard shouldMonitor != model.isMonitoring else { return } + model.toggleMonitoring() + } + ) } } diff --git a/MicStatusAI/Views/MicrophoneErrorView.swift b/MicStatusAI/Views/MicrophoneErrorView.swift new file mode 100644 index 0000000..ef659ae --- /dev/null +++ b/MicStatusAI/Views/MicrophoneErrorView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct MicrophoneErrorView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + + Button(action: onRetry) { + Label { + Text(L10n.actionRetry) + } icon: { + Image(systemName: "arrow.clockwise") + } + } + .controlSize(.small) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.08), in: .rect(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.orange.opacity(0.2)) + } + .accessibilityElement(children: .contain) + } +} + +#Preview("Microphone Error") { + MicrophoneErrorView(message: "No default microphone found.") { + // Preview action + } + .padding() + .frame(width: 330) +} diff --git a/MicStatusAI/Views/MicrophoneStatusHeader.swift b/MicStatusAI/Views/MicrophoneStatusHeader.swift index 9e04b4e..4060444 100644 --- a/MicStatusAI/Views/MicrophoneStatusHeader.swift +++ b/MicStatusAI/Views/MicrophoneStatusHeader.swift @@ -1,6 +1,9 @@ import SwiftUI struct MicrophoneStatusHeader: View { + @Environment(\.accessibilityReduceMotion) + private var reduceMotion + let status: MicrophoneStatus let isMonitoring: Bool @@ -10,16 +13,45 @@ struct MicrophoneStatusHeader: View { .font(.title2) .foregroundStyle(status.color) .frame(width: 28) + .contentTransition(.opacity) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(status.menuTitle) .font(.headline) + .contentTransition(.opacity) Text(isMonitoring ? L10n.monitoringActive : L10n.monitoringPaused) .font(.caption) .foregroundStyle(.secondary) + .contentTransition(.opacity) } } + .animation(statusAnimation, value: status) + .animation(statusAnimation, value: isMonitoring) .accessibilityElement(children: .combine) } + + private var statusAnimation: Animation? { + reduceMotion ? nil : .easeInOut(duration: 0.15) + } +} + +#Preview("Active") { + MicrophoneStatusHeader(status: .active(0.64), isMonitoring: true) + .padding() +} + +#Preview("Muted") { + MicrophoneStatusHeader(status: .muted, isMonitoring: true) + .padding() +} + +#Preview("Monitoring Stopped") { + MicrophoneStatusHeader(status: .stopped, isMonitoring: false) + .padding() +} + +#Preview("Unavailable") { + MicrophoneStatusHeader(status: .unavailable("No microphone"), isMonitoring: true) + .padding() } diff --git a/MicStatusAI/Views/StatusOverlaySettingsView.swift b/MicStatusAI/Views/StatusOverlaySettingsView.swift index 58b044b..3bae16b 100644 --- a/MicStatusAI/Views/StatusOverlaySettingsView.swift +++ b/MicStatusAI/Views/StatusOverlaySettingsView.swift @@ -5,69 +5,122 @@ struct StatusOverlaySettingsView: View { @Binding var duration: StatusOverlayDuration @Binding var placement: StatusOverlayPlacement @Binding var transparency: Double + let analytics: any AnalyticsTracking + let onShowPreview: () -> Void var body: some View { - GroupBox { - VStack(alignment: .leading, spacing: 8) { - Toggle(L10n.overlayEnabled, isOn: $isEnabled) + Section { + Toggle(L10n.overlayEnabled, isOn: $isEnabled) - Picker(L10n.overlayDuration, selection: $duration) { - ForEach(StatusOverlayDuration.allCases) { option in - Text(option.displayName) - .tag(option) - } + Picker(L10n.overlayDuration, selection: $duration) { + ForEach(StatusOverlayDuration.allCases) { option in + Text(option.displayName) + .tag(option) } - .pickerStyle(.menu) - .disabled(!isEnabled) + } + .pickerStyle(.menu) + .disabled(!isEnabled) - Picker(L10n.overlayPlacement, selection: $placement) { - ForEach(StatusOverlayPlacement.allCases) { option in - Text(option.displayName) - .tag(option) - } + Picker(L10n.overlayPlacement, selection: $placement) { + ForEach(StatusOverlayPlacement.allCases) { option in + Text(option.displayName) + .tag(option) } - .pickerStyle(.menu) - .disabled(!isEnabled) + } + .pickerStyle(.menu) + .disabled(!isEnabled) + ViewThatFits(in: .horizontal) { LabeledContent { - HStack(spacing: 8) { - Slider( - value: $transparency, - in: StatusOverlayTransparency.range, - step: StatusOverlayTransparency.step - ) { - Text(L10n.overlayTransparency) - } - .labelsHidden() - .accessibilityLabel(L10n.overlayTransparency) - .accessibilityValue( - Text( - transparency, - format: .percent.precision(.fractionLength(0)) - ) - ) - - Text( - transparency, - format: .percent.precision(.fractionLength(0)) - ) - .monospacedDigit() - .frame(minWidth: 42, alignment: .trailing) - .accessibilityHidden(true) - } + transparencyControl } label: { Text(L10n.overlayTransparency) } - .disabled(!isEnabled) - Text(L10n.overlayHelp) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 8) { + Text(L10n.overlayTransparency) + transparencyControl + } } - .padding(.vertical, 4) - } label: { + .disabled(!isEnabled) + + Button(action: onShowPreview) { + Label { + Text(L10n.overlayPreview) + } icon: { + Image(systemName: "play.display") + } + } + } header: { Text(L10n.overlayTitle) + } footer: { + Text(L10n.overlayHelp) + } + .onChange(of: isEnabled) { _, newValue in + analytics.track(.overlayEnabledChanged(enabled: newValue)) + } + .onChange(of: duration) { _, newValue in + analytics.track(.overlayDurationChanged(seconds: newValue.rawValue)) + } + .onChange(of: placement) { _, newValue in + analytics.track(.overlayPlacementChanged(placement: newValue.rawValue)) + } + } + + private var transparencyControl: some View { + HStack(spacing: 8) { + Slider( + value: $transparency, + in: StatusOverlayTransparency.range, + step: StatusOverlayTransparency.step, + onEditingChanged: { isEditing in + guard !isEditing else { return } + let percentBucket = Int((transparency * 4).rounded()) * 25 + analytics.track( + .overlayTransparencyChanged(percentBucket: percentBucket) + ) + }, + label: { + Text(L10n.overlayTransparency) + } + ) + .labelsHidden() + .frame(minWidth: 100, idealWidth: 140, maxWidth: 160) + .accessibilityLabel(L10n.overlayTransparency) + .accessibilityValue( + Text( + transparency, + format: .percent.precision(.fractionLength(0)) + ) + ) + + Text( + transparency, + format: .percent.precision(.fractionLength(0)) + ) + .monospacedDigit() + .frame(minWidth: 42, alignment: .trailing) + .accessibilityHidden(true) + } + } +} + +#Preview("Narrow Overlay Settings", traits: .fixedLayout(width: 340, height: 360)) { + @Previewable @State var isEnabled = true + @Previewable @State var duration = StatusOverlayDuration.oneSecond + @Previewable @State var placement = StatusOverlayPlacement.center + @Previewable @State var transparency = StatusOverlayTransparency.defaultValue + + Form { + StatusOverlaySettingsView( + isEnabled: $isEnabled, + duration: $duration, + placement: $placement, + transparency: $transparency, + analytics: NoopAnalytics() + ) { + // Preview action } } + .formStyle(.grouped) } diff --git a/MicStatusAI/Views/StatusOverlayView.swift b/MicStatusAI/Views/StatusOverlayView.swift index 2d98085..ad3c5b8 100644 --- a/MicStatusAI/Views/StatusOverlayView.swift +++ b/MicStatusAI/Views/StatusOverlayView.swift @@ -28,3 +28,13 @@ struct StatusOverlayView: View { .accessibilityElement(children: .combine) } } + +#Preview("Muted Overlay") { + StatusOverlayView(status: .muted) + .padding() +} + +#Preview("Active Overlay") { + StatusOverlayView(status: .active(0.72)) + .padding() +} diff --git a/MicStatusAI/Views/StatusPanel.swift b/MicStatusAI/Views/StatusPanel.swift index 20892e8..fb10536 100644 --- a/MicStatusAI/Views/StatusPanel.swift +++ b/MicStatusAI/Views/StatusPanel.swift @@ -11,10 +11,10 @@ struct StatusPanel: View { ) if let message = model.status.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) + MicrophoneErrorView( + message: message, + onRetry: model.retryMonitoring + ) } InputLevelControl(model: model) @@ -25,6 +25,6 @@ struct StatusPanel: View { StatusPanelFooter() } .padding(14) - .frame(width: 330) + .frame(minWidth: 300, idealWidth: 330, maxWidth: 360) } } diff --git a/README.md b/README.md index 24e752d..a683fc4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ MicStatusAI supports Apple silicon Macs only. > **If macOS blocks the app on first launch remove the quarantine attribute:** > -> ``` +> ```sh > xattr -dr com.apple.quarantine /Applications/MicStatusAI.app > open /Applications/MicStatusAI.app > ``` @@ -66,8 +66,19 @@ xcodegen generate Open `MicStatusAI.xcodeproj` in Xcode and run the `MicStatusAI` scheme. SwiftLint runs automatically during builds. +Anonymous analytics use Aptabase. Supply its client app key without committing it: + +- Local Xcode runs: add `APTABASE_APP_KEY` to scheme environment variables. +- Archives or command-line builds: pass `APTABASE_APP_KEY=A-REGION-ID` to `xcodebuild`. + +Analytics stay inactive when no valid key is configured. + `project.yml` is project source of truth. Generated `MicStatusAI.xcodeproj` is ignored by Git. +## Anonymous Analytics + +When configured, Aptabase analytics are enabled by default and can be disabled under Settings → Privacy. Events cover feature usage and sanitized error categories. MicStatusAI never sends audio, microphone names, shortcut keys, or raw error descriptions. + ## Localization Add short keys and translations to `MicStatusAI/Resources/Localizable.xcstrings`, then run `Scripts/generate-l10n.sh`. [L10nXcstrings](https://github.com/Disconnecter/L10nXcstrings) generates `MicStatusAI/Generated/L10n.swift`; never edit generated code manually. diff --git a/project.yml b/project.yml index 644bf08..a039801 100644 --- a/project.yml +++ b/project.yml @@ -12,6 +12,11 @@ configs: Debug: debug Release: release +packages: + Aptabase: + url: https://github.com/aptabase/aptabase-swift.git + from: 0.3.11 + settings: base: CLANG_ENABLE_MODULES: "YES" @@ -31,6 +36,7 @@ targets: - path: MicStatusAI/Resources/Localizable.xcstrings - path: MicStatusAI/Resources/Assets.xcassets dependencies: + - package: Aptabase - sdk: CoreAudio.framework - sdk: Carbon.framework settings: