From 06eac377195462a9787d1c3288dffd681f6e746c Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Mon, 10 Aug 2026 23:37:42 +0300 Subject: [PATCH 1/8] feat: improve settings layout and links --- MicStatusAI/App/MicStatusAIApp.swift | 1 + MicStatusAI/Generated/L10n.swift | 20 +++ MicStatusAI/Resources/Localizable.xcstrings | 48 +++++++ MicStatusAI/Views/HotKeySettingsView.swift | 117 +++++++++++++++--- .../Views/StatusOverlaySettingsView.swift | 64 ++++++---- 5 files changed, 204 insertions(+), 46 deletions(-) diff --git a/MicStatusAI/App/MicStatusAIApp.swift b/MicStatusAI/App/MicStatusAIApp.swift index 84ecb29..ef6548e 100644 --- a/MicStatusAI/App/MicStatusAIApp.swift +++ b/MicStatusAI/App/MicStatusAIApp.swift @@ -49,5 +49,6 @@ struct MicStatusAIApp: App { statusOverlayTransparency: $statusOverlayTransparency ) } + .windowResizability(.contentSize) } } diff --git a/MicStatusAI/Generated/L10n.swift b/MicStatusAI/Generated/L10n.swift index d1d472d..9e1e95c 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -195,6 +195,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 +215,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/Resources/Localizable.xcstrings b/MicStatusAI/Resources/Localizable.xcstrings index 449d4c3..fd914b9 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -457,6 +457,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 +505,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/Views/HotKeySettingsView.swift b/MicStatusAI/Views/HotKeySettingsView.swift index bda55d1..0157ac7 100644 --- a/MicStatusAI/Views/HotKeySettingsView.swift +++ b/MicStatusAI/Views/HotKeySettingsView.swift @@ -8,6 +8,12 @@ struct HotKeySettingsView: View { @Binding var statusOverlayTransparency: Double @State private var recordingError: String? + 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) { Label { @@ -22,15 +28,17 @@ struct HotKeySettingsView: View { .fixedSize(horizontal: false, vertical: true) GroupBox { - LabeledContent { - HotKeyRecorderView( - configuration: model.hotKey, - onChange: { model.hotKey = $0 }, - onValidationError: { recordingError = $0 } - ) - .frame(width: 170, height: 28) - } label: { - Text(L10n.hotkeyLabel) + ViewThatFits(in: .horizontal) { + LabeledContent { + hotKeyRecorder + } label: { + Text(L10n.hotkeyLabel) + } + + VStack(alignment: .leading, spacing: 8) { + Text(L10n.hotkeyLabel) + hotKeyRecorder + } } .padding(.vertical, 4) } label: { @@ -59,22 +67,91 @@ struct HotKeySettingsView: View { transparency: $statusOverlayTransparency ) + GroupBox { + ViewThatFits(in: .horizontal) { + HStack { + versionLabel + Spacer() + projectLinks + } + + VStack(alignment: .leading, spacing: 8) { + versionLabel + projectLinks + } + } + .padding(.vertical, 4) + } label: { + Text(L10n.settingsAbout) + } + Divider() - HStack { - Text(L10n.hotkeyCancelHelp) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button { - recordingError = nil - model.restoreDefaultHotKey() - } label: { - Text(L10n.actionRestoreHotkey) + ViewThatFits(in: .horizontal) { + HStack { + cancelHelp + Spacer() + restoreHotKeyButton + } + + VStack(alignment: .leading, spacing: 8) { + cancelHelp + restoreHotKeyButton } } } .scenePadding() - .frame(minWidth: 480, idealWidth: 480, minHeight: 450) + .frame(minWidth: 320, idealWidth: 340, maxWidth: 360) + } + + private var hotKeyRecorder: some View { + HotKeyRecorderView( + configuration: model.hotKey, + onChange: { model.hotKey = $0 }, + onValidationError: { recordingError = $0 } + ) + .frame(width: 170, height: 28) + } + + 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) + } + } + + private var cancelHelp: some View { + Text(L10n.hotkeyCancelHelp) + .font(.caption) + .foregroundStyle(.secondary) + } + + private var restoreHotKeyButton: some View { + Button { + recordingError = nil + model.restoreDefaultHotKey() + } label: { + Text(L10n.actionRestoreHotkey) + } } } diff --git a/MicStatusAI/Views/StatusOverlaySettingsView.swift b/MicStatusAI/Views/StatusOverlaySettingsView.swift index 58b044b..d0e7b2a 100644 --- a/MicStatusAI/Views/StatusOverlaySettingsView.swift +++ b/MicStatusAI/Views/StatusOverlaySettingsView.swift @@ -29,34 +29,17 @@ struct StatusOverlaySettingsView: View { .pickerStyle(.menu) .disabled(!isEnabled) - 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)) - ) - ) + ViewThatFits(in: .horizontal) { + LabeledContent { + transparencyControl + } label: { + Text(L10n.overlayTransparency) + } - Text( - transparency, - format: .percent.precision(.fractionLength(0)) - ) - .monospacedDigit() - .frame(minWidth: 42, alignment: .trailing) - .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { + Text(L10n.overlayTransparency) + transparencyControl } - } label: { - Text(L10n.overlayTransparency) } .disabled(!isEnabled) @@ -70,4 +53,33 @@ struct StatusOverlaySettingsView: View { Text(L10n.overlayTitle) } } + + private var transparencyControl: some View { + HStack(spacing: 8) { + Slider( + value: $transparency, + in: StatusOverlayTransparency.range, + step: StatusOverlayTransparency.step + ) { + 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) + } + } } From 50d0709349a21e0551d4fee1f382e31a5a0b80a5 Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Mon, 10 Aug 2026 23:46:26 +0300 Subject: [PATCH 2/8] feat: modernize settings and add overlay preview --- MicStatusAI/App/MicStatusAIApp.swift | 12 +- MicStatusAI/Generated/L10n.swift | 5 + MicStatusAI/Resources/Localizable.xcstrings | 12 ++ MicStatusAI/Views/HotKeySettingsView.swift | 186 ++++++++++-------- .../Views/StatusOverlaySettingsView.swift | 71 +++---- 5 files changed, 166 insertions(+), 120 deletions(-) diff --git a/MicStatusAI/App/MicStatusAIApp.swift b/MicStatusAI/App/MicStatusAIApp.swift index ef6548e..6918a0b 100644 --- a/MicStatusAI/App/MicStatusAIApp.swift +++ b/MicStatusAI/App/MicStatusAIApp.swift @@ -46,9 +46,19 @@ struct MicStatusAIApp: App { statusOverlayEnabled: $statusOverlayEnabled, statusOverlayDuration: $statusOverlayDuration, statusOverlayPlacement: $statusOverlayPlacement, - statusOverlayTransparency: $statusOverlayTransparency + statusOverlayTransparency: $statusOverlayTransparency, + onShowOverlayPreview: showOverlayPreview ) } .windowResizability(.contentSize) } + + private func showOverlayPreview() { + statusOverlayPresenter.show( + status: model.status, + duration: statusOverlayDuration.seconds, + placement: statusOverlayPlacement, + transparency: statusOverlayTransparency + ) + } } diff --git a/MicStatusAI/Generated/L10n.swift b/MicStatusAI/Generated/L10n.swift index 9e1e95c..5b2ec02 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -185,6 +185,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") diff --git a/MicStatusAI/Resources/Localizable.xcstrings b/MicStatusAI/Resources/Localizable.xcstrings index fd914b9..99277a7 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -433,6 +433,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", diff --git a/MicStatusAI/Views/HotKeySettingsView.swift b/MicStatusAI/Views/HotKeySettingsView.swift index 0157ac7..91fbfa1 100644 --- a/MicStatusAI/Views/HotKeySettingsView.swift +++ b/MicStatusAI/Views/HotKeySettingsView.swift @@ -6,6 +6,8 @@ struct HotKeySettingsView: View { @Binding var statusOverlayDuration: StatusOverlayDuration @Binding var statusOverlayPlacement: StatusOverlayPlacement @Binding var statusOverlayTransparency: Double + let onShowOverlayPreview: () -> Void + @State private var recordingError: String? private static let appVersion = Bundle.main.object( @@ -15,7 +17,7 @@ struct HotKeySettingsView: View { 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: { @@ -23,85 +25,52 @@ struct HotKeySettingsView: View { } .font(.title2.bold()) - Text(L10n.hotkeyInstructions) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - GroupBox { - ViewThatFits(in: .horizontal) { - LabeledContent { - hotKeyRecorder - } label: { - Text(L10n.hotkeyLabel) - } - - VStack(alignment: .leading, spacing: 8) { - Text(L10n.hotkeyLabel) - hotKeyRecorder - } + Form { + Section { + Text(L10n.hotkeyInstructions) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + shortcutControl + hotKeyStatus + shortcutActions + } header: { + Text(L10n.hotkeyTitle) } - .padding(.vertical, 4) - } label: { - Text(L10n.hotkeyTitle) - } - 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") + StatusOverlaySettingsView( + isEnabled: $statusOverlayEnabled, + duration: $statusOverlayDuration, + placement: $statusOverlayPlacement, + transparency: $statusOverlayTransparency, + onShowPreview: onShowOverlayPreview + ) + + Section { + aboutContent + } header: { + Text(L10n.settingsAbout) } - .font(.callout) - .foregroundStyle(.green) } + .formStyle(.grouped) + } + .scenePadding() + .frame(minWidth: 320, idealWidth: 340, maxWidth: 360) + } - StatusOverlaySettingsView( - isEnabled: $statusOverlayEnabled, - duration: $statusOverlayDuration, - placement: $statusOverlayPlacement, - transparency: $statusOverlayTransparency - ) - - GroupBox { - ViewThatFits(in: .horizontal) { - HStack { - versionLabel - Spacer() - projectLinks - } - - VStack(alignment: .leading, spacing: 8) { - versionLabel - projectLinks - } - } - .padding(.vertical, 4) + private var shortcutControl: some View { + ViewThatFits(in: .horizontal) { + LabeledContent { + hotKeyRecorder } label: { - Text(L10n.settingsAbout) + Text(L10n.hotkeyLabel) } - Divider() - - ViewThatFits(in: .horizontal) { - HStack { - cancelHelp - Spacer() - restoreHotKeyButton - } - - VStack(alignment: .leading, spacing: 8) { - cancelHelp - restoreHotKeyButton - } + VStack(alignment: .leading, spacing: 8) { + Text(L10n.hotkeyLabel) + hotKeyRecorder } } - .scenePadding() - .frame(minWidth: 320, idealWidth: 340, maxWidth: 360) } private var hotKeyRecorder: some View { @@ -113,6 +82,68 @@ struct HotKeySettingsView: View { .frame(width: 170, height: 28) } + @ViewBuilder private var hotKeyStatus: some View { + 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") + } + .font(.callout) + .foregroundStyle(.green) + } + } + + private var shortcutActions: some View { + ViewThatFits(in: .horizontal) { + HStack { + cancelHelp + Spacer() + restoreHotKeyButton + } + + VStack(alignment: .leading, spacing: 8) { + cancelHelp + restoreHotKeyButton + } + } + } + + private var cancelHelp: some View { + Text(L10n.hotkeyCancelHelp) + .font(.caption) + .foregroundStyle(.secondary) + } + + private var restoreHotKeyButton: some View { + Button { + recordingError = nil + model.restoreDefaultHotKey() + } label: { + Text(L10n.actionRestoreHotkey) + } + } + + private var aboutContent: some View { + ViewThatFits(in: .horizontal) { + HStack { + versionLabel + Spacer() + projectLinks + } + + VStack(alignment: .leading, spacing: 8) { + versionLabel + projectLinks + } + } + } + private var versionLabel: some View { Text(L10n.settingsVersion(Self.appVersion)) .foregroundStyle(.secondary) @@ -139,19 +170,4 @@ struct HotKeySettingsView: View { .buttonStyle(.bordered) } } - - private var cancelHelp: some View { - Text(L10n.hotkeyCancelHelp) - .font(.caption) - .foregroundStyle(.secondary) - } - - private var restoreHotKeyButton: some View { - Button { - recordingError = nil - model.restoreDefaultHotKey() - } label: { - Text(L10n.actionRestoreHotkey) - } - } } diff --git a/MicStatusAI/Views/StatusOverlaySettingsView.swift b/MicStatusAI/Views/StatusOverlaySettingsView.swift index d0e7b2a..d4bf13a 100644 --- a/MicStatusAI/Views/StatusOverlaySettingsView.swift +++ b/MicStatusAI/Views/StatusOverlaySettingsView.swift @@ -5,52 +5,55 @@ struct StatusOverlaySettingsView: View { @Binding var duration: StatusOverlayDuration @Binding var placement: StatusOverlayPlacement @Binding var transparency: Double + 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 { - transparencyControl - } label: { - Text(L10n.overlayTransparency) - } + ViewThatFits(in: .horizontal) { + LabeledContent { + transparencyControl + } label: { + Text(L10n.overlayTransparency) + } - VStack(alignment: .leading, spacing: 8) { - Text(L10n.overlayTransparency) - transparencyControl - } + VStack(alignment: .leading, spacing: 8) { + Text(L10n.overlayTransparency) + transparencyControl } - .disabled(!isEnabled) + } + .disabled(!isEnabled) - Text(L10n.overlayHelp) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Button(action: onShowPreview) { + Label { + Text(L10n.overlayPreview) + } icon: { + Image(systemName: "play.display") + } } - .padding(.vertical, 4) - } label: { + } header: { Text(L10n.overlayTitle) + } footer: { + Text(L10n.overlayHelp) } } From 61a4820dc8eef1b7db23973fc0448b9a4f5276d7 Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Mon, 10 Aug 2026 23:49:29 +0300 Subject: [PATCH 3/8] feat: clarify microphone controls and recovery --- MicStatusAI/Generated/L10n.swift | 26 ++++++--- .../Models/MicrophoneStatusModel.swift | 8 +++ MicStatusAI/Resources/Localizable.xcstrings | 48 ++++++++++++----- MicStatusAI/Views/InputLevelControl.swift | 54 +++++++++++-------- MicStatusAI/Views/MicrophoneActionsView.swift | 45 ++++++++-------- MicStatusAI/Views/MicrophoneErrorView.swift | 32 +++++++++++ MicStatusAI/Views/StatusPanel.swift | 10 ++-- 7 files changed, 153 insertions(+), 70 deletions(-) create mode 100644 MicStatusAI/Views/MicrophoneErrorView.swift diff --git a/MicStatusAI/Generated/L10n.swift b/MicStatusAI/Generated/L10n.swift index 5b2ec02..6577e09 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -10,6 +10,11 @@ public enum L10n { 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 +25,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 @@ -135,6 +135,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 +150,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") diff --git a/MicStatusAI/Models/MicrophoneStatusModel.swift b/MicStatusAI/Models/MicrophoneStatusModel.swift index b9c4936..2aa2d52 100644 --- a/MicStatusAI/Models/MicrophoneStatusModel.swift +++ b/MicStatusAI/Models/MicrophoneStatusModel.swift @@ -149,6 +149,14 @@ final class MicrophoneStatusModel { } } + func retryMonitoring() { + if isMonitoring { + refreshStatus() + } else { + startMonitoring() + } + } + func restoreDefaultHotKey() { hotKey = .defaultValue } diff --git a/MicStatusAI/Resources/Localizable.xcstrings b/MicStatusAI/Resources/Localizable.xcstrings index 99277a7..a05425e 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -13,50 +13,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" } } } @@ -313,6 +313,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 +349,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", diff --git a/MicStatusAI/Views/InputLevelControl.swift b/MicStatusAI/Views/InputLevelControl.swift index a82f9b2..56ad47e 100644 --- a/MicStatusAI/Views/InputLevelControl.swift +++ b/MicStatusAI/Views/InputLevelControl.swift @@ -5,34 +5,46 @@ 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 + ) { + 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..66b397b 100644 --- a/MicStatusAI/Views/MicrophoneActionsView.swift +++ b/MicStatusAI/Views/MicrophoneActionsView.swift @@ -4,34 +4,31 @@ 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(action: model.toggleMute) { + 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..20e88fa --- /dev/null +++ b/MicStatusAI/Views/MicrophoneErrorView.swift @@ -0,0 +1,32 @@ +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) + } +} 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) } } From b8a26ff4c6b084b4aec9e2be86d2cc99d7a5b89c Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Mon, 10 Aug 2026 23:52:08 +0300 Subject: [PATCH 4/8] feat: improve hotkey recording feedback --- .../HotKeyRecorderCoordinator.swift | 4 ++ MicStatusAI/Generated/L10n.swift | 10 ++++ MicStatusAI/Resources/Localizable.xcstrings | 24 ++++++++++ MicStatusAI/Views/HotKeyRecorderButton.swift | 19 ++++++-- MicStatusAI/Views/HotKeyRecorderView.swift | 12 +++++ MicStatusAI/Views/HotKeySettingsView.swift | 46 ++++++++++++++----- 6 files changed, 100 insertions(+), 15 deletions(-) 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/Generated/L10n.swift b/MicStatusAI/Generated/L10n.swift index 6577e09..0d4e8ae 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -5,6 +5,11 @@ 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") @@ -110,6 +115,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") diff --git a/MicStatusAI/Resources/Localizable.xcstrings b/MicStatusAI/Resources/Localizable.xcstrings index a05425e..32325e0 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -1,6 +1,18 @@ { "sourceLanguage" : "en", "strings" : { + "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", @@ -253,6 +265,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", 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 91fbfa1..a53affe 100644 --- a/MicStatusAI/Views/HotKeySettingsView.swift +++ b/MicStatusAI/Views/HotKeySettingsView.swift @@ -9,6 +9,7 @@ struct HotKeySettingsView: View { let onShowOverlayPreview: () -> Void @State private var recordingError: String? + @State private var isRecordingHotKey = false private static let appVersion = Bundle.main.object( forInfoDictionaryKey: "CFBundleShortVersionString" @@ -76,6 +77,7 @@ struct HotKeySettingsView: View { private var hotKeyRecorder: some View { HotKeyRecorderView( configuration: model.hotKey, + isRecording: $isRecordingHotKey, onChange: { model.hotKey = $0 }, onValidationError: { recordingError = $0 } ) @@ -83,7 +85,16 @@ struct HotKeySettingsView: View { } @ViewBuilder private var hotKeyStatus: some View { - if let error = recordingError ?? model.hotKeyRegistrationError { + 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(.orange) @@ -99,18 +110,22 @@ struct HotKeySettingsView: View { } } - private var shortcutActions: some View { - ViewThatFits(in: .horizontal) { - HStack { - cancelHelp - Spacer() - restoreHotKeyButton - } + @ViewBuilder private var shortcutActions: some View { + if isRecordingHotKey { + ViewThatFits(in: .horizontal) { + HStack { + cancelHelp + Spacer() + cancelRecordingButton + } - VStack(alignment: .leading, spacing: 8) { - cancelHelp - restoreHotKeyButton + VStack(alignment: .leading, spacing: 8) { + cancelHelp + cancelRecordingButton + } } + } else { + restoreHotKeyButton } } @@ -120,6 +135,15 @@ struct HotKeySettingsView: View { .foregroundStyle(.secondary) } + private var cancelRecordingButton: some View { + Button { + recordingError = nil + isRecordingHotKey = false + } label: { + Text(L10n.actionCancel) + } + } + private var restoreHotKeyButton: some View { Button { recordingError = nil From 12b9bab78ff245213895b1199c2bfd4765c5032b Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Mon, 10 Aug 2026 23:54:38 +0300 Subject: [PATCH 5/8] style: polish status feedback and previews --- .../Services/StatusOverlayPresenter.swift | 37 +++++++++++++------ MicStatusAI/Views/MicrophoneErrorView.swift | 8 ++++ .../Views/MicrophoneStatusHeader.swift | 32 ++++++++++++++++ .../Views/StatusOverlaySettingsView.swift | 19 ++++++++++ MicStatusAI/Views/StatusOverlayView.swift | 10 +++++ 5 files changed, 95 insertions(+), 11 deletions(-) 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/MicrophoneErrorView.swift b/MicStatusAI/Views/MicrophoneErrorView.swift index 20e88fa..ef659ae 100644 --- a/MicStatusAI/Views/MicrophoneErrorView.swift +++ b/MicStatusAI/Views/MicrophoneErrorView.swift @@ -30,3 +30,11 @@ struct MicrophoneErrorView: View { .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 d4bf13a..1854731 100644 --- a/MicStatusAI/Views/StatusOverlaySettingsView.swift +++ b/MicStatusAI/Views/StatusOverlaySettingsView.swift @@ -86,3 +86,22 @@ struct StatusOverlaySettingsView: View { } } } + +#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 + ) { + // 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() +} From 2472caef2dd11c0528a5727bbda502d05030bcb9 Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Tue, 11 Aug 2026 00:21:43 +0300 Subject: [PATCH 6/8] feat: add Aptabase analytics foundation --- MicStatusAI/Analytics/AnalyticsEvent.swift | 104 ++++++++++++++++++ MicStatusAI/Info.plist | 2 + .../Interfaces/AnalyticsTracking.swift | 5 + MicStatusAI/Services/AptabaseAnalytics.swift | 63 +++++++++++ MicStatusAI/Services/NoopAnalytics.swift | 10 ++ README.md | 9 +- project.yml | 6 + 7 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 MicStatusAI/Analytics/AnalyticsEvent.swift create mode 100644 MicStatusAI/Interfaces/AnalyticsTracking.swift create mode 100644 MicStatusAI/Services/AptabaseAnalytics.swift create mode 100644 MicStatusAI/Services/NoopAnalytics.swift 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/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/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/README.md b/README.md index 24e752d..5414e15 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,6 +66,13 @@ 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. ## Localization 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: From 8ed7def3c70bcece393ddee2488b0bfd91ce583d Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Tue, 11 Aug 2026 00:28:58 +0300 Subject: [PATCH 7/8] feat: track anonymous product analytics --- MicStatusAI/App/MicStatusAIApp.swift | 26 ++++++- MicStatusAI/Errors/MicrophoneError.swift | 15 ++++ MicStatusAI/Generated/L10n.swift | 15 ++++ .../Models/MicrophoneStatusModel.swift | 76 ++++++++++++++----- MicStatusAI/Resources/Localizable.xcstrings | 36 +++++++++ MicStatusAI/Views/HotKeySettingsView.swift | 20 +++++ MicStatusAI/Views/InputLevelControl.swift | 14 +++- MicStatusAI/Views/MicrophoneActionsView.swift | 4 +- .../Views/StatusOverlaySettingsView.swift | 29 +++++-- README.md | 4 + 10 files changed, 210 insertions(+), 29 deletions(-) diff --git a/MicStatusAI/App/MicStatusAIApp.swift b/MicStatusAI/App/MicStatusAIApp.swift index 6918a0b..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 { @@ -47,6 +68,8 @@ struct MicStatusAIApp: App { statusOverlayDuration: $statusOverlayDuration, statusOverlayPlacement: $statusOverlayPlacement, statusOverlayTransparency: $statusOverlayTransparency, + analyticsEnabled: $analyticsEnabled, + analytics: analytics, onShowOverlayPreview: showOverlayPreview ) } @@ -60,5 +83,6 @@ struct MicStatusAIApp: App { placement: statusOverlayPlacement, transparency: statusOverlayTransparency ) + analytics.track(.overlayPreviewed) } } 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 0d4e8ae..a60b155 100644 --- a/MicStatusAI/Generated/L10n.swift +++ b/MicStatusAI/Generated/L10n.swift @@ -40,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) diff --git a/MicStatusAI/Models/MicrophoneStatusModel.swift b/MicStatusAI/Models/MicrophoneStatusModel.swift index 2aa2d52..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,7 @@ final class MicrophoneStatusModel { if isMonitoring { status = .unavailable(error.localizedDescription) } + trackMicrophoneError(error, operation: .mute) } } @@ -181,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 { @@ -213,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 32325e0..6f09978 100644 --- a/MicStatusAI/Resources/Localizable.xcstrings +++ b/MicStatusAI/Resources/Localizable.xcstrings @@ -1,6 +1,42 @@ { "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", diff --git a/MicStatusAI/Views/HotKeySettingsView.swift b/MicStatusAI/Views/HotKeySettingsView.swift index a53affe..194619e 100644 --- a/MicStatusAI/Views/HotKeySettingsView.swift +++ b/MicStatusAI/Views/HotKeySettingsView.swift @@ -6,6 +6,8 @@ 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? @@ -44,9 +46,24 @@ struct HotKeySettingsView: View { 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) + } + Section { aboutContent } header: { @@ -57,6 +74,9 @@ struct HotKeySettingsView: View { } .scenePadding() .frame(minWidth: 320, idealWidth: 340, maxWidth: 360) + .onAppear { + analytics.track(.settingsOpened) + } } private var shortcutControl: some View { diff --git a/MicStatusAI/Views/InputLevelControl.swift b/MicStatusAI/Views/InputLevelControl.swift index 56ad47e..d437018 100644 --- a/MicStatusAI/Views/InputLevelControl.swift +++ b/MicStatusAI/Views/InputLevelControl.swift @@ -16,10 +16,16 @@ struct InputLevelControl: View { get: { model.inputLevel }, set: { model.setInputLevel($0) } ), - in: 0 ... 1 - ) { - Text(L10n.inputAccessibility) - } + 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))) diff --git a/MicStatusAI/Views/MicrophoneActionsView.swift b/MicStatusAI/Views/MicrophoneActionsView.swift index 66b397b..a023041 100644 --- a/MicStatusAI/Views/MicrophoneActionsView.swift +++ b/MicStatusAI/Views/MicrophoneActionsView.swift @@ -8,7 +8,9 @@ struct MicrophoneActionsView: View { Toggle(L10n.monitoringEnabled, isOn: monitoringBinding) .toggleStyle(.switch) - Button(action: model.toggleMute) { + Button { + model.toggleMute(source: .button) + } label: { Label { Text(model.isMuted ? L10n.actionUnmute : L10n.actionMute) } icon: { diff --git a/MicStatusAI/Views/StatusOverlaySettingsView.swift b/MicStatusAI/Views/StatusOverlaySettingsView.swift index 1854731..3bae16b 100644 --- a/MicStatusAI/Views/StatusOverlaySettingsView.swift +++ b/MicStatusAI/Views/StatusOverlaySettingsView.swift @@ -5,6 +5,7 @@ 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 { @@ -55,6 +56,15 @@ struct StatusOverlaySettingsView: View { } 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 { @@ -62,10 +72,18 @@ struct StatusOverlaySettingsView: View { Slider( value: $transparency, in: StatusOverlayTransparency.range, - step: StatusOverlayTransparency.step - ) { - Text(L10n.overlayTransparency) - } + 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) @@ -98,7 +116,8 @@ struct StatusOverlaySettingsView: View { isEnabled: $isEnabled, duration: $duration, placement: $placement, - transparency: $transparency + transparency: $transparency, + analytics: NoopAnalytics() ) { // Preview action } diff --git a/README.md b/README.md index 5414e15..a683fc4 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ 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. From 1838e60d385363d8f76f83d1a2c72ed432cc524a Mon Sep 17 00:00:00 2001 From: Serhiy Zabolotnyy Date: Tue, 11 Aug 2026 00:31:57 +0300 Subject: [PATCH 8/8] ci: inject Aptabase key into release builds --- .github/workflows/release.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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"