fix: 应用 xhigh 代码评审的跨端修复
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s

来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端:
- server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、
  billing 行锁、redis Lua 过期与设备槽刷新、config 解析
- desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询
- android:握手时序、请求头封装、账户状态派生、按需重组
- ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 11:50:08 +08:00
parent 50b49f3cbe
commit b5ab92a57e
42 changed files with 2125 additions and 469 deletions
+95 -44
View File
@@ -20,9 +20,13 @@ import UIKit
final class AsrAudioCapture {
private let engine = AVAudioEngine()
/// 21Bconverter / outFormat / buffered / firstBufferSeen
/// tap 线 consume()stop() 线 nil
/// stop() removeTap q.sync nil EXC_BAD_ACCESS
private let q = DispatchQueue(label: "app.dudu.asr.capture")
private var converter: AVAudioConverter?
private var outFormat: AVAudioFormat?
/// tap 线
/// q
private var buffered = Data()
private var firstBufferSeen = false
private var running = false
@@ -50,10 +54,12 @@ final class AsrAudioCapture {
let conv = AVAudioConverter(from: inFormat, to: out) else {
throw ApiError(code: "AUDIO", message: "音频格式不支持")
}
converter = conv
outFormat = out
buffered.removeAll()
firstBufferSeen = false
q.sync {
converter = conv
outFormat = out
buffered.removeAll()
firstBufferSeen = false
}
input.removeTap(onBus: 0)
// tap 线buffer
@@ -69,42 +75,54 @@ final class AsrAudioCapture {
func stop() {
guard running else { return }
running = false
// tap q.sync consume nil
engine.inputNode.removeTap(onBus: 0)
engine.stop()
converter = nil
outFormat = nil
q.sync {
converter = nil
outFormat = nil
buffered.removeAll()
}
// <100ms
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
}
/// tap 线 q 访 converter/outFormat/buffered
/// buffer q.sync
private func consume(_ buf: AVAudioPCMBuffer) {
guard let converter, let outFormat else { return }
if !firstBufferSeen {
firstBufferSeen = true
onFirstBuffer?()
}
let ratio = outFormat.sampleRate / buf.format.sampleRate
let capacity = AVAudioFrameCount(Double(buf.frameLength) * ratio) + 64
guard let out = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: capacity) else { return }
var fed = false
var convErr: NSError?
// buffer buf noDataNow tap
converter.convert(to: out, error: &convErr) { _, status in
if fed {
status.pointee = .noDataNow
return nil
var firstSeen = false
var frames: [Data] = []
q.sync {
guard let converter, let outFormat else { return } // stop nil
if !firstBufferSeen {
firstBufferSeen = true
firstSeen = true
}
let ratio = outFormat.sampleRate / buf.format.sampleRate
let capacity = AVAudioFrameCount(Double(buf.frameLength) * ratio) + 64
guard let out = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: capacity) else { return }
var fed = false
var convErr: NSError?
// buffer buf noDataNow tap
converter.convert(to: out, error: &convErr) { _, status in
if fed {
status.pointee = .noDataNow
return nil
}
fed = true
status.pointee = .haveData
return buf
}
guard convErr == nil, out.frameLength > 0, let ch = out.int16ChannelData else { return }
buffered.append(Data(bytes: ch[0], count: Int(out.frameLength) * MemoryLayout<Int16>.size))
while buffered.count >= DuduProtocol.frameBytes {
frames.append(Data(buffered.prefix(DuduProtocol.frameBytes)))
buffered.removeFirst(DuduProtocol.frameBytes)
}
fed = true
status.pointee = .haveData
return buf
}
guard convErr == nil, out.frameLength > 0, let ch = out.int16ChannelData else { return }
buffered.append(Data(bytes: ch[0], count: Int(out.frameLength) * MemoryLayout<Int16>.size))
while buffered.count >= DuduProtocol.frameBytes {
let frame = Data(buffered.prefix(DuduProtocol.frameBytes))
buffered.removeFirst(DuduProtocol.frameBytes)
onFrame?(frame)
}
// q /
if firstSeen { onFirstBuffer?() }
for frame in frames { onFrame?(frame) }
}
}
@@ -250,6 +268,8 @@ final class DictationController: ObservableObject {
private var pendingFrames: [Data] = []
private var timer: Timer?
private var finishFallback: Task<Void, Never>?
/// 21A final grace final 稿
private var finalGrace: Task<Void, Never>?
// MetricWhitelist
private var micDownAt: Date?
@@ -348,6 +368,8 @@ final class DictationController: ObservableObject {
}
finishFallback?.cancel()
finishFallback = nil
finalGrace?.cancel()
finalGrace = nil
stopCaptureAndTimer()
ws?.close()
ws = nil
@@ -417,27 +439,35 @@ final class DictationController: ObservableObject {
case DuduProtocol.ServerMsgType.final:
finalText += msg.text ?? "" // final 稿 partial
partialText = ""
// 21A stop final final
// grace final final 稿
// usage
if phase == .finishing {
armFinalGrace()
}
case DuduProtocol.ServerMsgType.usage:
// 21Ausage finalize
// usageLoop 2s usage tick
// final finalize final + grace
// armFinishFallback2.5s final
account?.applyUsage(balanceSeconds: msg.balanceSeconds,
trialRemaining: msg.trialRemaining)
if phase == .finishing {
finalize(cancelled: false) // stop usage =
}
case DuduProtocol.ServerMsgType.error:
let code = msg.code ?? "INTERNAL"
// 21G server-firsterrors.go /
let text = msg.message ?? Self.errText[code] ?? "出错了 · 再试一次"
if code == "SESSION_LIMIT" {
// 稿
errorText = Self.errText[code]
errorText = text
stopCaptureAndTimer()
if phase == .recording {
phase = .finishing
armFinishFallback()
}
} else {
failWith(code: code,
message: Self.errText[code] ?? (msg.message ?? "出错了 · 再试一次"))
failWith(code: code, message: text)
}
default:
@@ -450,6 +480,8 @@ final class DictationController: ObservableObject {
private func finalize(cancelled: Bool) {
finishFallback?.cancel()
finishFallback = nil
finalGrace?.cancel()
finalGrace = nil
stopCaptureAndTimer()
ws?.close()
ws = nil
@@ -478,15 +510,20 @@ final class DictationController: ObservableObject {
return
}
committedText = committed
// 宿+ App Groupdudu 120s
UIPasteboard.general.string = committed
PendingTextStore.write(committed)
// 21G = App Group pending_textdudu 120s consume
// App Group /
// finalize
if !PendingTextStore.write(committed) {
UIPasteboard.general.string = committed
}
phase = .done
}
private func failWith(code: String?, message: String) {
finishFallback?.cancel()
finishFallback = nil
finalGrace?.cancel()
finalGrace = nil
stopCaptureAndTimer()
ws?.close()
ws = nil
@@ -520,8 +557,10 @@ final class DictationController: ObservableObject {
Task { @MainActor in
guard let self, self.phase == .recording else { return }
self.elapsedSeconds += 1
// MaxSessionSeconds稿 SESSION_LIMIT
if self.elapsedSeconds >= DuduProtocol.maxSessionSeconds {
// 21E SESSION_LIMIT Android/
// localSafetyCapSeconds=200s
// SESSION_LIMIT
if self.elapsedSeconds >= DuduProtocol.localSafetyCapSeconds {
self.stop()
}
}
@@ -539,7 +578,19 @@ final class DictationController: ObservableObject {
finishFallback = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_500_000_000)
guard let self, !Task.isCancelled, self.phase == .finishing else { return }
self.finalize(cancelled: false) // usage 稿
self.finalize(cancelled: false) // final / 稿
}
}
/// 21A final grace 600ms final
/// final 稿 2.5s
/// usage
private func armFinalGrace() {
finalGrace?.cancel()
finalGrace = Task { [weak self] in
try? await Task.sleep(nanoseconds: 600_000_000)
guard let self, !Task.isCancelled, self.phase == .finishing else { return }
self.finalize(cancelled: false)
}
}
-4
View File
@@ -26,7 +26,6 @@
96EA045382879C3FEAB34491 /* PayClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDAC00E47916B8C92D5E1CAD /* PayClient.swift */; };
9C0B987F813A4C95EE21DC48 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE2739924D6777B2128D145 /* WaveformView.swift */; };
9C4548762EFA1E7A0D728B17 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA800811CF8E3D2EF0F9AF7 /* KeyboardView.swift */; };
AB818A27DE8367F71BB40757 /* CommitController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7D11ACDCB7E8129751AE4F /* CommitController.swift */; };
B760D93939F57C5678B6379A /* DuduApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC10A277F09485441D27F140 /* DuduApp.swift */; };
BB91C976BB44F21208C966F1 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE2739924D6777B2128D145 /* WaveformView.swift */; };
BD087D01A9FEB63DC81614C4 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3FE643E049274FD6C64184 /* AccountStore.swift */; };
@@ -72,7 +71,6 @@
4BA800811CF8E3D2EF0F9AF7 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; };
571D0A90CFE122E360A515B4 /* MockAsrDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAsrDriver.swift; sourceTree = "<group>"; };
5C1B0CEDF3F7F268A48BACB3 /* DuduProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuduProtocol.swift; sourceTree = "<group>"; };
6F7D11ACDCB7E8129751AE4F /* CommitController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitController.swift; sourceTree = "<group>"; };
8099F56A0503D89BC0AA6B90 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
8C524E4A9E51E9AB3C1B493B /* KeyboardExt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyboardExt.entitlements; sourceTree = "<group>"; };
9DE2739924D6777B2128D145 /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = "<group>"; };
@@ -95,7 +93,6 @@
18621DC7CA97E8B3490F2096 /* KeyboardExt */ = {
isa = PBXGroup;
children = (
6F7D11ACDCB7E8129751AE4F /* CommitController.swift */,
EE008586DD71949CA27B40D0 /* Info.plist */,
8C524E4A9E51E9AB3C1B493B /* KeyboardExt.entitlements */,
4BA800811CF8E3D2EF0F9AF7 /* KeyboardView.swift */,
@@ -233,7 +230,6 @@
files = (
816C6B3D217A42D3E89809FA /* AccountStore.swift in Sources */,
6C9986723B0F43A9493191B6 /* ApiClient.swift in Sources */,
AB818A27DE8367F71BB40757 /* CommitController.swift in Sources */,
3F7DDC3D7AAEAF40F3E3FB94 /* DuduProtocol.swift in Sources */,
45E603119FFE9CE9FBCE03AE /* DuduTheme.swift in Sources */,
9C4548762EFA1E7A0D728B17 /* KeyboardView.swift in Sources */,
@@ -1,68 +0,0 @@
// (final, partial) 宿
//
// 14D 1A spike App
// App/DictationController.swift App Group pending_text
// (final, partial)
//
//
// partial partial 宿 insertText
//
// A setMarkedText partial 宿
// 宿 markedText 宿 App
// 宿
//
// B final insertTextpartial
// 宿 markedText ""
// final deleteBackward N 退 emoji//宿
//
// C final/partial partial
// insertText(final + 稿 partial)
// doc/frontend-design.html 6.3 "partial 宿"
// " committedfinal + 稿 partial insertText "
// 宿partial
import UIKit
final class CommitController {
/// 稿final
private(set) var finalText = ""
/// 稿 partial
private(set) var partialText = ""
/// partial final / partial
var onChange: ((_ final: String, _ partial: String) -> Void)?
/// ws.go
/// partial 稿final 稿 partial
func apply(_ msg: DuduProtocol.ServerMsg) {
switch msg.type {
case DuduProtocol.ServerMsgType.partial:
partialText = msg.text ?? ""
case DuduProtocol.ServerMsgType.final:
finalText += msg.text ?? ""
partialText = ""
default:
return // usage / error
}
onChange?(finalText, partialText)
}
/// committed = final + 稿 partial宿
func commit(to proxy: UITextDocumentProxy) {
let committed = finalText + partialText
if !committed.isEmpty {
proxy.insertText(committed)
}
reset()
}
///
func cancel() {
reset()
}
private func reset() {
finalText = ""
partialText = ""
onChange?("", "")
}
}
@@ -16,7 +16,8 @@
// 稿 App Group pending_text+
// App iOS API
// viewWillAppear 120s pending_texttextDocumentProxy.insertText
// 14C UI / CommitController 使
// 14C UI / CommitController 21D
// (final, partial) App DictationController
import SwiftUI
import UIKit
+5 -2
View File
@@ -92,8 +92,11 @@ public final class AccountStore: ObservableObject {
}
/// 14DWS usage /
/// ws.go ServerMsg omitempty0
/// 0 error QUOTA_EXCEEDED GET /v1/me
/// 0ServerMsg decodeIfPresent
/// balance_seconds=0 0 nil 0
/// state balance=0 && trial=0 quota #16 omitempty 0
/// 便 omitempty 0 nil error
/// QUOTA_EXCEEDED GET /v1/me 0
public func applyUsage(balanceSeconds: Int64?, trialRemaining: Int?) {
if let b = balanceSeconds { defaults.set(Int(b), forKey: Key.balanceSeconds) }
if let t = trialRemaining { defaults.set(t, forKey: Key.trialRemaining) }
+5 -1
View File
@@ -8,8 +8,12 @@ public enum DuduProtocol {
public static let frameMillis = 100
public static let frameBytes = sampleRate * 2 * frameMillis / 1000 // 3200
/// 稿SESSION_LIMIT
/// 稿 SESSION_LIMIT
/// MaxSessionSeconds iOS
public static let maxSessionSeconds = 180
/// SESSION_LIMIT
/// 21E
public static let localSafetyCapSeconds = 200
///
public static let trialDailySeconds = 180
+32 -8
View File
@@ -39,6 +39,12 @@ public final class MetricsQueue {
private let fm = FileManager.default
private let defaults = UserDefaults(suiteName: AccountStore.appGroupID) ?? .standard
/// io 访track
/// lazy trim flush
///
/// flush() track O(n)
private var approxCount: Int?
/// App Group /metrics App Group退 caches
private var dir: URL? {
if let base = fm.containerURL(forSecurityApplicationGroupIdentifier: AccountStore.appGroupID) {
@@ -67,19 +73,34 @@ public final class MetricsQueue {
// flush
let name = String(format: "%017.6f", Date().timeIntervalSince1970)
+ "-" + UUID().uuidString + ".json"
if let data = try? JSONEncoder().encode(ev) {
try? data.write(to: dir.appendingPathComponent(name))
if let data = try? JSONEncoder().encode(ev),
(try? data.write(to: dir.appendingPathComponent(name))) != nil {
// +1 lazy track
// trim flush() maxQueued
let base = approxCount ?? diskCount(dir)
approxCount = base + 1
}
trimIfNeeded(dir)
}
}
/// flush / lazy track
private func diskCount(_ dir: URL) -> Int {
(try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
.filter { $0.pathExtension == "json" }.count ?? 0
}
/// flush trimio maxQueued approxCount
private func trimIfNeeded(_ dir: URL) {
guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil),
files.count > Self.maxQueued else { return }
let sorted = files.sorted { $0.lastPathComponent < $1.lastPathComponent }
for f in sorted.prefix(sorted.count - Self.maxQueued) {
try? fm.removeItem(at: f)
guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return }
let jsons = files.filter { $0.pathExtension == "json" }
if jsons.count > Self.maxQueued {
let sorted = jsons.sorted { $0.lastPathComponent < $1.lastPathComponent }
for f in sorted.prefix(sorted.count - Self.maxQueued) {
try? fm.removeItem(at: f)
}
approxCount = Self.maxQueued
} else {
approxCount = jsons.count
}
}
@@ -89,6 +110,9 @@ public final class MetricsQueue {
// 退
guard Date().timeIntervalSince1970 >= defaults.double(forKey: Key.nextAttempt),
let dir else { return }
// flush io trim + approxCount
// track
io.sync { trimIfNeeded(dir) }
guard var files = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
.filter({ $0.pathExtension == "json" })
.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }),
+20 -7
View File
@@ -1,6 +1,8 @@
// 14D deep link App Group UserDefaults
// App write(text)
// App dudu consume() insertText
// App write(text) App dudu
// consume() insertText
// 21GApp Group pending_text write
// App Group App Group DictationController
//
// ""iOS " App" API
// extensionContext.open App
@@ -17,15 +19,26 @@ public enum PendingTextStore {
///
public static let maxAgeSeconds: TimeInterval = 120
/// App Group suite
///
private static var appGroupDefaults: UserDefaults? {
UserDefaults(suiteName: AccountStore.appGroupID)
}
private static var defaults: UserDefaults {
UserDefaults(suiteName: AccountStore.appGroupID) ?? .standard
appGroupDefaults ?? .standard
}
/// App
public static func write(_ text: String) {
guard !text.isEmpty else { return }
defaults.set(text, forKey: Key.text)
defaults.set(Date().timeIntervalSince1970, forKey: Key.ts)
/// App Groupfalse App Group
/// 21G
@discardableResult
public static func write(_ text: String) -> Bool {
guard !text.isEmpty else { return false }
guard let group = appGroupDefaults else { return false } // App Group
group.set(text, forKey: Key.text)
group.set(Date().timeIntervalSince1970, forKey: Key.ts)
return true
}
///