fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands

Adapt typing/voice surfaces for iPad width and height, add the system globe
key and last-input editing flow, harden host-only Rime deployment, and remove
clipboard voice commands. Bump build to 61.
This commit is contained in:
Rocky
2026-08-10 13:50:50 +08:00
parent 53abad2050
commit 2bc8c1b87d
85 changed files with 5703 additions and 3997 deletions
+6 -1
View File
@@ -23,17 +23,22 @@ public struct TypingKeyHitTarget: Equatable, Identifiable, Sendable {
public let label: String
public let visualFrame: CGRect
public let behavior: TypingKeyTouchBehavior
/// Optional small number rendered above a letter key (iPad top row,
/// mirroring the iOS system keyboard's number overlay). `nil` elsewhere.
public let displayNumber: String?
public init(
id: String,
label: String,
visualFrame: CGRect,
behavior: TypingKeyTouchBehavior
behavior: TypingKeyTouchBehavior,
displayNumber: String? = nil
) {
self.id = id
self.label = label
self.visualFrame = visualFrame
self.behavior = behavior
self.displayNumber = displayNumber
}
public var center: CGPoint {
@@ -17,11 +17,16 @@ public enum PersonalDictionaryRimeSync {
/// Safe from any executor work is hoppped onto the main actor.
public nonisolated static func scheduleAfterDictionaryChange() {
Task { @MainActor in
// `AppGroupStore` is shared with the keyboard extension, so iOS
// compilation alone cannot identify the host. Never schedule
// librime deployment from an `.appex` process.
guard RimeResourceInstaller.canDeployInCurrentProcess else { return }
scheduleOnMainActor()
}
}
public static func deployNow() async {
guard RimeResourceInstaller.canDeployInCurrentProcess else { return }
pending?.cancel()
pending = nil
await deploy(retryOnMemoryPressure: false)
@@ -51,7 +56,6 @@ public enum PersonalDictionaryRimeSync {
}
FlowSessionBridge.setHostHeavy(true)
defer { FlowSessionBridge.setHostHeavy(false) }
let typingConfig = TypingInputConfiguration.shared.snapshot
let dictionary = AppGroupStore().personalDictionary
@@ -61,8 +65,14 @@ public enum PersonalDictionaryRimeSync {
personalDictionary: dictionary,
force: false
)
// Notify only after releasing the host-heavy gate. Otherwise the
// keyboard receives the notification, retries immediately, sees
// the host as busy, and has no later event to trigger recovery.
FlowSessionBridge.setHostHeavy(false)
AppGroupConfigDarwin.postConfigChanged()
OSGDiag.log("rime.personalDictionary deploy done", category: "boot")
} catch {
FlowSessionBridge.setHostHeavy(false)
OSGDiag.log(
"rime.personalDictionary deploy failed error=\(error.localizedDescription)",
category: "boot"
@@ -14,6 +14,7 @@ public enum RimeResourceError: LocalizedError {
case lockUnavailable
case deploymentFailed
case resourcesNotInstalled
case hostAppRequired
public var errorDescription: String? {
switch self {
@@ -27,6 +28,21 @@ public enum RimeResourceError: LocalizedError {
return "输入法资源部署失败"
case .resourcesNotInstalled:
return "请先打开 OSGKeyboard 完成输入法初始化"
case .hostAppRequired:
return "输入法资源只能由 OSGKeyboard 主应用部署"
}
}
/// Whether opening the host app can actually resolve this failure. Only
/// host-side deployment fixes missing or broken resources; App Group and
/// lock failures resolve on their own.
public var isResolvedByHostDeployment: Bool {
switch self {
case .resourcesNotInstalled, .deploymentFailed, .bundledResourceMissing,
.hostAppRequired:
return true
case .appGroupUnavailable, .lockUnavailable:
return false
}
}
}
@@ -70,6 +86,17 @@ public actor RimeResourceInstaller {
)
}
/// Full deployment is forbidden inside an app-extension process. Keeping
/// this check beside the heavy operation makes the host-only contract
/// enforceable even though the readiness API lives in the shared framework.
public static var canDeployInCurrentProcess: Bool {
canDeploy(bundleURL: Bundle.main.bundleURL)
}
static func canDeploy(bundleURL: URL) -> Bool {
bundleURL.pathExtension.lowercased() != "appex"
}
/// Installs source data and asks librime to prebuild schemas. Call only
/// from the host app, never from the keyboard extension.
///
@@ -80,6 +107,10 @@ public actor RimeResourceInstaller {
personalDictionary: PersonalDictionary? = nil,
force: Bool = false
) throws {
guard Self.canDeployInCurrentProcess else {
throw RimeResourceError.hostAppRequired
}
let dictionary = personalDictionary ?? AppGroupStore().personalDictionary
let personalYAML = try Self.makePersonalDictionaryYAML(from: dictionary)
let personalFingerprint = RimePersonalDictionaryExporter.fingerprint(of: personalYAML)
@@ -175,7 +206,6 @@ public actor RimeResourceInstaller {
TypingInputConfiguration.setInstalledResourceVersion(Self.resourceVersion)
TypingInputConfiguration.setInstalledPersonalDictionaryFingerprint(personalFingerprint)
AppGroupConfigDarwin.postConfigChanged()
}
private static func makePersonalDictionaryYAML(
+110 -9
View File
@@ -60,6 +60,12 @@ public enum TypingKeyLayoutBuilder {
public var bottomActionSpacing: CGFloat
/// Gap between the last letter row and the bottom action row.
public var gridToBottomSpacing: CGFloat
/// When true, `secondRowInset` is ignored and the second row is inset
/// so its keys are exactly as wide as the first row's, leaving a half
/// key at each end what the system keyboard does. A fixed inset is a
/// fraction of a 700 pt column and stops reading as a deliberate
/// indent once the grid fills an iPad's width.
public var derivesSecondRowInsetFromKeyWidth: Bool
public init(
keyRowHeight: CGFloat = 50,
@@ -68,7 +74,8 @@ public enum TypingKeyLayoutBuilder {
secondRowInset: CGFloat = 18,
bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight,
bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: CGFloat = 7
gridToBottomSpacing: CGFloat = 7,
derivesSecondRowInsetFromKeyWidth: Bool = false
) {
self.keyRowHeight = keyRowHeight
self.keyRowSpacing = keyRowSpacing
@@ -77,16 +84,53 @@ public enum TypingKeyLayoutBuilder {
self.bottomRowHeight = bottomRowHeight
self.bottomActionSpacing = bottomActionSpacing
self.gridToBottomSpacing = gridToBottomSpacing
self.derivesSecondRowInsetFromKeyWidth = derivesSecondRowInsetFromKeyWidth
}
}
/// Inset that makes `row` keys as wide as a full `referenceCount` row.
/// Both rows are laid out at the same unit width, so the second row simply
/// gives back the width of the keys it does not have, split evenly.
static func derivedSecondRowInset(
totalWidth: CGFloat,
referenceCount: Int,
rowCount: Int,
spacing: CGFloat,
weightTotal: CGFloat,
referenceWeightTotal: CGFloat
) -> CGFloat {
guard referenceCount > 0, rowCount > 0, referenceWeightTotal > 0 else { return 0 }
let referenceSpacing = spacing * CGFloat(max(0, referenceCount - 1))
let unitWidth = (totalWidth - referenceSpacing) / referenceWeightTotal
let rowWidth = unitWidth * weightTotal + spacing * CGFloat(max(0, rowCount - 1))
return max(0, (totalWidth - rowWidth) / 2)
}
/// Bottom-row semantic labels used by the touch pad (not always the glyph).
/// When present on iPad, the globe key is excluded from the touch pad's hit
/// testing its `SystemGlobeKey` UIButton handles tap (advance) /
/// long-press (system input-mode list) directly.
public enum BottomKeyID: String, Sendable {
case globe = "bottom.globe"
case pageSwitch = "bottom.page"
case comma = "bottom.comma"
case space = "bottom.space"
case period = "bottom.period"
case `return` = "bottom.return"
}
/// Comma / period glyphs for the iPad bottom row. `nil` keeps the phone's
/// four-slot row.
public struct PunctuationKeys: Equatable, Sendable {
public let comma: String
public let period: String
public init(comma: String, period: String) {
self.comma = comma
self.period = period
}
}
public static func build(
size: CGSize,
letterRows: [[String]],
@@ -94,14 +138,37 @@ public enum TypingKeyLayoutBuilder {
spaceLabel: String,
returnLabel: String,
metrics: Metrics = Metrics(),
includeGlobeKey: Bool = true,
punctuationKeys: PunctuationKeys? = nil,
showTopRowNumbers: Bool = false,
topRowNumbers: [String] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
keyWeight: (_ label: String, _ index: Int, _ rowIndex: Int) -> CGFloat
) -> TypingKeyLayout {
var keys: [TypingKeyHitTarget] = []
var cursorY: CGFloat = 0
let firstRow = letterRows.first ?? []
let firstRowWeightTotal = firstRow.enumerated()
.map { keyWeight($0.element, $0.offset, 0) }
.reduce(0, +)
for (rowIndex, row) in letterRows.enumerated() {
let inset = rowIndex == 1 ? metrics.secondRowInset : 0
let weights = row.enumerated().map { keyWeight($0.element, $0.offset, rowIndex) }
let inset: CGFloat
if rowIndex == 1 {
inset = metrics.derivesSecondRowInsetFromKeyWidth
? derivedSecondRowInset(
totalWidth: size.width,
referenceCount: firstRow.count,
rowCount: row.count,
spacing: metrics.keyHorizontalSpacing,
weightTotal: weights.reduce(0, +),
referenceWeightTotal: firstRowWeightTotal
)
: metrics.secondRowInset
} else {
inset = 0
}
let spacingTotal = metrics.keyHorizontalSpacing * CGFloat(max(0, row.count - 1))
let availableWidth = size.width - inset * 2 - spacingTotal
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
@@ -115,12 +182,20 @@ public enum TypingKeyLayoutBuilder {
width: width,
height: metrics.keyRowHeight
)
// iPad top letter row carries the small number overlay (10),
// mirroring the iOS system keyboard. Interior rows don't.
let number = (showTopRowNumbers
&& rowIndex == 0
&& keyIndex < topRowNumbers.count)
? topRowNumbers[keyIndex]
: nil
keys.append(
TypingKeyHitTarget(
id: "grid.\(rowIndex).\(keyIndex)",
label: label,
visualFrame: frame,
behavior: TypingKeyBehaviorResolver.behavior(for: label)
behavior: TypingKeyBehaviorResolver.behavior(for: label),
displayNumber: number
)
)
x += width + metrics.keyHorizontalSpacing
@@ -134,12 +209,38 @@ public enum TypingKeyLayoutBuilder {
cursorY += metrics.gridToBottomSpacing
let bottomY = cursorY
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: size.width)
let bottomFrames: [(String, String, CGFloat)] = [
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side)
]
// On iPad the globe lives at the far-left as a UIKit-backed key
// (handled by SystemGlobeKey); registering its frame reserves the slot
// and lets the touch pad skip hit-testing it. iPhone omits the slot.
let bottomFrames: [(String, String, CGFloat)]
if let punctuationKeys {
let widths = KeyboardChromeLayout.iPadActionKeyWidths(availableWidth: size.width)
bottomFrames = [
(BottomKeyID.globe.rawValue, "", widths.globe),
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.pageSwitch),
(BottomKeyID.comma.rawValue, punctuationKeys.comma, widths.comma),
(BottomKeyID.space.rawValue, spaceLabel, widths.space),
(BottomKeyID.period.rawValue, punctuationKeys.period, widths.period),
(BottomKeyID.return.rawValue, returnLabel, widths.return)
]
} else if includeGlobeKey {
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: size.width)
bottomFrames = [
(BottomKeyID.globe.rawValue, "", widths.globe),
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side2)
]
} else {
let widths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
availableWidth: size.width
)
bottomFrames = [
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side2)
]
}
var bottomX: CGFloat = 0
for (index, item) in bottomFrames.enumerated() {
@@ -21,6 +21,9 @@ public final class TypingSessionController: ObservableObject {
/// Chinese-only: key grid replaced by a same-height candidate grid.
@Published public private(set) var isCandidatePanelExpanded: Bool = false
@Published public var lastError: String?
/// `true` when `lastError` can only be cleared by deploying resources in
/// the host app drives the keyboard's tappable setup affordance.
@Published public private(set) var lastErrorNeedsHostDeployment: Bool = false
/// When true, English suggestions / autocorrect stay off (secure fields).
@Published public var suggestionsEnabled: Bool = true
@@ -663,6 +666,7 @@ public final class TypingSessionController: ObservableObject {
engineReady = engine.isReady
schema = engine.schema
lastError = nil
lastErrorNeedsHostDeployment = false
if language == .english {
refreshEnglishSuggestions()
}
@@ -672,6 +676,8 @@ public final class TypingSessionController: ObservableObject {
)
} catch {
lastError = error.localizedDescription
lastErrorNeedsHostDeployment =
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
engineReady = false
OSGDiag.log(
"rime.prepare failed error=\(error.localizedDescription) \(OSGDiag.memoryTag())",
@@ -679,4 +685,19 @@ public final class TypingSessionController: ObservableObject {
)
}
}
/// Retries a previously failed prepare once host-side resources land.
/// Driven by the App Group config Darwin notification the host posts after
/// a successful deployment, so a keyboard already showing the setup error
/// recovers without the user switching surfaces.
public func retryPrepareAfterResourceDeployment() {
guard !prepared, lastError != nil else { return }
guard prepareTask == nil else { return }
guard RimeResourceInstaller.isReady else { return }
OSGDiag.log("rime.prepare retry after deployment", category: "boot")
prepareTask = Task { [weak self] in
await self?.prepareIfNeeded()
self?.prepareTask = nil
}
}
}