chore(release): bump version to 1.6.5 (build 51)

Ship automatic low-profile PiP keep-alive so background dictation stays ready without visible startup UI or idle audio work.
This commit is contained in:
Rocky
2026-08-06 21:39:31 +08:00
parent 9b02e8948c
commit 77504facc8
10 changed files with 193 additions and 110 deletions
+5
View File
@@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.6.5] - 2026-08-06
### Added
- **Volcengine ASR API Key auth**: settings can switch to the new-console single `X-Api-Key` mode while keeping legacy APP ID + Access Token as the default; SAUC resource is fixed to Doubao streaming 2.0 (`volc.seedasr.sauc.duration`). / **火山 ASR API Key 鉴权**:设置可切换到新控制台单字段 `X-Api-Key`,默认仍为旧版 APP ID + Access TokenSAUC 资源固定为豆包流式 2.0(`volc.seedasr.sauc.duration`)。
- **Custom style mood emoji**: custom polish styles can opt in (default off) to allow emotion-matched emoji; the prompt overrides R5 and post-processing keeps them on screen. Paste-only prompts that declare emoji opt-in are detected automatically. / **自定义风格情绪 emoji**:自定义润色风格可单独开启(默认关)按情绪点缀 emoji;提示词覆盖 R5,后处理保留上屏。仅粘贴声明允许 emoji 的 prompt 也会自动识别。
### Changed
- **Automatic low-profile PiP**: replace the 18 FPS sample-buffer teaching video with a transparent 0.1 pt VideoCall PiP; every host-app open now arms it automatically, keyboard appearance performs one silent handoff when it is absent (including Pinyin/English typing mode), and idle mic UI stays green without PiP startup copy or host overlays. Once active, PiP stops frame rendering, releases the idle audio session, allows screen sleep, and refreshes audio levels only while recording. / **自动低感知 PiP**:用透明且高度仅 0.1pt 的 VideoCall PiP 替换 18 FPS SampleBuffer 教学视频;每次打开主 App 都自动武装,键盘出现且 PiP 缺失时静默跳转一次(包括默认拼音/英文输入模式),空闲麦克风始终保持绿色,不再显示 PiP 启动文案或主 App 浮层。PiP 激活后停止帧渲染、释放空闲音频会话、允许屏幕休眠,并仅在录音时刷新音量。
### Fixed
- **Shared LevelDB privacy manifest**: ship `PrivacyInfo.xcprivacy` inside `OSGKeyboardShared.framework` so App Store Connect no longer rejects uploads for ITMS-91061 (leveldb via librime). / **Shared LevelDB 隐私清单**:在 `OSGKeyboardShared.framework` 内打包 `PrivacyInfo.xcprivacy`,避免 App Store Connect 因 ITMS-91061librime 内嵌 leveldb)拒收。
- **PiP post-utterance yellow flash**: after a voice turn, the mic no longer briefly shows yellow「正在启动画中画」— hold ready once the session proved live, refresh host ready on ack, and avoid labeling an already-active PiP as `.starting`. / **PiP 句末黄色闪烁**:语音说完后麦克风不再短暂变黄并提示「正在启动画中画」——会话曾就绪后保持绿灯、ack 后立即刷新 host ready,且已激活的 PiP 不再标成 `.starting`
@@ -1,9 +1,10 @@
// FlowPictureInPictureController.swift
// OSGKeyboard · Main App
//
// PiP keep-alive for Flow sessions: enqueues a looping tuck to edge
// teaching animation (OSG logo card) so the host stays eligible for
// multitasking while the mic is off between utterances.
// Low-profile PiP keep-alive for Flow sessions. Production uses a transparent,
// static AVPictureInPictureVideoCallViewController with a 0.1 pt content
// height, no frame pump, and no idle audio session. The legacy sample-buffer
// teaching animation remains as a code-level fallback.
import AVFoundation
import AVKit
@@ -47,6 +48,8 @@ final class FlowPictureInPictureController: NSObject {
let displayLayer = AVSampleBufferDisplayLayer()
private var pipController: AVPictureInPictureController?
private var videoCallContentController: AVPictureInPictureVideoCallViewController?
private var transparentContentView: UIView?
private var displayLink: CADisplayLink?
private weak var hostView: UIView?
private var isStoppingProgrammatically = false
@@ -56,6 +59,17 @@ final class FlowPictureInPictureController: NSObject {
/// Last system failure reported by the PiP delegate (cleared on each start).
private var lastSystemStartFailure: Error?
/// Production route: a static, transparent VideoCall PiP. Unlike the
/// sample-buffer teaching animation, this needs no video frame pump and can
/// release AVAudioSession after PiP becomes active.
private let usesLowPowerVideoCallPiP = true
/// Community implementations confirm that VideoCall PiP accepts an extreme
/// aspect ratio. The system may clamp its final width, but a 0.1 pt content
/// height makes the surface visually negligible without private positioning
/// APIs.
private static let lowProfileContentSize = CGSize(width: 300, height: 0.1)
private enum Canvas {
static let width = 480
static let height = 270
@@ -69,6 +83,10 @@ final class FlowPictureInPictureController: NSObject {
func attachHostView(_ view: UIView) {
hostView = view
hasHostView = true
if usesLowPowerVideoCallPiP {
displayLayer.removeFromSuperlayer()
return
}
let bounds = view.bounds
displayLayer.frame = (bounds.width >= 1 && bounds.height >= 1)
? bounds
@@ -114,20 +132,25 @@ final class FlowPictureInPictureController: NSObject {
pipController = nil
}
configureControllerIfNeeded()
warmLogoCacheIfNeeded()
animationStartedAt = CACurrentMediaTime()
startFramePump()
if !usesLowPowerVideoCallPiP {
warmLogoCacheIfNeeded()
animationStartedAt = CACurrentMediaTime()
startFramePump()
}
guard pipController != nil else { return false }
if pipController?.isPictureInPictureActive == true {
isPictureInPictureActive = true
releaseAudioSessionForLowPowerPiP()
return true
}
// Prime a few frames before asking the system to start PiP.
enqueueGuideFrame()
enqueueGuideFrame()
pipController?.invalidatePlaybackState()
if !usesLowPowerVideoCallPiP {
// Prime a few frames before asking the system to start PiP.
enqueueGuideFrame()
enqueueGuideFrame()
pipController?.invalidatePlaybackState()
}
pipController?.startPictureInPicture()
return true
}
@@ -200,10 +223,12 @@ final class FlowPictureInPictureController: NSObject {
isStoppingProgrammatically = true
stopFramePump()
pipController?.stopPictureInPicture()
displayLayer.sampleBufferRenderer.flush(
removingDisplayedImage: true,
completionHandler: nil
)
if !usesLowPowerVideoCallPiP {
displayLayer.sampleBufferRenderer.flush(
removingDisplayedImage: true,
completionHandler: nil
)
}
isPictureInPictureActive = false
animationStartedAt = nil
lastSystemStartFailure = nil
@@ -214,13 +239,20 @@ final class FlowPictureInPictureController: NSObject {
/// `canStartPictureInPictureAutomaticallyFromInline` can take over.
func prepareForBackgroundAutoStart() async {
guard isPictureInPictureActive || pipController != nil else { return }
_ = await activateAudioSessionForPiP()
startFramePump()
enqueueGuideFrame()
pipController?.invalidatePlaybackState()
if !isPictureInPictureActive {
pipController?.startPictureInPicture()
if isPictureInPictureActive {
releaseAudioSessionForLowPowerPiP()
return
}
_ = await activateAudioSessionForPiP()
if !usesLowPowerVideoCallPiP {
startFramePump()
enqueueGuideFrame()
pipController?.invalidatePlaybackState()
}
// `canStartPictureInPictureAutomaticallyFromInline` is the primary
// transition when the app backgrounds. This explicit request is a
// foreground/inactive fallback and remains idempotent.
pipController?.startPictureInPicture()
}
/// Keep the shared audio session eligible for PiP after capture stops its
@@ -228,7 +260,11 @@ final class FlowPictureInPictureController: NSObject {
/// the stable playAndRecord category instead of flipping back to playback.
@discardableResult
func reassertKeepAliveAudioSession() async -> Bool {
await activateAudioSessionForPiP()
if usesLowPowerVideoCallPiP, isPictureInPictureActive {
releaseAudioSessionForLowPowerPiP()
return true
}
return await activateAudioSessionForPiP()
}
/// Kept for FlowSessionManager call sites; guide animation ignores live levels.
@@ -267,10 +303,39 @@ final class FlowPictureInPictureController: NSObject {
guard pipController == nil else { return }
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: displayLayer,
playbackDelegate: self
)
let contentSource: AVPictureInPictureController.ContentSource
if usesLowPowerVideoCallPiP, #available(iOS 15.0, *),
let hostView {
let contentController = AVPictureInPictureVideoCallViewController()
contentController.preferredContentSize = Self.lowProfileContentSize
contentController.view.backgroundColor = .clear
contentController.view.isOpaque = false
contentController.view.layer.backgroundColor = UIColor.clear.cgColor
contentController.view.layer.isOpaque = false
contentController.view.clipsToBounds = true
let transparentView = UIView(frame: contentController.view.bounds)
transparentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
transparentView.backgroundColor = .clear
transparentView.isOpaque = false
transparentView.isUserInteractionEnabled = false
transparentView.layer.backgroundColor = UIColor.clear.cgColor
transparentView.layer.isOpaque = false
transparentView.layer.opacity = 0
contentController.view.addSubview(transparentView)
videoCallContentController = contentController
transparentContentView = transparentView
contentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: hostView,
contentViewController: contentController
)
} else {
contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: displayLayer,
playbackDelegate: self
)
}
let controller = AVPictureInPictureController(contentSource: contentSource)
controller.delegate = self
controller.canStartPictureInPictureAutomaticallyFromInline = true
@@ -279,6 +344,13 @@ final class FlowPictureInPictureController: NSObject {
didActivateAudioSessionBeforeController = true
}
private func releaseAudioSessionForLowPowerPiP() {
guard usesLowPowerVideoCallPiP, isPictureInPictureActive else { return }
stopFramePump()
FlowAudioSessionCoordinator.shared.deactivate()
FlowDiagnostics.log("low-profile PiP active — released audio session and frame pump")
}
private func startFramePump() {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
@@ -572,6 +644,7 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
) {
isPictureInPictureActive = true
lastSystemStartFailure = nil
releaseAudioSessionForLowPowerPiP()
}
func pictureInPictureControllerDidStopPictureInPicture(
@@ -587,8 +660,8 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
// First attempts often fail while the sample-buffer source is still
// warming; keep retrying via the display link / auto-inline path.
// Sample-buffer fallback may need warm-up retries. VideoCall PiP uses
// the automatic-inline path plus the bounded startAndWait fallback.
lastSystemStartFailure = error
FlowDiagnostics.log("PiP start attempt failed (will retry): \(error.localizedDescription)")
}
+29 -33
View File
@@ -309,10 +309,10 @@ final class FlowSessionManager: ObservableObject {
/// Foreground entry for Flow.
///
/// Default is **light**: clear orphaned Live Activities / permission UI, but
/// do **not** start continuous capture. Auto-capture on every foreground was
/// holding the host at ~170220 MB RSS and jetsamming the keyboard extension
/// before `KVC.init` could run.
/// Default is **light** for audio work: clear orphaned Live Activities /
/// permission UI and automatically arm the low-profile PiP, but do not
/// start capture or ASR. The transparent PiP is intentionally cheap; the
/// 170220 MB capture/model path still starts only on explicit speech.
///
/// Pass `startCapture: true` for explicit user intent (Home Start, etc.).
/// Keyboard mic cold-start uses `osgkeyboard://startflow` `startSession`.
@@ -349,14 +349,19 @@ final class FlowSessionManager: ObservableObject {
return
}
guard startCapture else {
let shouldAutoArmPiP = keepAliveMode == .pictureInPicture
guard startCapture || shouldAutoArmPiP else {
OSGDiag.log(
"activateOnForeground light — skip capture (keyboard survival) \(OSGDiag.memoryTag())",
category: "flow"
)
return
}
startSession(reason: "activateOnForeground:\(reason)")
startSession(
reason: shouldAutoArmPiP && !startCapture
? "activateOnForeground.autoPiP:\(reason)"
: "activateOnForeground:\(reason)"
)
}
func dismissColdStartOverlay() {
@@ -976,7 +981,9 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.markSessionActive(duration: duration, sessionId: sessionId)
FlowSessionDarwin.postSessionChanged()
isActive = true
ScreenWakeLock.acquire()
// Low-profile PiP is a system-owned keep-alive surface. Keeping the
// display awake wastes far more power than the static PiP itself.
ScreenWakeLock.release()
sessionExpiresAt = nil
startHeartbeat()
@@ -1097,11 +1104,9 @@ final class FlowSessionManager: ObservableObject {
private func presentColdStartReadyOverlay() {
let hostEntry = HostReturnService.pendingHostEntry()
coldStartContext = FlowColdStartContext(
hostEntry: hostEntry,
state: .ready,
keepAliveMode: keepAliveMode
)
// Handoff remains fully automatic; no preparing/ready overlay is
// mounted in the host UI.
coldStartContext = nil
scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry)
}
@@ -1110,7 +1115,8 @@ final class FlowSessionManager: ObservableObject {
guard skipSwitch, hostEntry != nil else { return }
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 450_000_000)
guard let self, self.coldStartContext?.state == .ready else { return }
guard let self, self.isColdStartHandoff, self.isActive,
FlowSessionBridge.isHostReady() else { return }
if HostReturnService.openPendingHostIfPossible() {
self.dismissColdStartOverlay()
}
@@ -1197,38 +1203,24 @@ final class FlowSessionManager: ObservableObject {
}
private func showColdStartPreparing() {
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .preparing,
keepAliveMode: keepAliveMode
)
coldStartContext = nil
}
private func showColdStartPermissionFailure() {
FlowSessionBridge.setHostReady(false)
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .failed(.permission(message: permissionWarningMessage())),
keepAliveMode: keepAliveMode
)
coldStartContext = nil
}
private func showColdStartAudioFailure(message: String) {
FlowSessionBridge.setHostReady(false)
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .failed(.audio(message: message)),
keepAliveMode: keepAliveMode
)
_ = message
coldStartContext = nil
}
private func showColdStartPipFailure(message: String) {
FlowSessionBridge.setHostReady(false)
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
state: .failed(.pip(message: message)),
keepAliveMode: .pictureInPicture
)
_ = message
coldStartContext = nil
}
private func bindSessionASRIfNeeded(force: Bool = false) {
@@ -2515,6 +2507,10 @@ final class FlowSessionManager: ObservableObject {
levelTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self, self.isActive else { break }
guard self.isUtteranceRecording || self.capture.engineIsLive else {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
let levels = self.capture.currentAudioLevels()
if self.usesPiPKeepAlive {
self.pipController.updateWaveformLevels(levels)
+2 -15
View File
@@ -30,19 +30,6 @@ struct MainAppRoot: View {
}
.environment(\.locale, config.uiLanguage.swiftUILocale)
.environmentObject(flowManager)
.overlay {
if let context = flowManager.coldStartContext {
FlowColdStartOverlay(
context: context,
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
onDismiss: { flowManager.dismissColdStartOverlay() },
onRetry: { flowManager.retryColdStartReadiness() },
onOpenSettings: { flowManager.openColdStartPermissionSettings() }
)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
.background {
FlowPiPHostView { view in
flowManager.attachPiPHostView(view)
@@ -75,8 +62,8 @@ struct MainAppRoot: View {
// Heavy work (Flow / CLM / Rime) only after onboarding. Doing it
// earlier jetsams the host (~150 MB+) and the keyboard dies with it.
if config.hasCompletedOnboarding {
// Light path only: never auto-start continuous capture here.
// Capture starts on Home Start / keyboard startflow / mic press.
// Automatically arm the low-profile PiP on every host open.
// Capture/ASR remain lazy and start only on an actual mic press.
flowManager.activateOnForeground(reason: "MainAppRoot.onAppear")
schedulePostOnboardingWarmup(reason: "MainAppRoot.onAppear")
} else {
@@ -197,6 +197,11 @@ public final class KeyboardViewController: UIInputViewController {
disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
// Run after the extension is fully presented so UIKit accepts the
// containing-app handoff even when typing mode is the default surface.
DispatchQueue.main.async { [weak self] in
self?.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
}
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
category: "boot"
@@ -116,6 +116,33 @@ final class KeyboardFlowCoordinator {
stopHostReadyWait()
}
/// Ensure the containing app has armed its low-profile PiP even when the
/// keyboard opens directly into Pinyin/English typing mode. The mic stays
/// visually ready; if the host contract is missing, one automatic handoff
/// prepares PiP so the next press does not need another app switch.
func ensurePiPReadyOnKeyboardOpen() {
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
FlowSessionPolicy.keepAliveMode() == .pictureInPicture,
state.hasCompletedOnboarding,
hasFullAccess(),
AppGroup.isAvailable,
!isPendingFlowStart,
!isFlowRecording,
!isAwaitingFlowResult else { return }
FlowSessionBridge.reloadFromDisk()
if FlowSessionBridge.isHostReady() { return }
if let reason = FlowSessionBridge.readySnapshot()?.reason,
reason == .recording || reason == .processing || reason == .awaitingDelivery {
return
}
detectAndStoreAppContext()
beginFlowStart(recordAfterHandoff: false)
traceState("keyboardOpen.autoArmPiP")
}
func refreshSessionState() {
FlowSessionBridge.reloadFromDisk()
refreshConfigFromAppGroup()
@@ -527,11 +554,7 @@ final class KeyboardFlowCoordinator {
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = ExtL10n.string(
FlowSessionPolicy.keepAliveMode() == .pictureInPicture
? "keyboard.flow.startingSession.pip"
: "keyboard.flow.startingSession"
)
state.lastTranscript = ""
recomputeMicVoiceAvailability()
OSGDiag.log(
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
+16 -17
View File
@@ -280,17 +280,15 @@ public struct KeyboardRootView: View {
}
private var buttonPhase: RecordButton.Phase {
if case .error = state.phase { return .error }
if case .denied = state.phase { return .error }
switch state.micVoiceAvailability {
case .ready:
return .idleReady
case .unavailable:
return .idleUnavailable
case .recording:
return .recording
case .processing:
return .processing
case .ready, .unavailable:
// Host/PiP readiness is handled by an automatic app handoff. Keep
// the idle mic visually green instead of exposing startup state.
return .idleReady
}
}
}
@@ -409,7 +407,16 @@ private struct TranscriptLine: View {
@ViewBuilder
private var idleHint: some View {
let isWarning = micVoiceAvailability.isUnavailable
let isWarning: Bool = {
switch micVoiceAvailability {
case .unavailable(.hostNotReady), .unavailable(.preparingSession):
return false
case .unavailable:
return true
case .ready, .recording, .processing:
return false
}
}()
Group {
switch micVoiceAvailability {
case .ready:
@@ -417,17 +424,9 @@ private struct TranscriptLine: View {
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
if FlowSessionPolicy.keepAliveMode() == .pictureInPicture {
ExtL10n.text("keyboard.flow.sessionInactive.pip")
} else {
ExtL10n.text("keyboard.flow.sessionInactive")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.preparingSession):
if FlowSessionPolicy.keepAliveMode() == .pictureInPicture {
ExtL10n.text("keyboard.flow.startingSession.pip")
} else {
ExtL10n.text("keyboard.flow.startingSession")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
@@ -25,15 +25,10 @@ public enum FlowColdStartOverlayDecision: Equatable, Sendable {
}
public enum FlowHandoffPolicy {
/// Proactive keyboard auto-launch of the host is intentionally disabled.
/// Opening the host must be driven by an explicit mic press (or a Live
/// Activity tap when that keep-alive mode is selected). PiP sessions
/// never auto-jump once `hostReady` is published.
///
/// Ready-wait polls may observe a dead host, but must still gate
/// `startflow` on mic intent (`recordWhenHostReady`) otherwise an idle
/// keyboard open relaunches ASR and jetsams the extension.
public static let allowsProactiveHostAutoLaunch = false
/// Low-profile PiP is armed proactively whenever the keyboard appears.
/// This opens the lightweight host only when its ready contract is absent;
/// capture, ASR and model warm-up remain lazy until an actual mic press.
public static let allowsProactiveHostAutoLaunch = true
/// Samples of "host truly dead" required before a cold-start jump is allowed
/// from a mic-driven ready-wait recovery. Idle opens must never cold-start.
@@ -259,8 +259,8 @@ final class FlowHandoffPolicyTests: XCTestCase {
// MARK: - debouncer + proactive launch flag
func testProactiveAutoLaunchDisabled() {
XCTAssertFalse(FlowHandoffPolicy.allowsProactiveHostAutoLaunch)
func testProactiveAutoLaunchEnabledForLowProfilePiP() {
XCTAssertTrue(FlowHandoffPolicy.allowsProactiveHostAutoLaunch)
}
func testDebouncerIgnoresSingleDeadSample() {
+2 -2
View File
@@ -51,8 +51,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.6.2"
CURRENT_PROJECT_VERSION: "50"
MARKETING_VERSION: "1.6.5"
CURRENT_PROJECT_VERSION: "51"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target