Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Core/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ let package = Package(
platforms: [.macOS(.v13)],
products: products,
dependencies: [
.package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "8e3a6eb89e088efd868aa28dadb74c697df4e6fb", traits: kanaKanjiConverterTraits),
.package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "67ec8e68d17a534b5b9929b920995ef3811e89fe", traits: kanaKanjiConverterTraits),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0")
],
Expand Down
8 changes: 8 additions & 0 deletions Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ extension ConverterServer {
actual: request.eventID
)
}
if let activation = request.activation {
session.config = activation.config
session.inputLanguage = activation.inputLanguage
if activation.inputLanguage == .english {
session.manager.stopJapaneseInput()
}
session.manager.activate()
}
session.setContext(request.context)
Config.DebugPredictiveTyping().value = request.enablePredictiveTyping
Config.DebugTypoCorrection().value = request.enableTypoCorrection
Expand Down
4 changes: 2 additions & 2 deletions Core/Sources/ConverterServer/ConverterServer+Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ extension ConverterServer {
}

@MainActor
static func makeSegmentsManager() -> SegmentsManager {
static func makeSegmentsManager(kanaKanjiConverter: KanaKanjiConverter) -> SegmentsManager {
CustomInputTableStore.registerIfExists()
let containerURL = AppGroup.containerURL()
let applicationDirectoryURL = AppGroup.memoryDirectoryURL()
Expand All @@ -102,7 +102,7 @@ extension ConverterServer {
withIntermediateDirectories: true
)
return SegmentsManager(
kanaKanjiConverter: KanaKanjiConverter.withDefaultDictionary(),
kanaKanjiConverter: kanaKanjiConverter,
applicationDirectoryURL: applicationDirectoryURL,
containerURL: containerURL,
context: .init(useZenzai: true, resourcesDirectoryURL: appResourcesDirectoryURL())
Expand Down
7 changes: 6 additions & 1 deletion Core/Sources/ConverterServer/ConverterSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ final class ConverterSession: SegmentManagerDelegate {
static let replaceSuggestionContextLength = 100

let manager: SegmentsManager
let conversionSessionID: KanaKanjiConverter.ConversionSessionID
var inputState: InputState = .none
var inputLanguage: InputLanguage = .japanese
var lastHandledKeyEventID: UInt64?
Expand All @@ -21,8 +22,12 @@ final class ConverterSession: SegmentManagerDelegate {
var replaceSuggestions: [Candidate] = []
var replaceSuggestionSelectionIndex: Int?

init(manager: SegmentsManager) {
init(
manager: SegmentsManager,
conversionSessionID: KanaKanjiConverter.ConversionSessionID
) {
self.manager = manager
self.conversionSessionID = conversionSessionID
self.manager.delegate = self
}

Expand Down
134 changes: 103 additions & 31 deletions Core/Sources/ConverterServer/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ private enum ConverterServerXPC {
}

final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Sendable {
private static let learningDataCommitDelay: TimeInterval = 2

private var sessions: [String: ConverterSession] = [:]
private let kanaKanjiConverter = KanaKanjiConverter.withDefaultDictionary()
private let learningDataCommitScheduler = DebouncedActionScheduler()

func openSession(with reply: @escaping @Sendable (String) -> Void) {
DispatchQueue.main.async {
MainActor.assumeIsolated {
let sessionID = UUID().uuidString
self.sessions[sessionID] = ConverterSession(manager: Self.makeSegmentsManager())
self.createSessionIfNeeded(sessionID)
reply(sessionID)
}
}
Expand All @@ -30,7 +34,11 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
func closeSession(_ sessionID: String, with reply: @escaping @Sendable (Bool) -> Void) {
DispatchQueue.main.async {
MainActor.assumeIsolated {
let removed = self.sessions.removeValue(forKey: sessionID) != nil
let session = self.sessions.removeValue(forKey: sessionID)
if let session {
self.kanaKanjiConverter.removeSession(session.conversionSessionID)
}
let removed = session != nil
reply(removed)
}
}
Expand All @@ -47,8 +55,14 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
do {
let command = try ConverterServerCodec.decodeCommand(from: data)
let response = try await self.handle(command)
self.learningDataCommitScheduler.postponeIfScheduled(
after: Self.learningDataCommitDelay
)
reply(try ConverterServerCodec.encode(response), nil)
} catch {
self.learningDataCommitScheduler.postponeIfScheduled(
after: Self.learningDataCommitDelay
)
reply(nil, error.localizedDescription as NSString)
}
}
Expand All @@ -62,11 +76,26 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
return ConverterServerResponse(snapshot: .empty)
case .maintenance(let command):
return try handle(command)
case .openSession(let sessionID, let command):
createSessionIfNeeded(sessionID)
return try await handle(command, sessionID: sessionID)
case .session(let sessionID, let command):
return try await handle(command, sessionID: sessionID)
}
}

@MainActor
private func createSessionIfNeeded(_ sessionID: String) {
guard sessions[sessionID] == nil else {
return
}
let conversionSessionID = kanaKanjiConverter.createSession()
sessions[sessionID] = ConverterSession(
manager: Self.makeSegmentsManager(kanaKanjiConverter: kanaKanjiConverter),
conversionSessionID: conversionSessionID
)
}

@MainActor
private func handle(_ command: ConverterMaintenanceCommand) throws -> ConverterServerResponse {
switch command {
Expand All @@ -75,13 +104,12 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
if forceExport || !CompiledUserDictionaryStore.hasExportedDictionary(memoryDirectoryURL: memoryDirectoryURL) {
try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL)
}
for session in sessions.values {
session.manager.reloadUserDictionary()
}
kanaKanjiConverter.updateUserDictionaryURL(
CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: memoryDirectoryURL),
forceReload: true
)
case .resetLearningData:
for session in sessions.values {
session.manager.resetLearningData()
}
kanaKanjiConverter.resetMemory()
}
return ConverterServerResponse(snapshot: .empty)
}
Expand All @@ -91,23 +119,43 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
let session = try getSession(sessionID)
switch command {
case .lifecycle(let command):
return handle(command, session: session)
return try withConverterSession(session) {
handle(command, session: session)
}
case .settings(let command):
return try handle(command, session: session)
return try withConverterSession(session) {
try handle(command, session: session)
}
case .updateConfig(let config):
session.config = config
return makeResponse(for: session, inputState: .none)
return try withConverterSession(session) {
session.config = config
return makeResponse(for: session, inputState: .none)
}
case .handleKeyEvent(let request):
return try handleKeyEvent(sessionID: sessionID, request: request)
return try withConverterSession(session) {
try handleKeyEvent(sessionID: sessionID, request: request)
}
case .composition(let command):
return handle(command, session: session)
return try withConverterSession(session) {
handle(command, session: session)
}
case .candidate(let command):
return handle(command, session: session)
return try withConverterSession(session) {
handle(command, session: session)
}
case .replaceSuggestion(let command):
return try await handle(command, session: session)
}
}

@MainActor
private func withConverterSession<Result>(
_ session: ConverterSession,
operation: () throws -> Result
) throws -> Result {
try kanaKanjiConverter.withSession(session.conversionSessionID, operation: operation)
}

@MainActor
private func handle(
_ command: ConverterSessionLifecycleCommand,
Expand All @@ -118,7 +166,10 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
session.manager.activate()
return makeResponse(for: session, inputState: session.inputState)
case .deactivate:
session.manager.deactivate()
// アプリ切替直後のキー入力を、学習データの同期I/Oで塞がない。
// 共有Converterはプロセス内に残るため、永続化だけ入力のアイドル時まで遅延できる。
session.manager.deactivate(flushLearningData: false)
scheduleLearningDataCommit()
session.inputState = .none
session.clearReplaceSuggestions()
return makeResponse(for: session, inputState: session.inputState)
Expand Down Expand Up @@ -218,23 +269,37 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
case .request(let context):
session.setContext(context)
try await requestReplaceSuggestion(session: session)
session.inputState = .replaceSuggestion
return makeResponse(for: session, inputState: session.inputState, responseInputState: .replaceSuggestion)
return try withConverterSession(session) {
session.inputState = .replaceSuggestion
return makeResponse(
for: session,
inputState: session.inputState,
responseInputState: .replaceSuggestion
)
}
case .selectReplaceSuggestionCandidate(let index):
session.selectReplaceSuggestion(at: index)
session.inputState = .replaceSuggestion
return makeResponse(for: session, inputState: session.inputState, responseInputState: .replaceSuggestion)
return try withConverterSession(session) {
session.selectReplaceSuggestion(at: index)
session.inputState = .replaceSuggestion
return makeResponse(
for: session,
inputState: session.inputState,
responseInputState: .replaceSuggestion
)
}
case .submitSelectedReplaceSuggestion:
var effects: [ConverterClientEffect] = []
let didSubmit = submitSelectedReplaceSuggestion(session: session, effects: &effects)
let nextInputState: InputState = didSubmit ? .none : .replaceSuggestion
session.inputState = nextInputState
return makeResponse(
for: session,
inputState: nextInputState,
effects: effects,
responseInputState: ConverterInputState(nextInputState)
)
return try withConverterSession(session) {
var effects: [ConverterClientEffect] = []
let didSubmit = submitSelectedReplaceSuggestion(session: session, effects: &effects)
let nextInputState: InputState = didSubmit ? .none : .replaceSuggestion
session.inputState = nextInputState
return makeResponse(
for: session,
inputState: nextInputState,
effects: effects,
responseInputState: ConverterInputState(nextInputState)
)
}
}
}

Expand All @@ -244,6 +309,13 @@ final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Se
}
}

