Skip to content
Open
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
7 changes: 7 additions & 0 deletions Core/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ targets.append(
swiftSettings: [.interoperabilityMode(.Cxx)]
)
)
targets.append(
.testTarget(
name: "ConverterServerTests",
dependencies: ["ConverterServer"],
swiftSettings: [.interoperabilityMode(.Cxx)]
)
)
#endif

let package = Package(
Expand Down
3 changes: 1 addition & 2 deletions Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ extension ConverterServer {
session.manager.activate()
}
session.setContext(request.context)
Config.DebugPredictiveTyping().value = request.enablePredictiveTyping
Config.DebugTypoCorrection().value = request.enableTypoCorrection
applyRequestSettings(request)

if request.enableOptionDirectFullWidthInput,
let text = OptionDirectInputResolver.resolve(
Expand Down
319 changes: 319 additions & 0 deletions Core/Sources/ConverterServer/ConverterServer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import Core
import Darwin
import Foundation
import KanaKanjiConverterModuleWithDefaultDictionary

enum ConverterServerXPC {
static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer"
}

@objc protocol ConverterServerXPCProtocol {
func openSession(with reply: @escaping @Sendable (String) -> Void)
func closeSession(_ sessionID: String, with reply: @escaping @Sendable (Bool) -> Void)
func handleCommand(_ data: Data, with reply: @escaping @Sendable (Data?, NSString?) -> Void)
func ping(_ message: String, with reply: @escaping @Sendable (String) -> Void)
}

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

private var sessions: [String: ConverterSession] = [:]
private var sessionOwners: [String: UUID] = [:]
private let kanaKanjiConverter = KanaKanjiConverter.withDefaultDictionary()
private let learningDataCommitScheduler = DebouncedActionScheduler()
private let makeManager: @MainActor (KanaKanjiConverter) -> SegmentsManager
let applyRequestSettings: @Sendable (ConverterKeyEventRequest) -> Void

init(
makeManager: @escaping @MainActor (KanaKanjiConverter) -> SegmentsManager = {
ConverterServer.makeSegmentsManager(kanaKanjiConverter: $0)
},
applyRequestSettings: @escaping @Sendable (ConverterKeyEventRequest) -> Void = {
Config.DebugPredictiveTyping().value = $0.enablePredictiveTyping
Config.DebugTypoCorrection().value = $0.enableTypoCorrection
}
) {
self.makeManager = makeManager
self.applyRequestSettings = applyRequestSettings
}

@MainActor
func execute(_ command: ConverterServerCommand, owner: UUID) async throws -> ConverterServerResponse {
defer { learningDataCommitScheduler.postponeIfScheduled(after: Self.learningDataCommitDelay) }
switch command {
case .shutdown:
Self.scheduleShutdown()
return ConverterServerResponse(snapshot: .empty)
case .maintenance(let command):
return try handle(command)
case .openSession(let sessionID, let command):
createSessionIfNeeded(sessionID)
sessionOwners[sessionID] = owner
return try await handle(command, sessionID: sessionID)
case .session(let sessionID, let command):
_ = try getSession(sessionID)
sessionOwners[sessionID] = owner
return try await handle(command, sessionID: sessionID)
}
}

@MainActor
func removeSessions(ownedBy owner: UUID) {
for id in sessionOwners.filter({ $0.value == owner }).map(\.key) {
removeSession(id)
}
}

@MainActor
@discardableResult
func removeSession(_ id: String) -> Bool {
sessionOwners.removeValue(forKey: id)
guard let session = sessions.removeValue(forKey: id) else {
return false
}
kanaKanjiConverter.removeSession(session.conversionSessionID)
scheduleLearningDataCommit()
return true
}

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

@MainActor
private func handle(_ command: ConverterMaintenanceCommand) throws -> ConverterServerResponse {
switch command {
case .synchronizeUserDictionary(let forceExport):
let memoryDirectoryURL = AppGroup.memoryDirectoryURL()
if forceExport || !CompiledUserDictionaryStore.hasExportedDictionary(memoryDirectoryURL: memoryDirectoryURL) {
try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL)
}
kanaKanjiConverter.updateUserDictionaryURL(
CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: memoryDirectoryURL),
forceReload: true
)
case .resetLearningData:
kanaKanjiConverter.resetMemory()
}
return ConverterServerResponse(snapshot: .empty)
}

