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
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" = "偏好设置";