refactor(mac): split menubar UI into smaller files
This commit is contained in:
325
apps/macos/Sources/Clawdis/CritterStatusLabel.swift
Normal file
325
apps/macos/Sources/Clawdis/CritterStatusLabel.swift
Normal file
@@ -0,0 +1,325 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct CritterStatusLabel: View {
|
||||
var isPaused: Bool
|
||||
var isWorking: Bool
|
||||
var earBoostActive: Bool
|
||||
var blinkTick: Int
|
||||
var sendCelebrationTick: Int
|
||||
var relayStatus: RelayProcessManager.Status
|
||||
var animationsEnabled: Bool
|
||||
|
||||
@State private var blinkAmount: CGFloat = 0
|
||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
@State private var wiggleAngle: Double = 0
|
||||
@State private var wiggleOffset: CGFloat = 0
|
||||
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
||||
@State private var legWiggle: CGFloat = 0
|
||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State private var earWiggle: CGFloat = 0
|
||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if self.isPaused {
|
||||
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
|
||||
.frame(width: 18, height: 16)
|
||||
} else {
|
||||
Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive))
|
||||
.frame(width: 18, height: 16)
|
||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||
.offset(x: self.wiggleOffset)
|
||||
.onReceive(self.ticker) { now in
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorking {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.animationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.relayNeedsAttention {
|
||||
Circle()
|
||||
.fill(self.relayBadgeColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
self.blinkAmount = 0
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
self.legWiggle = 0
|
||||
self.earWiggle = 0
|
||||
}
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggle() {
|
||||
let targetAngle = Double.random(in: -4.5...4.5)
|
||||
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = targetAngle
|
||||
self.wiggleOffset = targetOffset
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleLegs() {
|
||||
let target = CGFloat.random(in: 0.35...0.9)
|
||||
withAnimation(.easeInOut(duration: 0.14)) {
|
||||
self.legWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scurry() {
|
||||
let target = CGFloat.random(in: 0.7...1.0)
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
self.legWiggle = target
|
||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||
withAnimation(.easeOut(duration: 0.16)) {
|
||||
self.legWiggle = 0.25
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleEars() {
|
||||
let target = CGFloat.random(in: -1.2...1.2)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRandomTimers(from date: Date) {
|
||||
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
private var relayNeedsAttention: Bool {
|
||||
switch self.relayStatus {
|
||||
case .failed, .stopped:
|
||||
!self.isPaused
|
||||
case .starting, .restarting, .running:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private var relayBadgeColor: Color {
|
||||
switch self.relayStatus {
|
||||
case .failed: .red
|
||||
case .stopped: .orange
|
||||
default: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CritterIconRenderer {
|
||||
private static let size = NSSize(width: 18, height: 16)
|
||||
|
||||
static func makeIcon(
|
||||
blink: CGFloat,
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false) -> NSImage
|
||||
{
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
defer { image.unlockFocus() }
|
||||
|
||||
guard let ctx = NSGraphicsContext.current?.cgContext else { return image }
|
||||
|
||||
let w = self.size.width
|
||||
let h = self.size.height
|
||||
|
||||
let bodyW = w * 0.78
|
||||
let bodyH = h * 0.58
|
||||
let bodyX = (w - bodyW) / 2
|
||||
let bodyY = h * 0.36
|
||||
let bodyCorner = w * 0.09
|
||||
|
||||
let earW = w * 0.22
|
||||
let earH = bodyH * 0.66 * earScale * (1 - 0.08 * abs(earWiggle))
|
||||
let earCorner = earW * 0.24
|
||||
let leftEarRect = CGRect(
|
||||
x: bodyX - earW * 0.55 + earWiggle,
|
||||
y: bodyY + bodyH * 0.08 + earWiggle * 0.4,
|
||||
width: earW,
|
||||
height: earH)
|
||||
let rightEarRect = CGRect(
|
||||
x: bodyX + bodyW - earW * 0.45 - earWiggle,
|
||||
y: bodyY + bodyH * 0.08 - earWiggle * 0.4,
|
||||
width: earW,
|
||||
height: earH)
|
||||
|
||||
let legW = w * 0.11
|
||||
let legH = h * 0.26
|
||||
let legSpacing = w * 0.085
|
||||
let legsWidth = 4 * legW + 3 * legSpacing
|
||||
let legStartX = (w - legsWidth) / 2
|
||||
let legLift = legH * 0.35 * legWiggle
|
||||
let legYBase = bodyY - legH + h * 0.05
|
||||
|
||||
let eyeOpen = max(0.05, 1 - blink)
|
||||
let eyeW = bodyW * 0.2
|
||||
let eyeH = bodyH * 0.26 * eyeOpen
|
||||
let eyeY = bodyY + bodyH * 0.56
|
||||
let eyeOffset = bodyW * 0.24
|
||||
|
||||
ctx.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
|
||||
cornerWidth: bodyCorner,
|
||||
cornerHeight: bodyCorner,
|
||||
transform: nil))
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: leftEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: rightEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
for i in 0..<4 {
|
||||
let x = legStartX + CGFloat(i) * (legW + legSpacing)
|
||||
let lift = (i % 2 == 0 ? legLift : -legLift)
|
||||
let rect = CGRect(x: x, y: legYBase + lift, width: legW, height: legH * (1 - 0.12 * legWiggle))
|
||||
ctx.addPath(CGPath(roundedRect: rect, cornerWidth: legW * 0.34, cornerHeight: legW * 0.34, transform: nil))
|
||||
}
|
||||
ctx.fillPath()
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setBlendMode(.clear)
|
||||
|
||||
let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY)
|
||||
let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY)
|
||||
|
||||
if earHoles || earScale > 1.05 {
|
||||
let holeW = earW * 0.6
|
||||
let holeH = earH * 0.46
|
||||
let holeCorner = holeW * 0.34
|
||||
let leftHoleRect = CGRect(
|
||||
x: leftEarRect.midX - holeW / 2,
|
||||
y: leftEarRect.midY - holeH / 2 + earH * 0.04,
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: rightEarRect.midX - holeW / 2,
|
||||
y: rightEarRect.midY - holeH / 2 + earH * 0.04,
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y - eyeH))
|
||||
left.addLine(to: CGPoint(x: leftCenter.x + eyeW / 2, y: leftCenter.y))
|
||||
left.addLine(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y + eyeH))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y - eyeH))
|
||||
right.addLine(to: CGPoint(x: rightCenter.x - eyeW / 2, y: rightCenter.y))
|
||||
right.addLine(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y + eyeH))
|
||||
right.closeSubpath()
|
||||
|
||||
ctx.addPath(left)
|
||||
ctx.addPath(right)
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
@@ -6,7 +5,6 @@ import MenuBarExtraAccess
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
import Network
|
||||
|
||||
@main
|
||||
struct ClawdisApp: App {
|
||||
@@ -54,617 +52,6 @@ struct ClawdisApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MenuContent: View {
|
||||
@ObservedObject var state: AppState
|
||||
let updater: UpdaterProviding?
|
||||
@ObservedObject private var relayManager = RelayProcessManager.shared
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
||||
@ObservedObject private var controlChannel = ControlChannel.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
let label = self.state.connectionMode == .remote ? "Remote Clawdis Active" : "Clawdis Active"
|
||||
Text(label)
|
||||
}
|
||||
self.statusRow
|
||||
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
||||
self.heartbeatStatusRow
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
if AppStateStore.webChatEnabled {
|
||||
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
|
||||
}
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
Button("About Clawdis") { self.open(tab: .about) }
|
||||
if let updater, updater.isAvailable {
|
||||
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
Divider()
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
.task(id: self.state.swabbleEnabled) {
|
||||
if self.state.swabbleEnabled {
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||
}
|
||||
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func open(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
let health = self.healthStore.state
|
||||
let isRefreshing = self.healthStore.isRefreshing
|
||||
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
||||
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
if isRefreshing {
|
||||
// Prefer health while the probe is running.
|
||||
label = "Health check running…"
|
||||
color = health.tint
|
||||
} else {
|
||||
// Show last health result + age; relay is implicit when healthy.
|
||||
switch health {
|
||||
case .ok:
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
label = "Health ok\(ageText)"
|
||||
color = .green
|
||||
case .linkingNeeded:
|
||||
label = "Health: login required"
|
||||
color = .red
|
||||
case let .degraded(reason):
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
label = "Health degraded: \(reason)\(ageText)"
|
||||
color = .orange
|
||||
case .unknown:
|
||||
label = "Health pending"
|
||||
color = .secondary
|
||||
}
|
||||
}
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func relayLabel(_ status: RelayProcessManager.Status) -> String {
|
||||
switch status {
|
||||
case .running: "Running"
|
||||
case .starting: "Starting…"
|
||||
case .restarting: "Restarting…"
|
||||
case let .failed(reason): "Failed: \(reason)"
|
||||
case .stopped: "Stopped"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: RelayProcessManager.Status) -> Color {
|
||||
switch status {
|
||||
case .running: .green
|
||||
case .starting, .restarting: .orange
|
||||
case .failed: .red
|
||||
case .stopped: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatStatusRow: some View {
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
if case .degraded = self.controlChannel.state {
|
||||
label = "Control channel disconnected"
|
||||
color = .red
|
||||
} else if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
label = "Last heartbeat sent · \(ageText)"
|
||||
color = .blue
|
||||
case "ok-empty", "ok-token":
|
||||
label = "Heartbeat ok · \(ageText)"
|
||||
color = .green
|
||||
case "skipped":
|
||||
label = "Heartbeat skipped · \(ageText)"
|
||||
color = .secondary
|
||||
case "failed":
|
||||
label = "Heartbeat failed · \(ageText)"
|
||||
color = .red
|
||||
default:
|
||||
label = "Heartbeat · \(ageText)"
|
||||
color = .secondary
|
||||
}
|
||||
} else {
|
||||
label = "No heartbeat yet"
|
||||
color = .secondary
|
||||
}
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var heartbeatsBinding: Binding<Bool> {
|
||||
Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 })
|
||||
}
|
||||
|
||||
private var voiceWakeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.state.swabbleEnabled },
|
||||
set: { newValue in
|
||||
Task { await self.state.setVoiceWakeEnabled(newValue) }
|
||||
})
|
||||
}
|
||||
|
||||
private var showVoiceWakeMicPicker: Bool {
|
||||
voiceWakeSupported && self.state.swabbleEnabled
|
||||
}
|
||||
|
||||
private var voiceWakeMicMenu: some View {
|
||||
Menu {
|
||||
self.microphoneMenuItems
|
||||
|
||||
if self.loadingMics {
|
||||
Divider()
|
||||
Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath")
|
||||
.labelStyle(.titleOnly)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Microphone")
|
||||
Spacer()
|
||||
Text(self.selectedMicLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task { await self.loadMicrophones() }
|
||||
}
|
||||
|
||||
private var selectedMicLabel: String {
|
||||
if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel }
|
||||
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
|
||||
return match.name
|
||||
}
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
private var microphoneMenuItems: some View {
|
||||
Group {
|
||||
Button {
|
||||
self.state.voiceWakeMicID = ""
|
||||
} label: {
|
||||
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
ForEach(self.availableMics) { mic in
|
||||
Button {
|
||||
self.state.voiceWakeMicID = mic.uid
|
||||
} label: {
|
||||
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultMicLabel: String {
|
||||
if let host = Host.current().localizedName, !host.isEmpty {
|
||||
return "Auto-detect (\(host))"
|
||||
}
|
||||
return "System default"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicrophones(force: Bool = false) async {
|
||||
guard self.showVoiceWakeMicPicker else {
|
||||
self.availableMics = []
|
||||
self.loadingMics = false
|
||||
return
|
||||
}
|
||||
if !force, !self.availableMics.isEmpty { return }
|
||||
self.loadingMics = true
|
||||
let discovery = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
self.availableMics = discovery.devices
|
||||
.sorted { lhs, rhs in
|
||||
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
|
||||
}
|
||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private func primarySessionKey() -> String {
|
||||
// Prefer canonical main session; fall back to most recent.
|
||||
let storePath = SessionLoader.defaultStorePath
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
|
||||
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
{
|
||||
if decoded.keys.contains("main") { return "main" }
|
||||
|
||||
let sorted = decoded.sorted { a, b -> Bool in
|
||||
let lhs = a.value.updatedAt ?? 0
|
||||
let rhs = b.value.updatedAt ?? 0
|
||||
return lhs > rhs
|
||||
}
|
||||
if let first = sorted.first { return first.key }
|
||||
}
|
||||
return "+1003"
|
||||
}
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
var id: String { self.uid }
|
||||
}
|
||||
}
|
||||
|
||||
private struct CritterStatusLabel: View {
|
||||
var isPaused: Bool
|
||||
var isWorking: Bool
|
||||
var earBoostActive: Bool
|
||||
var blinkTick: Int
|
||||
var sendCelebrationTick: Int
|
||||
var relayStatus: RelayProcessManager.Status
|
||||
var animationsEnabled: Bool
|
||||
|
||||
@State private var blinkAmount: CGFloat = 0
|
||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
@State private var wiggleAngle: Double = 0
|
||||
@State private var wiggleOffset: CGFloat = 0
|
||||
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
||||
@State private var legWiggle: CGFloat = 0
|
||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State private var earWiggle: CGFloat = 0
|
||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if self.isPaused {
|
||||
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
|
||||
.frame(width: 18, height: 16)
|
||||
} else {
|
||||
Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive))
|
||||
.frame(width: 18, height: 16)
|
||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||
.offset(x: self.wiggleOffset)
|
||||
.onReceive(self.ticker) { now in
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorking {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.animationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.relayNeedsAttention {
|
||||
Circle()
|
||||
.fill(self.relayBadgeColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
self.blinkAmount = 0
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
self.legWiggle = 0
|
||||
self.earWiggle = 0
|
||||
}
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggle() {
|
||||
let targetAngle = Double.random(in: -4.5...4.5)
|
||||
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = targetAngle
|
||||
self.wiggleOffset = targetOffset
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleLegs() {
|
||||
let target = CGFloat.random(in: 0.35...0.9)
|
||||
withAnimation(.easeInOut(duration: 0.14)) {
|
||||
self.legWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scurry() {
|
||||
let target = CGFloat.random(in: 0.7...1.0)
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
self.legWiggle = target
|
||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||
withAnimation(.easeOut(duration: 0.16)) {
|
||||
self.legWiggle = 0.25
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleEars() {
|
||||
let target = CGFloat.random(in: -1.2...1.2)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRandomTimers(from date: Date) {
|
||||
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
private var relayNeedsAttention: Bool {
|
||||
switch self.relayStatus {
|
||||
case .failed, .stopped:
|
||||
!self.isPaused
|
||||
case .starting, .restarting, .running:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private var relayBadgeColor: Color {
|
||||
switch self.relayStatus {
|
||||
case .failed: .red
|
||||
case .stopped: .orange
|
||||
default: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CritterIconRenderer {
|
||||
private static let size = NSSize(width: 18, height: 16)
|
||||
|
||||
static func makeIcon(
|
||||
blink: CGFloat,
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false) -> NSImage
|
||||
{
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
defer { image.unlockFocus() }
|
||||
|
||||
guard let ctx = NSGraphicsContext.current?.cgContext else { return image }
|
||||
|
||||
let w = self.size.width
|
||||
let h = self.size.height
|
||||
|
||||
let bodyW = w * 0.78
|
||||
let bodyH = h * 0.58
|
||||
let bodyX = (w - bodyW) / 2
|
||||
let bodyY = h * 0.36
|
||||
let bodyCorner = w * 0.09
|
||||
|
||||
let earW = w * 0.22
|
||||
let earH = bodyH * 0.66 * earScale * (1 - 0.08 * abs(earWiggle))
|
||||
let earCorner = earW * 0.24
|
||||
let leftEarRect = CGRect(
|
||||
x: bodyX - earW * 0.55 + earWiggle,
|
||||
y: bodyY + bodyH * 0.08 + earWiggle * 0.4,
|
||||
width: earW,
|
||||
height: earH)
|
||||
let rightEarRect = CGRect(
|
||||
x: bodyX + bodyW - earW * 0.45 - earWiggle,
|
||||
y: bodyY + bodyH * 0.08 - earWiggle * 0.4,
|
||||
width: earW,
|
||||
height: earH)
|
||||
|
||||
let legW = w * 0.11
|
||||
let legH = h * 0.26
|
||||
let legSpacing = w * 0.085
|
||||
let legsWidth = 4 * legW + 3 * legSpacing
|
||||
let legStartX = (w - legsWidth) / 2
|
||||
let legLift = legH * 0.35 * legWiggle
|
||||
let legYBase = bodyY - legH + h * 0.05
|
||||
|
||||
let eyeOpen = max(0.05, 1 - blink)
|
||||
let eyeW = bodyW * 0.2
|
||||
let eyeH = bodyH * 0.26 * eyeOpen
|
||||
let eyeY = bodyY + bodyH * 0.56
|
||||
let eyeOffset = bodyW * 0.24
|
||||
|
||||
ctx.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
|
||||
cornerWidth: bodyCorner,
|
||||
cornerHeight: bodyCorner,
|
||||
transform: nil))
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: leftEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: rightEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
for i in 0..<4 {
|
||||
let x = legStartX + CGFloat(i) * (legW + legSpacing)
|
||||
let lift = (i % 2 == 0 ? legLift : -legLift)
|
||||
let rect = CGRect(x: x, y: legYBase + lift, width: legW, height: legH * (1 - 0.12 * legWiggle))
|
||||
ctx.addPath(CGPath(roundedRect: rect, cornerWidth: legW * 0.34, cornerHeight: legW * 0.34, transform: nil))
|
||||
}
|
||||
ctx.fillPath()
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setBlendMode(.clear)
|
||||
|
||||
let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY)
|
||||
let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY)
|
||||
|
||||
if earHoles || earScale > 1.05 {
|
||||
let holeW = earW * 0.6
|
||||
let holeH = earH * 0.46
|
||||
let holeCorner = holeW * 0.34
|
||||
let leftHoleRect = CGRect(
|
||||
x: leftEarRect.midX - holeW / 2,
|
||||
y: leftEarRect.midY - holeH / 2 + earH * 0.04,
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: rightEarRect.midX - holeW / 2,
|
||||
y: rightEarRect.midY - holeH / 2 + earH * 0.04,
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y - eyeH))
|
||||
left.addLine(to: CGPoint(x: leftCenter.x + eyeW / 2, y: leftCenter.y))
|
||||
left.addLine(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y + eyeH))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y - eyeH))
|
||||
right.addLine(to: CGPoint(x: rightCenter.x - eyeW / 2, y: rightCenter.y))
|
||||
right.addLine(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y + eyeH))
|
||||
right.closeSubpath()
|
||||
|
||||
ctx.addPath(left)
|
||||
ctx.addPath(right)
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
|
||||
private var listener: NSXPCListener?
|
||||
private var state: AppState?
|
||||
|
||||
272
apps/macos/Sources/Clawdis/MenuContentView.swift
Normal file
272
apps/macos/Sources/Clawdis/MenuContentView.swift
Normal file
@@ -0,0 +1,272 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Menu contents for the Clawdis menu bar extra.
|
||||
struct MenuContent: View {
|
||||
@ObservedObject var state: AppState
|
||||
let updater: UpdaterProviding?
|
||||
@ObservedObject private var relayManager = RelayProcessManager.shared
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
||||
@ObservedObject private var controlChannel = ControlChannel.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
let label = self.state.connectionMode == .remote ? "Remote Clawdis Active" : "Clawdis Active"
|
||||
Text(label)
|
||||
}
|
||||
self.statusRow
|
||||
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
||||
self.heartbeatStatusRow
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
if AppStateStore.webChatEnabled {
|
||||
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
|
||||
}
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
Button("About Clawdis") { self.open(tab: .about) }
|
||||
if let updater, updater.isAvailable {
|
||||
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
Divider()
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
.task(id: self.state.swabbleEnabled) {
|
||||
if self.state.swabbleEnabled {
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||
}
|
||||
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func open(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
let health = self.healthStore.state
|
||||
let isRefreshing = self.healthStore.isRefreshing
|
||||
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
||||
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
if isRefreshing {
|
||||
label = "Health check running…"
|
||||
color = health.tint
|
||||
} else {
|
||||
switch health {
|
||||
case .ok:
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
label = "Health ok\(ageText)"
|
||||
color = .green
|
||||
case .linkingNeeded:
|
||||
label = "Health: login required"
|
||||
color = .red
|
||||
case let .degraded(reason):
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
label = "Health degraded: \(reason)\(ageText)"
|
||||
color = .orange
|
||||
case .unknown:
|
||||
label = "Health pending"
|
||||
color = .secondary
|
||||
}
|
||||
}
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var heartbeatStatusRow: some View {
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
if case .degraded = self.controlChannel.state {
|
||||
label = "Control channel disconnected"
|
||||
color = .red
|
||||
} else if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
label = "Last heartbeat sent · \(ageText)"
|
||||
color = .blue
|
||||
case "ok-empty", "ok-token":
|
||||
label = "Heartbeat ok · \(ageText)"
|
||||
color = .green
|
||||
case "skipped":
|
||||
label = "Heartbeat skipped · \(ageText)"
|
||||
color = .secondary
|
||||
case "failed":
|
||||
label = "Heartbeat failed · \(ageText)"
|
||||
color = .red
|
||||
default:
|
||||
label = "Heartbeat · \(ageText)"
|
||||
color = .secondary
|
||||
}
|
||||
} else {
|
||||
label = "No heartbeat yet"
|
||||
color = .secondary
|
||||
}
|
||||
|
||||
return HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var heartbeatsBinding: Binding<Bool> {
|
||||
Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 })
|
||||
}
|
||||
|
||||
private var voiceWakeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.state.swabbleEnabled },
|
||||
set: { newValue in
|
||||
Task { await self.state.setVoiceWakeEnabled(newValue) }
|
||||
})
|
||||
}
|
||||
|
||||
private var showVoiceWakeMicPicker: Bool {
|
||||
voiceWakeSupported && self.state.swabbleEnabled
|
||||
}
|
||||
|
||||
private var voiceWakeMicMenu: some View {
|
||||
Menu {
|
||||
self.microphoneMenuItems
|
||||
|
||||
if self.loadingMics {
|
||||
Divider()
|
||||
Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath")
|
||||
.labelStyle(.titleOnly)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Microphone")
|
||||
Spacer()
|
||||
Text(self.selectedMicLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task { await self.loadMicrophones() }
|
||||
}
|
||||
|
||||
private var selectedMicLabel: String {
|
||||
if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel }
|
||||
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
|
||||
return match.name
|
||||
}
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
private var microphoneMenuItems: some View {
|
||||
Group {
|
||||
Button {
|
||||
self.state.voiceWakeMicID = ""
|
||||
} label: {
|
||||
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
ForEach(self.availableMics) { mic in
|
||||
Button {
|
||||
self.state.voiceWakeMicID = mic.uid
|
||||
} label: {
|
||||
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultMicLabel: String {
|
||||
if let host = Host.current().localizedName, !host.isEmpty {
|
||||
return "Auto-detect (\(host))"
|
||||
}
|
||||
return "System default"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicrophones(force: Bool = false) async {
|
||||
guard self.showVoiceWakeMicPicker else {
|
||||
self.availableMics = []
|
||||
self.loadingMics = false
|
||||
return
|
||||
}
|
||||
if !force, !self.availableMics.isEmpty { return }
|
||||
self.loadingMics = true
|
||||
let discovery = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
self.availableMics = discovery.devices
|
||||
.sorted { lhs, rhs in
|
||||
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
|
||||
}
|
||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private func primarySessionKey() -> String {
|
||||
// Prefer canonical main session; fall back to most recent.
|
||||
let storePath = SessionLoader.defaultStorePath
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
|
||||
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
{
|
||||
if decoded.keys.contains("main") { return "main" }
|
||||
|
||||
let sorted = decoded.sorted { a, b -> Bool in
|
||||
let lhs = a.value.updatedAt ?? 0
|
||||
let rhs = b.value.updatedAt ?? 0
|
||||
return lhs > rhs
|
||||
}
|
||||
if let first = sorted.first { return first.key }
|
||||
}
|
||||
return "+1003"
|
||||
}
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
var id: String { self.uid }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user