diff --git a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift new file mode 100644 index 000000000..ffae26a37 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift @@ -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 + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index f21e44239..9beae3329 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -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 { - Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) - } - - private var heartbeatsBinding: Binding { - Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 }) - } - - private var voiceWakeBinding: Binding { - 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? diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift new file mode 100644 index 000000000..6cc256323 --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -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 { + Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) + } + + private var heartbeatsBinding: Binding { + Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 }) + } + + private var voiceWakeBinding: Binding { + 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 } + } +}