checkpoint before checking out main
This commit is contained in:
@@ -13,7 +13,7 @@ public struct CardPageContent<Content: View>: View {
|
||||
private let content: Content
|
||||
|
||||
public init(
|
||||
spacing: CGFloat = Spacing.md,
|
||||
spacing: CGFloat = CardLayoutMetrics.sectionSpacing,
|
||||
topPadding: CGFloat = Spacing.md,
|
||||
bottomPadding: CGFloat = Spacing.md,
|
||||
@ViewBuilder content: () -> Content
|
||||
@@ -95,12 +95,8 @@ public struct SurfaceCardModifier: ViewModifier {
|
||||
in: shape
|
||||
)
|
||||
// Clip child backgrounds as well as the card surface. Without
|
||||
// this, a full-width child can visually square off a corner
|
||||
// even though the shared background and border use Radius.xl.
|
||||
// this, a full-width child can visually square off a corner.
|
||||
.clipShape(shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
@@ -158,6 +158,14 @@ public enum Spacing {
|
||||
public static let hero: CGFloat = 48
|
||||
}
|
||||
|
||||
/// Semantic spacing for sibling card surfaces.
|
||||
public enum CardLayoutMetrics {
|
||||
/// Vertical gap between page-level cards or card sections.
|
||||
public static let sectionSpacing: CGFloat = 18
|
||||
/// Horizontal and vertical gap between compact cards or list-item cards.
|
||||
public static let compactItemSpacing: CGFloat = Spacing.xs
|
||||
}
|
||||
|
||||
// MARK: - Corner radius scale
|
||||
|
||||
public enum Radius {
|
||||
@@ -227,10 +235,6 @@ private struct CardSurfaceModifier: ViewModifier {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,10 +271,6 @@ private struct SecondaryButtonModifier: ViewModifier {
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Flat semantic surface used by home / dashboard stats on every platform.
|
||||
// Deliberately shadowless — hierarchy comes from fill + hairline border.
|
||||
// Deliberately shadowless — hierarchy comes from the semantic surface fill.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -30,8 +30,5 @@ public struct UsageSurfaceCard<Content: View>: View {
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.clipShape(shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var uiLanguage: AppUILanguage
|
||||
public var translationTargetLocaleId: String
|
||||
public var handednessPreference: HandednessPreference
|
||||
/// Decode-only compatibility field for cursor drag pads removed in 2.0.0.
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
/// Typing-grid haptic strength (off / light / strong).
|
||||
public var keyboardHapticIntensity: KeyboardHapticIntensity
|
||||
@@ -300,7 +301,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
),
|
||||
cursorDragNavigationEnabled: {
|
||||
if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||
}(),
|
||||
@@ -594,17 +595,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return .empty
|
||||
}
|
||||
do {
|
||||
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
if dictionary.entries.contains(where: { $0.source == .history }) {
|
||||
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
||||
dictionary.entries[index].source = .manual
|
||||
}
|
||||
dictionary.version += 1
|
||||
if let migrated = try? JSONEncoder().encode(dictionary) {
|
||||
defaults.set(migrated, forKey: Keys.personalDictionary)
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
} catch {
|
||||
OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return .empty
|
||||
|
||||
@@ -39,12 +39,11 @@ public enum KeyboardChromeLayout {
|
||||
public static let horizontalInset: CGFloat = 8
|
||||
/// Voice-surface content column cap.
|
||||
///
|
||||
/// The voice surface is a sparse cluster — two cursor-drag pads flanking a
|
||||
/// fixed 121 pt mic — over a transparent background, so filling an iPad's
|
||||
/// width buys no visual width; it only parks delete/return at the screen
|
||||
/// edges and turns each drag pad into a ~450 pt runway. The typing surface
|
||||
/// has the opposite need (a key grid must fill the width to match the
|
||||
/// system keyboard), which is why it no longer shares this constant.
|
||||
/// The voice surface is a sparse control cluster over a transparent
|
||||
/// background, so filling an iPad's width buys no visual width and only
|
||||
/// parks delete/return at the screen edges. The typing surface has the
|
||||
/// opposite need (a key grid must fill the width to match the system
|
||||
/// keyboard), which is why it no longer shares this constant.
|
||||
public static let voiceContentMaxWidth: CGFloat = 700
|
||||
|
||||
/// Width at or above which the typing surface switches to wide-iPad
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// Sources (mutually exclusive per entry):
|
||||
// - `.manual` user typed it in by hand
|
||||
// - `.history` legacy auto-learned entries (migrated to `.manual`)
|
||||
// - `.history` user-confirmed recommendations from typing history
|
||||
// - `.contacts` imported from the iOS Contacts framework
|
||||
// - `.recentEdit` extracted from edits the user made to a
|
||||
// polished transcript before sending
|
||||
@@ -274,12 +274,28 @@ extension PersonalDictionary {
|
||||
return entries.first { $0.term.lowercased() == key }
|
||||
}
|
||||
|
||||
/// Insert or update a manual entry. Returns the saved entry.
|
||||
/// Insert or update a manual entry. Kept as a convenience for existing callers.
|
||||
@discardableResult
|
||||
public mutating func upsertManual(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
regenerateAliases: Bool = false
|
||||
) -> Entry? {
|
||||
upsert(
|
||||
term: term,
|
||||
existingID: existingID,
|
||||
source: .manual,
|
||||
regenerateAliases: regenerateAliases
|
||||
)
|
||||
}
|
||||
|
||||
/// Insert or update an entry while preserving its user-visible origin.
|
||||
@discardableResult
|
||||
public mutating func upsert(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
source: Entry.Source,
|
||||
regenerateAliases: Bool = false
|
||||
) -> Entry? {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
@@ -292,7 +308,7 @@ extension PersonalDictionary {
|
||||
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entry.source = source
|
||||
if termChanged || regenerateAliases {
|
||||
entry.aliases = []
|
||||
}
|
||||
@@ -307,7 +323,7 @@ extension PersonalDictionary {
|
||||
var entry = entries[idx]
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entry.source = source
|
||||
entry.updatedAt = Date()
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
@@ -318,7 +334,7 @@ extension PersonalDictionary {
|
||||
term: trimmed,
|
||||
aliases: [],
|
||||
category: category,
|
||||
source: .manual,
|
||||
source: source,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
@@ -113,7 +113,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, localeId != configuration.localeId else { return }
|
||||
configuration.localeId = localeId
|
||||
persistConfiguration()
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + user's LLM polish (requires user API key).
|
||||
@@ -220,16 +220,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
cursorDragNavigationEnabled != configuration.cursorDragNavigationEnabled else { return }
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing-grid haptic strength (off / light / strong). Default is light.
|
||||
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity {
|
||||
didSet {
|
||||
@@ -445,7 +435,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
uiLanguage = configuration.uiLanguage
|
||||
translationTargetLocaleId = configuration.translationTargetLocaleId
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
keyboardHapticIntensity = configuration.keyboardHapticIntensity
|
||||
polishIntensity = configuration.polishIntensity
|
||||
aiResponseLength = configuration.aiResponseLength
|
||||
@@ -551,7 +540,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
uiLanguage = fresh.uiLanguage
|
||||
translationTargetLocaleId = fresh.translationTargetLocaleId
|
||||
handednessPreference = fresh.handednessPreference
|
||||
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
|
||||
keyboardHapticIntensity = fresh.keyboardHapticIntensity
|
||||
polishIntensity = fresh.polishIntensity
|
||||
aiResponseLength = fresh.aiResponseLength
|
||||
|
||||
@@ -70,6 +70,18 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
mergedCatalog.filter { !layout.isEnabled($0.id) }
|
||||
}
|
||||
|
||||
public var skillManagementEnabledSkills: [AIClipboardSkill] {
|
||||
enabledSkills.filter {
|
||||
!AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id)
|
||||
}
|
||||
}
|
||||
|
||||
public var skillManagementAvailableSkills: [AIClipboardSkill] {
|
||||
availableSkills.filter {
|
||||
!AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id)
|
||||
}
|
||||
}
|
||||
|
||||
public func userSkill(id: String) -> AIUserSkill? {
|
||||
userCatalog.skill(id: id)
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
empathyReplyID,
|
||||
businessReplyID
|
||||
]
|
||||
/// Contextual system actions remain available to semantic ranking but are
|
||||
/// not user-managed entries in the host app's Skills catalog.
|
||||
public static let hiddenFromSkillManagementIDs: Set<String> = [
|
||||
replyID,
|
||||
declineInvitationID,
|
||||
empathyReplyID,
|
||||
acceptInvitationID,
|
||||
callPhoneID,
|
||||
createContactID,
|
||||
clarifyRequestID
|
||||
]
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||
|
||||
@@ -86,7 +86,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
public var translationEnabled: Bool { configuration.translationEnabled }
|
||||
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
|
||||
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
|
||||
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
|
||||
public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity }
|
||||
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
|
||||
public var aiResponseLength: AIResponseLength { configuration.aiResponseLength }
|
||||
@@ -140,6 +139,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
|
||||
public func setLocaleId(_ id: String) {
|
||||
mutateConfiguration { $0.localeId = id }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setEngineMode(_ mode: String) {
|
||||
@@ -180,11 +180,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
||||
mutateConfiguration { $0.cursorDragNavigationEnabled = enabled }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) {
|
||||
mutateConfiguration { $0.keyboardHapticIntensity = intensity }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
@@ -438,6 +433,19 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
// v6 persists canonical IDs for the consolidated reply, summary,
|
||||
// and clarification skills. `sanitized` performs the mapping.
|
||||
if storedMigrationVersion < 7 {
|
||||
// Product baseline: install these text skills once for existing
|
||||
// layouts. Skill-management visibility is a separate policy.
|
||||
additionIDs.formUnion([
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
])
|
||||
}
|
||||
let additions = catalog.map(\.id).filter {
|
||||
additionIDs.contains($0) && !decoded.enabledIDs.contains($0)
|
||||
}
|
||||
@@ -463,7 +471,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 6
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 7
|
||||
|
||||
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
|
||||
@@ -547,6 +555,28 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Applies a read-modify-write operation to the latest persisted dictionary.
|
||||
///
|
||||
/// Returning `nil` cancels the write. Callers must not mutate the dictionary
|
||||
/// before returning `nil`.
|
||||
@discardableResult
|
||||
public func updatePersonalDictionary<Result>(
|
||||
_ transform: (inout PersonalDictionary) -> Result?
|
||||
) -> (dictionary: PersonalDictionary, result: Result)? {
|
||||
var output: (dictionary: PersonalDictionary, result: Result)?
|
||||
mutateConfiguration { config in
|
||||
guard let result = transform(&config.personalDictionary) else { return }
|
||||
output = (config.personalDictionary, result)
|
||||
}
|
||||
guard let output else { return nil }
|
||||
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
#if os(iOS)
|
||||
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
|
||||
#endif
|
||||
return output
|
||||
}
|
||||
|
||||
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
|
||||
mutateConfiguration { config in
|
||||
config.personalDictionary.entries.removeAll { $0.id == id }
|
||||
|
||||
@@ -31,8 +31,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns only skills supported by current semantic evidence. No matching
|
||||
/// label produces no recommendation instead of a fixed fallback row.
|
||||
/// Returns semantically relevant skills and always keeps the generic Reply
|
||||
/// action available as a safe fallback for accepted clipboard text.
|
||||
public static func recommended(
|
||||
skills: [AIClipboardSkill],
|
||||
sourceText: String,
|
||||
@@ -42,22 +42,42 @@ public enum ClipboardSkillSemanticRanker {
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [AIClipboardSkill] {
|
||||
guard limit > 0 else { return [] }
|
||||
let scores = relevanceScores(
|
||||
var scores = relevanceScores(
|
||||
sourceText: sourceText,
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID }
|
||||
if genericReply != nil {
|
||||
scores[AIClipboardSkillCatalog.replyID, default: 0] = max(
|
||||
1,
|
||||
scores[AIClipboardSkillCatalog.replyID, default: 0]
|
||||
)
|
||||
}
|
||||
let relevant = skills.filter { scores[$0.id, default: 0] > 0 }
|
||||
var selected: [AIClipboardSkill] = []
|
||||
var replyCount = 0
|
||||
var specializedReplyCount = 0
|
||||
for skill in sorted(relevant, scores: scores) {
|
||||
guard selected.count < limit else { break }
|
||||
let mustReserveGenericReply = genericReply != nil
|
||||
&& !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID })
|
||||
&& skill.id != AIClipboardSkillCatalog.replyID
|
||||
let availableCount = limit - (mustReserveGenericReply ? 1 : 0)
|
||||
guard selected.count < availableCount else { continue }
|
||||
if skill.id == AIClipboardSkillCatalog.replyID {
|
||||
selected.append(skill)
|
||||
continue
|
||||
}
|
||||
if skill.supportsReplyStyle {
|
||||
guard replyCount < maximumReplyRecommendations else { continue }
|
||||
replyCount += 1
|
||||
guard specializedReplyCount < maximumReplyRecommendations else { continue }
|
||||
specializedReplyCount += 1
|
||||
}
|
||||
selected.append(skill)
|
||||
}
|
||||
if let genericReply,
|
||||
selected.count < limit,
|
||||
!selected.contains(where: { $0.id == genericReply.id }) {
|
||||
selected.append(genericReply)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var returnKeyRole: ReturnKeyRole = .newline
|
||||
/// Opt-in clipboard history capture (mirrored from App Group).
|
||||
@Published public var clipboardHistoryEnabled: Bool = false
|
||||
/// Current extension permission state. Clipboard history remains readable
|
||||
/// without Full Access, but new system pasteboard entries cannot be captured.
|
||||
@Published public var hasFullAccess: Bool = false
|
||||
public var needsClipboardFullAccessHint: Bool {
|
||||
clipboardHistoryEnabled && !hasFullAccess
|
||||
}
|
||||
/// Opt-in clipboard suggestion strip (requires history enabled).
|
||||
@Published public var clipboardCandidateBarEnabled: Bool = false
|
||||
/// Skills-tab order for clipboard chips. Empty → hint carousel.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// PersonalDictionaryEntryService.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Keeps every user-confirmed term on the same save, alias-generation,
|
||||
// and cloud-sync path, regardless of which UI initiated the change.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class PersonalDictionaryEntryService {
|
||||
public typealias AliasGeneration = @MainActor (String) async -> [String]
|
||||
public typealias CloudPush = @MainActor (PersonalDictionary) async -> Void
|
||||
|
||||
public struct SavedEntry: Sendable {
|
||||
public let entryID: UUID
|
||||
public let term: String
|
||||
public let source: PersonalDictionary.Entry.Source
|
||||
public let dictionary: PersonalDictionary
|
||||
public let shouldGenerateAliases: Bool
|
||||
}
|
||||
|
||||
private let store: AppGroupStore
|
||||
private let generateAliases: AliasGeneration
|
||||
private let pushToCloud: CloudPush
|
||||
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
aliasGeneration: AliasGeneration? = nil,
|
||||
cloudPush: CloudPush? = nil
|
||||
) {
|
||||
self.store = store
|
||||
if let aliasGeneration {
|
||||
generateAliases = aliasGeneration
|
||||
} else {
|
||||
let generator = DictionaryAliasGenerator()
|
||||
generateAliases = { term in
|
||||
await generator.generateAliases(for: term)
|
||||
}
|
||||
}
|
||||
pushToCloud = cloudPush ?? { dictionary in
|
||||
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves immediately so alias generation never blocks the user's action.
|
||||
public func saveEntry(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
source: PersonalDictionary.Entry.Source,
|
||||
minimumUsageCount: Int? = nil
|
||||
) -> SavedEntry? {
|
||||
var shouldGenerateAliases = false
|
||||
guard let mutation = store.updatePersonalDictionary({ dictionary -> PersonalDictionary.Entry? in
|
||||
let previousTerm = existingID.flatMap { id in
|
||||
dictionary.entries.first(where: { $0.id == id })?.term
|
||||
}
|
||||
let termChanged = previousTerm.map {
|
||||
$0.caseInsensitiveCompare(term) != .orderedSame
|
||||
} ?? true
|
||||
|
||||
guard let saved = dictionary.upsert(
|
||||
term: term,
|
||||
existingID: existingID,
|
||||
source: source
|
||||
),
|
||||
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
|
||||
return nil
|
||||
}
|
||||
if let minimumUsageCount {
|
||||
dictionary.entries[index].usageCount = max(
|
||||
dictionary.entries[index].usageCount,
|
||||
minimumUsageCount
|
||||
)
|
||||
}
|
||||
dictionary.version += 1
|
||||
shouldGenerateAliases = existingID == nil || termChanged
|
||||
return dictionary.entries[index]
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SavedEntry(
|
||||
entryID: mutation.result.id,
|
||||
term: mutation.result.term,
|
||||
source: mutation.result.source,
|
||||
dictionary: mutation.dictionary,
|
||||
shouldGenerateAliases: shouldGenerateAliases
|
||||
)
|
||||
}
|
||||
|
||||
/// Finishes the asynchronous work against the latest dictionary snapshot.
|
||||
///
|
||||
/// The identity and canonical term checks prevent a late LLM response from
|
||||
/// restoring a deleted entry or attaching aliases after the term changed.
|
||||
@discardableResult
|
||||
public func finishSaving(_ saved: SavedEntry) async -> PersonalDictionary {
|
||||
await pushToCloud(store.personalDictionary)
|
||||
guard saved.shouldGenerateAliases else { return store.personalDictionary }
|
||||
|
||||
let aliases = await generateAliases(saved.term)
|
||||
guard !aliases.isEmpty else { return store.personalDictionary }
|
||||
|
||||
guard let mutation = store.updatePersonalDictionary({ dictionary -> UUID? in
|
||||
guard let current = dictionary.entries.first(where: { $0.id == saved.entryID }),
|
||||
current.term == saved.term,
|
||||
current.source == saved.source else {
|
||||
return nil
|
||||
}
|
||||
dictionary.updateAliases(for: saved.entryID, aliases: aliases)
|
||||
dictionary.version += 1
|
||||
return saved.entryID
|
||||
}) else {
|
||||
return store.personalDictionary
|
||||
}
|
||||
|
||||
await pushToCloud(mutation.dictionary)
|
||||
// Cloud sync may merge remote entries into local storage while pushing.
|
||||
return store.personalDictionary
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
|
||||
}
|
||||
|
||||
public enum PolishStyleLearningCorpusBuilder {
|
||||
public static let requiredEffectiveCharacterCount = 5_000
|
||||
public static let requiredEffectiveCharacterCount = 2_500
|
||||
|
||||
public static func build(
|
||||
from entries: [SpeechHistoryEntry]
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SettingsDeepLink: String, Sendable {
|
||||
public enum SettingsDeepLink: String, Sendable, Equatable {
|
||||
case aiService
|
||||
case speechRecognition
|
||||
case textPolish
|
||||
case clipboard
|
||||
|
||||
private static let pendingKey = "settings.pendingDeepLink"
|
||||
|
||||
@@ -19,11 +19,11 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
dictationCharacterCount + translationCharacterCount + aiCharacterCount
|
||||
}
|
||||
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
|
||||
/// the home page's 7-day chart.
|
||||
/// the home page and dashboard usage visualizations.
|
||||
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
|
||||
|
||||
/// How many days of daily buckets to retain on disk. Well beyond the 7-day
|
||||
/// chart window so a device that syncs in late still contributes recent days.
|
||||
/// How many days of daily buckets to retain on disk. This preserves several
|
||||
/// complete monthly views when a device syncs late.
|
||||
private static let dailyRetentionDays = 90
|
||||
|
||||
public let defaults: UserDefaults
|
||||
@@ -121,13 +121,18 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
|
||||
}
|
||||
|
||||
// MARK: - 7-day chart data
|
||||
// MARK: - Daily chart data
|
||||
|
||||
/// One day's dictation total for the home page chart.
|
||||
/// One day's dictation total for home and dashboard visualizations.
|
||||
public struct DailyUsagePoint: Identifiable, Equatable, Sendable {
|
||||
public let date: Date
|
||||
public let value: Int
|
||||
public var id: Date { date }
|
||||
|
||||
public init(date: Date, value: Int) {
|
||||
self.date = date
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing 7 local days (oldest → newest), zero-filled for days with no
|
||||
@@ -151,6 +156,34 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
return points
|
||||
}
|
||||
|
||||
/// Every local-calendar day in the current month, including future days.
|
||||
/// Missing buckets are zero-filled so the UI can always render a complete
|
||||
/// calendar instead of changing shape as usage accumulates.
|
||||
public var currentMonth: [DailyUsagePoint] {
|
||||
Self.currentMonth(from: dailyDictationCharacters)
|
||||
}
|
||||
|
||||
public static func currentMonth(
|
||||
from daily: [String: Int],
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> [DailyUsagePoint] {
|
||||
let monthComponents = calendar.dateComponents([.year, .month], from: now)
|
||||
guard let monthStart = calendar.date(from: monthComponents),
|
||||
let dayRange = calendar.range(of: .day, in: .month, for: monthStart)
|
||||
else { return [] }
|
||||
|
||||
return dayRange.compactMap { day in
|
||||
guard let date = calendar.date(
|
||||
byAdding: .day,
|
||||
value: day - 1,
|
||||
to: monthStart
|
||||
) else { return nil }
|
||||
let key = UsageStatisticsDayKey.key(for: date, calendar: calendar)
|
||||
return DailyUsagePoint(date: date, value: daily[key] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time cleanup: the pre-fix code overwrote a device slice with the
|
||||
/// cross-device *sum*, so every reload/record re-added the other devices'
|
||||
/// totals and one slice ballooned to ~8× the true usage. We can't recover
|
||||
|
||||
@@ -60,6 +60,9 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
public static let slotCount = 3
|
||||
/// In-vocabulary words only yield to a much more common transposition / neighbor.
|
||||
public static let inVocabularyFrequencyGap = 250
|
||||
/// Rare words benefit from explicit personal-dictionary treatment; common
|
||||
/// words are already covered well by the bundled lexicon.
|
||||
public static let personalTermFrequencyCeiling = 680
|
||||
|
||||
private let lexicon: EnglishLexicon
|
||||
|
||||
@@ -150,6 +153,36 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether repeated use of this word would add value to PersonalDictionary.
|
||||
/// Existing personal/system terms and common lexicon words need no prompt.
|
||||
func isPersonalTermCandidate(
|
||||
_ word: String,
|
||||
personalTerms: [String],
|
||||
systemWords: [String]
|
||||
) -> Bool {
|
||||
guard FrequentTermStore.normalizedCandidate(from: word) != nil,
|
||||
PersonalDictionary.isEnglishTypingHotword(word) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let lower = word.lowercased()
|
||||
guard !personalTerms.contains(where: { $0.lowercased() == lower }),
|
||||
!systemWords.contains(where: { $0.lowercased() == lower }) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Preserve intentional product spelling even when the base word is
|
||||
// common enough to exist in the bundled lexicon (OpenAI, iOS26, GPT-5).
|
||||
let hasDistinctiveShape = word.dropFirst().contains(where: \.isUppercase)
|
||||
|| word.contains(where: \.isNumber)
|
||||
|| word.contains(where: { "-.+#".contains($0) })
|
||||
if hasDistinctiveShape {
|
||||
return true
|
||||
}
|
||||
|
||||
return lexicon.frequency(of: lower) <= Self.personalTermFrequencyCeiling
|
||||
}
|
||||
|
||||
// MARK: - Board
|
||||
|
||||
private struct Board {
|
||||
@@ -304,9 +337,11 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
private func isProtectedToken(_ typed: String) -> Bool {
|
||||
if typed.count <= 2 { return true }
|
||||
if typed.allSatisfy(\.isUppercase) { return true }
|
||||
if typed.dropFirst().contains(where: \.isUppercase) { return true }
|
||||
if typed.contains(where: \.isNumber) { return true }
|
||||
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
||||
if typed.contains("-") || typed.contains("_") { return true }
|
||||
if typed.contains("'") || typed.contains("+") || typed.contains("#") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+44
-21
@@ -1,13 +1,14 @@
|
||||
// RimeFrequentTermStore.swift
|
||||
// FrequentTermStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Small App Group sidecar for Rime commits. Reading librime's LevelDB userdb
|
||||
// while the keyboard owns it can race the engine, so the extension records
|
||||
// eligible committed terms here and the host app ranks them for suggestions.
|
||||
// Small App Group sidecar for committed typing terms. Reading librime's
|
||||
// LevelDB userdb while the keyboard owns it can race the engine, so the
|
||||
// extension records eligible Chinese and English terms here and the host app
|
||||
// ranks them for personal-dictionary suggestions.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
public struct FrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
public var id: String { term.lowercased() }
|
||||
|
||||
public let term: String
|
||||
@@ -28,10 +29,11 @@ public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures repeated Rime candidate commits without writing to the curated
|
||||
/// Captures repeated eligible commits without writing to the curated
|
||||
/// PersonalDictionary until the user explicitly confirms a suggestion.
|
||||
public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "rimeTyping.frequentTerms.v1"
|
||||
public final class FrequentTermStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "typing.frequentTerms.v2"
|
||||
public static let legacyRimeDefaultsKey = "rimeTyping.frequentTerms.v1"
|
||||
public static let minimumSuggestionCount = 2
|
||||
public static let maximumTrackedTerms = 256
|
||||
|
||||
@@ -52,7 +54,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
let key = term.lowercased()
|
||||
if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) {
|
||||
let existing = terms[index]
|
||||
terms[index] = RimeFrequentTerm(
|
||||
terms[index] = FrequentTerm(
|
||||
term: term,
|
||||
commitCount: min(10_000, existing.commitCount + 1),
|
||||
firstSeenAt: existing.firstSeenAt,
|
||||
@@ -60,7 +62,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
)
|
||||
} else {
|
||||
terms.append(
|
||||
RimeFrequentTerm(
|
||||
FrequentTerm(
|
||||
term: term,
|
||||
commitCount: 1,
|
||||
firstSeenAt: date,
|
||||
@@ -78,11 +80,11 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
saveLocked(Array(terms.prefix(Self.maximumTrackedTerms)))
|
||||
}
|
||||
|
||||
/// Repeated, recent Rime commits that are not already curated.
|
||||
/// Repeated, recent commits that are not already curated.
|
||||
public func suggestions(
|
||||
excludingPersonalTerms personalTerms: Set<String>,
|
||||
limit: Int = 5
|
||||
) -> [RimeFrequentTerm] {
|
||||
) -> [FrequentTerm] {
|
||||
guard limit > 0 else { return [] }
|
||||
let excluded = Set(personalTerms.map { $0.lowercased() })
|
||||
|
||||
@@ -111,13 +113,14 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
defaults.removeObject(forKey: Self.defaultsKey)
|
||||
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func normalizedCandidate(from text: String) -> String? {
|
||||
let term = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard (2...12).contains(term.count),
|
||||
!commonTerms.contains(term),
|
||||
guard (2...32).contains(term.count),
|
||||
!commonTerms.contains(term.lowercased()),
|
||||
!term.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
}) else {
|
||||
@@ -131,7 +134,8 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
semanticCharacterCount += 1
|
||||
continue
|
||||
}
|
||||
// Product names and proper nouns commonly contain these separators.
|
||||
// Product names, proper nouns, and English contractions commonly
|
||||
// contain these separators.
|
||||
guard allowedSeparators.contains(Character(String(scalar))) else {
|
||||
return nil
|
||||
}
|
||||
@@ -139,21 +143,40 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
return semanticCharacterCount >= 2 ? term : nil
|
||||
}
|
||||
|
||||
private func loadLocked() -> [RimeFrequentTerm] {
|
||||
guard let data = defaults.data(forKey: Self.defaultsKey),
|
||||
let terms = try? JSONDecoder().decode([RimeFrequentTerm].self, from: data) else {
|
||||
private func loadLocked() -> [FrequentTerm] {
|
||||
if let data = defaults.data(forKey: Self.defaultsKey),
|
||||
let terms = try? JSONDecoder().decode([FrequentTerm].self, from: data) {
|
||||
return terms
|
||||
}
|
||||
|
||||
guard let legacyData = defaults.data(forKey: Self.legacyRimeDefaultsKey),
|
||||
let legacyTerms = try? JSONDecoder().decode([FrequentTerm].self, from: legacyData) else {
|
||||
return []
|
||||
}
|
||||
return terms
|
||||
|
||||
// The legacy value has the same Codable shape, so migration only
|
||||
// changes its key and keeps every user's existing Chinese history.
|
||||
saveLocked(legacyTerms)
|
||||
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
|
||||
return legacyTerms
|
||||
}
|
||||
|
||||
private func saveLocked(_ terms: [RimeFrequentTerm]) {
|
||||
private func saveLocked(_ terms: [FrequentTerm]) {
|
||||
guard let data = try? JSONEncoder().encode(terms) else { return }
|
||||
defaults.set(data, forKey: Self.defaultsKey)
|
||||
}
|
||||
|
||||
/// Avoid recommending ubiquitous conversational glue as a personal term.
|
||||
private static let commonTerms: Set<String> = [
|
||||
"a", "about", "after", "all", "also", "am", "an", "and", "any", "are",
|
||||
"as", "at", "be", "because", "been", "but", "by", "can", "could", "did",
|
||||
"do", "does", "for", "from", "get", "got", "had", "has", "have", "he",
|
||||
"her", "here", "him", "his", "how", "i", "if", "in", "is", "it", "its",
|
||||
"just", "me", "more", "my", "no", "not", "now", "of", "on", "one", "or",
|
||||
"our", "out", "she", "so", "some", "than", "that", "the", "their", "them",
|
||||
"then", "there", "they", "this", "to", "too", "up", "us", "was", "we",
|
||||
"were", "what", "when", "where", "which", "who", "why", "will", "with",
|
||||
"would", "you", "your",
|
||||
"一个", "一下", "不会", "不是", "什么", "他们", "但是", "你们", "你好",
|
||||
"可能", "可以", "因为", "好的", "如果", "已经", "应该", "怎么", "我们",
|
||||
"所以", "时候", "明天", "昨天", "有点", "没有", "然后", "现在", "知道",
|
||||
@@ -161,5 +184,5 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
"那里", "那样", "需要", "今天", "就是"
|
||||
]
|
||||
|
||||
private static let allowedSeparators: Set<Character> = ["-", ".", "+", "#", "·"]
|
||||
private static let allowedSeparators: Set<Character> = ["-", "'", ".", "+", "#", "·"]
|
||||
}
|
||||
@@ -7,14 +7,14 @@
|
||||
import Foundation
|
||||
|
||||
public enum TypingHabitStore {
|
||||
/// Clears English boosts and Chinese Rime user dictionaries.
|
||||
/// Clears English boosts, cross-language frequent terms, and Rime user dictionaries.
|
||||
/// Does not touch PersonalDictionary / osg_personal.
|
||||
public static func clearAll(
|
||||
englishStore: EnglishLearningStore = EnglishLearningStore(),
|
||||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||||
frequentTermStore: FrequentTermStore = FrequentTermStore()
|
||||
) async throws {
|
||||
englishStore.clear()
|
||||
rimeFrequentTermStore.clear()
|
||||
frequentTermStore.clear()
|
||||
try await RimeResourceInstaller.shared.clearUserDictionary()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,16 @@ public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var capsLock: Bool = false
|
||||
/// Finger is down on Shift (iOS: hold for continuous uppercase; release ends).
|
||||
@Published public private(set) var shiftHeld: Bool = false
|
||||
@Published public private(set) var composition: TypingComposition = .empty
|
||||
@Published public private(set) var candidateRevision: UInt64 = 0
|
||||
@Published public private(set) var composition: TypingComposition = .empty {
|
||||
didSet {
|
||||
if oldValue != composition {
|
||||
candidateRevision &+= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public private(set) var engineReady: Bool = false
|
||||
@Published public private(set) var isPreparingEngine: Bool = false
|
||||
@Published public private(set) var schema: TypingInputSchema
|
||||
/// Chinese-only: key grid replaced by a same-height candidate grid.
|
||||
@Published public private(set) var isCandidatePanelExpanded: Bool = false
|
||||
@@ -29,8 +37,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// Chinese composition is also skipped so passwords never enter Rime userdb.
|
||||
@Published public var suggestionsEnabled: Bool = true {
|
||||
didSet {
|
||||
guard oldValue, !suggestionsEnabled else { return }
|
||||
abandonChineseComposition()
|
||||
guard oldValue != suggestionsEnabled else { return }
|
||||
if !suggestionsEnabled {
|
||||
clearDocumentScopedState()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `UITextChecker` completions / guesses. Empty in unit tests.
|
||||
@@ -54,12 +64,16 @@ public final class TypingSessionController: ObservableObject {
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engineFactory: @MainActor () -> RimeEngineBridging
|
||||
private let englishFactory: @MainActor () -> EnglishSuggestionEngine
|
||||
private let hostHeavyProvider: @MainActor () -> Bool
|
||||
private let learningStore: EnglishLearningStore
|
||||
private let rimeFrequentTermStore: RimeFrequentTermStore
|
||||
private let frequentTermStore: FrequentTermStore
|
||||
private var engineStorage: RimeEngineBridging?
|
||||
private var englishStorage: EnglishSuggestionEngine?
|
||||
private var prepared = false
|
||||
private var prepareTask: Task<Void, Never>?
|
||||
private var engineEpoch: UInt64 = 0
|
||||
private var documentPresentationCounter: UInt64 = 0
|
||||
private var activeDocumentPresentationID: UInt64?
|
||||
|
||||
private var engine: RimeEngineBridging {
|
||||
if let engineStorage { return engineStorage }
|
||||
@@ -99,13 +113,17 @@ public final class TypingSessionController: ObservableObject {
|
||||
layout: TypingLayoutProviding = StandardTypingLayout(),
|
||||
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore(),
|
||||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||||
frequentTermStore: FrequentTermStore = FrequentTermStore(),
|
||||
hostHeavyProvider: @escaping @MainActor () -> Bool = {
|
||||
FlowSessionBridge.isHostHeavy()
|
||||
}
|
||||
) {
|
||||
self.engineFactory = engine ?? { LibrimeEngine() }
|
||||
self.layout = layout
|
||||
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
|
||||
self.hostHeavyProvider = hostHeavyProvider
|
||||
self.learningStore = learningStore
|
||||
self.rimeFrequentTermStore = rimeFrequentTermStore
|
||||
self.frequentTermStore = frequentTermStore
|
||||
// Avoid constructing librime until the first Chinese keystroke / prepare.
|
||||
schema = TypingInputConfiguration.shared.schema
|
||||
}
|
||||
@@ -134,7 +152,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func enterTypingMode() {
|
||||
let hostHeavy = FlowSessionBridge.isHostHeavy()
|
||||
let hostHeavy = hostHeavyProvider()
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.enter.begin",
|
||||
details: "language=\(language.rawValue) schema=\(schema.rawValue) "
|
||||
@@ -165,6 +183,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
|
||||
}
|
||||
syncAutocapitalization()
|
||||
isPreparingEngine = !prepared
|
||||
if hostHeavy {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.deferred",
|
||||
@@ -172,24 +191,28 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||||
prepareTask?.cancel()
|
||||
engineEpoch &+= 1
|
||||
let epoch = engineEpoch
|
||||
prepareTask = Task { [weak self] in
|
||||
for _ in 0..<40 {
|
||||
while self?.hostHeavyProvider() == true {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if !FlowSessionBridge.isHostHeavy() {
|
||||
await self.prepareIfNeeded()
|
||||
self.prepareTask = nil
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
guard let self, self.engineEpoch == epoch, !Task.isCancelled else { return }
|
||||
await self.prepareIfNeeded(engineEpoch: epoch)
|
||||
if self.engineEpoch == epoch {
|
||||
self.prepareTask = nil
|
||||
}
|
||||
self?.prepareTask = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
if prepareTask == nil, !prepared {
|
||||
engineEpoch &+= 1
|
||||
let epoch = engineEpoch
|
||||
prepareTask = Task { [weak self] in
|
||||
await self?.prepareIfNeeded()
|
||||
self?.prepareTask = nil
|
||||
await self?.prepareIfNeeded(engineEpoch: epoch)
|
||||
guard let self, self.engineEpoch == epoch else { return }
|
||||
self.prepareTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,17 +223,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
details: "language=\(language.rawValue)"
|
||||
)
|
||||
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
|
||||
engineEpoch &+= 1
|
||||
prepareTask?.cancel()
|
||||
prepareTask = nil
|
||||
endDocumentPresentation()
|
||||
engineStorage?.teardown()
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
clearPeriodShortcut()
|
||||
isPreparingEngine = false
|
||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||
EnglishLexicon.shared.unload()
|
||||
englishStorage = nil
|
||||
@@ -220,6 +240,32 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts a new host-document scope without tearing down dictionaries or
|
||||
/// the Rime runtime. A keyboard presentation can move between apps while
|
||||
/// the extension process and controller remain alive.
|
||||
@discardableResult
|
||||
public func beginDocumentPresentation() -> UInt64 {
|
||||
documentPresentationCounter &+= 1
|
||||
let presentationID = documentPresentationCounter
|
||||
activeDocumentPresentationID = presentationID
|
||||
candidateRevision &+= 1
|
||||
clearDocumentScopedState()
|
||||
return presentationID
|
||||
}
|
||||
|
||||
/// Invalidates all candidates and input shadows owned by the outgoing
|
||||
/// document. Heavy engines stay warm until typing mode itself is left.
|
||||
public func endDocumentPresentation() {
|
||||
documentPresentationCounter &+= 1
|
||||
activeDocumentPresentationID = nil
|
||||
candidateRevision &+= 1
|
||||
clearDocumentScopedState()
|
||||
}
|
||||
|
||||
public func isCurrentDocumentPresentation(_ presentationID: UInt64) -> Bool {
|
||||
activeDocumentPresentationID == presentationID
|
||||
}
|
||||
|
||||
public func toggleCandidatePanelExpanded() {
|
||||
guard canExpandCandidatePanel else {
|
||||
isCandidatePanelExpanded = false
|
||||
@@ -248,7 +294,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
if !raw.isEmpty { output = .insert(raw) }
|
||||
}
|
||||
language = newLanguage
|
||||
engine.setLanguage(newLanguage)
|
||||
engineStorage?.setLanguage(newLanguage)
|
||||
page = .letters
|
||||
isCandidatePanelExpanded = false
|
||||
clearPeriodShortcut()
|
||||
@@ -258,7 +304,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
englishEngine.prepare()
|
||||
refreshEnglishSuggestions()
|
||||
syncAutocapitalization()
|
||||
synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
if activeDocumentPresentationID != nil {
|
||||
synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
}
|
||||
} else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
EnglishLexicon.shared.unload()
|
||||
@@ -267,6 +315,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
return output
|
||||
}
|
||||
|
||||
/// Reloads host-curated terms after App Group configuration changes.
|
||||
public func reloadPersonalDictionaryTerms() {
|
||||
refreshPersonalTerms()
|
||||
if language == .english {
|
||||
refreshEnglishSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes raw preedit, selects the next built-in scheme, and returns the
|
||||
/// text that the caller should insert before switching.
|
||||
public func cycleSchema() -> TypingOutput {
|
||||
@@ -371,7 +427,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
clearOneShotShiftIfNeeded()
|
||||
recordRimeCommit(committed)
|
||||
recordChineseCommit(committed)
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
@@ -387,7 +443,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
recordChineseCommit(text)
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
@@ -403,7 +459,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
recordChineseCommit(text)
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
@@ -423,13 +479,28 @@ public final class TypingSessionController: ObservableObject {
|
||||
// Selecting always collapses; follow-up composition may reopen ▼.
|
||||
isCandidatePanelExpanded = false
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
recordChineseCommit(text)
|
||||
return text.isEmpty ? .none : .insert(text)
|
||||
}
|
||||
|
||||
private func recordRimeCommit(_ text: String) {
|
||||
/// Candidate taps originate from a rendered snapshot. Reject the tap when
|
||||
/// that snapshot has already been replaced by a newer composition.
|
||||
public func selectCandidate(
|
||||
id candidateID: String,
|
||||
candidateRevision expectedRevision: UInt64? = nil
|
||||
) -> TypingOutput {
|
||||
if let expectedRevision, expectedRevision != candidateRevision {
|
||||
return .none
|
||||
}
|
||||
guard let index = composition.candidates.firstIndex(where: { $0.id == candidateID }) else {
|
||||
return .none
|
||||
}
|
||||
return selectCandidate(at: index)
|
||||
}
|
||||
|
||||
private func recordChineseCommit(_ text: String) {
|
||||
guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return }
|
||||
rimeFrequentTermStore.recordCommittedText(text)
|
||||
frequentTermStore.recordCommittedText(text)
|
||||
}
|
||||
|
||||
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
|
||||
@@ -575,6 +646,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
UIKitEnglishSystemLexicon.learnWord(word)
|
||||
#endif
|
||||
}
|
||||
recordEnglishCommit(word)
|
||||
refreshEnglishSuggestions(afterCommittedWord: word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
@@ -597,6 +669,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
#if canImport(UIKit)
|
||||
UIKitEnglishSystemLexicon.learnWord(pending.original)
|
||||
#endif
|
||||
recordEnglishCommit(pending.original)
|
||||
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
||||
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
||||
}
|
||||
@@ -609,6 +682,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
} else {
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
}
|
||||
recordEnglishCommit(chosen)
|
||||
|
||||
if !englishCurrentWord.isEmpty {
|
||||
let deleteCount = englishCurrentWord.count
|
||||
@@ -627,6 +701,19 @@ public final class TypingSessionController: ObservableObject {
|
||||
return .insert(chosen + " ")
|
||||
}
|
||||
|
||||
private func recordEnglishCommit(_ text: String) {
|
||||
guard language == .english,
|
||||
suggestionsEnabled,
|
||||
englishEngine.isPersonalTermCandidate(
|
||||
text,
|
||||
personalTerms: personalTermsCache,
|
||||
systemWords: supplementaryWords
|
||||
) else {
|
||||
return
|
||||
}
|
||||
frequentTermStore.recordCommittedText(text)
|
||||
}
|
||||
|
||||
private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput {
|
||||
guard language == .english, !englishCurrentWord.isEmpty else { return .none }
|
||||
return commitEnglishWord(suffix: "")
|
||||
@@ -696,8 +783,15 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// Rebuilds English suggestion state from the real caret context.
|
||||
/// At the document end, callbacks keep the local shadow when a host
|
||||
/// briefly reports the immediately preceding edit (common in Notes).
|
||||
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
|
||||
public func synchronizeEnglishDocumentContext(
|
||||
caretMoved: Bool = false,
|
||||
presentationID: UInt64? = nil
|
||||
) {
|
||||
guard language == .english else { return }
|
||||
if let presentationID,
|
||||
activeDocumentPresentationID != presentationID {
|
||||
return
|
||||
}
|
||||
if caretMoved {
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
@@ -843,6 +937,16 @@ public final class TypingSessionController: ObservableObject {
|
||||
pendingAutocorrection = nil
|
||||
}
|
||||
|
||||
private func clearDocumentScopedState() {
|
||||
engineStorage?.clearComposition()
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
|
||||
private func synchronizeEnglishWordState(
|
||||
precedingText: String,
|
||||
followingText: String?
|
||||
@@ -942,15 +1046,19 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
private func prepareIfNeeded(engineEpoch expectedEpoch: UInt64) async {
|
||||
guard !prepared else { return }
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
if hostHeavyProvider() {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.deferred",
|
||||
details: "hostHeavy=1"
|
||||
)
|
||||
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
|
||||
return
|
||||
while hostHeavyProvider() {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
}
|
||||
}
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.begin",
|
||||
@@ -963,9 +1071,11 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
do {
|
||||
try await engine.prepare()
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
engine.setLanguage(language)
|
||||
prepared = true
|
||||
engineReady = engine.isReady
|
||||
isPreparingEngine = false
|
||||
schema = engine.schema
|
||||
lastError = nil
|
||||
lastErrorNeedsHostDeployment = false
|
||||
@@ -981,10 +1091,12 @@ public final class TypingSessionController: ObservableObject {
|
||||
category: "boot"
|
||||
)
|
||||
} catch {
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
lastError = error.localizedDescription
|
||||
lastErrorNeedsHostDeployment =
|
||||
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
|
||||
engineReady = false
|
||||
isPreparingEngine = false
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.failed",
|
||||
details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))"
|
||||
@@ -1005,9 +1117,13 @@ public final class TypingSessionController: ObservableObject {
|
||||
guard prepareTask == nil else { return }
|
||||
guard RimeResourceInstaller.isReady else { return }
|
||||
OSGDiag.log("rime.prepare retry after deployment", category: "boot")
|
||||
engineEpoch &+= 1
|
||||
let epoch = engineEpoch
|
||||
isPreparingEngine = true
|
||||
prepareTask = Task { [weak self] in
|
||||
await self?.prepareIfNeeded()
|
||||
self?.prepareTask = nil
|
||||
await self?.prepareIfNeeded(engineEpoch: epoch)
|
||||
guard let self, self.engineEpoch == epoch else { return }
|
||||
self.prepareTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
/* v0.3.0: Personal dictionary sources */
|
||||
"dict.source.manual" = "Manual";
|
||||
"dict.source.history" = "Auto-learned";
|
||||
"dict.source.history" = "Recommended hotword";
|
||||
"dict.source.contacts" = "From Contacts";
|
||||
"dict.source.recentEdit" = "From recent edit";
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
"mac.styles.learn.generating" = "Generating…";
|
||||
"mac.styles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"mac.styles.learn.limit" = "Delete a custom style before generating another one.";
|
||||
"mac.styles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
|
||||
"mac.styles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
"mac.styles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
|
||||
"mac.styles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
|
||||
"mac.styles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again.";
|
||||
@@ -234,6 +234,7 @@
|
||||
"stat.weekChart.title" = "Last 7 days";
|
||||
"stat.weekChart.caption" = "Dictation chars";
|
||||
"stat.weekChart.empty" = "No dictation yet this week";
|
||||
"stat.monthChart.caption" = "Monthly dictation chars";
|
||||
"mac.status.chipReady" = "Ready";
|
||||
"mac.status.chipProcessing" = "Processing";
|
||||
"mac.overlay.listening" = "Listening";
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
/* v0.3.0: 词库来源 */
|
||||
"dict.source.manual" = "手动添加";
|
||||
"dict.source.history" = "自动学习";
|
||||
"dict.source.history" = "推荐热词";
|
||||
"dict.source.contacts" = "来自通讯录";
|
||||
"dict.source.recentEdit" = "来自最近编辑";
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
"mac.styles.learn.generating" = "生成中…";
|
||||
"mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"mac.styles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
|
||||
"mac.styles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"mac.styles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
"mac.styles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
|
||||
@@ -233,6 +233,7 @@
|
||||
"stat.weekChart.title" = "近 7 天";
|
||||
"stat.weekChart.caption" = "听写字数";
|
||||
"stat.weekChart.empty" = "本周还没有听写记录";
|
||||
"stat.monthChart.caption" = "本月听写字数";
|
||||
"mac.status.chipReady" = "就绪";
|
||||
"mac.status.chipProcessing" = "处理中";
|
||||
"mac.overlay.listening" = "聆听中";
|
||||
|
||||
Reference in New Issue
Block a user