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:
@@ -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(
|
||||
|
||||
@@ -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 (1–0),
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user