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.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user