chore(typing): snapshot local pinyin WIP before syncing cloud branch

Preserve the in-progress local typing/pinyin implementation so feat/pinyin
can safely reset to origin/feat/pinyin (cloud English + Chinese typing).
This commit is contained in:
Rocky
2026-08-03 13:14:01 +08:00
parent c037be3654
commit d1e5fed964
58 changed files with 370539 additions and 121 deletions
@@ -0,0 +1,181 @@
// LibrimeEngine.swift
// OSGKeyboard · Shared
//
// Production Chinese IME backed by librime. Runtime access stays on the
// main actor because UIInputViewController and its text proxy are main-only;
// expensive schema deployment is performed by the host app beforehand.
import Foundation
@MainActor
public final class LibrimeEngine: RimeEngineBridging {
public private(set) var composition: TypingComposition = .empty
public private(set) var isReady = false
public private(set) var schema: TypingInputSchema
private var language: TypingInputLanguage = .chinese
private var bridge: OSGRimeBridge?
private let configurationProvider: () -> TypingInputConfigurationSnapshot
private let candidateLimit: Int
public init(
schema: TypingInputSchema = .fullPinyin,
candidateLimit: Int = 50,
configurationProvider: @escaping () -> TypingInputConfigurationSnapshot = {
TypingInputConfiguration.shared.snapshot
}
) {
self.schema = schema
self.candidateLimit = candidateLimit
self.configurationProvider = configurationProvider
}
public func prepare() async throws {
if isReady { return }
guard RimeResourceInstaller.isReady else {
throw RimeResourceError.resourcesNotInstalled
}
let paths = try RimeResourcePaths.resolve()
let runtime = OSGRimeBridge(
sharedDataDirectory: paths.sharedData.path,
userDataDirectory: paths.userData.path,
distributionVersion: RimeResourceInstaller.resourceVersion
)
try runtime.start()
let configured = configurationProvider()
schema = configured.schema
guard runtime.selectSchema(schema.rawValue) else {
runtime.stopSession()
throw LibrimeEngineError.schemaUnavailable(schema.rawValue)
}
_ = runtime.setASCIIMode(language == .english)
bridge = runtime
isReady = true
_ = refresh()
}
public func teardown() {
bridge?.clearComposition()
bridge?.stopSession()
bridge = nil
composition = .empty
isReady = false
}
public func setLanguage(_ language: TypingInputLanguage) {
self.language = language
_ = bridge?.setASCIIMode(language == .english)
if language == .english {
bridge?.clearComposition()
composition = .empty
} else {
_ = refresh()
}
}
@discardableResult
public func setSchema(_ schema: TypingInputSchema) -> Bool {
guard let bridge else {
self.schema = schema
return false
}
bridge.clearComposition()
guard bridge.selectSchema(schema.rawValue) else { return false }
self.schema = schema
composition = .empty
return true
}
public func processCharacter(_ character: Character) -> String? {
guard language == .chinese,
let scalar = character.asciiValue,
bridge?.processKeyCode(Int32(scalar), modifiers: 0) == true else {
return nil
}
return refresh()
}
public func processBackspace() -> String? {
guard bridge?.processKeyCode(OSGRimeKeyBackSpace, modifiers: 0) == true else {
return nil
}
return refresh()
}
public func processSpace() -> String? {
guard bridge?.processKeyCode(32, modifiers: 0) == true else {
return " "
}
return refresh()
}
public func processReturn() -> String? {
guard bridge?.processKeyCode(OSGRimeKeyReturn, modifiers: 0) == true else {
return "\n"
}
return refresh()
}
public func selectCandidate(at index: Int) -> String {
guard bridge?.selectCandidate(at: index) == true else { return "" }
return refresh() ?? ""
}
public func flushPreedit() -> String {
let raw = bridge?.rawInput() ?? ""
bridge?.clearComposition()
composition = .empty
return raw
}
public func clearComposition() {
bridge?.clearComposition()
composition = .empty
}
/// Copies librime-owned memory into Sendable Swift value types and returns
/// any commit emitted by the preceding key operation.
@discardableResult
private func refresh() -> String? {
guard let snapshot = bridge?.snapshot(withCandidateLimit: candidateLimit) else {
composition = .empty
return nil
}
let preedit = snapshot.preedit
composition = TypingComposition(
preedit: preedit,
candidates: snapshot.candidates.enumerated().map { index, candidate in
TypingCandidate(
id: "\(preedit)|\(index)|\(candidate.text)",
text: candidate.text,
annotation: candidate.comment.isEmpty ? nil : candidate.comment
)
}
)
return snapshot.commitText.isEmpty ? nil : snapshot.commitText
}
}
public enum LibrimeEngineError: LocalizedError {
case schemaUnavailable(String)
public var errorDescription: String? {
switch self {
case .schemaUnavailable(let id):
return "输入方案不可用:\(id)"
}
}
}
private extension Character {
var asciiValue: UInt8? {
guard let scalar = unicodeScalars.first,
unicodeScalars.count == 1,
scalar.value <= UInt8.max else {
return nil
}
return UInt8(scalar.value)
}
}
@@ -0,0 +1,76 @@
// RimeEngineBridging.swift
// OSGKeyboard · Shared
//
// Engine façade for the typing surface. `LibrimeEngine` is the production
// implementation; the protocol keeps SwiftUI independent of the C runtime.
import Foundation
/// Input language for the typing surface (not ASR locale).
public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
case chinese
case english
public var id: String { rawValue }
public var shortLabel: String {
switch self {
case .chinese: return ""
case .english: return ""
}
}
}
/// One candidate row item after composing.
public struct TypingCandidate: Identifiable, Equatable, Sendable {
public let id: String
public let text: String
public let annotation: String?
public init(id: String = UUID().uuidString, text: String, annotation: String? = nil) {
self.id = id
self.text = text
self.annotation = annotation
}
}
/// Snapshot the UI observes while composing.
public struct TypingComposition: Equatable, Sendable {
public var preedit: String
public var candidates: [TypingCandidate]
public init(preedit: String = "", candidates: [TypingCandidate] = []) {
self.preedit = preedit
self.candidates = candidates
}
public static let empty = TypingComposition()
}
/// Bridge between key events and IME state. Keep this small so Phase 2
/// can swap KeyboardKit-style shells or librime without UI rewrites.
@MainActor
public protocol RimeEngineBridging: AnyObject {
var composition: TypingComposition { get }
var isReady: Bool { get }
var schema: TypingInputSchema { get }
/// Load dictionaries / open session. Safe to call repeatedly.
func prepare() async throws
/// Drop heavy caches (leave typing mode / memory warning).
func teardown()
func setLanguage(_ language: TypingInputLanguage)
@discardableResult
func setSchema(_ schema: TypingInputSchema) -> Bool
/// Process one key and return newly committed text, if any.
func processCharacter(_ character: Character) -> String?
func processBackspace() -> String?
func processSpace() -> String?
func processReturn() -> String?
/// Commit candidate at index; returns text to insert (empty if invalid).
func selectCandidate(at index: Int) -> String
/// Force-commit current preedit as raw latin (or empty).
func flushPreedit() -> String
func clearComposition()
}
@@ -0,0 +1,169 @@
// RimeResourceInstaller.swift
// OSGKeyboard · Shared
//
// The host app owns Rime deployment. The keyboard extension only opens
// an already-built session, keeping expensive maintenance work out of
// the extension's constrained lifecycle.
import Foundation
import Darwin
public enum RimeResourceError: LocalizedError {
case appGroupUnavailable
case bundledResourceMissing(String)
case lockUnavailable
case deploymentFailed
case resourcesNotInstalled
public var errorDescription: String? {
switch self {
case .appGroupUnavailable:
return "App Group 不可用"
case .bundledResourceMissing(let name):
return "缺少输入法资源:\(name)"
case .lockUnavailable:
return "输入法资源正在被其他进程更新"
case .deploymentFailed:
return "输入法资源部署失败"
case .resourcesNotInstalled:
return "请先打开 OSGKeyboard 完成输入法初始化"
}
}
}
public struct RimeResourcePaths: Sendable {
public let root: URL
public let sharedData: URL
public let userData: URL
public let lockFile: URL
public static func resolve() throws -> RimeResourcePaths {
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroup.identifier
) else {
throw RimeResourceError.appGroupUnavailable
}
let root = container.appendingPathComponent("Rime", isDirectory: true)
return RimeResourcePaths(
root: root,
sharedData: root.appendingPathComponent("SharedSupport", isDirectory: true),
userData: root.appendingPathComponent("UserData", isDirectory: true),
lockFile: root.appendingPathComponent(".deployment.lock")
)
}
}
public actor RimeResourceInstaller {
public static let shared = RimeResourceInstaller()
public static let resourceVersion = "2.0.0"
public init() {}
public static var isReady: Bool {
guard TypingInputConfiguration.installedResourceVersion() == resourceVersion,
let paths = try? RimeResourcePaths.resolve() else {
return false
}
return FileManager.default.fileExists(
atPath: paths.userData.appendingPathComponent("build").path
)
}
/// Installs source data and asks librime to prebuild schemas. Call only
/// from the host app, never from the keyboard extension.
public func installIfNeeded(
configuration: TypingInputConfigurationSnapshot,
force: Bool = false
) throws {
if !force, Self.isReady { return }
let paths = try RimeResourcePaths.resolve()
let fileManager = FileManager.default
try fileManager.createDirectory(at: paths.root, withIntermediateDirectories: true)
try fileManager.createDirectory(at: paths.userData, withIntermediateDirectories: true)
let descriptor = open(paths.lockFile.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
guard descriptor >= 0 else { throw RimeResourceError.lockUnavailable }
defer {
flock(descriptor, LOCK_UN)
close(descriptor)
}
guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else {
throw RimeResourceError.lockUnavailable
}
let staging = paths.root.appendingPathComponent(
"SharedSupport.staging-\(UUID().uuidString)",
isDirectory: true
)
defer { try? fileManager.removeItem(at: staging) }
try fileManager.createDirectory(at: staging, withIntermediateDirectories: true)
for resource in ["osg_pinyin.dict", "manifest"] {
let ext = resource == "manifest" ? "json" : "yaml"
guard let source = Bundle(for: RimeResourceBundleToken.self).url(
forResource: resource,
withExtension: ext
) else {
throw RimeResourceError.bundledResourceMissing("\(resource).\(ext)")
}
try fileManager.copyItem(
at: source,
to: staging.appendingPathComponent("\(resource).\(ext)")
)
}
try RimeSchemaGenerator.defaultConfiguration().write(
to: staging.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(
for: schema,
fuzzyPairs: configuration.fuzzyPairs
).write(
to: staging.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
if fileManager.fileExists(atPath: paths.sharedData.path) {
try fileManager.removeItem(at: paths.sharedData)
}
try fileManager.moveItem(at: staging, to: paths.sharedData)
let bridge = OSGRimeBridge(
sharedDataDirectory: paths.sharedData.path,
userDataDirectory: paths.userData.path,
distributionVersion: Self.resourceVersion
)
do {
try bridge.deploy(withFullCheck: true)
} catch {
bridge.finalizeRuntime()
throw error
}
bridge.finalizeRuntime()
TypingInputConfiguration.setInstalledResourceVersion(Self.resourceVersion)
AppGroupConfigDarwin.postConfigChanged()
}
public func syncUserData() throws {
let paths = try RimeResourcePaths.resolve()
let bridge = OSGRimeBridge(
sharedDataDirectory: paths.sharedData.path,
userDataDirectory: paths.userData.path,
distributionVersion: Self.resourceVersion
)
try bridge.start()
// Destroying the session and finalizing librime flushes LevelDB
// user dictionaries. `sync_user_data` is for external Rime sync
// deployments and is intentionally not needed here.
bridge.finalizeRuntime()
}
}
private final class RimeResourceBundleToken: NSObject {}
@@ -0,0 +1,187 @@
// RimeSchemaGenerator.swift
// OSGKeyboard · Shared
//
// Generates OSG-owned schemas from public Microsoft/Sogou key maps.
// No GPL Rime schema files are copied or distributed.
import Foundation
public enum RimeSchemaGenerator {
public static func defaultConfiguration() -> String {
"""
# Generated by OSGKeyboard.
config_version: "1.0"
schema_list:
- schema: \(TypingInputSchema.fullPinyin.rawValue)
- schema: \(TypingInputSchema.microsoftDoublePinyin.rawValue)
- schema: \(TypingInputSchema.sogouDoublePinyin.rawValue)
switcher:
caption: 输入方案
hotkeys: []
menu:
page_size: 9
ascii_composer:
good_old_caps_lock: true
switch_key:
Shift_L: noop
Shift_R: noop
Control_L: noop
Control_R: noop
key_binder:
bindings: []
recognizer:
patterns: {}
"""
}
public static func schema(
for inputSchema: TypingInputSchema,
fuzzyPairs: Set<PinyinFuzzyPair>
) -> String {
let alphabet = inputSchema == .fullPinyin
? "zyxwvutsrqponmlkjihgfedcba"
: "zyxwvutsrqponmlkjihgfedcba;"
let algebra = fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
return """
# Generated by OSGKeyboard. Do not edit; change settings in the host app.
schema:
schema_id: \(inputSchema.rawValue)
name: \(inputSchema.displayName)
version: "1.0"
author:
- OSGKeyboard contributors
description: |
Commercially permissive OSG schema backed by osg_pinyin.
switches:
- name: ascii_mode
reset: 0
states: [中, 英]
engine:
processors:
- ascii_composer
- recognizer
- key_binder
- speller
- punctuator
- selector
- navigator
- express_editor
segmentors:
- ascii_segmentor
- matcher
- abc_segmentor
- punct_segmentor
- fallback_segmentor
translators:
- punct_translator
- script_translator
speller:
alphabet: "\(alphabet)"
initials: "\(alphabet.replacingOccurrences(of: ";", with: ""))"
delimiter: " '"
algebra:
\(algebraYAML)
translator:
dictionary: osg_pinyin
prism: \(inputSchema.rawValue)
enable_sentence: true
enable_completion: true
enable_user_dict: true
initial_quality: 1.2
punctuator:
half_shape:
",": ""
".": ""
"?": ""
"!": ""
key_binder:
import_preset: default
recognizer:
import_preset: default
"""
}
/// Rules run against full-pinyin dictionary codes before double-pinyin
/// transforms, so fuzzy pairs work consistently in all three schemas.
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
var rules: [String] = []
for pair in PinyinFuzzyPair.allCases where enabled.contains(pair) {
switch pair {
case .zhZ:
rules += ["derive/^zh/z/", "derive/^z([^h])/zh$1/"]
case .chC:
rules += ["derive/^ch/c/", "derive/^c([^h])/ch$1/"]
case .shS:
rules += ["derive/^sh/s/", "derive/^s([^h])/sh$1/"]
case .nL:
rules += ["derive/^n/l/", "derive/^l/n/"]
case .fH:
rules += ["derive/^f/h/", "derive/^h/f/"]
case .anAng:
rules += ["derive/ang$/an/", "derive/an$/ang/"]
case .enEng:
rules += ["derive/eng$/en/", "derive/en$/eng/"]
case .inIng:
rules += ["derive/ing$/in/", "derive/in$/ing/"]
}
}
return rules
}
private static func algebraRules(for schema: TypingInputSchema) -> [String] {
switch schema {
case .fullPinyin:
return [
"derive/^([jqxy])u$/$1v/",
"abbrev/^([a-z]).+$/$1/"
]
case .microsoftDoublePinyin, .sogouDoublePinyin:
// Microsoft and Sogou's commonly shipped layouts are key-compatible.
// Uppercase markers prevent transforms from matching each other.
return [
"erase/^xx$/",
"derive/^([jqxy])u$/$1v/",
"derive/^([aoe].*)$/o$1/",
"xform/^([ae])(.*)$/$1$1$2/",
"xform/iu$/Q/",
"xform/[iu]a$/W/",
"xform/er$|[uv]an$/R/",
"xform/[uv]e$/T/",
"xform/v$|uai$/Y/",
"xform/^sh/U/",
"xform/^ch/I/",
"xform/^zh/V/",
"xform/uo$/O/",
"xform/[uv]n$/P/",
"xform/(.)i?ong$/$1S/",
"xform/[iu]ang$/D/",
"xform/(.)en$/$1F/",
"xform/(.)eng$/$1G/",
"xform/(.)ang$/$1H/",
"xform/ian$/M/",
"xform/(.)an$/$1J/",
"xform/iao$/C/",
"xform/(.)ao$/$1K/",
"xform/(.)ai$/$1L/",
"xform/(.)ei$/$1Z/",
"xform/ie$/X/",
"xform/ui$/V/",
"derive/T$/V/",
"xform/(.)ou$/$1B/",
"xform/in$/N/",
"xform/ing$/;/",
"xlit/QWRTYUIOPSDFGHMJCKLZXVBN/qwrtyuiopsdfghmjcklzxvbn/"
]
}
}
}
@@ -0,0 +1,52 @@
// TypingLayoutProviding.swift
// OSGKeyboard · Shared
//
// Phase 2 escape hatch: replace the in-repo SwiftUI key shell with a
// KeyboardKit-based (or other) layout without changing the engine bridge.
import Foundation
/// Page shown by the typing key shell.
public enum TypingKeyPage: String, CaseIterable, Sendable {
case letters
case numbers
case symbols
}
/// Abstraction over which characters the current page shows.
/// The Phase 1 SwiftUI keyboard reads this; a future Kit-backed shell
/// can feed the same consumer.
public protocol TypingLayoutProviding: Sendable {
func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]]
}
/// Standard phone QWERTY + 123 + light symbols (NanoMouse / system-like).
public struct StandardTypingLayout: TypingLayoutProviding {
public init() {}
public func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]] {
switch page {
case .letters:
let upper = shiftActive
let map: (String) -> String = { upper ? $0.uppercased() : $0 }
return [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"].map(map),
["a", "s", "d", "f", "g", "h", "j", "k", "l"].map(map),
["", "z", "x", "c", "v", "b", "n", "m", ""].map { $0.count == 1 ? map($0) : $0 }
]
case .numbers:
return [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
["#+=", ".", ",", "?", "!", "'", ""]
]
case .symbols:
return [
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", "·"],
["123", ".", ",", "?", "!", "'", ""],
["", "", "", "", "", "", ""]
]
}
}
}
@@ -0,0 +1,191 @@
// TypingSessionController.swift
// OSGKeyboard · Shared
//
// Owns the typing-surface engine + layout provider. Injected into the
// keyboard extension; torn down when leaving typing mode.
import Foundation
import Combine
@MainActor
public final class TypingSessionController: ObservableObject {
@Published public private(set) var language: TypingInputLanguage = .chinese
@Published public private(set) var page: TypingKeyPage = .letters
@Published public private(set) var shiftActive: Bool = false
@Published public private(set) var capsLock: Bool = false
@Published public private(set) var composition: TypingComposition = .empty
@Published public private(set) var engineReady: Bool = false
@Published public private(set) var schema: TypingInputSchema
@Published public var lastError: String?
public let layout: TypingLayoutProviding
private let engine: RimeEngineBridging
private var prepared = false
public init(
engine: RimeEngineBridging = LibrimeEngine(),
layout: TypingLayoutProviding = StandardTypingLayout()
) {
self.engine = engine
self.layout = layout
schema = engine.schema
}
public var keyRows: [[String]] {
var rows = layout.rows(for: page, shiftActive: shiftActive || capsLock)
if page == .letters,
language == .chinese,
schema != .fullPinyin,
rows.indices.contains(2),
rows[2].first == "" {
// Microsoft/Sogou use semicolon for "ing"; Chinese composition
// does not need Shift, so keep the standard row width stable.
rows[2][0] = ";"
}
return rows
}
public func enterTypingMode() {
TypingInputConfiguration.shared.reload()
Task { await prepareIfNeeded() }
}
public func leaveTypingMode() {
engine.teardown()
prepared = false
engineReady = false
composition = .empty
page = .letters
shiftActive = false
capsLock = false
}
public func toggleLanguage() -> String {
let next: TypingInputLanguage = language == .chinese ? .english : .chinese
return setLanguage(next)
}
/// Selects a specific language for the shared voice / Chinese / English
/// capsule. Any active preedit is returned so callers can commit it
/// before switching modes.
public func setLanguage(_ newLanguage: TypingInputLanguage) -> String {
guard language != newLanguage else { return "" }
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
language = newLanguage
engine.setLanguage(newLanguage)
composition = engine.composition
page = .letters
return raw
}
/// Flushes raw preedit, selects the next built-in scheme, and returns the
/// raw text that the caller should insert before switching.
public func cycleSchema() -> String {
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
let schemas = TypingInputSchema.allCases
let current = schemas.firstIndex(of: schema) ?? 0
let next = schemas[(current + 1) % schemas.count]
if engine.setSchema(next) {
schema = next
TypingInputConfiguration.shared.schema = next
}
composition = engine.composition
return raw
}
public func setPage(_ page: TypingKeyPage) {
self.page = page
shiftActive = false
}
/// Handle a visible key label. Returns text the proxy should insert now
/// (may be empty when composing Chinese).
public func handleKey(_ label: String) -> String {
switch label {
case "":
if shiftActive {
capsLock = true
shiftActive = false
} else if capsLock {
capsLock = false
} else {
shiftActive = true
}
return ""
case "":
if language == .chinese, !engine.composition.preedit.isEmpty {
let committed = engine.processBackspace() ?? ""
composition = engine.composition
return committed
}
return "\u{8}" // sentinel: caller deletes backward
case "123":
setPage(.numbers)
return ""
case "#+=":
setPage(.symbols)
return ""
case "ABC", "abc":
setPage(.letters)
return ""
default:
break
}
if page != .letters {
// Number/symbol: insert directly
let out = label
if !capsLock { shiftActive = false }
return out
}
guard let ch = label.first else { return "" }
if language == .english {
let out = String(ch)
if !capsLock { shiftActive = false }
return out
}
// Chinese letters compose
let committed = engine.processCharacter(ch) ?? ""
composition = engine.composition
if !capsLock { shiftActive = false }
return committed
}
public func handleSpace() -> String {
if language == .english { return " " }
let text = engine.processSpace() ?? " "
composition = engine.composition
return text
}
public func handleReturn() -> String {
if language == .english { return "\n" }
let text = engine.processReturn() ?? "\n"
composition = engine.composition
return text
}
public func selectCandidate(at index: Int) -> String {
let text = engine.selectCandidate(at: index)
composition = engine.composition
return text
}
private func prepareIfNeeded() async {
guard !prepared else { return }
do {
try await engine.prepare()
engine.setLanguage(language)
prepared = true
engineReady = engine.isReady
schema = engine.schema
lastError = nil
} catch {
lastError = error.localizedDescription
engineReady = false
}
}
}