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:
Rocky
2026-08-08 00:20:42 +08:00
parent f197b68573
commit 9da32b81e9
45 changed files with 3670 additions and 422 deletions
+16 -2
View File
@@ -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

+12
View File
@@ -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"
}
+31 -4
View File
@@ -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
}
}
}
+6
View File
@@ -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
+12
View File
@@ -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()
+71
View File
@@ -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)
+52 -17
View File
@@ -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)
+1 -1
View File
@@ -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()
+20 -10
View File
@@ -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() {
+16
View File
@@ -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" = "偏好设置";
+93 -16
View File
@@ -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
// systemalert 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
+124 -50
View File
@@ -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 {
+13
View File
@@ -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 isnt usable";
"keyboard.clipboard.reject.secureField" = "Clipboard commands arent available in password fields";
"keyboard.clipboard.reject.noFullAccess" = "Full Access is required for clipboard commands";
"keyboard.clipboard.reject.prepareFailed" = "Mic wasnt 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" = "本地";
+2 -1
View File
@@ -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()
}
}
}
}
}
+7 -2
View File
@@ -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 systemalert 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 infor 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 clipboardso 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))
}
}
+243
View File
@@ -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"
+312
View File
@@ -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 520580 @ 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())
+193
View File
@@ -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"
+457
View File
@@ -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 dont 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 &amp; 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 isnt used</li>
<li>Removed redundant multi-step prompts; smoother experience when a session isnt 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
View File
@@ -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.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target