fix: 应用 xhigh 代码评审的跨端修复
来自 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:
@@ -20,9 +20,13 @@ import UIKit
|
||||
|
||||
final class AsrAudioCapture {
|
||||
private let engine = AVAudioEngine()
|
||||
/// 21B:converter / 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:
|
||||
// 21A:usage 仅用于实时回写余额,不再当作收尾信号立即 finalize。
|
||||
// 服务端 usageLoop 每 2s 独立下发周期 usage,松开瞬间可能恰好落在 tick 窗口、
|
||||
// 早于尾部 final 到达——若据此 finalize 会丢字。收尾改以 final 帧 + grace 窗口
|
||||
// 为准,并由 armFinishFallback(2.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-first(errors.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 Group(dudu 键盘 120s 内自动上屏)
|
||||
UIPasteboard.general.string = committed
|
||||
PendingTextStore.write(committed)
|
||||
// 21G:主通道 = App Group pending_text(dudu 键盘 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 帧到达即增量 insertText、partial 只留在键盘内:
|
||||
// 宿主反馈较快且无 markedText 兼容性问题,但"上滑取消=丢弃本次结果"做不干净:
|
||||
// 已上屏的 final 只能靠 deleteBackward N 次回退,对 emoji/代理对/宿主自动纠错都很脆。
|
||||
//
|
||||
// 方案 C(采用)—— 录音期间 final/partial 都只累积在缓冲并显示在键盘的 partial 文本条,
|
||||
// 松开时一次性 insertText(final + 未定稿 partial):
|
||||
// 与 doc/frontend-design.html 6.3 一致("partial 不进宿主输入框"、
|
||||
// "松开 → committed(final + 未定稿 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_text,textDocumentProxy.insertText 自动上屏。
|
||||
// 14C 的录音态 UI / CommitController 流式上屏框架不再被键盘使用,文件保留备查。
|
||||
// 14C 的录音态 UI / 流式上屏框架(CommitController)已随 21D 删除(零调用方),
|
||||
// 双缓冲 (final, partial) 语义已在主 App DictationController 复刻。
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
|
||||
@@ -92,8 +92,11 @@ public final class AccountStore: ObservableObject {
|
||||
}
|
||||
|
||||
/// 14D:WS usage 帧实时回写余额 / 试用。
|
||||
/// ws.go ServerMsg 字段带 omitempty,0 值会被省略 → 仅在字段存在时覆盖,
|
||||
/// 用尽(0)的最终态由 error 帧(QUOTA_EXCEEDED)与下次 GET /v1/me 兜底。
|
||||
/// 语义为「字段存在即覆盖」(含 0):ServerMsg 用 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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 入口同步 trim(io 队列):超 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 }),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 14D deep link 听写的跨进程交接(App Group UserDefaults):
|
||||
// 主 App 听写完成 → write(text)(同时复制到剪贴板,双保险);
|
||||
// 用户手动切回原 App 调起 dudu 键盘 → consume() 取走并 insertText 自动上屏。
|
||||
// 主 App 听写完成 → write(text);用户手动切回原 App 调起 dudu 键盘
|
||||
// → consume() 取走并 insertText 自动上屏。
|
||||
// 21G:App 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 Group(主通道);false 表示 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
|
||||
}
|
||||
|
||||
/// 键盘扩展消费:取走并立即清除(只上屏一次)。
|
||||
|
||||
Reference in New Issue
Block a user