Files
OSGKeyboard/OSGKeyboardShared/Views/FlowDebugPanel.swift
T
Rocky cdf833935a feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when
recording began while the ready overlay was still up; also remove temporary
on-screen Flow DEBUG panels after the orange-mic investigation, and land the
macOS overlay/catalog/layout polish plus related Flow recovery hardening.
2026-07-10 12:39:41 +08:00

175 lines
7.3 KiB
Swift

// FlowDebugPanel.swift
// OSGKeyboard · Shared
//
// TEMPORARY debug overlay for cross-process Flow state. Remove after the
// orange-mic investigation. Shows the same App Group contract fields on both
// the host app and the keyboard extension so we can see where they diverge.
import SwiftUI
/// One labeled row in the temporary Flow debug panel.
public struct FlowDebugRow: Equatable, Sendable {
public let label: String
public let value: String
public init(_ label: String, _ value: String) {
self.label = label
self.value = value
}
}
/// Builds the App Group half of the debug snapshot (readable from both processes).
public enum FlowDebugAppGroupSnapshot {
public static func rows(defaults: UserDefaults? = nil) -> [FlowDebugRow] {
FlowSessionBridge.reloadFromDisk(defaults: defaults)
let snapshot = FlowSessionBridge.readySnapshot(defaults: defaults)
let staleness = FlowSessionBridge.heartbeatStaleness(defaults: defaults)
let generation = FlowSessionBridge.currentHostGeneration(defaults: defaults)
let shortGen: String = {
guard let generation, generation.count >= 8 else { return generation ?? "nil" }
return String(generation.prefix(8))
}()
let snapGen: String = {
guard let g = snapshot?.hostGeneration, g.count >= 8 else {
return snapshot?.hostGeneration ?? "nil"
}
return String(g.prefix(8))
}()
let expires: String = {
guard let ts = FlowSessionBridge.sessionExpiresAt(defaults: defaults) else { return "nil" }
let remaining = ts - Date().timeIntervalSince1970
return String(format: "%.0fs", remaining)
}()
return [
FlowDebugRow("sessionActive", FlowSessionBridge.isSessionActive(defaults: defaults) ? "1" : "0"),
FlowDebugRow("expiresIn", expires),
FlowDebugRow("hostReachable", FlowSessionBridge.isHostReachable(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hostReady", FlowSessionBridge.isHostReady(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hostStale", FlowSessionBridge.isHostStale(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hbStale", staleness.map { String(format: "%.1fs", $0) } ?? "nil"),
FlowDebugRow("snap.ready", snapshot.map { $0.ready ? "1" : "0" } ?? "nil"),
FlowDebugRow("snap.reason", snapshot?.reason.rawValue ?? "nil"),
FlowDebugRow("snap.session", shortUUID(snapshot?.sessionId)),
FlowDebugRow("gen.now", shortGen),
FlowDebugRow("gen.snap", snapGen),
FlowDebugRow("gen.match", {
guard let a = snapshot?.hostGeneration,
let b = generation else { return "n/a" }
return a == b ? "1" : "0"
}()),
FlowDebugRow("pendingHost", FlowSessionBridge.pendingHostBundleId(defaults: defaults) ?? "nil"),
FlowDebugRow("recState", FlowSessionBridge.recordingState(defaults: defaults).rawValue),
FlowDebugRow("appGroup", AppGroup.isAvailable ? "1" : "0")
]
}
private static func shortUUID(_ id: UUID?) -> String {
guard let id else { return "nil" }
return String(id.uuidString.prefix(8))
}
}
/// Collapsible monospaced status panel. Temporary — for investigation only.
public struct FlowDebugPanel: View {
public let title: String
public let rows: [FlowDebugRow]
@Binding public var isExpanded: Bool
public var maxContentHeight: CGFloat
public init(
title: String,
rows: [FlowDebugRow],
isExpanded: Binding<Bool>,
maxContentHeight: CGFloat = 180
) {
self.title = title
self.rows = rows
self._isExpanded = isExpanded
self.maxContentHeight = maxContentHeight
}
public var body: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: 6) {
Text(isExpanded ? "▼" : "▶")
.font(.system(size: 10, weight: .bold, design: .monospaced))
Text(title)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
Spacer(minLength: 0)
Text(summaryChip)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(summaryColor)
}
.foregroundStyle(Color.primary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(alignment: .top, spacing: 6) {
Text(row.label)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(Color.secondary)
.frame(width: 92, alignment: .leading)
Text(row.value)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundStyle(Color.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.frame(maxHeight: maxContentHeight)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.ultraThinMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.orange.opacity(0.7), lineWidth: 1)
)
}
private var summaryChip: String {
let hostReady = rows.first(where: { $0.label == "hostReady" })?.value
?? rows.first(where: { $0.label == "bridgeReady" })?.value
?? "?"
let mic = rows.first(where: { $0.label == "mic" })?.value
if let mic {
return "mic=\(shortMic(mic)) hr=\(hostReady)"
}
let active = rows.first(where: { $0.label == "isActive" })?.value ?? "?"
return "active=\(active) hr=\(hostReady)"
}
private var summaryColor: Color {
let hostReady = rows.first(where: { $0.label == "hostReady" })?.value
?? rows.first(where: { $0.label == "bridgeReady" })?.value
if hostReady == "1" { return .green }
return .orange
}
private func shortMic(_ value: String) -> String {
if value.hasPrefix("ready") { return "ready" }
if value.contains("preparing") { return "prep" }
if value.contains("hostNotReady") { return "notReady" }
if value.contains("recording") { return "rec" }
if value.contains("processing") { return "proc" }
if value.contains("noFullAccess") { return "noFA" }
if value.contains("appGroup") { return "noAG" }
if value.contains("missingAPIKey") { return "noKey" }
return String(value.prefix(12))
}
}