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:
@@ -117,5 +117,12 @@
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<!-- Voice recordings: processed on-device by default; uploaded to
|
||||
the user's configured ASR provider only when the cloud engine
|
||||
is explicitly enabled (opt-in with acknowledgment). -->
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
|
||||
|
||||
@@ -97,8 +97,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
for item in items {
|
||||
// `sourceApplication` is only non-nil when the caller belongs to
|
||||
// the same Apple Developer Team (our own keyboard extension) —
|
||||
// exactly what the host-return whitelist relies on.
|
||||
if let source = item.source {
|
||||
// exactly what the host-return whitelist relies on. Record it
|
||||
// ONLY for the `startflow` handoff: overwriting it for every
|
||||
// deep link (e.g. `osgkeyboard://settings`) could point a later
|
||||
// cold-start "return to host" at the wrong app.
|
||||
if let source = item.source, item.url.host == "startflow" {
|
||||
FlowSessionBridge.setPendingHostBundleId(source)
|
||||
}
|
||||
AppOpenURLRouter.shared.route(item.url)
|
||||
|
||||
@@ -15,11 +15,17 @@ enum FlowLiveActivityController {
|
||||
nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
|
||||
|
||||
/// If the host app is force-quit its `endSession()` never runs, orphaning
|
||||
/// the Live Activity. A short `staleDate` lets the system grey it out and
|
||||
/// reclaim it on its own within ~45s of the process dying. While the host is
|
||||
/// alive the heartbeat calls `keepAlive()` well inside this window, so a
|
||||
/// genuinely active session never looks stale.
|
||||
private static let staleWindow: TimeInterval = 45
|
||||
/// the Live Activity. `staleDate` semantics (verified against ActivityKit
|
||||
/// behaviour, not folklore): the *Dynamic Island* presentation is reliably
|
||||
/// removed shortly after the stale date passes, but the *lock-screen*
|
||||
/// banner may linger greyed-out depending on the iOS version — it is NOT
|
||||
/// guaranteed to be dismissed. Treating staleDate as "auto-cleanup" is
|
||||
/// therefore wrong on its own; the full zombie defence is this short
|
||||
/// window + launch-time reconciliation (`clearOrphanedActivities`) + the
|
||||
/// widget rendering an explicit "disconnected" state via
|
||||
/// `context.isStale`. While the host is alive the heartbeat calls
|
||||
/// `keepAlive()` every ~10 s, well inside this window.
|
||||
private static let staleWindow: TimeInterval = 30
|
||||
|
||||
private static func freshContent(
|
||||
phase: FlowActivityAttributes.ContentState.Phase
|
||||
@@ -118,6 +124,8 @@ enum FlowLiveActivityController {
|
||||
}
|
||||
|
||||
/// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。
|
||||
/// 等待必须带超时:ActivityKit 的 `end` 走异步 XPC,若在 watchdog 杀进程前
|
||||
/// 没有返回,无限期 `wait()` 会吞掉整个 ~5 秒终止窗口,反而让后续清理全部没跑。
|
||||
nonisolated static func endAllSynchronouslyOnTerminate() {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task.detached(priority: .userInitiated) {
|
||||
@@ -129,7 +137,7 @@ enum FlowLiveActivityController {
|
||||
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
_ = semaphore.wait(timeout: .now() + 2)
|
||||
currentPhase = .idle
|
||||
currentActivity = nil
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var currentUtteranceId: UUID?
|
||||
private var currentCommandSeq: Int64 = 0
|
||||
private var lastHandledCommandSeq: Int64 = 0
|
||||
private var isUtteranceRecording = false
|
||||
/// Published so Home / debug UI can show "recording" instead of a false "ready".
|
||||
@Published private(set) var isUtteranceRecording = false
|
||||
/// True from `stopped` until the result/error is written back to App Group.
|
||||
private var isUtteranceProcessing = false
|
||||
@Published private(set) var isUtteranceProcessing = false
|
||||
private var finalizeTask: Task<Void, Never>?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var utteranceSafetyTask: Task<Void, Never>?
|
||||
@@ -77,17 +78,50 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var coldStartRecoveryTask: Task<Void, Never>?
|
||||
/// Initial proof window — cold mic sessions often need >2.5s after app switch.
|
||||
private static let coldStartAudioProofTimeout: TimeInterval = 6
|
||||
/// Extra window after the first timeout while the overlay shows a failure hint.
|
||||
private static let coldStartRecoveryProofTimeout: TimeInterval = 12
|
||||
/// Guards the once-per-process launch reconciliation (scene reconnects
|
||||
/// recreate the `@StateObject`-owned manager within the same process).
|
||||
private static var didRunLaunchReconciliation = false
|
||||
|
||||
init() {
|
||||
// Sessions are (re)started explicitly on app foreground via
|
||||
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
||||
// stored session here — after a force-quit that would resurrect capture
|
||||
// (and keep a stale Live Activity alive) without the user re-opening.
|
||||
//
|
||||
// Launch reconciliation: a brand-new process can never own an
|
||||
// in-flight session, so whatever the previous generation persisted
|
||||
// (force-quit skips `applicationWillTerminate` entirely when the app
|
||||
// was suspended) is void. Rotating the generation token also lets the
|
||||
// keyboard invalidate stale ready snapshots instantly instead of
|
||||
// waiting out the 60 s heartbeat-zombie window.
|
||||
//
|
||||
// Once per PROCESS, not per manager: iOS can disconnect and later
|
||||
// reconnect the sole scene without killing the process, which
|
||||
// recreates the `@StateObject` (and thus this init). Re-rotating then
|
||||
// would wipe live state that belongs to this very process.
|
||||
if AppGroup.isAvailable, !Self.didRunLaunchReconciliation {
|
||||
Self.didRunLaunchReconciliation = true
|
||||
let previous = FlowSessionBridge.rotateHostGeneration()
|
||||
if previous != nil || FlowSessionBridge.isSessionActive() {
|
||||
FlowSessionBridge.clearFlowStateOnHostLaunch()
|
||||
FlowLiveActivityController.clearOrphanedActivities()
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
debug("launch reconciliation: voided previous-generation Flow state")
|
||||
}
|
||||
}
|
||||
|
||||
capture.onEngineLiveChanged = { [weak self] _ in
|
||||
self?.refreshHostReady()
|
||||
}
|
||||
// A system interruption (call / Siri) stops audio frames mid-utterance;
|
||||
// fail fast so the user is not silently recording into a gap.
|
||||
capture.onInterruptionBegan = { [weak self] in
|
||||
guard let self, self.isUtteranceRecording else { return }
|
||||
self.failUtterance(
|
||||
message: AppL10n.string("flow.error.recognitionInterrupted"),
|
||||
kind: .recognitionInterrupted
|
||||
)
|
||||
}
|
||||
FlowTerminationCoordinator.register(self)
|
||||
}
|
||||
|
||||
@@ -126,6 +160,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
traceState("startSession.ignored", extra: "reason=alreadyStarting")
|
||||
return
|
||||
}
|
||||
// Claim the flag synchronously: on a cold start the URL router and
|
||||
// `activateOnForeground()` both fire in the same runloop turn, and
|
||||
// setting it inside the async body let two start bodies interleave.
|
||||
isStarting = true
|
||||
|
||||
startTask?.cancel()
|
||||
startTask = Task { @MainActor [weak self] in
|
||||
@@ -190,11 +228,15 @@ final class FlowSessionManager: ObservableObject {
|
||||
func dismissColdStartOverlay() {
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = nil
|
||||
// Clear handoff flags BEFORE any refreshHostReady call. Otherwise
|
||||
// refresh → reconcileColdStartOverlayIfRecovered → dismiss → refresh
|
||||
// recurses until the main-thread stack overflows (EXC_BAD_ACCESS,
|
||||
// "Thread stack size exceeded due to excessive recursion").
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
if isActive {
|
||||
refreshHostReady()
|
||||
}
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
}
|
||||
|
||||
func returnToPendingHostFromColdStart() {
|
||||
@@ -204,6 +246,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
func retryColdStartReadiness() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
// A failed cold start leaves capture in a running-but-dead state on
|
||||
// purpose (the recovery loop keeps probing it). A user-initiated
|
||||
// retry must instead begin from a clean pipeline: tear down capture
|
||||
// and the cached ASR instance so `startSession` rebuilds both —
|
||||
// otherwise the retry reuses the zombie engine and is guaranteed to
|
||||
// hit the same audio-proof timeout.
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = nil
|
||||
if capture.running {
|
||||
capture.stop()
|
||||
}
|
||||
sessionASR?.cancel()
|
||||
sessionASR = nil
|
||||
sessionASREngineMode = nil
|
||||
sessionASRWarmedLocaleID = nil
|
||||
startSession(coldStart: true)
|
||||
}
|
||||
|
||||
@@ -241,7 +298,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
if isUtteranceRecording || isUtteranceProcessing {
|
||||
capture.cancelUtterance()
|
||||
asr.cancel()
|
||||
// `asr` is a computed property that ALLOCATES a fresh ASRService
|
||||
// when `sessionASR` is nil — never do that inside the ~5 s
|
||||
// termination window; only cancel an instance that exists.
|
||||
sessionASR?.cancel()
|
||||
}
|
||||
|
||||
capture.cancelUtterance()
|
||||
@@ -407,16 +467,42 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
private func reactivateCaptureIfNeeded() async {
|
||||
guard isActive else { return }
|
||||
// A system interruption (call / Siri) may be in progress. Probe it:
|
||||
// `setActive(true)` inside `reassertIfRunning` fails while the
|
||||
// interruption is live and succeeds once it ends — which also covers
|
||||
// the documented case where iOS never delivers `.ended` (the latch
|
||||
// must not depend on that notification, or the session is dead until
|
||||
// its TTL). While the probe fails we deliberately do NOT stop or
|
||||
// rebuild: tearing the engine down would remove the observers the
|
||||
// `.ended` rebuild relies on and churn the shared session mid-call.
|
||||
if capture.isInterrupted {
|
||||
guard capture.reassertIfRunning(), !capture.isInterrupted else { return }
|
||||
}
|
||||
|
||||
if capture.running {
|
||||
let reasserted = capture.reassertIfRunning()
|
||||
if reasserted, capture.engineHasRecentAudio() {
|
||||
if reasserted, await capture.awaitAudioFlowing(timeout: 2) {
|
||||
sessionWarning = nil
|
||||
} else if !reasserted {
|
||||
sessionWarning = AppL10n.string("flow.error.audioUnavailable")
|
||||
refreshHostReady()
|
||||
return
|
||||
}
|
||||
refreshHostReady()
|
||||
return
|
||||
// The await above is a suspension point: the session may have
|
||||
// ended (expiry, user, teardown) while we waited. Never restart
|
||||
// the microphone for a session that no longer exists.
|
||||
guard isActive, !Task.isCancelled else { return }
|
||||
// Never tear capture down underneath a live utterance either — a
|
||||
// stalled route transition mid-recording must surface through the
|
||||
// utterance pipeline (safety timer / empty-transcript error), not
|
||||
// as a silent stop that truncates the take with no error at all.
|
||||
guard !isUtteranceRecording, !isUtteranceProcessing else {
|
||||
refreshHostReady()
|
||||
return
|
||||
}
|
||||
// Reassert failed, or the engine reports running yet produces no
|
||||
// frames (zombie after suspend / mediaserverd reset) — fall
|
||||
// through to a full rebuild instead of leaving it half-dead.
|
||||
capture.stop()
|
||||
debug("capture zombie after foreground — rebuilding")
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -442,7 +528,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
reason: .noSession,
|
||||
engineMode: store.engineMode,
|
||||
localeId: store.localeId,
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration()
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -486,7 +573,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
engineMode: store.engineMode,
|
||||
localeId: store.localeId,
|
||||
busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil,
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration()
|
||||
)
|
||||
)
|
||||
let signature = [
|
||||
@@ -509,9 +597,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
/// shows a stale preparing/failed snapshot, heal automatically.
|
||||
private func reconcileColdStartOverlayIfRecovered() {
|
||||
guard isColdStartHandoff, isActive else { return }
|
||||
guard FlowSessionBridge.isHostReady() else { return }
|
||||
guard let context = coldStartContext else { return }
|
||||
|
||||
// Mid-utterance is not "ready" — dismiss the ready overlay so Home
|
||||
// does not keep advertising "语音已就绪" while utt.rec=1.
|
||||
if isUtteranceRecording || isUtteranceProcessing {
|
||||
if case .ready = context.state {
|
||||
dismissColdStartOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard FlowSessionBridge.isHostReady() else { return }
|
||||
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
presentColdStartReadyOverlay()
|
||||
@@ -556,10 +654,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
// MARK: - Session start
|
||||
|
||||
private func startSessionAsync(duration: TimeInterval?) async {
|
||||
traceState("startSessionAsync.begin")
|
||||
isStarting = true
|
||||
sessionWarning = nil
|
||||
// `isStarting` was claimed synchronously in `startSession()`.
|
||||
defer { isStarting = false }
|
||||
guard !Task.isCancelled else { return }
|
||||
traceState("startSessionAsync.begin")
|
||||
sessionWarning = nil
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
@@ -586,7 +685,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard await waitForAudioProof() else {
|
||||
let audioProved = await waitForAudioProof()
|
||||
guard !Task.isCancelled else { return }
|
||||
guard audioProved else {
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout")
|
||||
@@ -658,6 +759,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
refreshHostReady()
|
||||
guard FlowSessionBridge.isHostReady() else {
|
||||
// Busy ≠ broken: a startflow arriving mid-utterance (e.g. tapping
|
||||
// the Live Activity while dictating) finds a healthy session that
|
||||
// is simply recording/processing. Showing the audio-failure
|
||||
// overlay here would be a lie — and its recovery loop could even
|
||||
// stop capture and kill the live utterance.
|
||||
if isUtteranceRecording || isUtteranceProcessing {
|
||||
dismissColdStartOverlay()
|
||||
debug("cold-start handoff ignored: session busy with an utterance")
|
||||
return
|
||||
}
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
showColdStartAudioFailure(message: message)
|
||||
@@ -687,18 +798,54 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps proving mic readiness after the first timeout instead of tearing
|
||||
/// capture down — many handoffs become ready a few seconds later.
|
||||
/// Actively rebuilds the audio pipeline after a failed cold start instead
|
||||
/// of passively waiting for frames that a dead engine will never produce.
|
||||
/// Escalates per attempt: reassert the session → full engine rebuild →
|
||||
/// bounce the audio session and rebuild. Force-quit relaunches routinely
|
||||
/// inherit stale mediaserverd state that only a rebuild clears.
|
||||
private func scheduleColdStartRecovery(duration: TimeInterval?) {
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let recovered = await self.capture.awaitAudioFlowing(
|
||||
timeout: Self.coldStartRecoveryProofTimeout
|
||||
)
|
||||
var recovered = false
|
||||
for attempt in 1...3 {
|
||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
||||
switch attempt {
|
||||
case 1:
|
||||
_ = self.capture.reassertIfRunning()
|
||||
case 2:
|
||||
self.capture.stop()
|
||||
try? self.capture.start()
|
||||
default:
|
||||
self.capture.stop()
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
try? self.capture.start()
|
||||
}
|
||||
recovered = await self.capture.awaitAudioFlowing(
|
||||
timeout: TimeInterval(attempt + 1)
|
||||
)
|
||||
self.traceState(
|
||||
"coldStartRecovery.attempt",
|
||||
extra: "attempt=\(attempt) recovered=\(recovered)"
|
||||
)
|
||||
if recovered { break }
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
guard self.isColdStartHandoff else { return }
|
||||
guard recovered else { return }
|
||||
guard recovered else {
|
||||
// Out of attempts — leave the failure overlay up; its retry
|
||||
// button now performs a full teardown so the user always has
|
||||
// a working escape hatch (no more force-quit loops). Only
|
||||
// tear capture down when no session owns it: for an active
|
||||
// session the 1 Hz heartbeat keeps self-healing, and a stop
|
||||
// here would just fight it.
|
||||
if !self.isActive {
|
||||
self.capture.stop()
|
||||
}
|
||||
self.traceState("coldStartRecovery.exhausted")
|
||||
return
|
||||
}
|
||||
|
||||
self.sessionWarning = nil
|
||||
self.traceState("coldStartRecovery.recovered")
|
||||
@@ -1027,12 +1174,22 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
// Do NOT cancel `asrTask` or `asr` — drain trailing PCM, then finalize.
|
||||
|
||||
// Capture ids now: a cancelled finalize must still clear *this*
|
||||
// utterance's processing gate even if currentUtteranceId was cleared
|
||||
// by a racing fail/abort path.
|
||||
let drainingSessionId = activeSessionId
|
||||
let drainingUtteranceId = currentUtteranceId
|
||||
let drainingCommandSeq = currentCommandSeq
|
||||
finalizeTask?.cancel()
|
||||
finalizeTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let drainReport = await self.capture.endUtteranceAndDrain()
|
||||
FlowDiagnostics.logDrain(drainReport)
|
||||
await self.finalizeUtterance()
|
||||
await self.finalizeUtterance(
|
||||
sessionId: drainingSessionId,
|
||||
utteranceId: drainingUtteranceId,
|
||||
commandSeq: drainingCommandSeq
|
||||
)
|
||||
}
|
||||
debug("utterance stopped, draining tail")
|
||||
}
|
||||
@@ -1109,20 +1266,22 @@ final class FlowSessionManager: ObservableObject {
|
||||
debug("utterance processing failed: \(message)")
|
||||
}
|
||||
|
||||
private func finalizeUtterance() async {
|
||||
let finalizeSessionId = activeSessionId
|
||||
let finalizeUtteranceId = currentUtteranceId
|
||||
private func finalizeUtterance(
|
||||
sessionId finalizeSessionId: UUID?,
|
||||
utteranceId finalizeUtteranceId: UUID?,
|
||||
commandSeq finalizeCommandSeq: Int64
|
||||
) async {
|
||||
let pipelineStarted = Date()
|
||||
// ALWAYS clear the processing gate for this utterance. The previous
|
||||
// guard required currentUtteranceId to still match; a racing
|
||||
// fail/abort/cancel path could nil the id (or leave processing stuck)
|
||||
// and then skip refreshHostReady — keyboard stayed white forever
|
||||
// while host logs still said "utterance finalized".
|
||||
defer {
|
||||
if activeSessionId == finalizeSessionId,
|
||||
currentUtteranceId == finalizeUtteranceId {
|
||||
isUtteranceProcessing = false
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
touchSessionActivity()
|
||||
currentUtteranceId = nil
|
||||
currentCommandSeq = 0
|
||||
refreshHostReady()
|
||||
}
|
||||
completeFinalizeCleanup(
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId
|
||||
)
|
||||
}
|
||||
|
||||
let asrWait = asrWaitTimeout()
|
||||
@@ -1134,6 +1293,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
while Date() < asrDeadline {
|
||||
if !lastFinal.isEmpty { break }
|
||||
if asrTask?.isCancelled == true { break }
|
||||
// Honour cooperative cancel so a replaced finalize exits promptly,
|
||||
// but still run defer cleanup (unlike an early `return` mid-polish
|
||||
// that used to leave processing=true when ids no longer matched).
|
||||
if Task.isCancelled { break }
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
|
||||
@@ -1151,14 +1314,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
guard !text.isEmpty else {
|
||||
let key = (asrTask?.isCancelled == true)
|
||||
let key = (asrTask?.isCancelled == true || Task.isCancelled)
|
||||
? "flow.error.recognitionInterrupted"
|
||||
: "flow.error.noSpeech"
|
||||
let kind: FlowSessionKeys.TranscriptionErrorKind =
|
||||
(asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech
|
||||
(asrTask?.isCancelled == true || Task.isCancelled)
|
||||
? .recognitionInterrupted : .noSpeech
|
||||
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
|
||||
utteranceRecordingStartedAt = nil
|
||||
storeCurrentError(AppL10n.string(key), kind: kind)
|
||||
storeFinalizedError(
|
||||
AppL10n.string(key),
|
||||
kind: kind,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1178,23 +1348,33 @@ final class FlowSessionManager: ObservableObject {
|
||||
"translationTarget=\(pipelineStore.translationTargetLocaleId)"
|
||||
)
|
||||
do {
|
||||
// If the finalize task was cancelled (cold-start churn / abort),
|
||||
// skip the LLM round-trip and deliver the raw transcript so the
|
||||
// keyboard is not left waiting on a result that never arrives.
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
let polished = try await polisher.polish(
|
||||
text,
|
||||
mode: polishMode,
|
||||
providerIdOverride: pipelineStore.polishProviderIdOverride
|
||||
)
|
||||
delivered = polished
|
||||
storeCurrentFinal(polished, warning: chunkNote)
|
||||
storeFinalizedResult(
|
||||
polished,
|
||||
warning: chunkNote,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
FlowDiagnostics.log(
|
||||
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
|
||||
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
|
||||
)
|
||||
} catch {
|
||||
// v0.2.0: local + cloud-polish-on + no API key surfaces
|
||||
// `.missingAPIKey`. We translate it into a polishWarning
|
||||
// so the keyboard can show the "fill in your key" hint
|
||||
// inline rather than a generic failure message. The raw
|
||||
// transcript is still delivered — no data loss.
|
||||
// CancellationError is common when the user jumps back via
|
||||
// startflow mid-polish; still deliver raw text. Other errors
|
||||
// keep the existing polish-warning fallback.
|
||||
let fallback = Self.makeFallbackDelivery(
|
||||
rawText: text,
|
||||
error: error,
|
||||
@@ -1206,7 +1386,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
"\(error.localizedDescription)"
|
||||
)
|
||||
delivered = fallback.text
|
||||
storeCurrentFinal(fallback.text, warning: fallback.polishWarning)
|
||||
storeFinalizedResult(
|
||||
fallback.text,
|
||||
warning: fallback.polishWarning,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
}
|
||||
|
||||
SpeechHistoryStore.shared.recordUtterance(
|
||||
@@ -1223,6 +1409,92 @@ final class FlowSessionManager: ObservableObject {
|
||||
debug("utterance finalized length=\(text.count)")
|
||||
}
|
||||
|
||||
/// Drop the processing gate and republish hostReady after finalize.
|
||||
/// Must not depend on a perfect id match — a racing fail/abort/cancel
|
||||
/// used to skip this block and leave the keyboard stuck on white「识别中」
|
||||
/// even after "utterance finalized" was logged.
|
||||
private func completeFinalizeCleanup(sessionId: UUID?, utteranceId: UUID?) {
|
||||
// A newer utterance may have started; never clobber its gate.
|
||||
if let current = currentUtteranceId,
|
||||
let finished = utteranceId,
|
||||
current != finished {
|
||||
debug(
|
||||
"finalize cleanup skipped — newer utterance live " +
|
||||
"finished=\(finished.uuidString.prefix(8)) current=\(current.uuidString.prefix(8))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let wasProcessing = isUtteranceProcessing
|
||||
isUtteranceProcessing = false
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
if isActive {
|
||||
touchSessionActivity()
|
||||
}
|
||||
if currentUtteranceId == utteranceId || currentUtteranceId == nil {
|
||||
currentUtteranceId = nil
|
||||
currentCommandSeq = 0
|
||||
}
|
||||
refreshHostReady()
|
||||
debug(
|
||||
"finalize cleanup done wasProcessing=\(wasProcessing ? 1 : 0) " +
|
||||
"utterance=\(utteranceId?.uuidString.prefix(8) ?? "nil") " +
|
||||
"session=\(sessionId?.uuidString.prefix(8) ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
private func storeFinalizedResult(
|
||||
_ text: String,
|
||||
warning: String?,
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.noSpeech"),
|
||||
kind: .noSpeech,
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
status: .final,
|
||||
text: trimmed,
|
||||
warning: warning
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func storeFinalizedError(
|
||||
_ message: String,
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind,
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64,
|
||||
status: FlowResult.Status = .error
|
||||
) {
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
status: status,
|
||||
text: message,
|
||||
errorKind: kind
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String {
|
||||
switch mode {
|
||||
case .polish:
|
||||
@@ -1252,35 +1524,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
engineMode: String,
|
||||
chunkWarning: String?
|
||||
) -> TranscriptionDelivery {
|
||||
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
|
||||
let warning = warningFromPolishError(error, engineMode: engineMode)
|
||||
?? polishDegradedWarning()
|
||||
?? chunkWarning
|
||||
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
|
||||
}
|
||||
|
||||
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
|
||||
if let polishError = error as? PolishingService.PolishError {
|
||||
switch polishError {
|
||||
case .missingAPIKey:
|
||||
if engineMode == "local" {
|
||||
return SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
}
|
||||
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
case .timeout:
|
||||
return polishDegradedWarning()
|
||||
case .noTranscript:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if error is LLMError {
|
||||
return polishDegradedWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func polishDegradedWarning() -> String? {
|
||||
SharedL10n.string("flow.warning.polishDegraded")
|
||||
TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: rawText,
|
||||
error: error,
|
||||
engineMode: engineMode,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
}
|
||||
|
||||
private func asrWaitTimeout() -> TimeInterval {
|
||||
@@ -1318,8 +1567,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
// Refresh the Live Activity `staleDate` every N heartbeat ticks
|
||||
// (1 Hz) — well inside `FlowLiveActivityController.staleWindow` so a
|
||||
// live session never looks stale, while a force-quit stops these
|
||||
// refreshes and lets the system reclaim the orphaned island.
|
||||
let liveActivityKeepAliveEveryTicks = 15
|
||||
// refreshes and lets the island go stale within ~30 s.
|
||||
let liveActivityKeepAliveEveryTicks = 10
|
||||
var tick = 0
|
||||
while !Task.isCancelled {
|
||||
guard let self else { break }
|
||||
@@ -1351,6 +1600,30 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowDiagnostics.log(message)
|
||||
}
|
||||
|
||||
// MARK: - Temporary Flow debug panel (remove after orange-mic investigation)
|
||||
|
||||
/// Snapshot for the on-screen debug panel. Safe to call from the main actor.
|
||||
func makeDebugRows() -> [FlowDebugRow] {
|
||||
let snapshot = FlowSessionBridge.readySnapshot()
|
||||
let hostRows = FlowDebugAppGroupSnapshot.rows()
|
||||
let memRows: [FlowDebugRow] = [
|
||||
FlowDebugRow("isActive", isActive ? "1" : "0"),
|
||||
FlowDebugRow("isStarting", isStarting ? "1" : "0"),
|
||||
FlowDebugRow("coldStart", isColdStartHandoff ? "1" : "0"),
|
||||
FlowDebugRow("engineLive", capture.engineIsLive ? "1" : "0"),
|
||||
FlowDebugRow("audioFresh", capture.engineHasRecentAudio(maxAge: 2) ? "1" : "0"),
|
||||
FlowDebugRow("mem.reason", snapshot?.reason.rawValue ?? "nil"),
|
||||
FlowDebugRow("utt.rec", isUtteranceRecording ? "1" : "0"),
|
||||
FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"),
|
||||
FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"),
|
||||
FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"),
|
||||
FlowDebugRow("overlay", coldStartContext.map { String(describing: $0.state) } ?? "nil"),
|
||||
FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0")
|
||||
]
|
||||
// Prefer App Group snap.reason near the top of the shared block.
|
||||
return memRows + hostRows
|
||||
}
|
||||
|
||||
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
|
||||
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
|
||||
guard signature != lastIgnoredCommandSignature else { return }
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// ASRSettingsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Cloud ASR credentials — independent from the polish LLM card.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ASRSettingsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showKey: Bool = false
|
||||
@State private var testStatus: TestStatus = .idle
|
||||
|
||||
private enum TestStatus: Equatable {
|
||||
case idle
|
||||
case running
|
||||
case success
|
||||
case failure(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if CloudASRModelCatalog.strategy(for: config.asrProviderId) == .prompt {
|
||||
field(
|
||||
title: AppL10n.string("api.baseUrl"),
|
||||
placeholder: "https://api.openai.com/v1",
|
||||
text: $config.asrBaseURL,
|
||||
autocap: false
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
keyField
|
||||
Divider().background(palette.divider)
|
||||
field(
|
||||
title: AppL10n.string("settings.asr.model"),
|
||||
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
|
||||
text: $config.asrModel,
|
||||
autocap: false
|
||||
)
|
||||
if let url = LLMProvider.provider(id: config.asrProviderId).apiKeyURL {
|
||||
Divider().background(palette.divider)
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("api.getKey")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider().background(palette.divider)
|
||||
testConnectionRow
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var keyField: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("api.key")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
Button(action: { showKey.toggle() }) {
|
||||
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
TextField("sk-…", text: $config.asrApiKey)
|
||||
} else {
|
||||
SecureField("sk-…", text: $config.asrApiKey)
|
||||
}
|
||||
}
|
||||
.keyboardType(.asciiCapable)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func field(
|
||||
title: String,
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
autocap: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
TextField(placeholder, text: text)
|
||||
.keyboardType(.asciiCapable)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(autocap ? .sentences : .never)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
|
||||
}
|
||||
|
||||
private var testConnectionRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("settings.asr.testConnection")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Button(action: runTest) {
|
||||
Group {
|
||||
if testStatus == .running {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text(testButtonLabel)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(testTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(testStatus == .running)
|
||||
}
|
||||
if let detail = testDetail {
|
||||
Text(detail)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(testTint)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
|
||||
}
|
||||
|
||||
private var testButtonLabel: String {
|
||||
switch testStatus {
|
||||
case .idle: return AppL10n.string("api.test.idle")
|
||||
case .running: return AppL10n.string("api.test.running")
|
||||
case .success: return AppL10n.string("api.test.success")
|
||||
case .failure: return AppL10n.string("api.test.failure")
|
||||
}
|
||||
}
|
||||
|
||||
private var testTint: Color {
|
||||
switch testStatus {
|
||||
case .idle, .running: return palette.accent
|
||||
case .success: return palette.accent
|
||||
case .failure: return palette.danger
|
||||
}
|
||||
}
|
||||
|
||||
private var testDetail: String? {
|
||||
switch testStatus {
|
||||
case .idle, .running, .success: return nil
|
||||
case .failure(let message): return message
|
||||
}
|
||||
}
|
||||
|
||||
private func runTest() {
|
||||
testStatus = .running
|
||||
let store = AppGroupStore()
|
||||
let client = CloudASRClientFactory.make(store: store)
|
||||
Task {
|
||||
do {
|
||||
try await client.prepare(dictionary: store.personalDictionary)
|
||||
let samples = [Float](repeating: 0.01, count: 16_000)
|
||||
_ = try await client.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: Locale(identifier: store.localeId == "auto" ? "zh-CN" : store.localeId),
|
||||
dictionary: store.personalDictionary
|
||||
)
|
||||
testStatus = .success
|
||||
} catch CloudASRError.noAPIKey {
|
||||
testStatus = .failure(AppL10n.string("api.test.missing"))
|
||||
} catch let error as CloudASRError {
|
||||
testStatus = .failure(error.localizedDescription ?? "\(error)")
|
||||
} catch {
|
||||
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ enum AppTab: Int, CaseIterable {
|
||||
case .settings: return "tab.settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
||||
|
||||
var sidebarSystemImage: String {
|
||||
switch self {
|
||||
case .keyboard: return "house"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .dictionary: return "character.book.closed"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MinimalTabBar: View {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// WideLayoutComponents.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Reusable layout pieces for iPad / regular-width surfaces. Styled with the
|
||||
// shared design tokens so the wide Home dashboard can mirror the macOS shell
|
||||
// without pulling in AppKit-only types from OSGKeyboardMac.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Layout metrics
|
||||
|
||||
/// Fixed metrics that keep wide surfaces on the same grid as the macOS app.
|
||||
enum WideLayoutMetrics {
|
||||
static let sidebarWidth: CGFloat = 240
|
||||
static let sidebarInset: CGFloat = Spacing.md
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
static let pageHorizontalInset: CGFloat = 40
|
||||
static let dictationCanvasMinHeight: CGFloat = 120
|
||||
}
|
||||
|
||||
// MARK: - Card container
|
||||
|
||||
/// Elevated surface used for stat tiles and the dictation canvas.
|
||||
struct WideCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
var padding: CGFloat = Spacing.md
|
||||
var cornerRadius: CGFloat = Radius.medium
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat tile
|
||||
|
||||
struct WideStatCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let title: String
|
||||
let value: String
|
||||
let caption: String
|
||||
var systemImage: String?
|
||||
var accent: Bool = false
|
||||
/// Hero metric: wide horizontal layout for the primary word count.
|
||||
var prominent: Bool = false
|
||||
|
||||
var body: some View {
|
||||
WideCard(padding: Spacing.md) {
|
||||
if prominent {
|
||||
prominentBody
|
||||
} else {
|
||||
compactBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactBody: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var prominentBody: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
if let systemImage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.md)
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home stats cluster
|
||||
|
||||
/// Dashboard-style stat cluster for the wide Home layout.
|
||||
struct WideHomeStatsCluster: View {
|
||||
@ObservedObject private var stats = UsageStatisticsStore.shared
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
@State private var dictionaryCount = 0
|
||||
|
||||
private var language: AppUILanguage { config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
WideStatCard(
|
||||
title: AppL10n.string("home.stats.dictationCharacters", language: language),
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
stats.dictationCharacterCount,
|
||||
language: language
|
||||
),
|
||||
caption: AppL10n.string("home.wide.stat.transcribed", language: language),
|
||||
systemImage: "text.alignleft",
|
||||
accent: true,
|
||||
prominent: true
|
||||
)
|
||||
|
||||
HStack(spacing: Spacing.md) {
|
||||
WideStatCard(
|
||||
title: AppL10n.string("home.stats.dictationDuration", language: language),
|
||||
value: UsageStatisticsStore.formatDuration(
|
||||
stats.dictationDurationSeconds,
|
||||
language: language
|
||||
),
|
||||
caption: AppL10n.string("home.wide.stat.cumulativeDuration", language: language),
|
||||
systemImage: "waveform"
|
||||
)
|
||||
WideStatCard(
|
||||
title: AppL10n.string("home.stats.translationCharacters", language: language),
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
stats.translationCharacterCount,
|
||||
language: language
|
||||
),
|
||||
caption: AppL10n.string("home.wide.stat.cumulativeTranslation", language: language),
|
||||
systemImage: "character.bubble"
|
||||
)
|
||||
WideStatCard(
|
||||
title: AppL10n.string("home.stats.dictionaryEntries", language: language),
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
dictionaryCount,
|
||||
language: language
|
||||
),
|
||||
caption: AppL10n.string("home.wide.stat.customTerms", language: language),
|
||||
systemImage: "character.book.closed"
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: refreshDictionaryCount)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
stats.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshDictionaryCount() {
|
||||
dictionaryCount = AppGroupStore().personalDictionary.entries.count
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import UIKit
|
||||
struct HomeView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@@ -24,6 +25,10 @@ struct HomeView: View {
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
|
||||
private var usesWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
private var sessionIsLive: Bool {
|
||||
flowManager.isActive || flowManager.isStarting
|
||||
}
|
||||
@@ -52,6 +57,32 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesWideLayout {
|
||||
wideBody
|
||||
} else {
|
||||
phoneBody
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
.onChange(of: previewFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
Task { await flowManager.refreshForInlineKeyboardFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
|
||||
private var phoneBody: some View {
|
||||
GeometryReader { geo in
|
||||
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
|
||||
|
||||
@@ -98,20 +129,63 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
|
||||
// MARK: - Wide layout (iPad / regular width)
|
||||
|
||||
private var wideBody: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
wideHeroHeader
|
||||
|
||||
WideHomeStatsCluster()
|
||||
|
||||
if showsFlowSessionExtras {
|
||||
flowSessionExtras
|
||||
}
|
||||
|
||||
widePreviewStage
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.md)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
refreshPermissionStatuses()
|
||||
.background(palette.background)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if previewFocused {
|
||||
previewFocused = false
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
|
||||
private var wideHeroHeader: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text("home.wide.tagline")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
Text("home.wide.tagline.subtitle")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.onChange(of: previewFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
Task { await flowManager.refreshForInlineKeyboardFocus() }
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var widePreviewStage: some View {
|
||||
WideCard(padding: Spacing.md, cornerRadius: Radius.large) {
|
||||
previewFieldContent
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: WideLayoutMetrics.dictationCanvasMinHeight,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
@@ -187,7 +261,18 @@ struct HomeView: View {
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text("home.flow.recording")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text("home.flow.processing")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -322,10 +407,14 @@ struct HomeView: View {
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if needsCloudSetup { return palette.warning }
|
||||
if flowManager.isActive { return palette.accent }
|
||||
if flowManager.isUtteranceRecording { return palette.accent }
|
||||
if flowManager.isUtteranceProcessing { return palette.accent }
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||
if flowManager.isStarting { return palette.accent }
|
||||
if needsPermissionSetup { return palette.warning }
|
||||
if flowManager.sessionWarning != nil { return palette.warning }
|
||||
// Active but not host-ready (e.g. mid-utterance / audio proof) — amber.
|
||||
if flowManager.isActive { return palette.warning }
|
||||
return palette.textTertiary
|
||||
}
|
||||
|
||||
@@ -337,21 +426,26 @@ struct HomeView: View {
|
||||
if flowManager.isStarting {
|
||||
return AppL10n.string("home.flow.starting")
|
||||
}
|
||||
if flowManager.isActive {
|
||||
if flowManager.isUtteranceRecording {
|
||||
return AppL10n.string("home.flow.recording")
|
||||
}
|
||||
if flowManager.isUtteranceProcessing {
|
||||
return AppL10n.string("home.flow.processing")
|
||||
}
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
||||
return AppL10n.string("home.flow.label")
|
||||
}
|
||||
if flowManager.isActive {
|
||||
// Session flag is up but the ready contract is not — do not lie.
|
||||
return AppL10n.string("home.flow.notReady")
|
||||
}
|
||||
return AppL10n.string("home.flow.inactive")
|
||||
}
|
||||
|
||||
// MARK: - Preview field
|
||||
|
||||
private var previewField: some View {
|
||||
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.accent)
|
||||
.focused($previewFocused)
|
||||
.lineLimit(1...100)
|
||||
previewFieldContent
|
||||
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
@@ -359,11 +453,19 @@ struct HomeView: View {
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
|
||||
)
|
||||
// TextField only hit-tests the text line(s); expand taps to the full card.
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.onTapGesture { previewFocused = true }
|
||||
}
|
||||
|
||||
private var previewFieldContent: some View {
|
||||
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.accent)
|
||||
.focused($previewFocused)
|
||||
.lineLimit(1...100)
|
||||
}
|
||||
|
||||
private var engineStatusLine: some View {
|
||||
Text(
|
||||
EngineServiceLabel.summary(
|
||||
|
||||
@@ -39,14 +39,18 @@ struct MainAppRoot: View {
|
||||
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
flowManager.activateOnForeground()
|
||||
AppCloudSync.shared.startObservingExternalChanges()
|
||||
// Registering here also flushes any URL buffered during a cold
|
||||
// launch (the keyboard → app `startflow` handoff arrives via the
|
||||
// scene delegate before this view is on screen).
|
||||
// Register the URL handler BEFORE the foreground auto-start.
|
||||
// Registering flushes any URL buffered during a cold launch (the
|
||||
// keyboard → app `startflow` handoff arrives via the scene
|
||||
// delegate before this view is on screen), so a cold start takes
|
||||
// the cold-start path first and `activateOnForeground()`'s plain
|
||||
// start then no-ops on the isStarting guard — instead of two
|
||||
// start bodies racing each other on the main actor.
|
||||
AppOpenURLRouter.shared.register { url in
|
||||
handleIncomingURL(url)
|
||||
}
|
||||
flowManager.activateOnForeground()
|
||||
AppCloudSync.shared.startObservingExternalChanges()
|
||||
Task {
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// MainSplitView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// iPad / regular-width shell: sidebar navigation + detail workspace.
|
||||
// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs
|
||||
// and Flow session behaviour unchanged underneath.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
struct MainSplitView: View {
|
||||
@Binding var selection: AppTab
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
sidebar
|
||||
.navigationSplitViewColumnWidth(WideLayoutMetrics.sidebarWidth)
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.background(palette.background)
|
||||
// No floating dock in split mode — child scroll views should not
|
||||
// reserve bottom clearance for the phone tab bar.
|
||||
.environment(\.isTabBarVisible, false)
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
|
||||
private var sidebar: some View {
|
||||
VStack(spacing: 0) {
|
||||
brandHeader
|
||||
VStack(spacing: 4) {
|
||||
ForEach(AppTab.allCases, id: \.rawValue) { tab in
|
||||
WideSidebarRow(
|
||||
tab: tab,
|
||||
isSelected: selection == tab
|
||||
) {
|
||||
withAnimation(Motion.soft) { selection = tab }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset)
|
||||
Spacer()
|
||||
devicesFooter
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private var brandHeader: some View {
|
||||
HStack {
|
||||
Image("osglogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 28)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, WideLayoutMetrics.sidebarContentInset)
|
||||
.padding(.trailing, WideLayoutMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
Label("home.wide.devices", systemImage: "ipad.and.iphone")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset + Spacing.sm)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
private var detail: some View {
|
||||
VStack(spacing: 0) {
|
||||
MainTabContent(tab: selection)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.id(selection)
|
||||
.transition(.opacity)
|
||||
WideStatusFooter()
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar row
|
||||
|
||||
private struct WideSidebarRow: View {
|
||||
let tab: AppTab
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
|
||||
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
rowBackground,
|
||||
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(Motion.quick, value: isSelected)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var rowBackground: Color {
|
||||
isSelected ? palette.accentMuted : .clear
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status footer
|
||||
|
||||
/// Quiet bottom strip: engine mode + translation target + Flow readiness.
|
||||
private struct WideStatusFooter: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
|
||||
private var needsCloudSetup: Bool {
|
||||
!config.isLocalEngine && !config.isConfigured
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
micStatus != .granted || speechStatus != .granted
|
||||
}
|
||||
|
||||
private var canManuallyStartSession: Bool {
|
||||
!flowManager.isActive && !flowManager.isStarting && !needsPermissionSetup
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
Label(
|
||||
config.engineMode == "cloud" ? "home.wide.mode.cloud" : "home.wide.mode.local",
|
||||
systemImage: config.engineMode == "cloud" ? "cloud" : "cpu"
|
||||
)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(
|
||||
translationLabel,
|
||||
systemImage: "translate"
|
||||
)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
flowStatusControl
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.animation(Motion.quick, value: config.engineMode)
|
||||
.animation(Motion.quick, value: config.translationTargetLocaleId)
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
.onAppear { refreshPermissionStatuses() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var flowStatusControl: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Circle()
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsCloudSetup {
|
||||
Text("home.flow.notReady")
|
||||
.foregroundStyle(palette.warning)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text("home.flow.recording")
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text("home.flow.processing")
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
Text(":")
|
||||
Text(expires, style: .timer)
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text(flowStatusLabel)
|
||||
}
|
||||
|
||||
if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if canManuallyStartSession && !needsCloudSetup {
|
||||
Button {
|
||||
flowManager.activateOnForeground()
|
||||
} label: {
|
||||
Text("home.flow.startShort")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
micStatus = AppPermissions.micStatus
|
||||
speechStatus = AppPermissions.speechStatus
|
||||
}
|
||||
|
||||
private var translationLabel: String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(config.translationTargetLocaleId)
|
||||
if TranslationLanguageCatalog.isOff(resolved.id) {
|
||||
return AppL10n.string("keyboard.translation.offMenu", language: config.uiLanguage)
|
||||
}
|
||||
return resolved.nativeName
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if !config.isLocalEngine && !config.isConfigured { return palette.warning }
|
||||
if flowManager.isUtteranceRecording || flowManager.isUtteranceProcessing {
|
||||
return palette.accent
|
||||
}
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||
if flowManager.isStarting { return palette.accent }
|
||||
if flowManager.isActive { return palette.warning }
|
||||
return palette.textTertiary
|
||||
}
|
||||
|
||||
private var flowStatusLabel: LocalizedStringKey {
|
||||
if !config.isLocalEngine && !config.isConfigured {
|
||||
return "home.flow.notReady"
|
||||
}
|
||||
if flowManager.isStarting {
|
||||
return "home.flow.starting"
|
||||
}
|
||||
if flowManager.isUtteranceRecording {
|
||||
return "home.flow.recording"
|
||||
}
|
||||
if flowManager.isUtteranceProcessing {
|
||||
return "home.flow.processing"
|
||||
}
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
||||
return "home.flow.label"
|
||||
}
|
||||
if flowManager.isActive {
|
||||
return "home.flow.notReady"
|
||||
}
|
||||
return "home.flow.inactive"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// MainTabContent.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Shared tab destination switcher used by both the phone dock and the iPad
|
||||
// split-view detail column.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct MainTabContent: View {
|
||||
let tab: AppTab
|
||||
|
||||
var body: some View {
|
||||
switch tab {
|
||||
case .keyboard:
|
||||
HomeView()
|
||||
case .history:
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .settings:
|
||||
SettingsView(presentation: .tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,47 +6,52 @@ import OSGKeyboardShared
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
|
||||
@State private var tab: AppTab = .keyboard
|
||||
@State private var isTabBarHidden = false
|
||||
|
||||
private var usesSplitLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesSplitLayout {
|
||||
MainSplitView(selection: $tab)
|
||||
} else {
|
||||
phoneTabLayout
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
|
||||
private var phoneTabLayout: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch tab {
|
||||
case .keyboard:
|
||||
HomeView()
|
||||
case .history:
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .settings:
|
||||
SettingsView(presentation: .tab)
|
||||
MainTabContent(tab: tab)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.isTabBarVisible, !isTabBarHidden)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !isTabBarHidden {
|
||||
Color.clear.frame(height: 88)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.isTabBarVisible, !isTabBarHidden)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !isTabBarHidden {
|
||||
Color.clear.frame(height: 88)
|
||||
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
|
||||
withAnimation(Motion.quick) {
|
||||
isTabBarHidden = hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
|
||||
withAnimation(Motion.quick) {
|
||||
isTabBarHidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
if !isTabBarHidden {
|
||||
MinimalTabBar(selection: $tab)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// Keep home card/input/tab layout fixed when system keyboard appears.
|
||||
// Let the keyboard overlay the content instead of pushing it.
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,22 @@ struct ProviderPickerSection: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
var role: CloudProviderRole = .polish
|
||||
|
||||
private var selectedProviderId: String {
|
||||
role == .asr ? config.asrProviderId : config.providerId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// v0.2.1 follow-up: filter out presets marked as
|
||||
// `isUserSelectable == false` (DeepSeek is local-engine only).
|
||||
let visiblePresets = LLMProvider.userSelectablePresets
|
||||
let visiblePresets = role == .asr
|
||||
? LLMProvider.asrSelectablePresets
|
||||
: LLMProvider.userSelectablePresets
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
row(provider, selected: provider.id == config.providerId)
|
||||
row(provider, selected: provider.id == selectedProviderId)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < visiblePresets.count - 1 {
|
||||
@@ -35,7 +40,12 @@ struct ProviderPickerSection: View {
|
||||
|
||||
private func select(_ provider: LLMProvider) {
|
||||
withAnimation(Motion.quick) {
|
||||
config.apply(preset: provider)
|
||||
switch role {
|
||||
case .polish:
|
||||
config.apply(preset: provider)
|
||||
case .asr:
|
||||
config.applyAsr(preset: provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,16 +43,11 @@ struct SettingsView: View {
|
||||
dictionaryAndPolishSection
|
||||
flowSessionSection
|
||||
engineSection
|
||||
// v0.2.1: hide provider/api card when the
|
||||
// local engine is active regardless of the
|
||||
// cloud-polish toggle. Local mode is
|
||||
// contractually ASR-only, so provider/model/
|
||||
// base URL/API key controls have no use —
|
||||
// and exposing them invites the user to fill
|
||||
// out a DeepSeek key they can't use.
|
||||
polishProviderSection
|
||||
polishApiSection
|
||||
if config.engineMode == "cloud" {
|
||||
providerSection
|
||||
apiSection
|
||||
asrProviderSection
|
||||
asrApiSection
|
||||
}
|
||||
if config.engineMode == "local" {
|
||||
localEngineSettingsSection
|
||||
@@ -253,22 +248,42 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var providerSection: some View {
|
||||
private var polishProviderSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.provider.title")
|
||||
ProviderPickerSection(config: config)
|
||||
sectionHeader("settings.polishProvider.title")
|
||||
Text("settings.polishProvider.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
ProviderPickerSection(config: config, role: .polish)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
private var apiSection: some View {
|
||||
private var asrProviderSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.api.title")
|
||||
sectionHeader("settings.asrProvider.title")
|
||||
Text("settings.asrProvider.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
ProviderPickerSection(config: config, role: .asr)
|
||||
}
|
||||
}
|
||||
|
||||
private var polishApiSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.polishApi.title")
|
||||
APISettingsCard(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
private var asrApiSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.asrApi.title")
|
||||
ASRSettingsCard(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Language helpers
|
||||
|
||||
/// Falls back to a static list while dynamic locales are loading.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Permission prompts — English */
|
||||
|
||||
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
|
||||
|
||||
@@ -98,11 +98,19 @@
|
||||
"settings.engine.local.ios26" = "Always on-device, no network.";
|
||||
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
|
||||
"settings.engine.cloud.title" = "Cloud recognition & polish";
|
||||
"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider.";
|
||||
"settings.engine.cloud.subtitle" = "Cloud ASR and polish LLM are configured separately. Audio goes to your ASR provider.";
|
||||
"settings.engine.cloud.badge" = "Cloud engine";
|
||||
"settings.provider.title" = "Provider";
|
||||
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
|
||||
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
|
||||
"settings.polishProvider.title" = "Text polish (LLM)";
|
||||
"settings.polishProvider.subtitle" = "Cleans up the transcript after recognition. Independent from the ASR provider.";
|
||||
"settings.polishApi.title" = "Polish API";
|
||||
"settings.asrProvider.title" = "Speech recognition (ASR)";
|
||||
"settings.asrProvider.subtitle" = "Transcribes your audio in cloud mode. Can differ from the polish provider.";
|
||||
"settings.asrApi.title" = "ASR API";
|
||||
"settings.asr.model" = "ASR model";
|
||||
"settings.asr.testConnection" = "Test ASR";
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
"provider.qwen" = "Qwen (DashScope)";
|
||||
@@ -161,8 +169,8 @@
|
||||
"settings.privacy.fullAccess.title" = "About Full Access";
|
||||
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
|
||||
"settings.privacy.cloud.body" = "In Cloud polish mode, transcribed text is sent to the API endpoint you configure (e.g. OpenAI or your own server). OSGKeyboard does not operate servers and does not store transcripts in the cloud.";
|
||||
"settings.privacy.cloud.alert.title" = "Third-party API";
|
||||
"settings.privacy.cloud.alert.message" = "Cloud polish sends transcribed text to the third-party API you configure. OSGKeyboard never stores data on our servers. Continue?";
|
||||
"settings.privacy.cloud.alert.title" = "Audio leaves your device";
|
||||
"settings.privacy.cloud.alert.message" = "The cloud engine uploads your voice recordings to the third-party ASR provider you configure, and sends the transcript to its API for polish. That provider's privacy policy applies. OSGKeyboard never stores data on our servers. Continue?";
|
||||
"settings.link.support" = "Help & Feedback";
|
||||
"settings.link.github" = "GitHub";
|
||||
"settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on GitHub.";
|
||||
@@ -287,6 +295,8 @@
|
||||
"home.flow.label" = "Ready";
|
||||
"home.flow.inactive" = "Voice session inactive";
|
||||
"home.flow.notReady" = "Not ready";
|
||||
"home.flow.recording" = "Recording…";
|
||||
"home.flow.processing" = "Processing…";
|
||||
"home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
|
||||
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
|
||||
@@ -307,6 +317,15 @@
|
||||
"home.stats.dictationCharacters" = "Dictation chars";
|
||||
"home.stats.translationCharacters" = "Translation chars";
|
||||
"home.stats.dictionaryEntries" = "Dictionary";
|
||||
"home.wide.tagline" = "Voice dictation, anywhere.";
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.stat.transcribed" = "Total dictated";
|
||||
"home.wide.stat.cumulativeDuration" = "Cumulative duration";
|
||||
"home.wide.stat.cumulativeTranslation" = "Cumulative translation";
|
||||
"home.wide.stat.customTerms" = "Custom terms";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
"home.wide.mode.local" = "On-device";
|
||||
"home.wide.devices" = "iPhone & iPad";
|
||||
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+";
|
||||
"home.engine.warming" = "Loading ASR model into memory…";
|
||||
"home.engine.downloading" = "Downloading ASR model…";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 权限说明 — 简体中文 */
|
||||
|
||||
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 使用设备端语音识别将你的语音转为文字。音频仅在设备上处理,不会上传用于转写。";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
|
||||
|
||||
@@ -98,11 +98,19 @@
|
||||
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||
"settings.engine.cloud.title" = "云端识别与润色";
|
||||
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
|
||||
"settings.engine.cloud.subtitle" = "云端 ASR 与润色 LLM 分开配置;音频发送至转写服务商。";
|
||||
"settings.engine.cloud.badge" = "云端引擎";
|
||||
"settings.provider.title" = "云端引擎";
|
||||
"settings.provider.personalDictionaryBadge" = "个性词库";
|
||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||
"settings.polishProvider.title" = "文本润色(LLM)";
|
||||
"settings.polishProvider.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
|
||||
"settings.polishApi.title" = "润色接口";
|
||||
"settings.asrProvider.title" = "语音转写(ASR)";
|
||||
"settings.asrProvider.subtitle" = "云端模式下负责听写转文字,可与润色模型分开配置。";
|
||||
"settings.asrApi.title" = "转写接口";
|
||||
"settings.asr.model" = "ASR 模型";
|
||||
"settings.asr.testConnection" = "测试转写";
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
"provider.qwen" = "通义千问";
|
||||
@@ -161,8 +169,8 @@
|
||||
"settings.privacy.fullAccess.title" = "关于完全访问";
|
||||
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
|
||||
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
|
||||
"settings.privacy.cloud.alert.title" = "第三方 API";
|
||||
"settings.privacy.cloud.alert.message" = "云端润色会把转写文字发到你配置的第三方 API。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
|
||||
"settings.privacy.cloud.alert.title" = "音频将离开你的设备";
|
||||
"settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
|
||||
"settings.link.support" = "帮助与反馈";
|
||||
"settings.link.github" = "GitHub";
|
||||
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
|
||||
@@ -286,6 +294,8 @@
|
||||
"home.flow.label" = "就绪";
|
||||
"home.flow.inactive" = "语音会话未启动";
|
||||
"home.flow.notReady" = "未就绪";
|
||||
"home.flow.recording" = "录音中…";
|
||||
"home.flow.processing" = "处理中…";
|
||||
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
|
||||
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
|
||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||
@@ -306,6 +316,15 @@
|
||||
"home.stats.dictationCharacters" = "听写字数";
|
||||
"home.stats.translationCharacters" = "翻译字数";
|
||||
"home.stats.dictionaryEntries" = "个性词库";
|
||||
"home.wide.tagline" = "随处语音听写";
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.stat.transcribed" = "累计听写字数";
|
||||
"home.wide.stat.cumulativeDuration" = "累计听写时长";
|
||||
"home.wide.stat.cumulativeTranslation" = "累计翻译字数";
|
||||
"home.wide.stat.customTerms" = "自定义词条";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
"home.wide.mode.local" = "本机";
|
||||
"home.wide.devices" = "iPhone 与 iPad";
|
||||
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本";
|
||||
"home.engine.warming" = "正在加载语音识别模型…";
|
||||
"home.engine.downloading" = "正在下载语音识别模型…";
|
||||
|
||||
Reference in New Issue
Block a user