Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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"
Expand Down
104 changes: 104 additions & 0 deletions MicStatusAI/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 37 additions & 2 deletions MicStatusAI/App/MicStatusAIApp.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI

@main
@MainActor
struct MicStatusAIApp: App {
@AppStorage("statusOverlayEnabled")
private var statusOverlayEnabled = true
Expand All @@ -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)
Expand All @@ -31,6 +51,7 @@ struct MicStatusAIApp: App {
placement: statusOverlayPlacement,
transparency: statusOverlayTransparency
)
analytics.track(.overlayShown)
}
.onChange(of: statusOverlayEnabled) { _, isEnabled in
if !isEnabled {
Expand All @@ -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)
}
}
4 changes: 4 additions & 0 deletions MicStatusAI/Coordinators/HotKeyRecorderCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
15 changes: 15 additions & 0 deletions MicStatusAI/Errors/MicrophoneError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
76 changes: 68 additions & 8 deletions MicStatusAI/Generated/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -20,21 +30,31 @@ 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
public static var actionUnmute: String {
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)
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions MicStatusAI/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AptabaseAppKey</key>
<string>$(APTABASE_APP_KEY)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
Expand Down
Loading