@MainActor
private func scheduleLearningDataCommit() {
learningDataCommitScheduler.schedule(after: Self.learningDataCommitDelay) { [weak self] in
self?.kanaKanjiConverter.commitUpdateLearningData()
}
}

@MainActor
func getSession(_ sessionID: String) throws -> ConverterSession {
guard let session = sessions[sessionID] else {
Expand Down
6 changes: 4 additions & 2 deletions Core/Sources/Core/InputUtils/SegmentsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,11 @@ public final class SegmentsManager {
}

@MainActor
public func deactivate() {
public func deactivate(flushLearningData: Bool = true) {
self.kanaKanjiConverter.stopComposition()
self.kanaKanjiConverter.commitUpdateLearningData()
if flushLearningData {
self.kanaKanjiConverter.commitUpdateLearningData()
}
self.rawCandidates = nil
self.didExperienceSegmentEdition = false
self.lastOperation = .other
Expand Down
22 changes: 20 additions & 2 deletions Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ public enum ConverterServerCommand: Codable, Sendable {
/// Converter Process が所有する辞書・学習データを更新する。
case maintenance(ConverterMaintenanceCommand)

/// セッション作成と最初の命令を1回のXPC往復で原子的に処理する。
/// 同じIDで再送された場合は既存セッションへ同じ命令を配送する。
case openSession(sessionID: String, command: ConverterSessionCommand)

/// 指定したセッションへ命令を配送する。
case session(sessionID: String, command: ConverterSessionCommand)
}
Expand Down Expand Up @@ -261,7 +265,7 @@ public struct ConverterSettingDescriptor: Codable, Sendable, Equatable {
///
/// Keychain や UI 状態など Client 側に残る情報を、必要な範囲だけ Server セッションへ同期する。
/// 永続設定の一覧・更新は `ConverterSettingsCommand` が担当する。
public struct ConverterSessionConfig: Codable, Sendable {
public struct ConverterSessionConfig: Codable, Sendable, Equatable {
public var aiBackendPreference: Config.AIBackendPreference.Value
public var openAIModelName: String
public var openAIEndpoint: String
Expand All @@ -286,7 +290,7 @@ public struct ConverterSessionConfig: Codable, Sendable {
/// ログ出力時に値を伏せるための秘密文字列表現。
///
/// Codable では実値を運ぶが、`description` と `debugDescription` は `<redacted>` を返す。
public struct ConverterSecretString: Codable, Sendable, CustomStringConvertible, CustomDebugStringConvertible {
public struct ConverterSecretString: Codable, Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible {
public var value: String

public init(_ value: String) {
Expand Down Expand Up @@ -321,6 +325,17 @@ public struct ConverterTextContext: Codable, Sendable, Equatable {
}
}

/// アプリ切替後の最初のキーイベントと同時にServerへ適用するセッション初期値。
public struct ConverterSessionActivation: Codable, Sendable, Equatable {
public var config: ConverterSessionConfig
public var inputLanguage: InputLanguage

public init(config: ConverterSessionConfig, inputLanguage: InputLanguage) {
self.config = config
self.inputLanguage = inputLanguage
}
}

/// Client が受け取ったキーイベントと、その時点での IMK/UI 状態。
///
/// Server はこの情報だけを見て変換処理を進め、Client に必要な effect と snapshot を返す。
Expand All @@ -337,6 +352,7 @@ public struct ConverterKeyEventRequest: Codable, Sendable, Equatable {
public var typeBackSlash: Bool
public var optionDirectInputText: String?
public var context: ConverterTextContext
public var activation: ConverterSessionActivation?
public var visibleCandidateStartIndex: Int

public init(
Expand All @@ -352,6 +368,7 @@ public struct ConverterKeyEventRequest: Codable, Sendable, Equatable {
typeBackSlash: Bool = false,
optionDirectInputText: String? = nil,
context: ConverterTextContext = .init(),
activation: ConverterSessionActivation? = nil,
visibleCandidateStartIndex: Int = 0
) {
self.eventID = eventID
Expand All @@ -366,6 +383,7 @@ public struct ConverterKeyEventRequest: Codable, Sendable, Equatable {
self.typeBackSlash = typeBackSlash
self.optionDirectInputText = optionDirectInputText
self.context = context
self.activation = activation
self.visibleCandidateStartIndex = visibleCandidateStartIndex
}
}
Expand Down
Loading
Loading