Files
OSGKeyboard/OSGKeyboardShared/Services/AppGroupStore.swift
T
Rocky b1635d5d35 feat: iOS-style localization + onboarding engine picker + UX fixes
Five user-flagged issues addressed in this commit. Some of the
uncommitted files belong to a prior agent pass and are included as-is
so this is a clean working tree.

1. Remove globe (nextKeyboard) button from keyboard bottom bar.
   iOS already provides a globe key in the system keyboard strip
   for next-keyboard switching, so the in-extension one was
   redundant. The bar is now: ⌫ (delete) [space] ↩ (return).
   Affected: OSGKeyboardExt/Views/KeyboardRootView.swift and
   OSGKeyboard/Views/KeyboardPreviewStub.swift (preview mirrors).

2. Fix TabView with .page style auto-jumping on TextField focus.
   SwiftUI's `.tabViewStyle(.page(...))` wraps content in a
   UIPageViewController, which has a long-standing iOS 18 bug
   where the keyboard-showing layout reflow on a TextField focus
   is misread as a horizontal swipe — the page jumps back to
   step 1 the moment the user starts typing. Replaced the
   TabView with a ZStack + conditional view + transition. We give
   up swipe-to-page, but Back/Next buttons + page dots are the
   canonical onboarding affordance and the user is one tap from
   the next page anyway.

3. Onboarding APISetupPage now offers Engine choice (Local vs
   Cloud), matching the in-app Settings page. First-run users can
   pick the on-device engine and skip the API-key setup entirely.
   Extracted the engine section from SettingsView into a shared
   `EnginePickerSection` component used by both. Cloud path
   keeps the provider + API fields; Local path shows a
   "no API key needed" confirmation card.

4. APISettingsCard test-connection error messages are now built
   with NSLocalizedString + String.localizedStringWithFormat (for
   the interpolated HTTP status / reply preview). Status badge
   labels are also NSLocalizedString-backed.

5. iOS-standard localization.
   - New `en.lproj/Localizable.strings` and `zh-Hans.lproj/
     Localizable.strings` (in both the main app and the keyboard
     extension bundles — each .appex has its own bundle).
   - `project.yml` sets `CFBundleDevelopmentRegion: en` and
     `CFBundleLocalizations: [en, zh-Hans]`; the two .lproj dirs
     are added as resources.
   - Every `Text("...")` / `Button("...")` / `Label("...")` /
     `accessibilityLabel(Text("..."))` in user-facing views was
     rewritten to use `Text("key")` (SwiftUI auto-resolves
     string-literal `LocalizedStringKey`s against the strings
     file) or `NSLocalizedString("key", comment: "")` for
     interpolated / dynamic values. The hardcoded
     "中 · EN" / "EN · 中" pattern is gone.
   - Two helper signatures that took `String` for the title/body
     of a row (`footnoteRow`, `sectionHeader`) are now
     `LocalizedStringKey` so the row labels are looked up.
   - `Label("key", systemImage: "...")` and
     `.confirmationDialog(LocalizedStringKey("key"), ...)` and
     `.navigationTitle(LocalizedStringKey("key"))` are used where
     `String` would just print the key.
   - Preview-onboarding `APISetupPage` updated to use the same
     engine picker as Settings (issue 3), with a section that
     only renders the provider + API fields when the user picks
     Cloud.

Verified on iOS 26 simulator with system language set to both
zh-Hans (default) and en: the same screen renders "按住说话,松开
即得润色文字。" / "下一步" in Chinese, and "Hold to talk. Release
for polished text, in any app." / "Next" in English — no
"中 · EN" doubling, no missing keys.

Build: BUILD SUCCEEDED.
Tests: 21/21 pass.

🤖 Generated with Claude Code
2026-06-18 19:57:45 +08:00

136 lines
4.8 KiB
Swift

// AppGroupStore.swift
// OSGKeyboard · Shared
//
// Convenience wrapper around App Group UserDefaults for non-Published reads.
// Used by the keyboard extension (no SwiftUI) to read config without
// instantiating an ObservableObject.
//
// `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We
// share access between the host app and the keyboard extension via a
// shared keychain-access-group declared in both targets' entitlements.
import Foundation
public struct AppGroupStore: @unchecked Sendable {
public let defaults: UserDefaults
public init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
// MARK: - Keys
private enum Key {
static let providerId = "config.providerId"
static let baseURL = "config.baseURL"
static let model = "config.model"
static let systemPrompt = "config.systemPrompt"
static let modeId = "config.modeId"
static let localeId = "config.localeId"
static let requiresOnDevice = "config.requiresOnDevice"
static let engineMode = "config.engineMode"
}
// MARK: - Reads
public var providerId: String {
defaults.string(forKey: Key.providerId) ?? "openai"
}
public var baseURL: String {
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
}
/// API key lives in the Keychain (cross-process, encrypted at rest).
/// Returns "" when nothing is stored so the LLMClient can surface a
/// `noAPIKey` error rather than firing off an obviously-bad request.
public var apiKey: String {
Keychain.apiKey() ?? ""
}
public var model: String {
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
}
public var systemPrompt: String {
defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId)
}
public var modeId: String {
defaults.string(forKey: Key.modeId) ?? "polish"
}
public var localeId: String {
defaults.string(forKey: Key.localeId) ?? "auto"
}
/// When `true`, `SFSpeechRecognizer` is forced to on-device mode
/// (`requiresOnDeviceRecognition = true`). Ignored on iOS 26+ where
/// `SpeechAnalyzer` is always on-device.
public var requiresOnDevice: Bool {
defaults.bool(forKey: Key.requiresOnDevice)
}
/// "local" → on-device ASR only, no LLM polishing.
/// "cloud" → ASR + LLM polish (default behaviour).
public var engineMode: String {
defaults.string(forKey: Key.engineMode) ?? "cloud"
}
// MARK: - Writes
public func setModeId(_ id: String) {
defaults.set(id, forKey: Key.modeId)
}
public func setLocaleId(_ id: String) {
defaults.set(id, forKey: Key.localeId)
}
public func setRequiresOnDevice(_ value: Bool) {
defaults.set(value, forKey: Key.requiresOnDevice)
}
public func setEngineMode(_ mode: String) {
defaults.set(mode, forKey: Key.engineMode)
}
// MARK: - Client
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
baseURL: baseURL,
apiKey: apiKey,
model: model
)
}
// MARK: - Defaults
/// Per-provider default system prompt. We bias the prompt by the
/// provider's *primary* language so Chinese LLMs naturally return
/// Chinese for Chinese input, and English LLMs stay terse.
public static func defaultSystemPrompt(for providerId: String) -> String {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
return """
你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字:
1) 保留原意,不编造事实;保持输入语言。
2) 添加恰当的标点、大小写、段落。
3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。
4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。
5) 只输出润色后的正文,不要解释、不要加引号。
"""
default:
return """
You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text:
1) Preserve the user's original intent and meaning; do not invent facts.
2) Add proper punctuation, capitalization, and paragraph breaks.
3) When the user enumerates items ("first ... second ... third"), output a markdown list.
4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like).
5) Output in the same language as the input. No quotes, no explanation, no preamble.
"""
}
}
}