feat(keyboard): harden clipboard command and add What's New sheet
Stabilize clipboard long-press prepare/resume across paste alerts and cold start, add an in-app release-notes sheet with remote bilingual HTML, localize typing input settings, and bump build to 55.
This commit is contained in:
+16
-2
@@ -8,13 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Clipboard voice command**: long-press the mic after copying eligible text to treat speech as an instruction over the clipboard snapshot and insert the generated result; short press stays dictation. / **剪贴板语音指令**:复制合格文本后长按麦克风,将语音视为对剪贴板材料的指令并插入生成结果;短按仍为听写。
|
||||
- **Clipboard record confirm**: show recording UI only after the host confirms capture; enforce a short minimum record window; light-prewarm ASR when clipboard becomes eligible (no idle mic hold). / **剪贴板开录确认**:宿主确认采音后再显示录音态;确认后再保证最短有效录音;剪贴板刚合格时轻预热 ASR(空闲不占麦)。
|
||||
- **In-app release notes**: after onboarding, show a What's New sheet when the marketing version changes; Settings version row opens the same remote page (`download.osglab.com`) with current version, language, and theme. / **应用内更新说明**:完成引导后,在 marketing 版本变化时弹出更新说明;设置页版本行打开同一远程页(`download.osglab.com`),并传入当前版本、语言与深浅色。
|
||||
- **Typing input settings l10n**: localize schema / fuzzy / resource strings on the Text Input settings page (and related General toggles) via in-app language. / **文本输入设置双语**:文本输入设置页的方案 / 模糊音 / 资源文案(及通用里相关开关)跟随应用内语言。
|
||||
- **Clipboard voice command**: long-press the mic when the clipboard has text to treat speech as an instruction over a one-shot clipboard snapshot and insert the result; short press stays dictation; tap again to stop (not release-to-stop). / **剪贴板语音指令**:剪贴板有文字时长按麦克风,将语音视为对该次冻结快照的指令并插入结果;短按仍为听写;再点一下结束录音(非松手结束)。
|
||||
- **Clipboard record confirm**: show recording UI only after the host confirms capture; enforce a short minimum record window after confirm. / **剪贴板开录确认**:宿主确认采音后再显示录音态;确认后再保证最短有效录音。
|
||||
- **Clipboard recording chrome**: after host-confirmed clipboard recording, the mic disc animates red→blue and shows side captions (“指令录制中” / “点按结束处理”); dictation stays red. / **剪贴板录音视觉**:宿主确认剪贴板录音后,麦克风圆盘红→蓝过渡,两侧显示「指令录制中」「点按结束处理」;普通听写仍为红色。
|
||||
- **Clipboard failure tips**: when long-press cannot start (paste denied, empty/short/code-like material, secure field, no Full Access), show a brief reason above the mic. / **剪贴板失败提示**:长按无法开始时(拒贴、空/过短/验证码类、密码框、无完全访问)在麦克风上方短暂显示原因。
|
||||
|
||||
### Removed
|
||||
- **Keep-alive settings & Live Activity**: remove Settings keep-alive mode picker and its note; delete the Dynamic Island Live Activity extension and all ActivityKit session code. Voice sessions stay on silent low-profile PiP only, with user-facing copy that never names Picture in Picture. / **保活设置与灵动岛**:移除设置中的保活方式选项及说明;删除灵动岛 Live Activity 扩展与全部 ActivityKit 会话代码。语音会话仅保留静默低感知 PiP,用户可见文案不再出现「画中画」。
|
||||
- **Clipboard 30s window & continuous rewrite**: drop the copy-time eligibility clock and multi-turn rewrite session; each long-press is an independent round. / **剪贴板 30 秒窗与连续改写**:去掉复制后资格计时与多轮改写会话;每次长按为独立一轮。
|
||||
|
||||
### Fixed
|
||||
- **Clipboard prepare stuck**: after the first Allow Paste, do not double-`startRecording`; recover or cancel when host never confirms capture so UI cannot remain on「准备录音…」. / **剪贴板卡在准备录音**:首次「允许粘贴」后不再重复 `startRecording`;宿主未确认采音时及时恢复或取消,避免 UI 一直停在「准备录音…」。
|
||||
- **Clipboard prepare decision tests**: unit-cover restore/dedupe/adopt/abort matrices (including a 20-round stress case) so paste-alert reopen cannot regress into a second start. / **剪贴板准备态决策单测**:覆盖恢复/去重/领养/中止矩阵(含 20 轮加压),防止粘贴弹窗重开再次双发 start。
|
||||
- **Clipboard cold-start handoff**: when the host app is not alive, long-press stores a sticky voice+snapshot and opens `startflow` without entering fake「准备录音…」; after return (including default typing → forced voice), the user long-presses again to start blue clipboard recording. / **剪贴板冷启动交接**:宿主未活时长按只保留语音+快照粘性并走 `startflow`,不进入假「准备录音…」;返回后(含默认打字→强制语音)再长按才进入蓝色剪贴板录音。
|
||||
- **PiP arm re-jump**: keyboard open / Voice-tab appear no longer reopens `startflow` while a session is alive, warming, or within the arm cooldown; force-quit zombies can still arm after an 8s unreachable grace. / **PiP 反复跳转**:会话已存活/预热中或仍在拉起冷却窗内时不再重复 `startflow`;强杀后残留 session 在心跳失联超过 8s 仍可拉起一次。
|
||||
- **Clipboard prepare chrome**: while waiting for host capture confirm, the mic stays grey and disabled (not blue); blue + side captions appear only after confirmed recording. / **剪贴板准备态视觉**:等待宿主确认采音时麦克风保持灰色不可点(非蓝色);确认录音后才显示蓝色与两侧文案。
|
||||
- **Cold capture first-press**: host retries a soft-dead / failed engine start once and rejects `running && !engineLive` as success. / **冷启动首次采音**:宿主对软死/失败引擎自动重建一次,且不把 `running 但无 live` 当作成功。
|
||||
- **Clipboard paste prompt spam**: idle affordance peeks `hasStrings` only; pasteboard *contents* are read only on long-press, so opening the keyboard no longer repeatedly requests paste permission. / **剪贴板粘贴授权刷屏**:空闲仅探测 `hasStrings`;正文只在长按时读取,打开键盘不再反复申请粘贴权限。
|
||||
- **Clipboard paste-alert continuity**: survive the system Allow Paste alert without snapping to the typing grid, keep blue clipboard chrome from prepare onward, and ignore the finger-up that follows long-press so stop stays tap-to-finish. / **剪贴板粘贴弹窗连续性**:系统「允许粘贴」弹窗期间不跳回打字键盘;准备阶段即显示蓝色剪贴板态;忽略长按后的抬手,仍需再点结束。
|
||||
- **Clipboard paste-alert voice sticky**: persist a short App Group prefer-voice + snapshot flag (with `synchronize`) across paste-alert dismiss/recreate so reopen stays on the voice keyboard with blue clipboard chrome instead of the default typing grid / red dictation mic. / **剪贴板粘贴弹窗语音粘性**:用 App Group 短时标记(并 `synchronize`)跨弹窗销毁/重建,重开仍进语音键盘并恢复蓝色剪贴板态,而不是默认打字键盘 / 红色听写麦。
|
||||
- **Custom polish style editor**: open create/edit with `sheet(item:)` so the form always loads the selected pack instead of a blank default template. / **自定义润色风格编辑**:新建/编辑改为 `sheet(item:)` 呈现,表单始终加载所选风格,而不再偶发显示空白默认模板。
|
||||
|
||||
## [1.6.5] - 2026-08-06
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 649 KiB |
@@ -8,6 +8,18 @@
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Group 29 3.png",
|
||||
"name" : "Group 29 3",
|
||||
"position" : {
|
||||
"scale" : 0.67,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"hidden" : true,
|
||||
"image-name" : "Group 29.svg",
|
||||
"name" : "Group 29"
|
||||
}
|
||||
|
||||
@@ -1141,9 +1141,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
if capture.engineHasRecentAudio(maxAge: 2) {
|
||||
micReady = true
|
||||
} else {
|
||||
micReady = await capture.awaitAudioFlowing(
|
||||
var flowing = await capture.awaitAudioFlowing(
|
||||
timeout: Self.coldStartAudioProofTimeout
|
||||
)
|
||||
if !flowing {
|
||||
// First cold capture after PiP arm often proves audio late — one rebuild.
|
||||
debug("PiP audio proof timeout — one capture rebuild before failing")
|
||||
capture.stop(releaseSession: false)
|
||||
_ = await startCaptureForPiPUtteranceIfNeeded()
|
||||
flowing = await capture.awaitAudioFlowing(
|
||||
timeout: Self.coldStartAudioProofTimeout
|
||||
)
|
||||
}
|
||||
micReady = flowing
|
||||
}
|
||||
guard !Task.isCancelled, canContinueStart(startToken) else {
|
||||
releaseOrphanedCaptureIfNeeded()
|
||||
@@ -1168,16 +1178,33 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Start capture for a PiP utterance without blocking on the first frame.
|
||||
/// Cold first start after relaunch often needs one rebuild (VPIO / -66635).
|
||||
private func startCaptureForPiPUtteranceIfNeeded() async -> Bool {
|
||||
if capture.engineHasRecentAudio(maxAge: 2) {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try await capture.start()
|
||||
return true
|
||||
if capture.engineIsLive || capture.engineHasRecentAudio(maxAge: 2) {
|
||||
return true
|
||||
}
|
||||
debug("PiP utterance capture soft-dead after start — one rebuild retry")
|
||||
capture.stop(releaseSession: false)
|
||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
||||
try await capture.start()
|
||||
// Do not accept `running && !engineLive` — that is the silent-mic failure mode.
|
||||
return capture.engineIsLive || capture.engineHasRecentAudio(maxAge: 2)
|
||||
} catch {
|
||||
debug("PiP utterance capture start failed: \(error.localizedDescription)")
|
||||
return false
|
||||
debug("PiP utterance capture start failed: \(error.localizedDescription) — retry once")
|
||||
capture.stop(releaseSession: false)
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
do {
|
||||
try await capture.start()
|
||||
return capture.engineIsLive || capture.engineHasRecentAudio(maxAge: 2)
|
||||
} catch {
|
||||
debug("PiP utterance capture retry failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,10 @@ enum LegalLinks {
|
||||
static var supportURL: URL? {
|
||||
URL(string: "https://github.com/hkgood/OSGKeyboard/issues")
|
||||
}
|
||||
|
||||
/// In-app release notes (hosted on download.osglab.com). Prefer
|
||||
/// `ReleaseNotesStore.pageURL(language:colorScheme:)` so v/lang/theme are set.
|
||||
static var releaseNotesURL: URL? {
|
||||
URL(string: ReleaseNotesStore.pageURLString)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// ReleaseNotesStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Tracks which marketing version's release notes the user has already seen,
|
||||
// and builds the remote release-notes URL with v / lang / theme query params.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum ReleaseNotesStore {
|
||||
static let lastSeenKey = "config.lastSeenMarketingVersion"
|
||||
static let pageURLString = "https://download.osglab.com/osgkeyboardversion.html"
|
||||
|
||||
static var lastSeenMarketingVersion: String? {
|
||||
get {
|
||||
UserDefaults.standard.string(forKey: lastSeenKey)
|
||||
}
|
||||
set {
|
||||
if let newValue {
|
||||
UserDefaults.standard.set(newValue, forKey: lastSeenKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: lastSeenKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the user has not acknowledged the current marketing version.
|
||||
static var shouldPresentAutomatically: Bool {
|
||||
let current = AppVersionDisplay.marketingVersion
|
||||
guard current != "—" else { return false }
|
||||
return lastSeenMarketingVersion != current
|
||||
}
|
||||
|
||||
static func markCurrentVersionSeen() {
|
||||
let current = AppVersionDisplay.marketingVersion
|
||||
guard current != "—" else { return }
|
||||
lastSeenMarketingVersion = current
|
||||
}
|
||||
|
||||
/// Query language for the remote page (`zh` / `en`).
|
||||
static func queryLanguage(for uiLanguage: AppUILanguage) -> String {
|
||||
let code = uiLanguage.resolvedLanguageCode()
|
||||
return code.hasPrefix("zh") ? "zh" : "en"
|
||||
}
|
||||
|
||||
/// Query theme for the remote page (`light` / `dark`).
|
||||
static func queryTheme(for colorScheme: ColorScheme) -> String {
|
||||
colorScheme == .dark ? "dark" : "light"
|
||||
}
|
||||
|
||||
static func pageURL(
|
||||
version: String = AppVersionDisplay.marketingVersion,
|
||||
language: AppUILanguage,
|
||||
colorScheme: ColorScheme
|
||||
) -> URL? {
|
||||
guard version != "—",
|
||||
var components = URLComponents(string: pageURLString) else { return nil }
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "v", value: version),
|
||||
URLQueryItem(name: "lang", value: queryLanguage(for: language)),
|
||||
URLQueryItem(name: "theme", value: queryTheme(for: colorScheme)),
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReleaseNotesController: ObservableObject {
|
||||
static let shared = ReleaseNotesController()
|
||||
|
||||
@Published var isPresented = false
|
||||
|
||||
func presentIfNeeded(onboardingCompleted: Bool) {
|
||||
guard onboardingCompleted, ReleaseNotesStore.shouldPresentAutomatically else { return }
|
||||
isPresented = true
|
||||
}
|
||||
|
||||
func presentManually() {
|
||||
isPresented = true
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,26 @@ struct RemoteWebView: UIViewRepresentable {
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.backgroundColor = .clear
|
||||
// Sheet / NavigationStack already lays out inside the safe area.
|
||||
// Automatic adjustment would add a second bottom inset and leave a dead band.
|
||||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
webView.scrollView.contentInset = .zero
|
||||
webView.scrollView.scrollIndicatorInsets = .zero
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.loadedURL = url
|
||||
webView.load(URLRequest(url: url))
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {
|
||||
guard context.coordinator.loadedURL != url else { return }
|
||||
context.coordinator.loadedURL = url
|
||||
uiView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
@Binding var isLoading: Bool
|
||||
var loadedURL: URL?
|
||||
|
||||
init(isLoading: Binding<Bool>) {
|
||||
_isLoading = isLoading
|
||||
|
||||
@@ -15,6 +15,7 @@ struct MainAppRoot: View {
|
||||
// Singleton is owned by `ProviderConfig.shared`, not by this view —
|
||||
// `@ObservedObject` keeps subscriptions correct across Settings replay.
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
@State private var postOnboardingWarmupTask: Task<Void, Never>?
|
||||
|
||||
@@ -40,6 +41,14 @@ struct MainAppRoot: View {
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.sheet(isPresented: $releaseNotes.isPresented, onDismiss: {
|
||||
// Auto-present and Settings entry both count as acknowledged.
|
||||
ReleaseNotesStore.markCurrentVersionSeen()
|
||||
}) {
|
||||
// Pass language explicitly; sheet chrome uses AppL10n (in-app override).
|
||||
ReleaseNotesSheet(language: config.uiLanguage)
|
||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||
}
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
// Register the URL handler BEFORE the foreground auto-start.
|
||||
@@ -66,6 +75,7 @@ struct MainAppRoot: View {
|
||||
// Capture/ASR remain lazy and start only on an actual mic press.
|
||||
flowManager.activateOnForeground(reason: "MainAppRoot.onAppear")
|
||||
schedulePostOnboardingWarmup(reason: "MainAppRoot.onAppear")
|
||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||
} else {
|
||||
OSGDiag.log(
|
||||
"MainAppRoot.onAppear skip Flow/CLM/Rime (onboarding incomplete)",
|
||||
@@ -80,6 +90,7 @@ struct MainAppRoot: View {
|
||||
if done {
|
||||
flowManager.activateOnForeground(reason: "onboardingCompleted")
|
||||
schedulePostOnboardingWarmup(reason: "onboardingCompleted")
|
||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
@@ -94,6 +105,7 @@ struct MainAppRoot: View {
|
||||
flowManager.activateOnForeground(reason: "scenePhase.active")
|
||||
// Retry deferred Rime/CLM after a jetsam-prone launch.
|
||||
schedulePostOnboardingWarmup(reason: "scenePhase.active.retry")
|
||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||
}
|
||||
Task {
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// ReleaseNotesSheet.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Sheet that loads the remote release-notes HTML for the current version.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ReleaseNotesSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
/// In-app UI language (not system-only) — chrome strings go through `AppL10n`.
|
||||
let language: AppUILanguage
|
||||
|
||||
@AppStorage(AppearancePreference.storageKey)
|
||||
private var appearanceRaw = AppearancePreference.system.rawValue
|
||||
|
||||
@State private var isLoading = true
|
||||
|
||||
private var appearance: AppearancePreference {
|
||||
AppearancePreference.fromStored(appearanceRaw)
|
||||
}
|
||||
|
||||
/// Theme actually shown in this sheet (honors Settings → Appearance).
|
||||
private var resolvedColorScheme: ColorScheme {
|
||||
appearance.colorScheme ?? colorScheme
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let url = ReleaseNotesStore.pageURL(
|
||||
language: language,
|
||||
colorScheme: resolvedColorScheme
|
||||
) {
|
||||
ZStack {
|
||||
RemoteWebView(url: url, isLoading: $isLoading)
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
AppL10n.string("releaseNotes.unavailable.title", language: language),
|
||||
systemImage: "wifi.slash",
|
||||
description: Text(
|
||||
AppL10n.string("releaseNotes.unavailable.message", language: language)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("releaseNotes.title", language: language))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(AppL10n.string("common.done", language: language)) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sheet can drop WindowGroup environment; re-assert language + appearance.
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
.environment(\.themePalette, resolvedColorScheme == .dark ? Palette.dark : Palette.light)
|
||||
}
|
||||
}
|
||||
@@ -132,15 +132,16 @@ struct PolishIntensityPickerRow: View {
|
||||
|
||||
struct DefaultTypingInputToggleRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: $isOn) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("settings.typingInput.default.title")
|
||||
Text(AppL10n.string("settings.typingInput.default.title", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.typingInput.default.description")
|
||||
Text(AppL10n.string("settings.typingInput.default.description", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -153,15 +154,16 @@ struct DefaultTypingInputToggleRow: View {
|
||||
|
||||
struct RememberLastSurfaceToggleRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: $isOn) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("settings.typingInput.rememberLast.title")
|
||||
Text(AppL10n.string("settings.typingInput.rememberLast.title", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.typingInput.rememberLast.description")
|
||||
Text(AppL10n.string("settings.typingInput.rememberLast.description", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -13,15 +13,35 @@ import OSGKeyboardShared
|
||||
struct SettingsNavigationRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: LocalizedStringKey
|
||||
private let localizedTitle: LocalizedStringKey?
|
||||
private let resolvedTitle: String?
|
||||
var subtitle: String?
|
||||
|
||||
init(title: LocalizedStringKey, subtitle: String? = nil) {
|
||||
self.localizedTitle = title
|
||||
self.resolvedTitle = nil
|
||||
self.subtitle = subtitle
|
||||
}
|
||||
|
||||
/// Prefers in-app language via an already-resolved string (`AppL10n`).
|
||||
init(titleText: String, subtitle: String? = nil) {
|
||||
self.localizedTitle = nil
|
||||
self.resolvedTitle = titleText
|
||||
self.subtitle = subtitle
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(title)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
if let resolvedTitle {
|
||||
Text(resolvedTitle)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
} else if let localizedTitle {
|
||||
Text(localizedTitle)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -39,23 +59,33 @@ struct SettingsNavigationRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only version / build row for Settings home (below About).
|
||||
/// Version / build row for Settings home (below About). Opens release notes.
|
||||
struct SettingsVersionRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.version.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Text(AppVersionDisplay.detailedLabel)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Button {
|
||||
ReleaseNotesController.shared.presentManually()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(AppL10n.string("settings.version.title", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Text(AppVersionDisplay.detailedLabel)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.settingsListRow()
|
||||
.accessibilityElement(children: .combine)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(AppL10n.string("releaseNotes.openHint", language: config.uiLanguage))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +235,12 @@ struct GeneralSettingsView: View {
|
||||
NavigationLink {
|
||||
TypingInputSettingsView()
|
||||
} label: {
|
||||
SettingsNavigationRow(title: "settings.typingInput.title")
|
||||
SettingsNavigationRow(
|
||||
titleText: AppL10n.string(
|
||||
"settings.typingInput.title",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ struct SettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
// Read-only version row — not tappable.
|
||||
// Opens the remote release-notes sheet (same as post-upgrade prompt).
|
||||
SettingsVersionRow()
|
||||
}
|
||||
.surfaceCard()
|
||||
|
||||
@@ -9,6 +9,7 @@ import OSGKeyboardShared
|
||||
|
||||
struct TypingInputSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var configuration = TypingInputConfiguration.shared
|
||||
|
||||
@State private var isDeploying = false
|
||||
@@ -16,10 +17,14 @@ struct TypingInputSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("输入方案") {
|
||||
Picker("拼音方案", selection: $configuration.schema) {
|
||||
Section(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage)) {
|
||||
Picker(
|
||||
AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage),
|
||||
selection: $configuration.schema
|
||||
) {
|
||||
ForEach(TypingInputSchema.allCases) { schema in
|
||||
Text(schema.displayName).tag(schema)
|
||||
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
|
||||
.tag(schema)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
@@ -39,14 +44,14 @@ struct TypingInputSettingsView: View {
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("模糊音")
|
||||
Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage))
|
||||
} footer: {
|
||||
Text("默认全部关闭。只开启你需要的组合,避免候选噪音。")
|
||||
Text(AppL10n.string("settings.typingInput.fuzzy.footer", language: config.uiLanguage))
|
||||
}
|
||||
|
||||
Section("输入法资源") {
|
||||
Section(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage)) {
|
||||
HStack {
|
||||
Text("状态")
|
||||
Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage))
|
||||
Spacer()
|
||||
if isDeploying {
|
||||
ProgressView()
|
||||
@@ -59,7 +64,7 @@ struct TypingInputSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Button("重新部署输入法资源") {
|
||||
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
.disabled(isDeploying)
|
||||
@@ -67,13 +72,18 @@ struct TypingInputSettingsView: View {
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.typingInput.title")
|
||||
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let deploymentError { return deploymentError }
|
||||
return RimeResourceInstaller.isReady ? "已就绪" : "待初始化"
|
||||
return AppL10n.string(
|
||||
RimeResourceInstaller.isReady
|
||||
? "settings.typingInput.resources.ready"
|
||||
: "settings.typingInput.resources.pending",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
}
|
||||
|
||||
private func deployUpdatedSchemas() {
|
||||
|
||||
@@ -182,6 +182,10 @@
|
||||
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
|
||||
"settings.about.title" = "About";
|
||||
"settings.version.title" = "Version";
|
||||
"releaseNotes.title" = "What's New";
|
||||
"releaseNotes.openHint" = "View release notes for this version";
|
||||
"releaseNotes.unavailable.title" = "Cannot load page";
|
||||
"releaseNotes.unavailable.message" = "Release notes could not be opened. Check your network connection and try again.";
|
||||
"settings.daily.title" = "Daily";
|
||||
"settings.config.title" = "Configuration";
|
||||
"settings.general.title" = "General";
|
||||
@@ -193,6 +197,18 @@
|
||||
"settings.typingInput.default.description" = "Open the text input keyboard instead of voice input by default";
|
||||
"settings.typingInput.rememberLast.title" = "Remember Last Choice";
|
||||
"settings.typingInput.rememberLast.description" = "Reopen on the voice or text surface you left last time";
|
||||
"settings.typingInput.schema.section" = "Input Method";
|
||||
"settings.typingInput.schema.picker" = "Pinyin Scheme";
|
||||
"typing.schema.fullPinyin" = "Full Pinyin";
|
||||
"typing.schema.microsoftDoublePinyin" = "Microsoft Shuangpin";
|
||||
"typing.schema.sogouDoublePinyin" = "Sogou Shuangpin";
|
||||
"settings.typingInput.fuzzy.section" = "Fuzzy Pinyin";
|
||||
"settings.typingInput.fuzzy.footer" = "All off by default. Enable only the pairs you need to avoid noisy candidates.";
|
||||
"settings.typingInput.resources.section" = "Input Resources";
|
||||
"settings.typingInput.resources.status" = "Status";
|
||||
"settings.typingInput.resources.ready" = "Ready";
|
||||
"settings.typingInput.resources.pending" = "Not initialized";
|
||||
"settings.typingInput.resources.redeploy" = "Redeploy Input Resources";
|
||||
"settings.speechRecognition.title" = "Speech Recognition";
|
||||
"settings.textPolish.title" = "Text Polish";
|
||||
"settings.preferences.title" = "Preferences";
|
||||
|
||||
@@ -182,6 +182,10 @@
|
||||
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
||||
"settings.about.title" = "关于";
|
||||
"settings.version.title" = "版本";
|
||||
"releaseNotes.title" = "更新说明";
|
||||
"releaseNotes.openHint" = "查看本版本更新说明";
|
||||
"releaseNotes.unavailable.title" = "无法加载页面";
|
||||
"releaseNotes.unavailable.message" = "无法打开更新说明,请检查网络连接后重试。";
|
||||
"settings.daily.title" = "日常";
|
||||
"settings.config.title" = "配置";
|
||||
"settings.general.title" = "通用";
|
||||
@@ -193,6 +197,18 @@
|
||||
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
|
||||
"settings.typingInput.rememberLast.title" = "记住上次选择";
|
||||
"settings.typingInput.rememberLast.description" = "下次打开键盘时,保持你上次离开时的语音或文字输入界面";
|
||||
"settings.typingInput.schema.section" = "输入方案";
|
||||
"settings.typingInput.schema.picker" = "拼音方案";
|
||||
"typing.schema.fullPinyin" = "全拼";
|
||||
"typing.schema.microsoftDoublePinyin" = "微软双拼";
|
||||
"typing.schema.sogouDoublePinyin" = "搜狗双拼";
|
||||
"settings.typingInput.fuzzy.section" = "模糊音";
|
||||
"settings.typingInput.fuzzy.footer" = "默认全部关闭。只开启你需要的组合,避免候选噪音。";
|
||||
"settings.typingInput.resources.section" = "输入法资源";
|
||||
"settings.typingInput.resources.status" = "状态";
|
||||
"settings.typingInput.resources.ready" = "已就绪";
|
||||
"settings.typingInput.resources.pending" = "待初始化";
|
||||
"settings.typingInput.resources.redeploy" = "重新部署输入法资源";
|
||||
"settings.speechRecognition.title" = "语音识别配置";
|
||||
"settings.textPolish.title" = "文本润色配置";
|
||||
"settings.preferences.title" = "偏好设置";
|
||||
|
||||
@@ -50,6 +50,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
|
||||
private var keyboardHeightConstraint: NSLayoutConstraint?
|
||||
private var systemEncapsulatedHeight: CGFloat = 228
|
||||
/// Presentation height priming is only valid during the slide-in. After
|
||||
/// `viewDidAppear` we must keep the constraint at `target` — re-applying
|
||||
/// the offset (or letting a paste alert interrupt the appear sequence)
|
||||
/// makes the slot land at `target + encapsulated` and floats the chrome.
|
||||
private enum HeightPresentationPhase {
|
||||
case idle
|
||||
case priming
|
||||
case presented
|
||||
}
|
||||
private var heightPhase: HeightPresentationPhase = .idle
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var textInserter: KeyboardTextInserter!
|
||||
@@ -118,6 +128,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
_ = configSync.loadPersistedConfig()
|
||||
configSync.installDarwinObservers()
|
||||
flowCoordinator.refreshSessionState()
|
||||
flowCoordinator.restoreClipboardCommandIfNeeded()
|
||||
OSGDiag.log(
|
||||
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
|
||||
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
|
||||
@@ -133,15 +144,27 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
heightPhase = .idle
|
||||
// Block pasteboard content reads until the next viewDidAppear height lock.
|
||||
flowCoordinator.setClipboardContentReadsEnabled(false)
|
||||
flowCoordinator.stopSessionMonitor()
|
||||
// Remember what the user left on, then pre-position a reused
|
||||
// extension instance for the next open policy (no first-frame jump).
|
||||
TypingInputConfiguration.persistLastSurface(state.surface)
|
||||
prepareSurfaceForNextPresentation()
|
||||
// Skip snap-to-typing while clipboard paste alert / utterance owns the mic —
|
||||
// otherwise Allow Paste reopens on the typing grid (visible jump).
|
||||
let preserve = flowCoordinator.preservesLifecycleOnDisappear
|
||||
|| flowCoordinator.isClipboardCommandActive
|
||||
|| ClipboardCommandResume.shouldPreferVoice()
|
||||
TypingInputConfiguration.persistLastSurface(
|
||||
preserve || ClipboardCommandResume.shouldPreferVoice() ? .voice : state.surface
|
||||
)
|
||||
if !preserve {
|
||||
prepareSurfaceForNextPresentation()
|
||||
}
|
||||
if state.surface == .typing {
|
||||
typingSession.leaveTypingMode()
|
||||
}
|
||||
if flowCoordinator.preservesLifecycleOnDisappear {
|
||||
if preserve {
|
||||
return
|
||||
}
|
||||
ExtensionScreenWakeLock.releaseAll()
|
||||
@@ -159,13 +182,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
configureDictationBehavior()
|
||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||
state.debugHasFullAccess = hasFullAccess
|
||||
// Do NOT read pasteboard contents here — `refreshSessionState` may peek
|
||||
// changeCount only while content reads stay disabled until height locks.
|
||||
flowCoordinator.refreshSessionState()
|
||||
flowCoordinator.refreshClipboardEligibility()
|
||||
flowCoordinator.startSessionMonitor()
|
||||
configSync.syncOnboardingStateFromAppGroup()
|
||||
configSync.refreshConfigFromAppGroup()
|
||||
// Settings may have changed while the extension stayed alive.
|
||||
applyPreferredSurfaceOnOpen()
|
||||
// After paste-alert reopen: restore clipboard chrome if sticky + host busy.
|
||||
flowCoordinator.restoreClipboardCommandIfNeeded()
|
||||
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
||||
// skips when the extension process is reused, leaving generators cold.
|
||||
KeyboardHapticFeedback.prepare()
|
||||
@@ -181,9 +207,17 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewIsAppearing(_ animated: Bool) {
|
||||
super.viewIsAppearing(animated)
|
||||
applyPresentationHeightOffset()
|
||||
if heightPhase == .presented {
|
||||
// Spurious re-appear while already on screen (e.g. system alert
|
||||
// lifecycle noise) — never re-run the offset trick.
|
||||
lockPresentedKeyboardHeight()
|
||||
} else {
|
||||
heightPhase = .priming
|
||||
applyPresentationHeightOffset()
|
||||
}
|
||||
OSGDiag.log(
|
||||
"KVC.viewIsAppearing height=\(keyboardHeightConstraint?.constant ?? -1) "
|
||||
"KVC.viewIsAppearing phase=\(heightPhaseLog) "
|
||||
+ "height=\(keyboardHeightConstraint?.constant ?? -1) "
|
||||
+ "\(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
@@ -196,12 +230,15 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
category: "boot"
|
||||
)
|
||||
disableSystemGestureDelays()
|
||||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||||
heightPhase = .presented
|
||||
lockPresentedKeyboardHeight()
|
||||
refreshReturnKeyRole()
|
||||
// Run after the extension is fully presented so UIKit accepts the
|
||||
// containing-app handoff even when typing mode is the default surface.
|
||||
// After height is locked: (1) allow pasteboard content reads so the
|
||||
// paste alert cannot interrupt presentation math; (2) arm PiP handoff.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||||
guard let self, self.heightPhase == .presented else { return }
|
||||
self.flowCoordinator.setClipboardContentReadsEnabled(true)
|
||||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||||
}
|
||||
OSGDiag.log(
|
||||
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
|
||||
@@ -240,6 +277,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
cursorDrag?.layoutChrome()
|
||||
enforcePresentedKeyboardHeightIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Services
|
||||
@@ -290,9 +328,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.beginClipboardCommand = { [weak self] in
|
||||
self?.flowCoordinator.clipboardCommandPressBegan()
|
||||
}
|
||||
state.endClipboardCommand = { [weak self] in
|
||||
self?.flowCoordinator.clipboardCommandPressEnded()
|
||||
}
|
||||
state.refreshClipboardEligibility = { [weak self] in
|
||||
self?.flowCoordinator.refreshClipboardEligibility()
|
||||
}
|
||||
@@ -368,13 +403,21 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
private func applyPreferredSurfaceOnOpen() {
|
||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||
let sticky = ClipboardCommandResume.shouldPreferVoice()
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: state.locksTypingSurface,
|
||||
clipboardCommandActive: flowCoordinator.isClipboardCommandActive,
|
||||
stickyPreferVoice: sticky,
|
||||
preferred: preferred
|
||||
)
|
||||
OSGDiag.log(
|
||||
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
|
||||
+ "remember=\(TypingInputConfiguration.remembersLastSurface()) "
|
||||
+ "defaultTyping=\(TypingInputConfiguration.prefersTypingOnOpen())",
|
||||
+ "resolved=\(resolved.rawValue) stickyVoice=\(sticky ? 1 : 0) "
|
||||
+ "clipboardActive=\(flowCoordinator.isClipboardCommandActive ? 1 : 0) "
|
||||
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
|
||||
category: "boot"
|
||||
)
|
||||
applySurface(preferred)
|
||||
applySurface(resolved)
|
||||
}
|
||||
|
||||
/// When not remembering, snap to the static open preference while hidden
|
||||
@@ -395,10 +438,42 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// final assignment in `viewDidAppear`. Avoid synchronous layout here:
|
||||
// this is also called during `viewDidLoad`, where re-entrant layout can
|
||||
// observe partially initialized controller dependencies.
|
||||
if heightPhase == .presented {
|
||||
lockPresentedKeyboardHeight()
|
||||
} else {
|
||||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||||
view.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private var heightPhaseLog: String {
|
||||
switch heightPhase {
|
||||
case .idle: return "idle"
|
||||
case .priming: return "priming"
|
||||
case .presented: return "presented"
|
||||
}
|
||||
}
|
||||
|
||||
private func lockPresentedKeyboardHeight() {
|
||||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||||
view.setNeedsLayout()
|
||||
}
|
||||
|
||||
/// After presentation, the constraint must stay at `target`. Spurious
|
||||
/// lifecycle noise must not leave us primed at `target − encapsulated`.
|
||||
private func enforcePresentedKeyboardHeightIfNeeded() {
|
||||
guard heightPhase == .presented else { return }
|
||||
let target = targetKeyboardHeight
|
||||
guard let constraint = keyboardHeightConstraint else { return }
|
||||
let was = constraint.constant
|
||||
guard abs(was - target) > 0.5 else { return }
|
||||
constraint.constant = target
|
||||
OSGDiag.log(
|
||||
"KVC.heightEnforce constraint \(Int(was))→\(Int(target))",
|
||||
category: "boot"
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||
// Secure fields must not run English autocomplete / autocorrect / learning.
|
||||
@@ -480,6 +555,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func applyPresentationHeightOffset() {
|
||||
// Only valid while priming the slide-in. Callers must not invoke this
|
||||
// after `heightPhase == .presented`.
|
||||
if let encapsulated = view.constraints.first(where: { constraint in
|
||||
constraint.firstItem as? UIView === view
|
||||
&& constraint.firstAttribute == .height
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
// ClipboardPasteboardReader.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Opportunity-read of UIPasteboard for clipboard-command eligibility.
|
||||
// Pasteboard peeks for clipboard-command UI and long-press snapshot capture.
|
||||
//
|
||||
// Idle affordance must use metadata only (`hasStrings` / `changeCount`) so the
|
||||
// system「允许粘贴」alert never appears while the keyboard is merely open.
|
||||
// Content reads (`string`) happen only on an explicit long-press.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum ClipboardPasteboardReader {
|
||||
/// Metadata-only — does not trigger the paste permission prompt.
|
||||
static func changeCount() -> Int {
|
||||
UIPasteboard.general.changeCount
|
||||
}
|
||||
|
||||
/// Metadata-only — whether the pasteboard currently holds string items.
|
||||
static func hasStrings() -> Bool {
|
||||
UIPasteboard.general.hasStrings
|
||||
}
|
||||
|
||||
/// Content sample for long-press snapshot. May present the system paste alert.
|
||||
static func sample() -> (changeCount: Int, text: String?) {
|
||||
let board = UIPasteboard.general
|
||||
let changeCount = board.changeCount
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,7 +132,8 @@ public struct KeyboardRootView: View {
|
||||
transcript: state.lastTranscript,
|
||||
micVoiceAvailability: state.micVoiceAvailability,
|
||||
micDisabledHint: state.micDisabledHint,
|
||||
clipboardCommandEligible: state.clipboardCommandEligible || state.clipboardCommandSessionActive,
|
||||
clipboardCommandEligible: state.clipboardCommandEligible,
|
||||
clipboardFailureHint: state.clipboardFailureHint,
|
||||
cursorDragHintActive: state.cursorDragActive,
|
||||
openSettings: state.openSettings
|
||||
)
|
||||
@@ -177,18 +178,24 @@ public struct KeyboardRootView: View {
|
||||
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
|
||||
HStack(spacing: 0) {
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
.overlay {
|
||||
clipboardSideHint(
|
||||
ExtL10n.text("keyboard.clipboard.recordingLeft"),
|
||||
visible: state.clipboardCommandRecording && !dragging
|
||||
)
|
||||
}
|
||||
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
isEnabled: !state.micDisabled,
|
||||
isEnabled: micButtonEnabled,
|
||||
isClipboardCommandRecording: state.clipboardCommandRecording,
|
||||
onToggle: state.tapMic,
|
||||
onClipboardLongPressBegan: (state.clipboardCommandEligible || state.clipboardCommandSessionActive)
|
||||
onClipboardLongPressBegan: (state.clipboardCommandEligible
|
||||
|| state.clipboardCommandUtteranceActive)
|
||||
&& micButtonEnabled
|
||||
? state.beginClipboardCommand
|
||||
: nil,
|
||||
onClipboardLongPressEnded: (state.clipboardCommandEligible || state.clipboardCommandSessionActive)
|
||||
? state.endClipboardCommand
|
||||
: nil
|
||||
)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
@@ -196,8 +203,15 @@ public struct KeyboardRootView: View {
|
||||
.opacity(dragging ? 0 : 1)
|
||||
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
.overlay {
|
||||
clipboardSideHint(
|
||||
ExtL10n.text("keyboard.clipboard.recordingRight"),
|
||||
visible: state.clipboardCommandRecording && !dragging
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.micSize)
|
||||
.animation(.easeInOut(duration: 0.25), value: state.clipboardCommandRecording)
|
||||
|
||||
GeometryReader { proxy in
|
||||
let widths = KeyboardChromeLayout.actionKeyWidths(
|
||||
@@ -240,6 +254,23 @@ public struct KeyboardRootView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
/// Side caption beside the mic during clipboard-command recording.
|
||||
/// Vertically matches the mic disc (same upward offset); does not steal touches.
|
||||
private func clipboardSideHint(_ text: Text, visible: Bool) -> some View {
|
||||
text
|
||||
// 22pt → ~18pt (−20%); softer than body so it doesn't compete with the mic.
|
||||
.font(.system(size: 17.6, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary.opacity(0.42))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.minimumScaleFactor(0.7)
|
||||
.padding(.horizontal, 2)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(visible ? 1 : 0)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
private func bottomDeleteButton(disabled: Bool) -> some View {
|
||||
RepeatingDeleteButton(
|
||||
disabled: disabled,
|
||||
@@ -287,6 +318,14 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
|
||||
private var buttonPhase: RecordButton.Phase {
|
||||
switch clipboardMicChrome {
|
||||
case .preparingDisabled:
|
||||
return .processing
|
||||
case .recordingBlue:
|
||||
return .recording
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
switch state.micVoiceAvailability {
|
||||
case .recording:
|
||||
return .recording
|
||||
@@ -298,6 +337,32 @@ public struct KeyboardRootView: View {
|
||||
return .idleReady
|
||||
}
|
||||
}
|
||||
|
||||
/// Preparing clipboard capture: grey spinner, not tappable.
|
||||
private var micButtonEnabled: Bool {
|
||||
if state.micDisabled { return false }
|
||||
if clipboardMicChrome == .preparingDisabled { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private var clipboardMicChrome: ClipboardMicChrome {
|
||||
let phase: ClipboardPreparingPhase = {
|
||||
switch state.phase {
|
||||
case .idle: return .idle
|
||||
case .denied: return .denied
|
||||
case .error: return .error
|
||||
case .requestingPermissions: return .requestingPermissions
|
||||
case .recording: return .recording
|
||||
case .processing: return .processing
|
||||
}
|
||||
}()
|
||||
return ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: state.clipboardCommandUtteranceActive,
|
||||
phase: phase,
|
||||
awaitingHostConfirm: state.phase == .requestingPermissions
|
||||
|| (state.phase == .recording && !state.clipboardCommandRecording)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State alias
|
||||
@@ -347,6 +412,7 @@ private struct TranscriptLine: View {
|
||||
let micVoiceAvailability: MicVoiceAvailability
|
||||
let micDisabledHint: String
|
||||
let clipboardCommandEligible: Bool
|
||||
let clipboardFailureHint: String?
|
||||
let cursorDragHintActive: Bool
|
||||
let openSettings: () -> Void
|
||||
|
||||
@@ -419,52 +485,60 @@ private struct TranscriptLine: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var idleHint: some View {
|
||||
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:
|
||||
if clipboardCommandEligible {
|
||||
ExtL10n.text("keyboard.placeholder.idleClipboard")
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.missingAPIKey):
|
||||
Text(micDisabledHint)
|
||||
case .unavailable(.hostNotReady):
|
||||
if clipboardCommandEligible {
|
||||
ExtL10n.text("keyboard.placeholder.idleClipboard")
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.preparingSession):
|
||||
if clipboardCommandEligible {
|
||||
ExtL10n.text("keyboard.placeholder.idleClipboard")
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.noFullAccess):
|
||||
ExtL10n.text("keyboard.error.fullAccessRequired")
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
ExtL10n.text("keyboard.error.appGroupCommunication")
|
||||
case .unavailable(.onboardingIncomplete):
|
||||
ExtL10n.text("keyboard.hint.finishSetupInApp")
|
||||
case .recording, .processing:
|
||||
EmptyView()
|
||||
if let clipboardFailureHint, !clipboardFailureHint.isEmpty {
|
||||
Text(clipboardFailureHint)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
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:
|
||||
if clipboardCommandEligible {
|
||||
ExtL10n.text("keyboard.placeholder.idleClipboard")
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.missingAPIKey):
|
||||
Text(micDisabledHint)
|
||||
case .unavailable(.hostNotReady):
|
||||
if clipboardCommandEligible {
|
||||
ExtL10n.text("keyboard.placeholder.idleClipboard")
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.preparingSession):
|
||||
if clipboardCommandEligible {
|
||||
ExtL10n.text("keyboard.placeholder.idleClipboard")
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.noFullAccess):
|
||||
ExtL10n.text("keyboard.error.fullAccessRequired")
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
ExtL10n.text("keyboard.error.appGroupCommunication")
|
||||
case .unavailable(.onboardingIncomplete):
|
||||
ExtL10n.text("keyboard.hint.finishSetupInApp")
|
||||
case .recording, .processing:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
|
||||
@@ -116,8 +116,21 @@
|
||||
/* Keyboard (ext) */
|
||||
"keyboard.placeholder.idle" = "Tap to talk";
|
||||
"keyboard.placeholder.idleClipboard" = "Tap to talk, long-press for clipboard";
|
||||
"keyboard.clipboard.recordingLeft" = "Recording command";
|
||||
"keyboard.clipboard.recordingRight" = "Tap to finish";
|
||||
"keyboard.placeholder.preparing" = "Preparing";
|
||||
"keyboard.placeholder.preparingRecording" = "Preparing mic…";
|
||||
"keyboard.clipboard.reject.pasteDenied" = "Allow Paste to process the clipboard";
|
||||
"keyboard.clipboard.reject.empty" = "No text on the clipboard to process";
|
||||
"keyboard.clipboard.reject.phoneOrNumeric" = "Looks like a phone number — not started";
|
||||
"keyboard.clipboard.reject.emojiOrSymbolOnly" = "No usable text on the clipboard";
|
||||
"keyboard.clipboard.reject.verificationCode" = "Looks like a code — not started";
|
||||
"keyboard.clipboard.reject.tooShort" = "Clipboard text is too short";
|
||||
"keyboard.clipboard.reject.repetitiveSpam" = "Clipboard text isn’t usable";
|
||||
"keyboard.clipboard.reject.secureField" = "Clipboard commands aren’t available in password fields";
|
||||
"keyboard.clipboard.reject.noFullAccess" = "Full Access is required for clipboard commands";
|
||||
"keyboard.clipboard.reject.prepareFailed" = "Mic wasn’t ready in time — try again";
|
||||
"keyboard.clipboard.hint.hostStarting" = "Starting… return and long-press the mic again";
|
||||
"keyboard.placeholder.processing" = "Processing";
|
||||
"keyboard.placeholder.error" = "Polishing failed";
|
||||
"keyboard.placeholder.localBadge" = "On-device";
|
||||
|
||||
@@ -116,8 +116,21 @@
|
||||
/* Keyboard (ext) */
|
||||
"keyboard.placeholder.idle" = "点按说话";
|
||||
"keyboard.placeholder.idleClipboard" = "点击说话,长按处理剪贴板";
|
||||
"keyboard.clipboard.recordingLeft" = "指令录制中";
|
||||
"keyboard.clipboard.recordingRight" = "点按结束处理";
|
||||
"keyboard.placeholder.preparing" = "准备中…";
|
||||
"keyboard.placeholder.preparingRecording" = "准备录音…";
|
||||
"keyboard.clipboard.reject.pasteDenied" = "需要允许粘贴才能处理剪贴板";
|
||||
"keyboard.clipboard.reject.empty" = "剪贴板里没有可处理的文字";
|
||||
"keyboard.clipboard.reject.phoneOrNumeric" = "看起来像号码,未开始处理";
|
||||
"keyboard.clipboard.reject.emojiOrSymbolOnly" = "没有可处理的文字内容";
|
||||
"keyboard.clipboard.reject.verificationCode" = "看起来像验证码,未开始处理";
|
||||
"keyboard.clipboard.reject.tooShort" = "内容太短,请复制更完整的文字";
|
||||
"keyboard.clipboard.reject.repetitiveSpam" = "内容无效,未开始处理";
|
||||
"keyboard.clipboard.reject.secureField" = "密码框中不能使用剪贴板指令";
|
||||
"keyboard.clipboard.reject.noFullAccess" = "需要开启完全访问才能处理剪贴板";
|
||||
"keyboard.clipboard.reject.prepareFailed" = "麦克风未能及时就绪,请再试一次";
|
||||
"keyboard.clipboard.hint.hostStarting" = "正在启动,返回后请再长按处理剪贴板";
|
||||
"keyboard.placeholder.processing" = "处理中…";
|
||||
"keyboard.placeholder.error" = "润色失败";
|
||||
"keyboard.placeholder.localBadge" = "本地";
|
||||
|
||||
@@ -53,7 +53,8 @@ enum MacSystemPalette {
|
||||
? Color.white.opacity(0.12)
|
||||
: Color.black.opacity(0.10),
|
||||
|
||||
recordRed: resolved(.systemRed, dark: dark)
|
||||
recordRed: resolved(.systemRed, dark: dark),
|
||||
recordBlue: resolved(.systemBlue, dark: dark)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@ public struct RecordButton: View {
|
||||
public let level: Double
|
||||
public let remainingSeconds: Int?
|
||||
public let isEnabled: Bool
|
||||
/// When true (and `phase == .recording`), use blue clipboard-command chrome.
|
||||
public let isClipboardCommandRecording: Bool
|
||||
public let onToggle: () -> Void
|
||||
/// When non-nil, a 0.45s hold starts clipboard-command recording instead of toggle.
|
||||
/// When non-nil, a 0.45s hold starts clipboard-command recording (tap again to stop).
|
||||
public let onClipboardLongPressBegan: (() -> Void)?
|
||||
public let onClipboardLongPressEnded: (() -> Void)?
|
||||
|
||||
@State private var breath = false
|
||||
@State private var longPressArmed = false
|
||||
@@ -36,17 +37,17 @@ public struct RecordButton: View {
|
||||
level: Double,
|
||||
remainingSeconds: Int? = nil,
|
||||
isEnabled: Bool = true,
|
||||
isClipboardCommandRecording: Bool = false,
|
||||
onToggle: @escaping () -> Void,
|
||||
onClipboardLongPressBegan: (() -> Void)? = nil,
|
||||
onClipboardLongPressEnded: (() -> Void)? = nil
|
||||
onClipboardLongPressBegan: (() -> Void)? = nil
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.remainingSeconds = remainingSeconds
|
||||
self.isEnabled = isEnabled
|
||||
self.isClipboardCommandRecording = isClipboardCommandRecording
|
||||
self.onToggle = onToggle
|
||||
self.onClipboardLongPressBegan = onClipboardLongPressBegan
|
||||
self.onClipboardLongPressEnded = onClipboardLongPressEnded
|
||||
}
|
||||
|
||||
private var isUrgent: Bool {
|
||||
@@ -54,6 +55,17 @@ public struct RecordButton: View {
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
/// Active recording tint: blue for clipboard-command, red for dictation.
|
||||
private var recordingTint: Color {
|
||||
isClipboardCommandRecording ? palette.recordBlue : palette.recordRed
|
||||
}
|
||||
|
||||
private var waveformColor: Color {
|
||||
isClipboardCommandRecording
|
||||
? Color(red: 0.78, green: 0.88, blue: 1.0)
|
||||
: Color(red: 1.0, green: 0.78, blue: 0.78)
|
||||
}
|
||||
|
||||
private enum Layout {
|
||||
static let disc: CGFloat = 95
|
||||
static let outerRing: CGFloat = 106
|
||||
@@ -64,16 +76,17 @@ public struct RecordButton: View {
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.stroke(recordingTint.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.frame(width: Layout.breathRing, height: Layout.breathRing)
|
||||
.scaleEffect(breath ? 1.18 : 0.95)
|
||||
.opacity(phase == .recording ? 1 : 0)
|
||||
.animation(Motion.breath, value: breath)
|
||||
.animation(colorTransition, value: isClipboardCommandRecording)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [palette.recordRed.opacity(0.55), .clear],
|
||||
colors: [recordingTint.opacity(0.55), .clear],
|
||||
center: .center,
|
||||
startRadius: 46,
|
||||
endRadius: 92
|
||||
@@ -84,6 +97,7 @@ public struct RecordButton: View {
|
||||
.blur(radius: 18)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: level)
|
||||
.animation(colorTransition, value: isClipboardCommandRecording)
|
||||
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||
@@ -114,7 +128,7 @@ public struct RecordButton: View {
|
||||
}
|
||||
WaveformView(
|
||||
level: level,
|
||||
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||
color: waveformColor,
|
||||
active: true
|
||||
)
|
||||
.frame(width: 73, height: 32)
|
||||
@@ -137,6 +151,7 @@ public struct RecordButton: View {
|
||||
.frame(width: Layout.disc, height: Layout.disc)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
.animation(colorTransition, value: isClipboardCommandRecording)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
.modifier(
|
||||
@@ -146,8 +161,7 @@ public struct RecordButton: View {
|
||||
supportsClipboardLongPress: onClipboardLongPressBegan != nil,
|
||||
longPressArmed: $longPressArmed,
|
||||
onToggle: onToggle,
|
||||
onClipboardLongPressBegan: onClipboardLongPressBegan,
|
||||
onClipboardLongPressEnded: onClipboardLongPressEnded
|
||||
onClipboardLongPressBegan: onClipboardLongPressBegan
|
||||
)
|
||||
)
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
@@ -160,6 +174,11 @@ public struct RecordButton: View {
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
|
||||
}
|
||||
|
||||
/// Red ↔ blue mode switch (~0.25s).
|
||||
private var colorTransition: Animation {
|
||||
.easeInOut(duration: 0.25)
|
||||
}
|
||||
|
||||
private var isIdle: Bool {
|
||||
switch phase {
|
||||
case .idleReady, .idleUnavailable:
|
||||
@@ -179,8 +198,8 @@ public struct RecordButton: View {
|
||||
switch phase {
|
||||
case .recording:
|
||||
let colors: [Color] = isUrgent
|
||||
? [palette.recordRed, palette.recordRed.opacity(0.85)]
|
||||
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
|
||||
? [recordingTint, recordingTint.opacity(0.85)]
|
||||
: [recordingTint.opacity(0.95), recordingTint.opacity(0.75)]
|
||||
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
case .processing:
|
||||
return LinearGradient(
|
||||
@@ -213,45 +232,49 @@ private struct RecordButtonPressModifier: ViewModifier {
|
||||
@Binding var longPressArmed: Bool
|
||||
let onToggle: () -> Void
|
||||
let onClipboardLongPressBegan: (() -> Void)?
|
||||
let onClipboardLongPressEnded: (() -> Void)?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if supportsClipboardLongPress {
|
||||
content.onLongPressGesture(
|
||||
minimumDuration: ClipboardMaterialFilter.longPressDuration,
|
||||
maximumDistance: 120,
|
||||
pressing: { pressing in
|
||||
if pressing {
|
||||
longPressArmed = false
|
||||
return
|
||||
}
|
||||
// Released.
|
||||
if longPressArmed {
|
||||
onClipboardLongPressEnded?()
|
||||
longPressArmed = false
|
||||
} else if phase != .processing, isEnabled || phase == .idleUnavailable {
|
||||
// Short press → existing tap-toggle dictation.
|
||||
guard phase != .recording else {
|
||||
// If somehow recording without arming, end via toggle.
|
||||
onToggle()
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}
|
||||
},
|
||||
perform: {
|
||||
guard phase != .processing else { return }
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
longPressArmed = true
|
||||
onClipboardLongPressBegan?()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// While recording/processing, only tap-to-toggle — never treat finger-up
|
||||
// from the starting long-press as stop (clipboard is explicitly tap-to-stop).
|
||||
switch phase {
|
||||
case .recording, .processing:
|
||||
content.onTapGesture {
|
||||
guard phase != .processing else { return }
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
}
|
||||
case .idleReady, .idleUnavailable, .error:
|
||||
if supportsClipboardLongPress {
|
||||
content.onLongPressGesture(
|
||||
minimumDuration: ClipboardMaterialFilter.longPressDuration,
|
||||
maximumDistance: 120,
|
||||
pressing: { pressing in
|
||||
if pressing {
|
||||
longPressArmed = false
|
||||
return
|
||||
}
|
||||
// Released before / without arming → short press = dictation toggle.
|
||||
// Armed release is ignored here; stop happens on a later tap
|
||||
// once phase becomes `.recording`.
|
||||
if longPressArmed {
|
||||
longPressArmed = false
|
||||
return
|
||||
}
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
},
|
||||
perform: {
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
longPressArmed = true
|
||||
onClipboardLongPressBegan?()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
content.onTapGesture {
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ public struct ThemePalette: Sendable, Equatable {
|
||||
public let dividerStrong: Color
|
||||
|
||||
public let recordRed: Color
|
||||
/// Clipboard-command hold-to-talk recording (distinct from dictation red).
|
||||
public let recordBlue: Color
|
||||
}
|
||||
|
||||
public enum Palette {
|
||||
@@ -70,6 +72,7 @@ public enum Palette {
|
||||
|
||||
// Recording state
|
||||
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
||||
public static let recordBlue = Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
|
||||
|
||||
/// Canonical dark palette — preserves every legacy literal above so
|
||||
/// existing call sites that read `Palette.background` directly keep
|
||||
@@ -92,7 +95,8 @@ public enum Palette {
|
||||
textOnAccent: textOnAccent,
|
||||
divider: divider,
|
||||
dividerStrong: dividerStrong,
|
||||
recordRed: recordRed
|
||||
recordRed: recordRed,
|
||||
recordBlue: recordBlue
|
||||
)
|
||||
|
||||
/// Light palette — warm gray backgrounds for daytime use.
|
||||
@@ -113,7 +117,8 @@ public enum Palette {
|
||||
textOnAccent: Color.white,
|
||||
divider: Color.black.opacity(0.06),
|
||||
dividerStrong: Color.black.opacity(0.10),
|
||||
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
||||
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
|
||||
recordBlue: Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,53 @@ public enum FlowHandoffPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
/// Proactive keyboard-open PiP arm. Returns false when the host is already
|
||||
/// ready, warming, busy, or a recent startflow is still in cooldown —
|
||||
/// so Voice-tab / appear noise cannot re-jump to the app.
|
||||
///
|
||||
/// `heartbeatStaleness`: seconds since last host heartbeat (`nil` = never).
|
||||
/// A lingering `sessionActive` after force-quit must still allow arm once the
|
||||
/// heartbeat is clearly gone — without treating brief switcher flaps as death.
|
||||
public static let proactiveUnreachableArmGrace: TimeInterval = 8
|
||||
|
||||
public static func shouldProactivePiPArm(
|
||||
hostReady: Bool,
|
||||
snapshotReason: FlowReadySnapshot.Reason?,
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool,
|
||||
inCooldown: Bool,
|
||||
heartbeatStaleness: TimeInterval? = nil
|
||||
) -> Bool {
|
||||
guard !hostReady, !inCooldown else { return false }
|
||||
if let snapshotReason {
|
||||
switch snapshotReason {
|
||||
case .recording, .processing, .awaitingDelivery, .starting,
|
||||
.waitingForAudioProof, .audioEngineNotLive:
|
||||
return false
|
||||
case .ready, .noSession, .permissionMissing, .appGroupUnavailable,
|
||||
.hostLost, .error:
|
||||
break
|
||||
}
|
||||
}
|
||||
// Force-quit leaves sessionActive=true until the 60s zombie window.
|
||||
// If heartbeat has been gone long enough, arm once (cooldown still applies).
|
||||
if sessionActive,
|
||||
!hostReachable,
|
||||
!hostStale,
|
||||
let staleness = heartbeatStaleness,
|
||||
staleness >= proactiveUnreachableArmGrace {
|
||||
return true
|
||||
}
|
||||
return shouldOpenHostColdStart(
|
||||
sessionActive: sessionActive,
|
||||
hostReachable: hostReachable,
|
||||
hostStale: hostStale,
|
||||
withinReadyGrace: withinReadyGrace
|
||||
)
|
||||
}
|
||||
|
||||
/// Mic-press routing shared by the keyboard coordinator and unit tests.
|
||||
public static func micPressAction(
|
||||
availability: MicVoiceAvailability,
|
||||
|
||||
@@ -22,6 +22,7 @@ public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Send
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable Rime schema `name:` (not UI copy). Keep Chinese so redeploy fingerprints stay stable.
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .fullPinyin: return "全拼"
|
||||
@@ -29,6 +30,15 @@ public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Send
|
||||
case .sogouDoublePinyin: return "搜狗双拼"
|
||||
}
|
||||
}
|
||||
|
||||
/// Localizable UI label key (`AppL10n` / `SharedL10n`).
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .fullPinyin: return "typing.schema.fullPinyin"
|
||||
case .microsoftDoublePinyin: return "typing.schema.microsoftDoublePinyin"
|
||||
case .sogouDoublePinyin: return "typing.schema.sogouDoublePinyin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum PinyinFuzzyPair: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
|
||||
@@ -1,96 +1,46 @@
|
||||
// ClipboardCommandEligibility.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Tracks clipboard open-window eligibility (30s from first sighting of a
|
||||
// pasteboard change) for opportunity-read UI. Pure timing logic — no UIKit.
|
||||
// User-visible failure reasons when long-press clipboard command cannot start.
|
||||
// (30s eligibility window and continuous-rewrite sessions were removed.)
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ClipboardCommandEligibility: Equatable, Sendable {
|
||||
public var changeCount: Int
|
||||
public var snapshot: String
|
||||
public var startedAt: TimeInterval
|
||||
/// Why a clipboard-command long-press did not start recording.
|
||||
public enum ClipboardCommandFailure: Equatable, Sendable {
|
||||
case pasteDenied
|
||||
case secureField
|
||||
case noFullAccess
|
||||
/// Host never confirmed capture (double-start / mic not ready / timeout).
|
||||
case prepareFailed
|
||||
case material(ClipboardMaterialFilter.Rejection)
|
||||
|
||||
public init(changeCount: Int, snapshot: String, startedAt: TimeInterval = Date().timeIntervalSince1970) {
|
||||
self.changeCount = changeCount
|
||||
self.snapshot = snapshot
|
||||
self.startedAt = startedAt
|
||||
}
|
||||
|
||||
public func isOpen(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
|
||||
now - startedAt <= ClipboardMaterialFilter.eligibilityDuration
|
||||
}
|
||||
|
||||
public func remaining(at now: TimeInterval = Date().timeIntervalSince1970) -> TimeInterval {
|
||||
max(0, ClipboardMaterialFilter.eligibilityDuration - (now - startedAt))
|
||||
}
|
||||
}
|
||||
|
||||
public enum ClipboardCommandEligibilityTracker: Sendable {
|
||||
/// Update eligibility from an opportunity-read sample.
|
||||
/// - Parameters:
|
||||
/// - changeCount: `UIPasteboard.general.changeCount`
|
||||
/// - rawText: pasteboard string (may be nil)
|
||||
/// - previous: last known eligibility
|
||||
/// - now: clock
|
||||
public static func refresh(
|
||||
changeCount: Int,
|
||||
rawText: String?,
|
||||
previous: ClipboardCommandEligibility?,
|
||||
now: TimeInterval = Date().timeIntervalSince1970
|
||||
) -> ClipboardCommandEligibility? {
|
||||
guard let rawText else { return nil }
|
||||
|
||||
if let previous, previous.changeCount == changeCount {
|
||||
return previous.isOpen(at: now) ? previous : nil
|
||||
}
|
||||
|
||||
switch ClipboardMaterialFilter.evaluate(rawText) {
|
||||
case .eligible(let snapshot):
|
||||
return ClipboardCommandEligibility(
|
||||
changeCount: changeCount,
|
||||
snapshot: snapshot,
|
||||
startedAt: now
|
||||
)
|
||||
case .rejected:
|
||||
return nil
|
||||
/// Localization key under the keyboard extension `Keyboard.strings` table.
|
||||
public var localizationKey: String {
|
||||
switch self {
|
||||
case .pasteDenied:
|
||||
return "keyboard.clipboard.reject.pasteDenied"
|
||||
case .secureField:
|
||||
return "keyboard.clipboard.reject.secureField"
|
||||
case .noFullAccess:
|
||||
return "keyboard.clipboard.reject.noFullAccess"
|
||||
case .prepareFailed:
|
||||
return "keyboard.clipboard.reject.prepareFailed"
|
||||
case .material(let rejection):
|
||||
switch rejection {
|
||||
case .empty:
|
||||
return "keyboard.clipboard.reject.empty"
|
||||
case .phoneOrNumeric:
|
||||
return "keyboard.clipboard.reject.phoneOrNumeric"
|
||||
case .emojiOrSymbolOnly:
|
||||
return "keyboard.clipboard.reject.emojiOrSymbolOnly"
|
||||
case .verificationCode:
|
||||
return "keyboard.clipboard.reject.verificationCode"
|
||||
case .tooShort:
|
||||
return "keyboard.clipboard.reject.tooShort"
|
||||
case .repetitiveSpam:
|
||||
return "keyboard.clipboard.reject.repetitiveSpam"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory clipboard-command task session (plan §8 layer B). Owned by the keyboard.
|
||||
public struct ClipboardCommandTaskSession: Equatable, Sendable {
|
||||
public var snapshot: String
|
||||
public var previousOutput: String?
|
||||
public var lastInsertedText: String?
|
||||
public var expiresAt: TimeInterval
|
||||
public var fieldFingerprint: String?
|
||||
|
||||
public init(
|
||||
snapshot: String,
|
||||
previousOutput: String? = nil,
|
||||
lastInsertedText: String? = nil,
|
||||
expiresAt: TimeInterval,
|
||||
fieldFingerprint: String? = nil
|
||||
) {
|
||||
self.snapshot = snapshot
|
||||
self.previousOutput = previousOutput
|
||||
self.lastInsertedText = lastInsertedText
|
||||
self.expiresAt = expiresAt
|
||||
self.fieldFingerprint = fieldFingerprint
|
||||
}
|
||||
|
||||
public func isActive(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
|
||||
now <= expiresAt
|
||||
}
|
||||
|
||||
public mutating func refreshExpiry(at now: TimeInterval = Date().timeIntervalSince1970) {
|
||||
expiresAt = now + ClipboardMaterialFilter.sessionDuration
|
||||
}
|
||||
|
||||
public mutating func noteSuccessfulInsert(_ text: String, at now: TimeInterval = Date().timeIntervalSince1970) {
|
||||
lastInsertedText = text
|
||||
previousOutput = text
|
||||
refreshExpiry(at: now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// ClipboardCommandResume.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Sticky App Group flags so the system「允许粘贴」alert can dismiss / recreate
|
||||
// the keyboard extension without losing "stay on voice + clipboard chrome".
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ClipboardCommandResume: Sendable {
|
||||
private enum Key {
|
||||
/// Prefer voice surface on the next keyboard presentation.
|
||||
static let preferVoice = "clipboardCommand.preferVoice.v1"
|
||||
/// Frozen snapshot captured before / during paste alert (optional).
|
||||
static let snapshot = "clipboardCommand.pendingSnapshot.v1"
|
||||
/// Wall time when prefer-voice was marked (drop stale flags).
|
||||
static let markedAt = "clipboardCommand.preferVoiceAt.v1"
|
||||
/// Utterance id already sent as startRecording — blocks duplicate starts.
|
||||
static let startIssuedUtterance = "clipboardCommand.startIssuedUtterance.v1"
|
||||
}
|
||||
|
||||
/// How long a sticky prefer-voice / snapshot remains valid.
|
||||
public static let stickyTTL: TimeInterval = 120
|
||||
/// Max time to wait in「准备录音…」for host confirm before failing closed.
|
||||
public static let preparingTimeout: TimeInterval = 6
|
||||
|
||||
public static func markPreferVoice(defaults: UserDefaults? = nil) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||
store.set(true, forKey: Key.preferVoice)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.markedAt)
|
||||
// Paste alert often jetsams the extension — flush before we block on
|
||||
// UIPasteboard.string so a recreated process still sees prefer-voice.
|
||||
store.synchronize()
|
||||
}
|
||||
|
||||
public static func storeSnapshot(_ text: String, defaults: UserDefaults? = nil) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
store.set(trimmed, forKey: Key.snapshot)
|
||||
store.set(true, forKey: Key.preferVoice)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.markedAt)
|
||||
store.synchronize()
|
||||
}
|
||||
|
||||
public static func markStartIssued(_ utteranceId: UUID, defaults: UserDefaults? = nil) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||
store.set(utteranceId.uuidString, forKey: Key.startIssuedUtterance)
|
||||
store.set(true, forKey: Key.preferVoice)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.markedAt)
|
||||
store.synchronize()
|
||||
}
|
||||
|
||||
public static func startIssuedUtteranceId(defaults: UserDefaults? = nil) -> UUID? {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
||||
pruneIfStale(store: store)
|
||||
guard let raw = store.string(forKey: Key.startIssuedUtterance) else { return nil }
|
||||
return UUID(uuidString: raw)
|
||||
}
|
||||
|
||||
public static func hasStartIssued(defaults: UserDefaults? = nil) -> Bool {
|
||||
startIssuedUtteranceId(defaults: defaults) != nil
|
||||
}
|
||||
|
||||
public static func clear(defaults: UserDefaults? = nil) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||
store.removeObject(forKey: Key.preferVoice)
|
||||
store.removeObject(forKey: Key.snapshot)
|
||||
store.removeObject(forKey: Key.markedAt)
|
||||
store.removeObject(forKey: Key.startIssuedUtterance)
|
||||
store.synchronize()
|
||||
}
|
||||
|
||||
public static func shouldPreferVoice(defaults: UserDefaults? = nil) -> Bool {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return false }
|
||||
pruneIfStale(store: store)
|
||||
return store.bool(forKey: Key.preferVoice)
|
||||
}
|
||||
|
||||
public static func pendingSnapshot(defaults: UserDefaults? = nil) -> String? {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
||||
pruneIfStale(store: store)
|
||||
guard store.bool(forKey: Key.preferVoice) else { return nil }
|
||||
return store.string(forKey: Key.snapshot)
|
||||
}
|
||||
|
||||
private static func pruneIfStale(store: UserDefaults) {
|
||||
let markedAt = store.double(forKey: Key.markedAt)
|
||||
guard markedAt > 0 else {
|
||||
// Legacy / incomplete write — drop.
|
||||
if store.object(forKey: Key.preferVoice) != nil
|
||||
|| store.object(forKey: Key.startIssuedUtterance) != nil {
|
||||
store.removeObject(forKey: Key.preferVoice)
|
||||
store.removeObject(forKey: Key.snapshot)
|
||||
store.removeObject(forKey: Key.startIssuedUtterance)
|
||||
store.synchronize()
|
||||
}
|
||||
return
|
||||
}
|
||||
if Date().timeIntervalSince1970 - markedAt > stickyTTL {
|
||||
store.removeObject(forKey: Key.preferVoice)
|
||||
store.removeObject(forKey: Key.snapshot)
|
||||
store.removeObject(forKey: Key.markedAt)
|
||||
store.removeObject(forKey: Key.startIssuedUtterance)
|
||||
store.synchronize()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ public enum ClipboardMaterialFilter: Sendable {
|
||||
|
||||
public static let minimumLength = 15
|
||||
public static let maxSnapshotLength = 3_000
|
||||
public static let eligibilityDuration: TimeInterval = 30
|
||||
public static let sessionDuration: TimeInterval = 30
|
||||
public static let longPressDuration: TimeInterval = 0.45
|
||||
/// After the host confirms real capture, keep recording at least this long
|
||||
/// before honoring finger-up (avoids near-silent cold-start tails).
|
||||
/// before honoring an explicit stop tap (avoids near-silent cold-start tails).
|
||||
public static let minimumRecordingAfterHostConfirm: TimeInterval = 0.70
|
||||
/// How long a clipboard-command failure tip stays above the mic.
|
||||
public static let failureHintDuration: TimeInterval = 2.5
|
||||
|
||||
public enum Rejection: String, Equatable, Sendable {
|
||||
case empty
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// ClipboardPreparingPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure decisions for clipboard「准备录音…」so paste-alert restore / double-start
|
||||
// / host-failure recovery stay hermetic and regression-tested.
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Restore after paste-alert / cold-start recreate
|
||||
|
||||
public enum ClipboardRestoreAction: Equatable, Sendable {
|
||||
/// Mid-flight claim exists — reattach preparing/recording, never pressBegan again.
|
||||
case awaitExistingStart
|
||||
/// Sticky voice + snapshot only (e.g. after cold-start). Force voice; do **not** auto-record.
|
||||
case preferVoiceOnly
|
||||
/// Already in a live clipboard phase — only refresh UI / recover.
|
||||
case refreshOnly
|
||||
}
|
||||
|
||||
/// Whether a clipboard long-press may claim + start, or must warm the host first.
|
||||
public enum ClipboardHostGateAction: Equatable, Sendable {
|
||||
case startRecordingNow
|
||||
case openHostColdStart
|
||||
case waitForHost
|
||||
case ignore
|
||||
}
|
||||
|
||||
/// Mic chrome while a clipboard round is live.
|
||||
public enum ClipboardMicChrome: Equatable, Sendable {
|
||||
/// Grey / spinner / not tappable — waiting for host capture confirm.
|
||||
case preparingDisabled
|
||||
/// Blue recording chrome + side captions.
|
||||
case recordingBlue
|
||||
/// Not a clipboard recording chrome state.
|
||||
case none
|
||||
}
|
||||
|
||||
public enum ClipboardPreparingPolicy: Sendable {
|
||||
|
||||
public static func restoreAction(
|
||||
hasStartIssued: Bool,
|
||||
phase: ClipboardPreparingPhase
|
||||
) -> ClipboardRestoreAction {
|
||||
switch phase {
|
||||
case .idle, .denied, .error:
|
||||
// Cold-start return has snapshot/preferVoice but no startIssued → voice only.
|
||||
return hasStartIssued ? .awaitExistingStart : .preferVoiceOnly
|
||||
case .requestingPermissions, .recording, .processing:
|
||||
return .refreshOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the shared mic handoff decision onto clipboard (never auto-record after warm-up).
|
||||
public static func hostGateAction(
|
||||
micPressAction: FlowMicPressAction
|
||||
) -> ClipboardHostGateAction {
|
||||
switch micPressAction {
|
||||
case .startRecording:
|
||||
return .startRecordingNow
|
||||
case .openHostColdStart:
|
||||
return .openHostColdStart
|
||||
case .waitForHostReady:
|
||||
// Clipboard does not set recordWhenHostReady — user long-presses again.
|
||||
return .waitForHost
|
||||
case .ignore:
|
||||
return .ignore
|
||||
}
|
||||
}
|
||||
|
||||
public static func micChrome(
|
||||
isClipboardUtterance: Bool,
|
||||
phase: ClipboardPreparingPhase,
|
||||
awaitingHostConfirm: Bool
|
||||
) -> ClipboardMicChrome {
|
||||
guard isClipboardUtterance else { return .none }
|
||||
switch phase {
|
||||
case .requestingPermissions:
|
||||
return .preparingDisabled
|
||||
case .recording:
|
||||
return awaitingHostConfirm ? .preparingDisabled : .recordingBlue
|
||||
case .idle, .denied, .error, .processing:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stop while preparing
|
||||
|
||||
public static func stopWhilePreparing(
|
||||
awaitingHostConfirm: Bool
|
||||
) -> ClipboardPreparingStopAction {
|
||||
awaitingHostConfirm ? .abortPreparing : .requestStop
|
||||
}
|
||||
|
||||
// MARK: - Host moved on while preparing
|
||||
|
||||
public static func recoverWhilePreparing(
|
||||
awaitingHostConfirm: Bool,
|
||||
currentUtteranceId: UUID?,
|
||||
hostBusyUtteranceId: UUID?,
|
||||
hostReason: ClipboardHostBusyReason?,
|
||||
hasTerminalFailureForCurrent: Bool
|
||||
) -> ClipboardPreparingRecoverAction {
|
||||
guard awaitingHostConfirm else { return .none }
|
||||
|
||||
if hasTerminalFailureForCurrent {
|
||||
return .abortForHostFailure
|
||||
}
|
||||
|
||||
guard let hostReason, let busyId = hostBusyUtteranceId else {
|
||||
return .none
|
||||
}
|
||||
|
||||
switch hostReason {
|
||||
case .recording:
|
||||
if busyId == currentUtteranceId {
|
||||
return .confirmRecording
|
||||
}
|
||||
return .adoptSibling(busyId)
|
||||
case .processing:
|
||||
if busyId == currentUtteranceId {
|
||||
return .wait
|
||||
}
|
||||
return .adoptSibling(busyId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ensure at most one startRecording
|
||||
|
||||
public static func ensureStartAction(
|
||||
issuedUtteranceId: UUID?,
|
||||
isFlowRecording: Bool,
|
||||
currentUtteranceId: UUID?,
|
||||
hostBusyUtteranceId: UUID?,
|
||||
hostReason: ClipboardHostBusyReason?,
|
||||
hostReadyWithSession: Bool
|
||||
) -> ClipboardEnsureStartAction {
|
||||
guard let issued = issuedUtteranceId else { return .none }
|
||||
|
||||
if let busyId = hostBusyUtteranceId, let hostReason {
|
||||
switch hostReason {
|
||||
case .recording, .processing:
|
||||
return .adoptBusy(busyId, hostReason)
|
||||
}
|
||||
}
|
||||
|
||||
if isFlowRecording, currentUtteranceId == issued {
|
||||
return .alreadyInFlight
|
||||
}
|
||||
|
||||
if hostReadyWithSession {
|
||||
return .writeStart(issued)
|
||||
}
|
||||
|
||||
return .waitForHost
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard phase subset relevant to clipboard prepare/restore.
|
||||
public enum ClipboardPreparingPhase: Equatable, Sendable {
|
||||
case idle
|
||||
case denied
|
||||
case error
|
||||
case requestingPermissions
|
||||
case recording
|
||||
case processing
|
||||
}
|
||||
|
||||
public enum ClipboardPreparingStopAction: Equatable, Sendable {
|
||||
case abortPreparing
|
||||
case requestStop
|
||||
}
|
||||
|
||||
public enum ClipboardHostBusyReason: Equatable, Sendable {
|
||||
case recording
|
||||
case processing
|
||||
}
|
||||
|
||||
public enum ClipboardPreparingRecoverAction: Equatable, Sendable {
|
||||
case none
|
||||
case wait
|
||||
case confirmRecording
|
||||
case adoptSibling(UUID)
|
||||
case abortForHostFailure
|
||||
}
|
||||
|
||||
public enum ClipboardEnsureStartAction: Equatable, Sendable {
|
||||
case none
|
||||
case alreadyInFlight
|
||||
case adoptBusy(UUID, ClipboardHostBusyReason)
|
||||
case writeStart(UUID)
|
||||
case waitForHost
|
||||
}
|
||||
@@ -547,6 +547,20 @@ public enum FlowSessionBridge {
|
||||
setPendingHostBundleId(nil, defaults: defaults)
|
||||
}
|
||||
|
||||
/// True when a recent keyboard `startflow` arm should not be repeated.
|
||||
public static func isPiPArmInCooldown(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let last = store.double(forKey: FlowSessionKeys.lastPiPArmAttemptAt)
|
||||
guard last > 0 else { return false }
|
||||
return Date().timeIntervalSince1970 - last < FlowSessionKeys.pipArmCooldown
|
||||
}
|
||||
|
||||
public static func markPiPArmAttempt(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.lastPiPArmAttemptAt)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Session validity (keyboard)
|
||||
|
||||
/// True while the persistent PiP session contract is active.
|
||||
|
||||
@@ -33,6 +33,10 @@ public enum FlowSessionKeys {
|
||||
public static let audioLevels = "flow.audioLevels"
|
||||
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||
/// Wall-clock of the last keyboard→`startflow` PiP arm attempt (debounce re-jumps).
|
||||
public static let lastPiPArmAttemptAt = "flow.lastPiPArmAttemptAt.v1"
|
||||
/// Minimum gap between proactive / clipboard startflow jumps.
|
||||
public static let pipArmCooldown: TimeInterval = 45
|
||||
/// Wall-clock timestamp of the last utterance completion or session start.
|
||||
public static let lastActivityAt = "flow.lastActivityAt"
|
||||
/// One-shot token rotated by every host-process launch. State written by
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// KeyboardOpenSurfacePolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure open-surface decision used by the keyboard extension. Extracted so
|
||||
// paste-alert sticky resume can be unit-tested without UIKit.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum KeyboardOpenSurfacePolicy: Sendable {
|
||||
/// Surface to show on the first frame of a keyboard presentation.
|
||||
public static func resolve(
|
||||
locksTypingSurface: Bool,
|
||||
clipboardCommandActive: Bool,
|
||||
stickyPreferVoice: Bool,
|
||||
preferred: KeyboardState.Surface
|
||||
) -> KeyboardState.Surface {
|
||||
if locksTypingSurface || clipboardCommandActive || stickyPreferVoice {
|
||||
return .voice
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
@@ -130,10 +130,15 @@ public final class KeyboardState: ObservableObject {
|
||||
/// `true` while a cursor-drag pad is being pressed — drives the hint
|
||||
/// shown above the mic.
|
||||
@Published public var cursorDragActive: Bool = false
|
||||
/// Opportunity-read: clipboard text is eligible for long-press command mode.
|
||||
/// Idle affordance: pasteboard reports `hasStrings` (metadata only).
|
||||
@Published public var clipboardCommandEligible: Bool = false
|
||||
/// Active clipboard-command task (continuous rewrite window).
|
||||
@Published public var clipboardCommandSessionActive: Bool = false
|
||||
/// True while a clipboard-command utterance is in flight (preparing or recording).
|
||||
@Published public var clipboardCommandUtteranceActive: Bool = false
|
||||
/// True only while a clipboard-command utterance is in `.recording`
|
||||
/// (after host confirm) — drives blue mic chrome + side hints.
|
||||
@Published public var clipboardCommandRecording: Bool = false
|
||||
/// Transient tip after a failed clipboard long-press (auto-clears).
|
||||
@Published public var clipboardFailureHint: String? = nil
|
||||
/// Whether translate-and-polish is armed for the current engine.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
@@ -210,7 +215,6 @@ public final class KeyboardState: ObservableObject {
|
||||
public var endRecording: () -> Void = {}
|
||||
public var tapMic: () -> Void = {}
|
||||
public var beginClipboardCommand: () -> Void = {}
|
||||
public var endClipboardCommand: () -> Void = {}
|
||||
public var refreshClipboardEligibility: () -> Void = {}
|
||||
public var openSettings: () -> Void = {}
|
||||
public var startFlowSession: () -> Void = {}
|
||||
|
||||
@@ -30,29 +30,22 @@ final class ClipboardCommandPromptComposerTests: XCTestCase {
|
||||
XCTAssertFalse(system.contains("不是向你提出的问题或命令"))
|
||||
}
|
||||
|
||||
func testEligibilityTrackerKeepsSameChangeCountWindow() {
|
||||
let first = ClipboardCommandEligibilityTracker.refresh(
|
||||
changeCount: 3,
|
||||
rawText: "周末有空一起吃个饭吗?我想聊下项目进度。",
|
||||
previous: nil,
|
||||
now: 1_000
|
||||
func testFailureLocalizationKeysAreStable() {
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.pasteDenied.localizationKey,
|
||||
"keyboard.clipboard.reject.pasteDenied"
|
||||
)
|
||||
XCTAssertNotNil(first)
|
||||
|
||||
let same = ClipboardCommandEligibilityTracker.refresh(
|
||||
changeCount: 3,
|
||||
rawText: "周末有空一起吃个饭吗?我想聊下项目进度。",
|
||||
previous: first,
|
||||
now: 1_010
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.material(.tooShort).localizationKey,
|
||||
"keyboard.clipboard.reject.tooShort"
|
||||
)
|
||||
XCTAssertEqual(same?.startedAt, first?.startedAt)
|
||||
|
||||
let expired = ClipboardCommandEligibilityTracker.refresh(
|
||||
changeCount: 3,
|
||||
rawText: "周末有空一起吃个饭吗?我想聊下项目进度。",
|
||||
previous: first,
|
||||
now: 1_040
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.secureField.localizationKey,
|
||||
"keyboard.clipboard.reject.secureField"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.prepareFailed.localizationKey,
|
||||
"keyboard.clipboard.reject.prepareFailed"
|
||||
)
|
||||
XCTAssertNil(expired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// ClipboardCommandResumeTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class ClipboardCommandResumeTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var suiteName: String!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "ClipboardCommandResumeTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
if let suiteName {
|
||||
defaults?.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
defaults = nil
|
||||
suiteName = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMarkPreferVoiceSurvivesUntilCleared() {
|
||||
XCTAssertFalse(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
ClipboardCommandResume.markPreferVoice(defaults: defaults)
|
||||
XCTAssertTrue(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
ClipboardCommandResume.clear(defaults: defaults)
|
||||
XCTAssertFalse(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
}
|
||||
|
||||
func testStoreSnapshotRoundTrip() {
|
||||
ClipboardCommandResume.storeSnapshot("周末有空一起吃饭吗?", defaults: defaults)
|
||||
XCTAssertTrue(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.pendingSnapshot(defaults: defaults),
|
||||
"周末有空一起吃饭吗?"
|
||||
)
|
||||
}
|
||||
|
||||
/// Simulates extension jetsam: writer process flushes, reader process is new.
|
||||
func testStickySurvivesNewUserDefaultsInstanceAfterSynchronize() {
|
||||
let text = "是AI语音输入法,更是好输入法。It is an AI voice input method."
|
||||
ClipboardCommandResume.markPreferVoice(defaults: defaults)
|
||||
ClipboardCommandResume.storeSnapshot(text, defaults: defaults)
|
||||
|
||||
// New UserDefaults handle on the same suite ≈ new extension process.
|
||||
let reopened = UserDefaults(suiteName: suiteName!)
|
||||
XCTAssertNotNil(reopened)
|
||||
guard let reopened else { return }
|
||||
|
||||
XCTAssertTrue(
|
||||
ClipboardCommandResume.shouldPreferVoice(defaults: reopened),
|
||||
"Recreated process must still prefer voice after paste-alert jetsam"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.pendingSnapshot(defaults: reopened),
|
||||
text
|
||||
)
|
||||
|
||||
// And the open-surface policy must then force voice over default typing.
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: ClipboardCommandResume.shouldPreferVoice(defaults: reopened),
|
||||
preferred: .typing
|
||||
),
|
||||
.voice
|
||||
)
|
||||
}
|
||||
|
||||
func testOpenSurfacePolicyForcesVoiceWhenStickyEvenIfDefaultTyping() {
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: true,
|
||||
preferred: .typing
|
||||
)
|
||||
XCTAssertEqual(resolved, .voice)
|
||||
}
|
||||
|
||||
func testOpenSurfacePolicyHonorsDefaultTypingWhenNoSticky() {
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: false,
|
||||
preferred: .typing
|
||||
)
|
||||
XCTAssertEqual(resolved, .typing)
|
||||
}
|
||||
|
||||
func testOpenSurfacePolicyClipboardActiveBeatsTypingDefault() {
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: true,
|
||||
stickyPreferVoice: false,
|
||||
preferred: .typing
|
||||
)
|
||||
XCTAssertEqual(resolved, .voice)
|
||||
}
|
||||
|
||||
func testPasteAlertReopenDecisionMatrix() {
|
||||
// Exact bug from device: default typing + sticky after Allow Paste.
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: true,
|
||||
preferred: .typing
|
||||
),
|
||||
.voice,
|
||||
"Allow Paste reopen must not land on default Chinese typing grid"
|
||||
)
|
||||
|
||||
// Recording lock also forces voice.
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: true,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: false,
|
||||
preferred: .typing
|
||||
),
|
||||
.voice
|
||||
)
|
||||
}
|
||||
|
||||
func testMarkStartIssuedBlocksDuplicateRoundClaim() {
|
||||
let id = UUID()
|
||||
XCTAssertFalse(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
ClipboardCommandResume.markStartIssued(id, defaults: defaults)
|
||||
XCTAssertTrue(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
XCTAssertEqual(ClipboardCommandResume.startIssuedUtteranceId(defaults: defaults), id)
|
||||
XCTAssertTrue(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
|
||||
let reopened = UserDefaults(suiteName: suiteName!)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.startIssuedUtteranceId(defaults: reopened),
|
||||
id,
|
||||
"Recreated extension must see the already-issued start and not send another"
|
||||
)
|
||||
|
||||
ClipboardCommandResume.clear(defaults: defaults)
|
||||
XCTAssertFalse(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
XCTAssertNil(ClipboardCommandResume.startIssuedUtteranceId(defaults: reopened))
|
||||
}
|
||||
|
||||
func testClaimThenClearSurvivesTwentyAlternatingRounds() {
|
||||
for round in 1...20 {
|
||||
let id = UUID()
|
||||
ClipboardCommandResume.storeSnapshot("材料-\(round)", defaults: defaults)
|
||||
ClipboardCommandResume.markStartIssued(id, defaults: defaults)
|
||||
|
||||
let reader = UserDefaults(suiteName: suiteName!)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.startIssuedUtteranceId(defaults: reader),
|
||||
id,
|
||||
"round \(round) claim must survive new defaults handle"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: ClipboardCommandResume.shouldPreferVoice(defaults: reader),
|
||||
preferred: .typing
|
||||
),
|
||||
.voice,
|
||||
"round \(round) must stay on voice after paste-alert recreate"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: ClipboardCommandResume.hasStartIssued(defaults: reader),
|
||||
phase: .idle
|
||||
),
|
||||
.awaitExistingStart,
|
||||
"round \(round) must not pressBegan again"
|
||||
)
|
||||
|
||||
ClipboardCommandResume.clear(defaults: defaults)
|
||||
XCTAssertFalse(
|
||||
ClipboardCommandResume.hasStartIssued(defaults: reader),
|
||||
"round \(round) clear must drop claim for next independent long-press"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testPreparingTimeoutConstantIsPositive() {
|
||||
XCTAssertGreaterThan(ClipboardCommandResume.preparingTimeout, 1)
|
||||
XCTAssertLessThanOrEqual(ClipboardCommandResume.preparingTimeout, 15)
|
||||
}
|
||||
|
||||
func testColdStartStickyRestoreIsPreferVoiceOnlyWithoutClaim() {
|
||||
ClipboardCommandResume.storeSnapshot("冻结材料", defaults: defaults)
|
||||
XCTAssertFalse(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: false,
|
||||
phase: .idle
|
||||
),
|
||||
.preferVoiceOnly
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: ClipboardCommandResume.shouldPreferVoice(defaults: defaults),
|
||||
preferred: .typing
|
||||
),
|
||||
.voice,
|
||||
"Default typing keyboard must still open voice after cold-start sticky"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,6 @@ final class ClipboardMaterialFilterTests: XCTestCase {
|
||||
XCTAssertEqual(ClipboardMaterialFilter.maxSnapshotLength, 3_000)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.longPressDuration, 0.45, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.minimumRecordingAfterHostConfirm, 0.70, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.eligibilityDuration, 30, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.sessionDuration, 30, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.failureHintDuration, 2.5, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// ClipboardPreparingPolicyTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Decision-matrix coverage for clipboard prepare stuck / double-start bugs.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class ClipboardPreparingPolicyTests: XCTestCase {
|
||||
|
||||
// MARK: - Restore (paste-alert recreate)
|
||||
|
||||
func testRestoreAwaitExistingWhenStartAlreadyIssued() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .idle
|
||||
),
|
||||
.awaitExistingStart
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .error
|
||||
),
|
||||
.awaitExistingStart
|
||||
)
|
||||
}
|
||||
|
||||
func testRestorePreferVoiceOnlyWhenNoClaim() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: false,
|
||||
phase: .idle
|
||||
),
|
||||
.preferVoiceOnly,
|
||||
"Cold-start return must not auto-start recording"
|
||||
)
|
||||
}
|
||||
|
||||
func testRestoreRefreshOnlyWhenAlreadyLive() {
|
||||
for phase: ClipboardPreparingPhase in [
|
||||
.requestingPermissions, .recording, .processing
|
||||
] {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: phase
|
||||
),
|
||||
.refreshOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact device bug: Allow Paste recreates keyboard while claim exists.
|
||||
func testPasteAlertReopenMustNotPreferVoiceOnlyWhenClaimed() {
|
||||
let action = ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .idle
|
||||
)
|
||||
XCTAssertNotEqual(action, .preferVoiceOnly)
|
||||
XCTAssertEqual(action, .awaitExistingStart)
|
||||
}
|
||||
|
||||
func testHostGateNeverAutoRecordsAfterWarmup() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .startRecording),
|
||||
.startRecordingNow
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .openHostColdStart),
|
||||
.openHostColdStart
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(
|
||||
micPressAction: .waitForHostReady(recordWhenReady: true)
|
||||
),
|
||||
.waitForHost,
|
||||
"Clipboard must ignore recordWhenReady and require a second long-press"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .ignore),
|
||||
.ignore
|
||||
)
|
||||
}
|
||||
|
||||
func testMicChromeGreyWhilePreparingBlueOnlyAfterConfirm() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: true,
|
||||
phase: .requestingPermissions,
|
||||
awaitingHostConfirm: true
|
||||
),
|
||||
.preparingDisabled
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: true,
|
||||
phase: .recording,
|
||||
awaitingHostConfirm: false
|
||||
),
|
||||
.recordingBlue
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: false,
|
||||
phase: .recording,
|
||||
awaitingHostConfirm: false
|
||||
),
|
||||
.none
|
||||
)
|
||||
// Paste-acquire (idle + active flag) must not go blue.
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: true,
|
||||
phase: .idle,
|
||||
awaitingHostConfirm: false
|
||||
),
|
||||
.none
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Stop while preparing
|
||||
|
||||
func testTapDuringPreparingAbortsInsteadOfWaitingForever() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.stopWhilePreparing(awaitingHostConfirm: true),
|
||||
.abortPreparing
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.stopWhilePreparing(awaitingHostConfirm: false),
|
||||
.requestStop
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Recover while preparing
|
||||
|
||||
func testRecoverConfirmsMatchingRecordingUtterance() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: id,
|
||||
hostReason: .recording,
|
||||
hasTerminalFailureForCurrent: false
|
||||
),
|
||||
.confirmRecording
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoverAdoptsSiblingOnDoubleStart() {
|
||||
let ours = UUID()
|
||||
let sibling = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: ours,
|
||||
hostBusyUtteranceId: sibling,
|
||||
hostReason: .recording,
|
||||
hasTerminalFailureForCurrent: false
|
||||
),
|
||||
.adoptSibling(sibling)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: ours,
|
||||
hostBusyUtteranceId: sibling,
|
||||
hostReason: .processing,
|
||||
hasTerminalFailureForCurrent: false
|
||||
),
|
||||
.adoptSibling(sibling)
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoverAbortsOnHostTerminalFailure() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hasTerminalFailureForCurrent: true
|
||||
),
|
||||
.abortForHostFailure
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoverDoesNothingWhenNotAwaiting() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: false,
|
||||
currentUtteranceId: UUID(),
|
||||
hostBusyUtteranceId: UUID(),
|
||||
hostReason: .recording,
|
||||
hasTerminalFailureForCurrent: true
|
||||
),
|
||||
.none
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Ensure single startRecording
|
||||
|
||||
func testEnsureStartSkipsWhenAlreadyInFlight() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: id,
|
||||
isFlowRecording: true,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
),
|
||||
.alreadyInFlight
|
||||
)
|
||||
}
|
||||
|
||||
func testEnsureStartAdoptsHostBusyInsteadOfSecondWrite() {
|
||||
let issued = UUID()
|
||||
let busy = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: issued,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: issued,
|
||||
hostBusyUtteranceId: busy,
|
||||
hostReason: .recording,
|
||||
hostReadyWithSession: true
|
||||
),
|
||||
.adoptBusy(busy, .recording)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnsureStartWritesOnceWhenHostReady() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: id,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
),
|
||||
.writeStart(id)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnsureStartWaitsWhenHostNotReady() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: id,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: false
|
||||
),
|
||||
.waitForHost
|
||||
)
|
||||
}
|
||||
|
||||
/// Simulate paste-alert reopen loop: claim → restore → ensure must not
|
||||
/// produce two writeStart for the same issued id across polls.
|
||||
func testDoubleStartStressMatrixTwentyRounds() {
|
||||
for round in 1...20 {
|
||||
let claim = UUID()
|
||||
// First long-press claims.
|
||||
XCTAssertTrue(claim.uuidString.isEmpty == false)
|
||||
|
||||
// Restore after Allow Paste / cold-start:
|
||||
// - with claim → awaitExistingStart
|
||||
// - without claim → preferVoiceOnly (no auto-record)
|
||||
let restoreClaimed = ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .idle
|
||||
)
|
||||
XCTAssertEqual(restoreClaimed, .awaitExistingStart, "round \(round)")
|
||||
let restoreWarm = ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: false,
|
||||
phase: .idle
|
||||
)
|
||||
XCTAssertEqual(restoreWarm, .preferVoiceOnly, "round \(round)")
|
||||
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .openHostColdStart),
|
||||
.openHostColdStart,
|
||||
"round \(round)"
|
||||
)
|
||||
|
||||
// First ensure writes once.
|
||||
let first = ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: claim,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
)
|
||||
XCTAssertEqual(first, .writeStart(claim), "round \(round)")
|
||||
|
||||
// Second ensure (same process or restore) must not write again.
|
||||
let second = ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: claim,
|
||||
isFlowRecording: true,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
)
|
||||
XCTAssertEqual(second, .alreadyInFlight, "round \(round)")
|
||||
|
||||
// Sibling double-start on host → adopt, never another write.
|
||||
let sibling = UUID()
|
||||
let adopt = ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: claim,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: sibling,
|
||||
hostReason: .recording,
|
||||
hostReadyWithSession: true
|
||||
)
|
||||
XCTAssertEqual(adopt, .adoptBusy(sibling, .recording), "round \(round)")
|
||||
|
||||
// Mic-not-ready failure while preparing → abort.
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hasTerminalFailureForCurrent: true
|
||||
),
|
||||
.abortForHostFailure,
|
||||
"round \(round)"
|
||||
)
|
||||
|
||||
// User tap while preparing cancels.
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.stopWhilePreparing(awaitingHostConfirm: true),
|
||||
.abortPreparing,
|
||||
"round \(round)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,4 +330,113 @@ final class FlowHandoffPolicyTests: XCTestCase {
|
||||
.silence
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Proactive PiP arm (no re-jump)
|
||||
|
||||
func testProactivePiPArmSkippedWhenHostReady() {
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: true,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: true,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testProactivePiPArmSkippedWhileStartingOrCooldown() {
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .starting,
|
||||
sessionActive: true,
|
||||
hostReachable: true,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false
|
||||
),
|
||||
"Warming session must not re-open startflow on Voice tab"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: nil,
|
||||
sessionActive: false,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: true
|
||||
),
|
||||
"Cooldown after a recent arm must block re-jump"
|
||||
)
|
||||
}
|
||||
|
||||
func testProactivePiPArmAllowsForceQuitZombieSessionAfterHeartbeatGrace() {
|
||||
// sessionActive still true, but heartbeat gone > 8s → allow one arm.
|
||||
XCTAssertTrue(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false,
|
||||
heartbeatStaleness: FlowHandoffPolicy.proactiveUnreachableArmGrace
|
||||
)
|
||||
)
|
||||
// Brief switcher flap (< grace) must NOT re-jump.
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false,
|
||||
heartbeatStaleness: 3
|
||||
)
|
||||
)
|
||||
// Cooldown still wins after a recent arm.
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: true,
|
||||
heartbeatStaleness: 30
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testProactivePiPArmAllowedWhenHostTrulyDead() {
|
||||
XCTAssertTrue(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .noSession,
|
||||
sessionActive: false,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPiPArmCooldownRoundTrip() {
|
||||
let suite = "PiPArmCooldown.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
|
||||
XCTAssertFalse(FlowSessionBridge.isPiPArmInCooldown(defaults: defaults))
|
||||
FlowSessionBridge.markPiPArmAttempt(defaults: defaults)
|
||||
XCTAssertTrue(FlowSessionBridge.isPiPArmInCooldown(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cold-start stress on iOS Simulator.
|
||||
#
|
||||
# Usage:
|
||||
# ./Scripts/cold-start-stress.sh [COUNT=100] [DEVICE_NAME=iPhone 17]
|
||||
#
|
||||
# Method: terminate → simctl launch (host cold start that auto-arms PiP).
|
||||
# Keyboard URL wake (osgkeyboard://startflow) is optional once scheme is approved.
|
||||
#
|
||||
# Limitations (documented in summary):
|
||||
# - Simulator VideoCall PiP is typically `unsupported` — not a device signal.
|
||||
# - Keyboard extension process (KVC.init) is not spawned here.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
COUNT="${1:-100}"
|
||||
DEVICE_NAME="${2:-iPhone 17}"
|
||||
BUNDLE="com.osgkeyboard.ios"
|
||||
OUT_DIR="${ROOT}/.tmp/cold-start-stress-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$OUT_DIR"
|
||||
REPORT="$OUT_DIR/report.jsonl"
|
||||
SUMMARY="$OUT_DIR/summary.txt"
|
||||
LOG_FILE="$OUT_DIR/unified.log"
|
||||
|
||||
echo "==> Out: $OUT_DIR"
|
||||
echo "==> Count: $COUNT Device: '$DEVICE_NAME'"
|
||||
|
||||
# Exact device name match (avoid "iPhone 17" → "iPhone 17 Pro")
|
||||
UDID="$(xcrun simctl list devices available | awk -F '[()]' -v n="$DEVICE_NAME" '
|
||||
{
|
||||
line=$0
|
||||
# strip leading spaces
|
||||
sub(/^[[:space:]]+/, "", line)
|
||||
name=line
|
||||
sub(/ \(.*/, "", name)
|
||||
if (name == n && line ~ /Booted/) { print $2; exit }
|
||||
}
|
||||
')"
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID="$(xcrun simctl list devices available | awk -F '[()]' -v n="$DEVICE_NAME" '
|
||||
{
|
||||
line=$0
|
||||
sub(/^[[:space:]]+/, "", line)
|
||||
name=line
|
||||
sub(/ \(.*/, "", name)
|
||||
if (name == n && line !~ /unavailable/) { print $2; exit }
|
||||
}
|
||||
')"
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "error: exact device '$DEVICE_NAME' not found" >&2
|
||||
xcrun simctl list devices available | grep -i iphone | head -40 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "==> UDID: $UDID"
|
||||
|
||||
echo "==> Booting simulator"
|
||||
xcrun simctl boot "$UDID" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$UDID" -b
|
||||
|
||||
# Prefer signed cold-start DerivedData build, else any existing Debug-iphonesimulator app
|
||||
APP=""
|
||||
if [[ -d "$ROOT/.derivedData-cold-start/Build/Products/Debug-iphonesimulator/OSGKeyboard.app" ]]; then
|
||||
APP="$ROOT/.derivedData-cold-start/Build/Products/Debug-iphonesimulator/OSGKeyboard.app"
|
||||
else
|
||||
APP="$(ls -d "$HOME"/Library/Developer/Xcode/DerivedData/OSGKeyboard-*/Build/Products/Debug-iphonesimulator/OSGKeyboard.app 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
if [[ -z "$APP" || ! -d "$APP" ]]; then
|
||||
echo "error: no built OSGKeyboard.app — build for simulator first" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Installing $APP"
|
||||
xcrun simctl install "$UDID" "$APP" >/dev/null
|
||||
xcrun simctl privacy "$UDID" grant microphone "$BUNDLE" 2>/dev/null || true
|
||||
|
||||
seed_onboarding() {
|
||||
echo "==> Seeding onboarding (App Group + Keychain path)"
|
||||
xcrun simctl launch "$UDID" "$BUNDLE" >/dev/null || true
|
||||
sleep 2
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
|
||||
local group_container prefs
|
||||
group_container="$(xcrun simctl get_app_container "$UDID" "$BUNDLE" group.com.osgkeyboard.shared 2>/dev/null || true)"
|
||||
if [[ -z "$group_container" || ! -d "$group_container" ]]; then
|
||||
echo "warning: App Group container missing" >&2
|
||||
return 0
|
||||
fi
|
||||
prefs="$group_container/Library/Preferences/group.com.osgkeyboard.shared.plist"
|
||||
mkdir -p "$(dirname "$prefs")"
|
||||
if [[ -f "$prefs" ]]; then
|
||||
/usr/libexec/PlistBuddy -c "Set :config.hasCompletedOnboarding true" "$prefs" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :config.hasCompletedOnboarding bool true" "$prefs" 2>/dev/null || true
|
||||
else
|
||||
/usr/libexec/PlistBuddy -c "Add :config.hasCompletedOnboarding bool true" "$prefs" 2>/dev/null || true
|
||||
fi
|
||||
echo "==> Seeded hasCompletedOnboarding=$(/usr/libexec/PlistBuddy -c 'Print :config.hasCompletedOnboarding' "$prefs" 2>/dev/null || echo missing)"
|
||||
|
||||
# One launch so ProviderConfig mirrors App Group → Keychain
|
||||
xcrun simctl launch "$UDID" "$BUNDLE" >/dev/null || true
|
||||
sleep 2
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
|
||||
sleep 0.4
|
||||
}
|
||||
seed_onboarding
|
||||
|
||||
: >"$LOG_FILE"
|
||||
xcrun simctl spawn "$UDID" log stream \
|
||||
--style compact \
|
||||
--level debug \
|
||||
--predicate 'process == "OSGKeyboard"' \
|
||||
>"$LOG_FILE" 2>&1 &
|
||||
LOG_PID=$!
|
||||
trap 'kill $LOG_PID 2>/dev/null || true' EXIT
|
||||
sleep 1
|
||||
|
||||
logfile_window() {
|
||||
local offset="$1"
|
||||
local start=$((offset + 1))
|
||||
tail -c +"$start" "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Returns: host|pip
|
||||
# host: success|fail|unknown
|
||||
# pip: success|unsupported|fail|permissions|onboarding|unknown
|
||||
logfile_classify() {
|
||||
local chunk="$1"
|
||||
local host="unknown" pip="unknown"
|
||||
|
||||
# Use here-string (not pipe) to avoid SIGPIPE under `set -o pipefail`.
|
||||
if grep -Eq "OSGKeyboardApp\.init" <<<"$chunk"; then
|
||||
host="success"
|
||||
fi
|
||||
if grep -Eq "MainAppRoot\.onAppear skip Flow.*onboarding incomplete" <<<"$chunk"; then
|
||||
pip="onboarding"
|
||||
echo "$host|$pip"
|
||||
return
|
||||
fi
|
||||
if grep -Eq "activateOnForeground aborted reason=permissions|startSessionAsync\.blocked.*permissions" <<<"$chunk"; then
|
||||
pip="permissions"
|
||||
echo "$host|$pip"
|
||||
return
|
||||
fi
|
||||
if grep -Eq "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" <<<"$chunk"; then
|
||||
pip="success"
|
||||
elif grep -Eq "failure=unsupported|PiP keep-alive failed to start: unsupported" <<<"$chunk"; then
|
||||
pip="unsupported"
|
||||
elif grep -Eq "PiP keep-alive failed to start|PiP startAndWait failed|startSessionAsync\.failed.*pipUnavailable" <<<"$chunk"; then
|
||||
pip="fail"
|
||||
elif grep -Eq "activateOnForeground|startSession\.request.*autoPiP|startSessionAsync\.begin" <<<"$chunk"; then
|
||||
pip="seen_no_result"
|
||||
fi
|
||||
|
||||
echo "$host|$pip"
|
||||
}
|
||||
|
||||
HOST_OK=0
|
||||
HOST_FAIL=0
|
||||
PIP_SUCCESS=0
|
||||
PIP_UNSUPPORTED=0
|
||||
PIP_FAIL=0
|
||||
PIP_PERMISSIONS=0
|
||||
PIP_ONBOARDING=0
|
||||
PIP_OTHER=0
|
||||
|
||||
echo "==> Running $COUNT cold starts (terminate → launch)"
|
||||
for i in $(seq 1 "$COUNT"); do
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
|
||||
sleep 0.35
|
||||
|
||||
OFFSET=$(wc -c <"$LOG_FILE" | tr -d ' ')
|
||||
START_TS=$(date +%s)
|
||||
|
||||
if ! xcrun simctl launch "$UDID" "$BUNDLE" >/dev/null 2>"$OUT_DIR/launch-$i.err"; then
|
||||
printf '{"i":%d,"host":"fail","pip":"unknown","elapsed_s":0,"note":"launch_failed"}\n' "$i" >>"$REPORT"
|
||||
printf "[%3d/%d] host=fail pip=unknown\n" "$i" "$COUNT"
|
||||
HOST_FAIL=$((HOST_FAIL + 1))
|
||||
PIP_OTHER=$((PIP_OTHER + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
RESULT="unknown|unknown"
|
||||
for _ in $(seq 1 50); do # ~15s
|
||||
sleep 0.3
|
||||
CHUNK="$(logfile_window "$OFFSET")"
|
||||
RESULT="$(logfile_classify "$CHUNK")"
|
||||
PIP="${RESULT##*|}"
|
||||
if [[ "$PIP" == "success" || "$PIP" == "unsupported" || "$PIP" == "fail" || "$PIP" == "permissions" || "$PIP" == "onboarding" ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
ELAPSED=$(( $(date +%s) - START_TS ))
|
||||
HOST="${RESULT%%|*}"
|
||||
PIP="${RESULT##*|}"
|
||||
|
||||
case "$HOST" in
|
||||
success) HOST_OK=$((HOST_OK + 1)) ;;
|
||||
*) HOST_FAIL=$((HOST_FAIL + 1)) ;;
|
||||
esac
|
||||
case "$PIP" in
|
||||
success) PIP_SUCCESS=$((PIP_SUCCESS + 1)) ;;
|
||||
unsupported) PIP_UNSUPPORTED=$((PIP_UNSUPPORTED + 1)) ;;
|
||||
fail) PIP_FAIL=$((PIP_FAIL + 1)) ;;
|
||||
permissions) PIP_PERMISSIONS=$((PIP_PERMISSIONS + 1)) ;;
|
||||
onboarding) PIP_ONBOARDING=$((PIP_ONBOARDING + 1)) ;;
|
||||
*) PIP_OTHER=$((PIP_OTHER + 1)) ;;
|
||||
esac
|
||||
|
||||
printf '{"i":%d,"host":"%s","pip":"%s","elapsed_s":%d}\n' "$i" "$HOST" "$PIP" "$ELAPSED" >>"$REPORT"
|
||||
printf "[%3d/%d] host=%-8s pip=%-12s %2ds\n" "$i" "$COUNT" "$HOST" "$PIP" "$ELAPSED"
|
||||
done
|
||||
|
||||
{
|
||||
echo "Cold-start stress summary"
|
||||
echo "device=$DEVICE_NAME udid=$UDID count=$COUNT"
|
||||
echo "method=terminate + simctl launch (host cold start / autoPiP path)"
|
||||
echo
|
||||
echo "Host cold start (OSGKeyboardApp.init observed):"
|
||||
echo " success=$HOST_OK fail=$HOST_FAIL"
|
||||
echo " fail_rate=$(python3 -c "print(f'{$HOST_FAIL/$COUNT*100:.1f}%')")"
|
||||
echo
|
||||
echo "PiP keep-alive outcome:"
|
||||
echo " success=$PIP_SUCCESS"
|
||||
echo " unsupported=$PIP_UNSUPPORTED (expected on Simulator)"
|
||||
echo " fail_other=$PIP_FAIL"
|
||||
echo " permissions=$PIP_PERMISSIONS"
|
||||
echo " onboarding=$PIP_ONBOARDING"
|
||||
echo " unknown/other=$PIP_OTHER"
|
||||
echo " non-unsupported fail_rate=$(python3 -c "print(f'{($PIP_FAIL+$PIP_PERMISSIONS+$PIP_ONBOARDING+$PIP_OTHER)/$COUNT*100:.1f}%')")"
|
||||
echo
|
||||
echo "Artifacts: $OUT_DIR"
|
||||
echo "Caveats:"
|
||||
echo " - Simulator PiP is typically 'unsupported'; device PiP is the release gate."
|
||||
echo " - Keyboard extension KVC.init / openHostApp is not covered (needs physical keyboard enablement)."
|
||||
echo " - Host wake path covered = cold launch of the process the keyboard would open."
|
||||
} | tee "$SUMMARY"
|
||||
|
||||
kill "$LOG_PID" 2>/dev/null || true
|
||||
trap - EXIT
|
||||
echo "==> Done"
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose a Chinese App Store preview (iPhone 6.7\") for OSGKeyboard.
|
||||
|
||||
Uses real Simulator / marketing UI stills + ffmpeg Ken Burns + ASS titles.
|
||||
Output: docs/assets/app-preview/zh/OSGKeyboard-preview-6.7-zh.mp4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "docs" / "assets" / "app-preview" / "zh"
|
||||
WORK = ROOT / ".tmp" / "app-preview-zh" / "compose"
|
||||
W, H = 1290, 2796 # App Store Connect 6.7"
|
||||
FPS = 30
|
||||
|
||||
HOME_SRC = ROOT / ".tmp" / "app-preview-zh" / "shot-home-dark.png"
|
||||
KB_IDLE = ROOT / "docs" / "assets" / "screenshots" / "zh" / "dark" / "iphone-keyboard_idle.png"
|
||||
KB_REC = ROOT / "docs" / "assets" / "screenshots" / "zh" / "dark" / "iphone-keyboard_recording.png"
|
||||
ICON = ROOT / "docs" / "assets" / "app-icon.png"
|
||||
|
||||
# Prefer a clean Chinese UI font available on this machine.
|
||||
FONT_CANDIDATES = [
|
||||
Path("/Users/rocky/Library/Fonts/OPPO Sans 4.0.ttf"),
|
||||
Path("/System/Library/Fonts/STHeiti Medium.ttc"),
|
||||
Path("/System/Library/Fonts/Supplemental/Songti.ttc"),
|
||||
]
|
||||
|
||||
|
||||
def pick_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
for path in FONT_CANDIDATES:
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
return ImageFont.truetype(str(path), size=size, index=0)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def cover(img: Image.Image) -> Image.Image:
|
||||
"""Scale to cover WxH and center-crop."""
|
||||
src = img.convert("RGB")
|
||||
scale = max(W / src.width, H / src.height)
|
||||
nw, nh = int(src.width * scale), int(src.height * scale)
|
||||
resized = src.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
left = (nw - W) // 2
|
||||
top = (nh - H) // 2
|
||||
return resized.crop((left, top, left + W, top + H))
|
||||
|
||||
|
||||
def patch_home_warning(src: Path) -> Image.Image:
|
||||
"""Hide Simulator-only Flow warning banner for marketing stills."""
|
||||
im = Image.open(src).convert("RGB")
|
||||
# Banner sits under the three status dots (~y 520–580 @ 1320×2868).
|
||||
sample = im.getpixel((im.width // 2, 500))
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.rounded_rectangle((48, 518, im.width - 48, 586), radius=28, fill=sample)
|
||||
return cover(im)
|
||||
|
||||
|
||||
def title_card(lines: list[str], subtitle: str | None = None, with_icon: bool = False) -> Image.Image:
|
||||
im = Image.new("RGB", (W, H), (12, 12, 14))
|
||||
# Soft brand glow
|
||||
glow = Image.new("RGB", (W, H), (12, 12, 14))
|
||||
gdraw = ImageDraw.Draw(glow)
|
||||
cx, cy = W // 2, int(H * 0.38)
|
||||
for r, alpha in ((520, 28), (360, 40), (220, 55)):
|
||||
color = (18 + alpha // 4, 40 + alpha // 3, 28 + alpha // 5)
|
||||
gdraw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
|
||||
im = Image.blend(im, glow, 0.55)
|
||||
draw = ImageDraw.Draw(im)
|
||||
|
||||
y = int(H * 0.34)
|
||||
if with_icon and ICON.exists():
|
||||
icon = Image.open(ICON).convert("RGBA").resize((220, 220), Image.Resampling.LANCZOS)
|
||||
# Rounded mask
|
||||
mask = Image.new("L", (220, 220), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, 219, 219), radius=48, fill=255)
|
||||
im.paste(icon, ((W - 220) // 2, y - 280), mask)
|
||||
y = int(H * 0.42)
|
||||
|
||||
title_font = pick_font(96 if len(lines) == 1 else 84)
|
||||
sub_font = pick_font(44)
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=title_font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text(((W - tw) // 2, y), line, fill=(245, 246, 248), font=title_font)
|
||||
y += 120
|
||||
if subtitle:
|
||||
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text(((W - tw) // 2, y + 24), subtitle, fill=(140, 160, 145), font=sub_font)
|
||||
return im
|
||||
|
||||
|
||||
def overlay_caption(base: Image.Image, text: str) -> Image.Image:
|
||||
"""Bottom gradient + caption for ad-style supers."""
|
||||
im = base.copy().convert("RGBA")
|
||||
shade = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(shade)
|
||||
for i in range(420):
|
||||
a = int(190 * (i / 419))
|
||||
y = H - 420 + i
|
||||
d.line([(0, y), (W, y)], fill=(0, 0, 0, a))
|
||||
im = Image.alpha_composite(im, shade)
|
||||
draw = ImageDraw.Draw(im)
|
||||
font = pick_font(64)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text(((W - tw) // 2, H - 260), text, fill=(255, 255, 255, 255), font=font)
|
||||
return im.convert("RGB")
|
||||
|
||||
|
||||
def write_ass(path: Path) -> None:
|
||||
# Kept for optional future hardsubs; primary captions are burned into stills.
|
||||
path.write_text(
|
||||
"""[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 1290
|
||||
PlayResY: 2796
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Heiti SC,64,&H00FFFFFF,&H000000FF,&H64000000,&H80000000,0,0,0,0,100,100,0,0,1,0,0,2,60,60,120,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
print("+", " ".join(cmd[:8]), "..." if len(cmd) > 8 else "")
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not HOME_SRC.exists():
|
||||
print(f"missing home still: {HOME_SRC}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
WORK.mkdir(parents=True, exist_ok=True)
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
home = patch_home_warning(HOME_SRC)
|
||||
kb_idle = cover(Image.open(KB_IDLE))
|
||||
kb_rec = cover(Image.open(KB_REC))
|
||||
|
||||
scenes = {
|
||||
"01_open": title_card(["开口即文字"], with_icon=True),
|
||||
"02_hook": overlay_caption(home, "说,就好了"),
|
||||
"03_voice": overlay_caption(kb_rec, "说完就能用"),
|
||||
"04_type": overlay_caption(kb_idle, "打字也在行"),
|
||||
"05_privacy": overlay_caption(home, "默认不上传录音"),
|
||||
"06_end": title_card(["OSGKeyboard"], "开口即文字", with_icon=True),
|
||||
}
|
||||
for name, img in scenes.items():
|
||||
img.save(WORK / f"{name}.png", optimize=True)
|
||||
|
||||
# Durations (seconds) — total ~28s
|
||||
timeline = [
|
||||
("01_open", 2.2),
|
||||
("02_hook", 3.2),
|
||||
("03_voice", 7.0),
|
||||
("04_type", 6.0),
|
||||
("05_privacy", 5.0),
|
||||
("06_end", 4.0),
|
||||
]
|
||||
|
||||
# Build concat list with zoompan Ken Burns per still.
|
||||
parts: list[Path] = []
|
||||
for idx, (name, dur) in enumerate(timeline):
|
||||
src = WORK / f"{name}.png"
|
||||
part = WORK / f"part-{idx:02d}.mp4"
|
||||
frames = max(1, int(dur * FPS))
|
||||
# Gentle zoom: alternate direction for rhythm
|
||||
if idx % 2 == 0:
|
||||
z = f"min(zoom+0.00045,1.08)"
|
||||
x = "iw/2-(iw/zoom/2)"
|
||||
y = "ih/2-(ih/zoom/2)"
|
||||
else:
|
||||
z = f"if(eq(on,1),1.08,max(zoom-0.00045,1.0))"
|
||||
x = "iw/2-(iw/zoom/2)"
|
||||
y = "ih/2-(ih/zoom/2)"
|
||||
vf = (
|
||||
f"scale={W}:{H}:force_original_aspect_ratio=increase,"
|
||||
f"crop={W}:{H},"
|
||||
f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps={FPS},"
|
||||
f"setsar=1,format=yuv420p"
|
||||
)
|
||||
run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loop",
|
||||
"1",
|
||||
"-i",
|
||||
str(src),
|
||||
"-vf",
|
||||
vf,
|
||||
"-t",
|
||||
f"{dur:.2f}",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"high",
|
||||
"-level",
|
||||
"4.0",
|
||||
"-crf",
|
||||
"18",
|
||||
"-r",
|
||||
str(FPS),
|
||||
str(part),
|
||||
]
|
||||
)
|
||||
parts.append(part)
|
||||
|
||||
concat_list = WORK / "concat.txt"
|
||||
concat_list.write_text("".join(f"file '{p}'\n" for p in parts), encoding="utf-8")
|
||||
|
||||
silent = WORK / "preview-silent.mp4"
|
||||
run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_list),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-r",
|
||||
str(FPS),
|
||||
str(silent),
|
||||
]
|
||||
)
|
||||
|
||||
# Soft generated bed (no third-party music / no copyright risk).
|
||||
final = OUT_DIR / "OSGKeyboard-preview-6.7-zh.mp4"
|
||||
run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(silent),
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=196:sample_rate=44100,volume=0.035,afade=t=in:st=0:d=1.2,afade=t=out:st=25:d=2.5",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=293.66:sample_rate=44100,volume=0.02,afade=t=in:st=0:d=1.5,afade=t=out:st=25:d=2.5",
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=first:dropout_transition=2[a]",
|
||||
"-map",
|
||||
"0:v",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(final),
|
||||
]
|
||||
)
|
||||
|
||||
# Probe
|
||||
probe = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,duration",
|
||||
"-show_entries",
|
||||
"format=duration,size",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1",
|
||||
str(final),
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
print(probe)
|
||||
print(f"Wrote {final}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
|
||||
#
|
||||
# Usage:
|
||||
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50]
|
||||
#
|
||||
# Suites:
|
||||
# cold — terminate-existing launch (force-quit / cold start)
|
||||
# bgfg — open Safari (background) then relaunch OSG (foreground restore)
|
||||
# hold — start PiP, wait 20s in session, relaunch to verify still recoverable
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
UDID="${1:-00008130-001C249C0E52001C}"
|
||||
COUNT="${2:-50}"
|
||||
BUNDLE="com.osgkeyboard.ios"
|
||||
SAFARI="com.apple.mobilesafari"
|
||||
OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$OUT_DIR"
|
||||
REPORT="$OUT_DIR/report.jsonl"
|
||||
SUMMARY="$OUT_DIR/summary.txt"
|
||||
APP="$ROOT/.derivedData-device-stress/Build/Products/Debug-iphoneos/OSGKeyboard.app"
|
||||
|
||||
echo "==> Out: $OUT_DIR"
|
||||
echo "==> Device: $UDID count/suite: $COUNT"
|
||||
|
||||
if [[ ! -d "$APP" ]]; then
|
||||
echo "error: missing $APP — build for device first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Ensuring install"
|
||||
xcrun devicectl device install app --device "$UDID" "$APP" --timeout 180 >/dev/null
|
||||
|
||||
# Capture one console launch (timeout seconds). Prints raw console to stdout file.
|
||||
console_launch() {
|
||||
local outfile="$1"
|
||||
local secs="${2:-14}"
|
||||
local extra_flags="${3:-}" # e.g. --terminate-existing
|
||||
# shellcheck disable=SC2086
|
||||
timeout "$secs" xcrun devicectl device process launch \
|
||||
--device "$UDID" \
|
||||
--console \
|
||||
$extra_flags \
|
||||
"$BUNDLE" >"$outfile" 2>&1 || true
|
||||
}
|
||||
|
||||
classify_file() {
|
||||
local f="$1"
|
||||
local host="unknown" pip="unknown" mic="unknown"
|
||||
|
||||
if grep -Eq "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
|
||||
host="success"
|
||||
elif grep -Eq "Launched application with com\.osgkeyboard\.ios" "$f"; then
|
||||
host="launch_only"
|
||||
fi
|
||||
|
||||
if grep -Eq "onboarding incomplete" "$f"; then
|
||||
pip="onboarding"
|
||||
elif grep -Eq "aborted reason=permissions|blocked.*permissions" "$f"; then
|
||||
pip="permissions"
|
||||
elif grep -Eq "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" "$f"; then
|
||||
pip="success"
|
||||
elif grep -Eq "failure=unsupported|failed to start: unsupported" "$f"; then
|
||||
pip="unsupported"
|
||||
elif grep -Eq "PiP keep-alive failed to start|startSessionAsync\.failed.*pipUnavailable|startAndWait failed" "$f"; then
|
||||
pip="fail"
|
||||
elif grep -Eq "PiP start attempt failed" "$f"; then
|
||||
# Retry path — only fail if we never saw success above
|
||||
pip="retry_then_unknown"
|
||||
elif grep -Eq "activateOnForeground|autoPiP|startSessionAsync\.begin" "$f"; then
|
||||
pip="seen_no_result"
|
||||
fi
|
||||
|
||||
# Mic keep-alive contract: idle releases mic after PiP proves
|
||||
if grep -Eq "mic released between utterances|released audio session and frame pump" "$f"; then
|
||||
mic="released_ok"
|
||||
elif grep -Eq "PiP audio session ready" "$f"; then
|
||||
mic="armed"
|
||||
else
|
||||
mic="unknown"
|
||||
fi
|
||||
|
||||
# Promote retry_then_unknown if success markers appeared (grep order already handled)
|
||||
if [[ "$pip" == "retry_then_unknown" ]]; then
|
||||
if grep -Eq "low-profile PiP active|Flow session started \(PiP keep-alive\)" "$f"; then
|
||||
pip="success"
|
||||
else
|
||||
pip="fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$host|$pip|$mic"
|
||||
}
|
||||
|
||||
run_suite() {
|
||||
local suite="$1"
|
||||
local i outfile result host pip mic start_ts elapsed flags
|
||||
echo "==> Suite: $suite × $COUNT"
|
||||
for i in $(seq 1 "$COUNT"); do
|
||||
outfile="$OUT_DIR/${suite}-$i.console.log"
|
||||
start_ts=$(date +%s)
|
||||
flags=""
|
||||
|
||||
case "$suite" in
|
||||
cold)
|
||||
flags="--terminate-existing"
|
||||
console_launch "$outfile" 14 "$flags"
|
||||
;;
|
||||
bgfg)
|
||||
# Ensure app running with PiP first
|
||||
console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true
|
||||
# Background by opening Safari
|
||||
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
# Resume OSG without terminate
|
||||
console_launch "$outfile" 12 ""
|
||||
;;
|
||||
hold)
|
||||
console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true
|
||||
# Leave session alive ~20s (PiP should keep host)
|
||||
sleep 20
|
||||
# Background briefly then resume
|
||||
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
console_launch "$outfile" 12 ""
|
||||
;;
|
||||
esac
|
||||
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
result="$(classify_file "$outfile")"
|
||||
host="${result%%|*}"
|
||||
rest="${result#*|}"
|
||||
pip="${rest%%|*}"
|
||||
mic="${rest##*|}"
|
||||
|
||||
printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","elapsed_s":%d}\n' \
|
||||
"$suite" "$i" "$host" "$pip" "$mic" "$elapsed" >>"$REPORT"
|
||||
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s %2ds\n" \
|
||||
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$elapsed"
|
||||
done
|
||||
}
|
||||
|
||||
: >"$REPORT"
|
||||
run_suite cold
|
||||
run_suite bgfg
|
||||
run_suite hold
|
||||
|
||||
python3 - "$REPORT" "$SUMMARY" "$UDID" "$COUNT" "$OUT_DIR" <<'PY'
|
||||
import json, collections, sys
|
||||
from pathlib import Path
|
||||
report, summary, udid, count, out = sys.argv[1:6]
|
||||
rows = [json.loads(l) for l in Path(report).read_text().splitlines() if l.strip()]
|
||||
by = collections.defaultdict(list)
|
||||
for r in rows:
|
||||
by[r["suite"]].append(r)
|
||||
|
||||
lines = [
|
||||
"Device PiP / mic keep-alive stress summary",
|
||||
f"device=Rocky 15 PM udid={udid} count_per_suite={count}",
|
||||
f"total_rows={len(rows)}",
|
||||
"",
|
||||
]
|
||||
for suite in ("cold", "bgfg", "hold"):
|
||||
rs = by.get(suite, [])
|
||||
n = max(len(rs), 1)
|
||||
host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only"))
|
||||
pip_c = collections.Counter(r["pip"] for r in rs)
|
||||
mic_c = collections.Counter(r["mic"] for r in rs)
|
||||
pip_ok = pip_c.get("success", 0)
|
||||
lines += [
|
||||
f"[{suite}]",
|
||||
f" host_ok={host_ok}/{len(rs)} ({host_ok/n*100:.1f}%)",
|
||||
f" pip_success={pip_ok}/{len(rs)} ({pip_ok/n*100:.1f}%) breakdown={dict(pip_c)}",
|
||||
f" mic={dict(mic_c)}",
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
f"Artifacts: {out}",
|
||||
"Notes:",
|
||||
" - cold uses --terminate-existing (force-quit recovery).",
|
||||
" - bgfg backgrounds via Safari then resumes.",
|
||||
" - hold keeps session ~20s then Safari background + resume.",
|
||||
" - Keyboard-extension mic tap is not automated.",
|
||||
]
|
||||
text = "\n".join(lines) + "\n"
|
||||
Path(summary).write_text(text)
|
||||
print(text)
|
||||
PY
|
||||
|
||||
echo "==> Done"
|
||||
Binary file not shown.
@@ -0,0 +1,457 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hans" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>OSGKeyboard — What's New / 更新说明</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f7f5;
|
||||
--text: #1a1a1a;
|
||||
--text-secondary: #5c5c5c;
|
||||
--text-tertiary: #8a8a8a;
|
||||
--card: #ffffff;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--accent: #2f6fed;
|
||||
--chip-bg: rgba(47, 111, 237, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0a0a0b;
|
||||
--text: #f2f2f2;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-tertiary: #7a7a7a;
|
||||
--card: #161618;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--accent: #6b9fff;
|
||||
--chip-bg: rgba(107, 159, 255, 0.16);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
|
||||
"Hiragino Sans GB", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Fixed bottom padding only — App WKWebView already lays out above the home indicator. */
|
||||
padding: 20px 16px 28px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: none;
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty.visible { display: block; }
|
||||
|
||||
.version-block {
|
||||
display: none;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 18px 16px 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.version-block.visible { display: block; }
|
||||
|
||||
.version-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--chip-bg);
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group { margin-bottom: 12px; }
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1.15em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
li strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lang-block { display: none; }
|
||||
.lang-block.visible { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<div class="lang-block" data-lang="zh">
|
||||
<h1>更新说明</h1>
|
||||
<p class="subtitle">本页展示当前版本及更早版本的更新内容</p>
|
||||
</div>
|
||||
<div class="lang-block" data-lang="en">
|
||||
<h1>What's New</h1>
|
||||
<p class="subtitle">Notes for this version and earlier releases</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="empty" id="empty-zh">暂无与当前版本匹配的更新说明。</p>
|
||||
<p class="empty" id="empty-en">No release notes match this app version.</p>
|
||||
|
||||
<!-- ========== 1.6.5 ========== -->
|
||||
<article class="version-block" data-version="1.6.5">
|
||||
<div class="version-chip">1.6.5</div>
|
||||
|
||||
<div class="lang-block" data-lang="zh">
|
||||
<section class="section">
|
||||
<h2>新功能</h2>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">全新保活流程</p>
|
||||
<ul>
|
||||
<li>键盘保活流程全新升级,大幅提升后台保活逻辑,大幅降低耗电量并提升稳定性</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">新增键盘输入功能</p>
|
||||
<ul>
|
||||
<li>新增<strong>拼音</strong>与<strong>英文</strong>键盘:支持全拼、微软/搜狗双拼、词组候选、可选模糊音;拼音与英文均支持自定义词库</li>
|
||||
<li>键盘布局整体升级,语音 / 中文 / 英文外观统一;左侧 Logo 可跳转主 App,右侧改为输入方式切换与翻译入口</li>
|
||||
<li>新增<strong>中文候选词面板</strong>,候选池更大,长词和更多备选更容易找到;多字输入时可分词选择</li>
|
||||
<li>新增英文联想补全、自动纠错与候选撤销,输入更快更准</li>
|
||||
<li>新增键盘防误触和误输入纠正,整体体验更接近系统键盘</li>
|
||||
<li>新增<strong>按键音效与震动</strong>,以及轻 / 强震动设置</li>
|
||||
<li>新增长按删除加速,改长文更高效</li>
|
||||
<li>新增英文键盘智能大写:连续大写、句首自动大写</li>
|
||||
<li>新增<strong>键盘偏好</strong>设置,支持记住上次使用的语音或文字界面,再次打开无需反复切换</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">新增剪贴板润色模式</p>
|
||||
<ul>
|
||||
<li>新增<strong>剪贴板处理模式</strong>:复制文本后<strong>长按</strong>麦克风进入文本处理模式</li>
|
||||
<li>在剪贴板处理模式下,可通过语音指令对剪贴板内容进行翻译、精简、回复等多种操作</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">润色模式整体升级</p>
|
||||
<ul>
|
||||
<li>大幅升级<strong>趣味润色</strong>(含直男癌等),风格更鲜明,同时减少事实失真</li>
|
||||
<li>润色强度简化为轻度 / 重度:轻度优先保真,重度释放五种趣味风格的完整创意</li>
|
||||
<li>新增<strong>同音 / 近音纠错</strong>,语音识别后的错字修复更准</li>
|
||||
<li>长文本润色与翻译分段更清晰,列表结构更易读,翻译后保留段落格式</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">设置优化</p>
|
||||
<ul>
|
||||
<li>设置页层级与卡片布局更清晰,转写与润色入口更好找</li>
|
||||
<li>简化首次设置:未完成语音配置时仍可正常打字</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>稳定性与可靠性</h2>
|
||||
<ul>
|
||||
<li>重构蓝牙与音频路由处理:切换蓝牙设备(耳机)后,录音恢复更可靠</li>
|
||||
<li>修复录音启动 / 取消竞态,减少识别完成后卡在润色阶段的问题</li>
|
||||
<li>Apple 本地识别改用降噪接口:同房间旁人说话时,更倾向听清你的近讲声音</li>
|
||||
<li>为识别与润色增加明确超时与原文兜底,网络不稳时不再无限等待</li>
|
||||
<li>加强主 App 与键盘的结果确认与恢复,降低结果丢失或无法上屏的概率</li>
|
||||
<li>修复火山 ASR 连接问题;修复云端 ASR 配置验证误报</li>
|
||||
<li>重构键盘启动流程、降低内存峰值,减少闪退与被系统终止</li>
|
||||
<li>输入引擎按需加载,语音与文字切换更快、更省内存</li>
|
||||
<li>未使用麦克风时,不主动拉起主 App 占用内存</li>
|
||||
<li>移除冗余多步引导;优化会话未完成时的体验</li>
|
||||
<li>自定义词 / 本地识别相关能力打包更完整</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="lang-block" data-lang="en">
|
||||
<section class="section">
|
||||
<h2>New Features</h2>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Redesigned keep-alive flow</p>
|
||||
<ul>
|
||||
<li>Keyboard keep-alive is rebuilt for more reliable background continuity, lower power use, and better stability</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Typing keyboards</p>
|
||||
<ul>
|
||||
<li>New <strong>Pinyin</strong> and <strong>English</strong> keyboards: full Pinyin, Microsoft/Sogou double Pinyin, phrase candidates, optional fuzzy Pinyin; both support custom dictionaries</li>
|
||||
<li>Unified keyboard chrome across Voice / Chinese / English; left logo opens the main app; right side switches input mode and opens translation</li>
|
||||
<li>New <strong>Chinese candidate panel</strong> with a larger pool for long phrases and alternatives; multi-character input supports segmented selection</li>
|
||||
<li>English autocomplete, autocorrect, and candidate undo for faster, more accurate typing</li>
|
||||
<li>Anti-mis-tap and mistype correction for a closer-to-system keyboard feel</li>
|
||||
<li>New <strong>key sounds and haptics</strong>, with light / strong haptic options</li>
|
||||
<li>Accelerating long-press delete for faster long-text edits</li>
|
||||
<li>Smarter English capitalization: caps lock and sentence-start autocapitalization</li>
|
||||
<li>New <strong>Keyboard Preferences</strong>: remember the last Voice or Typing surface so you don’t keep switching</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Clipboard polish mode</p>
|
||||
<ul>
|
||||
<li>New <strong>clipboard processing mode</strong>: after copying text, <strong>long-press</strong> the mic to process that clipboard snapshot</li>
|
||||
<li>In clipboard mode, speak instructions to translate, shorten, reply, and more</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Polish upgrades</p>
|
||||
<ul>
|
||||
<li>Stronger <strong>fun polish</strong> styles (including Straight-Talk Rescue / 直男癌), with clearer voice and less factual drift</li>
|
||||
<li>Intensity simplified to Light / Heavy: Light prioritizes fidelity; Heavy unlocks the full creativity of the five fun styles</li>
|
||||
<li>New <strong>homophone / near-homophone correction</strong> after speech recognition</li>
|
||||
<li>Clearer segmentation for long polish and translation; lists read better; translations keep paragraph structure</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Settings</p>
|
||||
<ul>
|
||||
<li>Clearer Settings hierarchy and cards; transcription and polish are easier to find</li>
|
||||
<li>Simpler first-run setup: you can type even before voice setup is finished</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Stability & Reliability</h2>
|
||||
<ul>
|
||||
<li>Rebuilt Bluetooth / audio-route handling so recording recovers more reliably after switching headsets</li>
|
||||
<li>Fixed start / cancel races that could leave the UI stuck in polish after recognition finished</li>
|
||||
<li>Apple on-device recognition uses voice-processing / noise reduction so your near-talk mic is preferred over other speakers in the room</li>
|
||||
<li>Explicit timeouts and original-text fallback for recognition and polish — no endless waits on flaky networks</li>
|
||||
<li>Stronger host ↔ keyboard result confirmation and recovery to reduce lost or uninsertable text</li>
|
||||
<li>Fixed Volcengine ASR connection issues and false cloud ASR config-validation failures</li>
|
||||
<li>Rebuilt keyboard launch path with lower peak memory to reduce crashes and jetsams</li>
|
||||
<li>Input engines load on demand — faster Voice ↔ Typing switches and lower memory use</li>
|
||||
<li>Does not wake the host app just to hold memory when the mic isn’t used</li>
|
||||
<li>Removed redundant multi-step prompts; smoother experience when a session isn’t finished</li>
|
||||
<li>More complete packaging for custom words and on-device recognition assets</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- ========== 1.1.0 ========== -->
|
||||
<article class="version-block" data-version="1.1.0">
|
||||
<div class="version-chip">1.1.0</div>
|
||||
|
||||
<div class="lang-block" data-lang="zh">
|
||||
<section class="section">
|
||||
<h2>新功能</h2>
|
||||
<ul>
|
||||
<li>新增「润色风格包」:内置轻度清理、清晰结构、正式表达、日常聊天,并支持 iCloud 同步</li>
|
||||
<li>新增「趣味润色风格」:直男癌拯救器、装逼指南、大厂黑话、帝吧大神、小红书集美五种润色风格</li>
|
||||
<li>新增自定义润色风格功能</li>
|
||||
<li>新增「画中画」保活,并作为默认风格,支持在设置中切换回灵动岛;关掉画中画即可结束会话</li>
|
||||
<li>云端识别支持真流式(百炼 / 火山 / OpenAI),说话时文字会实时上屏</li>
|
||||
<li>新增历史记录可按天删除(只清当天)</li>
|
||||
<li>设置里对流式 ASR 供应商显示「流式识别」标签;OpenAI 云端识别默认走流式</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>体验改进</h2>
|
||||
<ul>
|
||||
<li>设置页重新设计,结构更清晰</li>
|
||||
<li>灵动岛保活默认改为 5 分钟(更省隐私与电量),支持多种时长设置</li>
|
||||
<li>更新 iPhone 历史、词库 Tab 图标,与 Mac / iPad 统一</li>
|
||||
<li>润色风格卡片与设置摘要更简洁,少装饰、少冗余状态字</li>
|
||||
<li>设置、风格等相关页面统一卡片留白与圆角</li>
|
||||
<li>更新权限与隐私说明</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>问题修复</h2>
|
||||
<ul>
|
||||
<li>完成新手引导后不再卡在最后一页</li>
|
||||
<li>删除重装后会重新出现欢迎引导</li>
|
||||
<li>松手后尾音更不容易丢字;识别过短时会整句再识别一次兜底</li>
|
||||
<li>按下录音时麦克风预热阶段的开头语音更不易被丢掉</li>
|
||||
<li>末段识别为空时,不再把前面已经识别好的字抹掉</li>
|
||||
<li>修复趣味润色在短句时的失真问题</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="lang-block" data-lang="en">
|
||||
<section class="section">
|
||||
<h2>New Features</h2>
|
||||
<ul>
|
||||
<li>Polish style packs: Light Cleanup, Clear Structure, Formal, and Casual Chat, with iCloud sync</li>
|
||||
<li>Fun polish styles: Straight-Talk Rescue, Flex Guide, Big-Tech Buzzwords, Tieba Legend, and Xiaohongshu Vibes</li>
|
||||
<li>Custom polish styles</li>
|
||||
<li>Picture-in-Picture keep-alive as the default, with Dynamic Island as an option in Settings; ending PiP ends the session</li>
|
||||
<li>True streaming cloud ASR (Bailian / Volcengine / OpenAI) with live on-screen text while you speak</li>
|
||||
<li>Delete history by day (clear only that day)</li>
|
||||
<li>Settings shows a “Streaming ASR” badge for streaming providers; OpenAI cloud ASR defaults to streaming</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Improvements</h2>
|
||||
<ul>
|
||||
<li>Redesigned Settings with a clearer structure</li>
|
||||
<li>Dynamic Island keep-alive defaults to 5 minutes (more privacy- and battery-friendly), with multiple duration options</li>
|
||||
<li>Updated iPhone History and Dictionary tab icons to match Mac / iPad</li>
|
||||
<li>Simpler polish-style cards and Settings summaries — less decoration and fewer status words</li>
|
||||
<li>Consistent card padding and corner radius across Settings and style pages</li>
|
||||
<li>Updated permission and privacy copy</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Fixes</h2>
|
||||
<ul>
|
||||
<li>Onboarding no longer sticks on the last page after completion</li>
|
||||
<li>Welcome onboarding shows again after delete-and-reinstall</li>
|
||||
<li>Less end-of-utterance truncation on release; very short captures retry the whole phrase</li>
|
||||
<li>Speech at the start of mic warm-up is less likely to be dropped</li>
|
||||
<li>An empty final segment no longer wipes earlier recognized text</li>
|
||||
<li>Fixed fun-polish distortion on short sentences</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function parseVersion(raw) {
|
||||
if (!raw) return null;
|
||||
var parts = String(raw).trim().split(".").map(function (p) {
|
||||
var n = parseInt(p, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
while (parts.length < 3) parts.push(0);
|
||||
return parts.slice(0, 3);
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] - b[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function readParams() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var lang = (params.get("lang") || "").toLowerCase();
|
||||
if (lang.indexOf("zh") === 0) lang = "zh";
|
||||
else if (lang.indexOf("en") === 0) lang = "en";
|
||||
else lang = (navigator.language || "").toLowerCase().indexOf("zh") === 0 ? "zh" : "en";
|
||||
|
||||
var theme = (params.get("theme") || "").toLowerCase();
|
||||
if (theme !== "light" && theme !== "dark") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
var version = params.get("v") || params.get("version") || "";
|
||||
return { lang: lang, theme: theme, version: version };
|
||||
}
|
||||
|
||||
var cfg = readParams();
|
||||
document.documentElement.lang = cfg.lang === "zh" ? "zh-Hans" : "en";
|
||||
document.documentElement.setAttribute("data-theme", cfg.theme);
|
||||
|
||||
document.querySelectorAll(".lang-block").forEach(function (el) {
|
||||
el.classList.toggle("visible", el.getAttribute("data-lang") === cfg.lang);
|
||||
});
|
||||
|
||||
var current = parseVersion(cfg.version);
|
||||
var blocks = Array.prototype.slice.call(document.querySelectorAll(".version-block"));
|
||||
var visibleCount = 0;
|
||||
|
||||
blocks
|
||||
.map(function (el) {
|
||||
return { el: el, version: parseVersion(el.getAttribute("data-version")) };
|
||||
})
|
||||
.filter(function (item) {
|
||||
if (!item.version) return false;
|
||||
// No v= → show all; otherwise only versions <= current app version.
|
||||
if (!current) return true;
|
||||
return compareVersions(item.version, current) <= 0;
|
||||
})
|
||||
.sort(function (a, b) {
|
||||
return compareVersions(b.version, a.version);
|
||||
})
|
||||
.forEach(function (item) {
|
||||
item.el.classList.add("visible");
|
||||
// Re-append in newest → oldest order.
|
||||
item.el.parentNode.appendChild(item.el);
|
||||
visibleCount += 1;
|
||||
});
|
||||
|
||||
var empty = document.getElementById(cfg.lang === "zh" ? "empty-zh" : "empty-en");
|
||||
if (empty) empty.classList.toggle("visible", visibleCount === 0);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -52,7 +52,7 @@ settings:
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "1.6.5"
|
||||
CURRENT_PROJECT_VERSION: "53"
|
||||
CURRENT_PROJECT_VERSION: "55"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
|
||||
Reference in New Issue
Block a user