chore(release): bump version to 1.0.1 (build 27)

Release the current PiP reliability, polish style, macOS dictionary, and UI updates.
This commit is contained in:
Rocky
2026-07-27 00:09:30 +08:00
parent 5b1283c3ed
commit 956331a2af
41 changed files with 1241 additions and 295 deletions
+102 -30
View File
@@ -84,13 +84,33 @@ final class FlowSessionManager: ObservableObject {
/// Initial proof window cold mic sessions often need >2.5s after app switch.
private static let coldStartAudioProofTimeout: TimeInterval = 6
private var keepAliveMode: FlowKeepAliveMode {
FlowSessionPolicy.keepAliveMode()
}
private var usesPiPKeepAlive: Bool {
FlowSessionPolicy.keepAliveMode() == .pictureInPicture
keepAliveMode == .pictureInPicture
}
func attachPiPHostView(_ view: UIView) {
pipController.attachHostView(view)
}
/// Live Activity is mutually exclusive with PiP keep-alive.
private func updateLiveActivityPhase(_ phase: FlowActivityAttributes.ContentState.Phase) {
guard !usesPiPKeepAlive else { return }
FlowLiveActivityController.update(phase: phase)
}
private func startLiveActivityIfNeeded() {
guard !usesPiPKeepAlive else {
// Sweep any orphan island left from a previous Live Activity session.
FlowLiveActivityController.clearOrphanedActivities()
return
}
FlowLiveActivityController.startSession()
}
/// Guards the once-per-process launch reconciliation (scene reconnects
/// recreate the `@StateObject`-owned manager within the same process).
private static var didRunLaunchReconciliation = false
@@ -463,8 +483,14 @@ final class FlowSessionManager: ObservableObject {
resumeAfterForeground()
case .inactive:
writeHeartbeatIfActive()
if usesPiPKeepAlive, isActive {
pipController.prepareForBackgroundAutoStart()
}
case .background:
setAppForeground(false)
if usesPiPKeepAlive, isActive {
pipController.prepareForBackgroundAutoStart()
}
if coldStartContext != nil {
dismissColdStartOverlay()
}
@@ -743,21 +769,22 @@ final class FlowSessionManager: ObservableObject {
}
if usesPiPKeepAlive {
let pipReady = await pipController.startAndWait()
guard pipReady else {
let message = AppL10n.string("flow.pip.error.unavailable")
switch await pipController.startAndWait() {
case .started:
activateFlowSessionAfterPiPProof(duration: duration)
traceState("startSessionAsync.ready")
debug("Flow session started (PiP keep-alive), mic released between utterances")
case .failed(let failure):
let message = AppL10n.string(failure.localizationKey)
sessionWarning = message
traceState("startSessionAsync.failed", extra: "reason=pipUnavailable")
traceState("startSessionAsync.failed", extra: "reason=pipUnavailable failure=\(failure)")
FlowSessionBridge.setHostReady(false)
if isColdStartHandoff {
showColdStartAudioFailure(message: message)
showColdStartPipFailure(message: message)
scheduleColdStartRecovery(duration: duration)
}
debug("PiP keep-alive failed to start")
return
debug("PiP keep-alive failed to start: \(failure)")
}
activateFlowSessionAfterPiPProof(duration: duration)
traceState("startSessionAsync.ready")
debug("Flow session started (PiP keep-alive), mic released between utterances")
return
}
@@ -816,7 +843,7 @@ final class FlowSessionManager: ObservableObject {
bindSessionASRIfNeeded()
scheduleASRWarmup()
FlowLiveActivityController.startSession()
startLiveActivityIfNeeded()
refreshHostReady()
traceState("activateFlowSessionAfterPiPProof.done")
@@ -841,7 +868,7 @@ final class FlowSessionManager: ObservableObject {
bindSessionASRIfNeeded()
scheduleASRWarmup()
FlowLiveActivityController.startSession()
startLiveActivityIfNeeded()
refreshHostReady()
traceState("activateFlowSessionAfterAudioProof.done")
@@ -851,6 +878,20 @@ final class FlowSessionManager: ObservableObject {
guard isColdStartHandoff, isActive else { return }
if usesPiPKeepAlive {
sessionWarning = nil
if !pipController.isPictureInPictureActive {
switch await pipController.startAndWait() {
case .started:
break
case .failed(let failure):
let message = AppL10n.string(failure.localizationKey)
sessionWarning = message
FlowSessionBridge.setHostReady(false)
showColdStartPipFailure(message: message)
scheduleColdStartRecovery(duration: nil)
debug("existing PiP session failed cold-start restart: \(failure)")
return
}
}
refreshHostReady()
handleColdStartAfterSessionReady()
return
@@ -890,9 +931,16 @@ final class FlowSessionManager: ObservableObject {
debug("cold-start handoff ignored: session busy with an utterance")
return
}
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
showColdStartAudioFailure(message: message)
let message: String
if usesPiPKeepAlive {
message = AppL10n.string("flow.pip.error.notPossible")
sessionWarning = message
showColdStartPipFailure(message: message)
} else {
message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
showColdStartAudioFailure(message: message)
}
scheduleColdStartRecovery(duration: nil)
debug("cold-start blocked: host ready contract not published")
return
@@ -903,7 +951,11 @@ final class FlowSessionManager: ObservableObject {
private func presentColdStartReadyOverlay() {
let hostEntry = HostReturnService.pendingHostEntry()
coldStartContext = FlowColdStartContext(hostEntry: hostEntry, state: .ready)
coldStartContext = FlowColdStartContext(
hostEntry: hostEntry,
state: .ready,
keepAliveMode: keepAliveMode
)
scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry)
}
@@ -929,17 +981,23 @@ final class FlowSessionManager: ObservableObject {
coldStartRecoveryTask = Task { @MainActor [weak self] in
guard let self else { return }
if self.usesPiPKeepAlive {
let recovered = await self.pipController.startAndWait()
self.traceState("coldStartRecovery.pip", extra: "recovered=\(recovered)")
let outcome = await self.pipController.startAndWait()
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
guard !Task.isCancelled, self.isColdStartHandoff else { return }
if recovered {
switch outcome {
case .started:
if self.isActive {
self.sessionWarning = nil
self.refreshHostReady()
self.handleColdStartAfterSessionReady()
} else {
self.activateFlowSessionAfterPiPProof(duration: duration)
self.handleColdStartAfterSessionReady()
}
case .failed(let failure):
let message = AppL10n.string(failure.localizationKey)
self.sessionWarning = message
self.showColdStartPipFailure(message: message)
}
return
}
@@ -995,7 +1053,8 @@ final class FlowSessionManager: ObservableObject {
private func showColdStartPreparing() {
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .preparing
state: .preparing,
keepAliveMode: keepAliveMode
)
}
@@ -1003,7 +1062,8 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.setHostReady(false)
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .failed(.permission(message: permissionWarningMessage()))
state: .failed(.permission(message: permissionWarningMessage())),
keepAliveMode: keepAliveMode
)
}
@@ -1011,7 +1071,17 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.setHostReady(false)
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .failed(.audio(message: message))
state: .failed(.audio(message: message)),
keepAliveMode: keepAliveMode
)
}
private func showColdStartPipFailure(message: String) {
FlowSessionBridge.setHostReady(false)
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .failed(.pip(message: message)),
keepAliveMode: .pictureInPicture
)
}
@@ -1221,6 +1291,8 @@ final class FlowSessionManager: ObservableObject {
guard usesPiPKeepAlive, capture.running else { return }
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
capture.stop()
// Capture deactivates AVAudioSession; restore playback so PiP stays eligible.
_ = pipController.reassertKeepAliveAudioSession()
pipController.updateWaveformLevels([])
refreshHostReady()
}
@@ -1274,7 +1346,7 @@ final class FlowSessionManager: ObservableObject {
utteranceRecordingStartedAt = Date()
startUtteranceSafetyTimer()
refreshHostReady()
FlowLiveActivityController.update(phase: .recording)
updateLiveActivityPhase(.recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) pipelined=true " +
@@ -1349,7 +1421,7 @@ final class FlowSessionManager: ObservableObject {
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
refreshHostReady()
FlowLiveActivityController.update(phase: .processing)
updateLiveActivityPhase(.processing)
// Snapshot pipelined partial before drain fallback if the final chunk ASR drops tail text.
bestPartialSnapshot = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1402,7 +1474,7 @@ final class FlowSessionManager: ObservableObject {
chunkWarnings = []
currentUtteranceId = nil
currentCommandSeq = 0
FlowLiveActivityController.update(phase: .idle)
updateLiveActivityPhase(.idle)
refreshHostReady()
debug("utterance aborted")
}
@@ -1432,7 +1504,7 @@ final class FlowSessionManager: ObservableObject {
storeCurrentError(message, kind: kind)
currentUtteranceId = nil
currentCommandSeq = 0
FlowLiveActivityController.update(phase: .idle)
updateLiveActivityPhase(.idle)
refreshHostReady()
debug("utterance failed: \(message)")
}
@@ -1458,7 +1530,7 @@ final class FlowSessionManager: ObservableObject {
storeCurrentError(message, kind: kind)
currentUtteranceId = nil
currentCommandSeq = 0
FlowLiveActivityController.update(phase: .idle)
updateLiveActivityPhase(.idle)
refreshHostReady()
debug("utterance processing failed: \(message)")
}
@@ -1638,7 +1710,7 @@ final class FlowSessionManager: ObservableObject {
let wasProcessing = isUtteranceProcessing
isUtteranceProcessing = false
FlowLiveActivityController.update(phase: .idle)
updateLiveActivityPhase(.idle)
if isActive {
touchSessionActivity()
}
@@ -1856,7 +1928,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.writeHeartbeat()
self.refreshHostReady()
tick += 1
if tick % liveActivityKeepAliveEveryTicks == 0 {
if !self.usesPiPKeepAlive, tick % liveActivityKeepAliveEveryTicks == 0 {
FlowLiveActivityController.keepAlive()
}
try? await Task.sleep(nanoseconds: 1_000_000_000)