@MainActor
private func handle(_ command: ConverterSessionCommand, sessionID: String) async throws -> ConverterServerResponse {
let session = try getSession(sessionID)
switch command {
case .lifecycle(let command):
return try withConverterSession(session) {
handle(command, session: session)
}
case .settings(let command):
return try withConverterSession(session) {
try handle(command, session: session)
}
case .updateConfig(let config):
return try withConverterSession(session) {
session.config = config
return makeResponse(for: session, inputState: .none)
}
case .handleKeyEvent(let request):
return try withConverterSession(session) {
try handleKeyEvent(sessionID: sessionID, request: request)
}
case .composition(let command):
return try withConverterSession(session) {
handle(command, session: session)
}
case .candidate(let command):
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,
session: ConverterSession
) -> ConverterServerResponse {
switch command {
case .activate:
session.manager.activate()
return makeResponse(for: session, inputState: session.inputState)
case .deactivate:
// アプリ切替直後のキー入力を、学習データの同期I/Oで塞がない。
// 共有Converterはプロセス内に残るため、永続化だけ入力のアイドル時まで遅延できる。
session.manager.deactivate(flushLearningData: false)
scheduleLearningDataCommit()
session.inputState = .none
session.clearReplaceSuggestions()
return makeResponse(for: session, inputState: session.inputState)
case .synchronizeInputLanguage(let language):
session.inputLanguage = language
if language == .english {
session.manager.stopJapaneseInput()
}
return makeResponse(
for: session,
inputState: session.inputState
)
}
}

@MainActor
private func handle(
_ command: ConverterSettingsCommand,
session: ConverterSession
) throws -> ConverterServerResponse {
switch command {
case .list(let capabilities):
return makeResponse(
for: session,
inputState: .none,
settings: Self.makeSettingDescriptors(capabilities: capabilities)
)
case .update(let key, let value):
try Self.updateSetting(key: key, value: value)
return makeResponse(for: session, inputState: .none)
}
}

@MainActor
private func handle(
_ command: ConverterCompositionCommand,
session: ConverterSession
) -> ConverterServerResponse {
switch command {
case .snapshot:
return makeResponse(for: session, inputState: session.inputState)
case .stopComposition:
session.manager.stopComposition()
session.inputState = .none
return makeResponse(for: session, inputState: session.inputState)
case .forgetMemory:
session.manager.forgetMemory()
return makeResponse(for: session, inputState: session.inputState)
case .commit:
let text = session.manager.commitMarkedText(inputState: session.inputState)
let effects: [ConverterClientEffect] = text.isEmpty ? [] : [.insertText(text)]
session.inputState = .none
return makeResponse(
for: session,
inputState: session.inputState,
effects: effects,
responseInputState: ConverterInputState.none
)
}
}

@MainActor
private func handle(
_ command: ConverterCandidateCommand,
session: ConverterSession
) -> ConverterServerResponse {
switch command {
case .selectCandidate(let index):
session.manager.requestSelectingRow(index)
session.inputState = .selecting
return makeResponse(for: session, inputState: session.inputState)
case .submitSelectedCandidate(let context):
session.setContext(context)
var effects: [ConverterClientEffect] = []
submitSelectedCandidate(
manager: session.manager,
leftSideContext: session.conversionLeftSideContext(),
effects: &effects
)
let nextInputState: InputState = session.manager.isEmpty ? .none : .previewing
session.inputState = nextInputState
return makeResponse(
for: session,
inputState: nextInputState,
effects: effects,
responseInputState: ConverterInputState(nextInputState)
)
}
}

@MainActor
private func handle(
_ command: ConverterReplaceSuggestionCommand,
session: ConverterSession
) async throws -> ConverterServerResponse {
switch command {
case .request(let context):
session.setContext(context)
try await requestReplaceSuggestion(session: session)
return try withConverterSession(session) {
session.inputState = .replaceSuggestion
return makeResponse(
for: session,
inputState: session.inputState,
responseInputState: .replaceSuggestion
)
}
case .selectReplaceSuggestionCandidate(let index):
return try withConverterSession(session) {
session.selectReplaceSuggestion(at: index)
session.inputState = .replaceSuggestion
return makeResponse(
for: session,
inputState: session.inputState,
responseInputState: .replaceSuggestion
)
}
case .submitSelectedReplaceSuggestion:
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)
)
}
}
}

private static func scheduleShutdown() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
exit(EXIT_SUCCESS)
}
}

@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 {
throw ConverterServerError.unknownSession(sessionID)
}
return session
}

}
Loading
Loading