From 59f78a417235eb50c93f2b850236b4a31f1ed426 Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Mon, 18 May 2026 15:33:31 +0900 Subject: [PATCH 1/5] feat: apply process separation --- Core/Package.swift | 9 + Core/Sources/ConverterServer/main.swift | 347 +++++++++++++++ .../Core/Configs/CustomInputTableStore.swift | 6 +- .../Core/InputUtils/SegmentsManager.swift | 23 +- .../Core/XPC/ConverterServerXPCProtocol.swift | 416 ++++++++++++++++++ .../ControlShortcutRoutingTests.swift | 53 +++ .../ConverterServerContractTests.swift | 74 ++++ .../install_converter_server_launch_agent.sh | 53 +++ azooKeyMac.xcodeproj/project.pbxproj | 26 ++ .../ConverterServerClient.swift | 345 +++++++++++++++ .../azooKeyMacInputController.swift | 371 +++++++++++++++- .../azooKeyMacInputControllerHelper.swift | 7 + azooKeyMac/azooKeyMac.entitlements | 4 + install.sh | 14 +- 14 files changed, 1732 insertions(+), 16 deletions(-) create mode 100644 Core/Sources/ConverterServer/main.swift create mode 100644 Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift create mode 100644 Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift create mode 100755 Tools/install_converter_server_launch_agent.sh create mode 100644 azooKeyMac/InputController/ConverterServerClient.swift diff --git a/Core/Package.swift b/Core/Package.swift index e42d2b6a..fa401b91 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -18,6 +18,10 @@ let package = Package( .library( name: "Core", targets: ["Core"] + ), + .executable( + name: "ConverterServer", + targets: ["ConverterServer"] ) ], dependencies: [ @@ -29,6 +33,11 @@ let package = Package( .executableTarget( name: "git-info-generator" ), + .executableTarget( + name: "ConverterServer", + dependencies: ["Core"], + swiftSettings: [.interoperabilityMode(.Cxx)] + ), .plugin( name: "GitInfoPlugin", capability: .buildTool(), diff --git a/Core/Sources/ConverterServer/main.swift b/Core/Sources/ConverterServer/main.swift new file mode 100644 index 00000000..6b21f511 --- /dev/null +++ b/Core/Sources/ConverterServer/main.swift @@ -0,0 +1,347 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +private final class ConverterSession: SegmentManagerDelegate { + let manager: SegmentsManager + private var leftSideContext: String? + + init(manager: SegmentsManager) { + self.manager = manager + self.manager.delegate = self + } + + func setLeftSideContext(_ value: String?) { + self.leftSideContext = value + } + + func getLeftSideContext(maxCount: Int) -> String? { + guard let leftSideContext else { + return nil + } + return String(leftSideContext.suffix(maxCount)) + } +} + +private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Sendable { + private var sessions: [String: ConverterSession] = [:] + + func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) { + do { + let info = ConverterServerInfo( + protocolVersion: ConverterServerProtocol.currentVersion, + minimumClientProtocolVersion: ConverterServerProtocol.minimumSupportedClientVersion, + supportedCommands: ConverterServerCommandName.allCases.map(\.rawValue), + serverKind: "launchd-mach-service", + buildIdentifier: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ) + reply(try ConverterServerCodec.encode(info), nil) + } catch { + reply(nil, error.localizedDescription as NSString) + } + } + + func openSession(with reply: @escaping @Sendable (String) -> Void) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + let sessionID = UUID().uuidString + self.sessions[sessionID] = ConverterSession(manager: Self.makeSegmentsManager()) + reply(sessionID) + } + } + } + + func closeSession(_ sessionID: String, with reply: @escaping @Sendable (Bool) -> Void) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + let removed = self.sessions.removeValue(forKey: sessionID) != nil + reply(removed) + } + } + } + + func ping(_ message: String, with reply: @escaping @Sendable (String) -> Void) { + reply("ConverterServer: \(message)") + } + + func handleCommand(_ data: Data, with reply: @escaping @Sendable (Data?, NSString?) -> Void) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + do { + let command = try ConverterServerCodec.decodeCommand(from: data) + let response = try self.handle(command) + reply(try ConverterServerCodec.encode(response), nil) + } catch { + reply(nil, error.localizedDescription as NSString) + } + } + } + } + + @MainActor + // swiftlint:disable:next cyclomatic_complexity + private func handle(_ command: ConverterServerCommand) throws -> ConverterServerResponse { + switch command { + case .activate(let sessionID): + return try withSession(sessionID, inputState: .none) { session in + session.manager.activate() + return nil + } + case .deactivate(let sessionID): + return try withSession(sessionID, inputState: .none) { session in + session.manager.deactivate() + return nil + } + case .snapshot(let sessionID, let inputState): + return makeResponse(sessionID: sessionID, inputState: inputState.inputState) + case .stopComposition(let sessionID): + return try withSession(sessionID, inputState: .none) { session in + session.manager.stopComposition() + return nil + } + case .insertText(let sessionID, let text, let inputStyle, let leftSideContext): + return try withSession(sessionID, inputState: .composing) { session in + session.setLeftSideContext(leftSideContext) + session.manager.insertAtCursorPosition(text, inputStyle: Self.resolveInputStyle(inputStyle)) + return nil + } + case .insertCompositionSeparator(let sessionID, let inputStyle, let skipUpdate): + return try withSession(sessionID, inputState: .previewing) { session in + session.manager.insertCompositionSeparator(inputStyle: Self.resolveInputStyle(inputStyle), skipUpdate: skipUpdate) + return nil + } + case .updateCandidates(let sessionID, let requestRichCandidates): + return try withSession(sessionID, inputState: .selecting) { session in + session.manager.update(requestRichCandidates: requestRichCandidates) + return nil + } + case .deleteBackward(let sessionID, let count, let leftSideContext): + return try withSession(sessionID, inputState: .composing) { session in + session.setLeftSideContext(leftSideContext) + session.manager.deleteBackwardFromCursorPosition(count: count) + return nil + } + case .editSegment(let sessionID, let count): + return try withSession(sessionID, inputState: .selecting) { session in + session.manager.editSegment(count: count) + return nil + } + case .setCandidateWindowVisible(let sessionID, let visible, let inputState): + return try withSession(sessionID, inputState: inputState.inputState) { session in + session.manager.requestSetCandidateWindowState(visible: visible) + return nil + } + case .selectNextCandidate(let sessionID): + return try withSession(sessionID, inputState: .selecting) { session in + session.manager.requestSelectingNextCandidate() + return nil + } + case .selectPreviousCandidate(let sessionID): + return try withSession(sessionID, inputState: .selecting) { session in + session.manager.requestSelectingPrevCandidate() + return nil + } + case .selectCandidate(let sessionID, let index): + return try withSession(sessionID, inputState: .selecting) { session in + session.manager.requestSelectingRow(index) + return nil + } + case .resetSelection(let sessionID): + return try withSession(sessionID, inputState: .composing) { session in + session.manager.requestResettingSelection() + return nil + } + case .submitSelectedCandidate(let sessionID, let leftSideContext): + return try withSession(sessionID, inputState: .selecting) { session in + guard let candidate = session.manager.selectedCandidate else { + return nil + } + session.manager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext ?? "") + return candidate.text + } + case .submitTransformedCandidate(let sessionID, let transform, let inputState, let leftSideContext): + return try withSession(sessionID, inputState: .selecting) { session in + let candidate = Self.transformedCandidate( + transform, + manager: session.manager, + inputState: inputState.inputState + ) + session.manager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext ?? "") + return candidate.text + } + case .commitMarkedText(let sessionID, let inputState): + return try withSession(sessionID, inputState: .none) { session in + session.manager.commitMarkedText(inputState: inputState.inputState) + } + case .forgetMemory(let sessionID): + return try withSession(sessionID, inputState: .none) { session in + session.manager.forgetMemory() + return nil + } + } + } + + @MainActor + private func withSession( + _ sessionID: String, + inputState: InputState, + body: (ConverterSession) throws -> String? + ) throws -> ConverterServerResponse { + guard let session = sessions[sessionID] else { + throw ConverterServerError.unknownSession(sessionID) + } + let committedText = try body(session) + return makeResponse(sessionID: sessionID, inputState: inputState, committedText: committedText) + } + + @MainActor + private func makeResponse( + sessionID: String, + inputState: InputState, + committedText: String? = nil + ) -> ConverterServerResponse { + guard let session = sessions[sessionID] else { + return ConverterServerResponse( + sessionID: sessionID, + committedText: committedText, + snapshot: .empty + ) + } + return ConverterServerResponse( + sessionID: sessionID, + committedText: committedText, + snapshot: snapshot(for: session.manager, inputState: inputState) + ) + } + + @MainActor + private func snapshot(for manager: SegmentsManager, inputState: InputState) -> ConverterSessionSnapshot { + if manager.isEmpty { + return .empty + } + let markedText = ConverterMarkedText(manager.getCurrentMarkedText(inputState: inputState)) + let candidateWindow: ConverterCandidateWindow + switch manager.getCurrentCandidateWindow(inputState: inputState) { + case .hidden: + candidateWindow = .hidden + case .composing(let candidates, let selectionIndex): + candidateWindow = .composing( + manager.makeCandidatePresentations(candidates).map(ConverterCandidatePresentation.init), + selectionIndex: selectionIndex + ) + case .selecting(let candidates, let selectionIndex): + candidateWindow = .selecting( + manager.makeCandidatePresentations(candidates).map(ConverterCandidatePresentation.init), + selectionIndex: selectionIndex + ) + } + return ConverterSessionSnapshot( + markedText: markedText, + candidateWindow: candidateWindow, + isEmpty: manager.isEmpty, + convertTarget: manager.convertTarget + ) + } + + @MainActor + private static func makeSegmentsManager() -> SegmentsManager { + CustomInputTableStore.registerIfExists() + return SegmentsManager( + kanaKanjiConverter: KanaKanjiConverter.withDefaultDictionary(), + applicationDirectoryURL: applicationSupportDirectoryURL(), + containerURL: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.azooKeyMacIdentifier), + context: .init(useZenzai: true, resourcesDirectoryURL: appResourcesDirectoryURL()) + ) + } + + private static func appResourcesDirectoryURL() -> URL { + if let executableURL = Bundle.main.executableURL { + return executableURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources", isDirectory: true) + } + if let resourceURL = Bundle.main.resourceURL { + return resourceURL + } + return Bundle.main.bundleURL.appendingPathComponent("Contents/Resources", isDirectory: true) + } + + @MainActor + private static func resolveInputStyle(_ inputStyle: ConverterInputStyle) -> InputStyle { + if case .tableName(CustomInputTableStore.tableName) = inputStyle, + !CustomInputTableStore.registerIfExists() { + return .mapped(id: .defaultRomanToKana) + } + return inputStyle.inputStyle + } + + private static func applicationSupportDirectoryURL() -> URL { + if #available(macOS 13, *) { + return URL.applicationSupportDirectory + .appending(path: "azooKey", directoryHint: .isDirectory) + .appending(path: "memory", directoryHint: .isDirectory) + } + return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("azooKey", isDirectory: true) + .appendingPathComponent("memory", isDirectory: true) + } + + @MainActor + private static func transformedCandidate( + _ transform: ConverterCandidateTransform, + manager: SegmentsManager, + inputState: InputState + ) -> Candidate { + switch transform { + case .hiragana: + manager.getModifiedRubyCandidate(inputState: inputState) { + $0.toHiragana() + } + case .katakana: + manager.getModifiedRubyCandidate(inputState: inputState) { + $0.toKatakana() + } + case .halfWidthKatakana: + manager.getModifiedRubyCandidate(inputState: inputState) { + $0.toKatakana().applyingTransform(.fullwidthToHalfwidth, reverse: false)! + } + case .fullWidthRoman: + manager.getModifiedRomanCandidate(inputState: inputState) { + $0.applyingTransform(.fullwidthToHalfwidth, reverse: true)! + } + case .halfWidthRoman: + manager.getModifiedRomanCandidate(inputState: inputState) { + $0.applyingTransform(.fullwidthToHalfwidth, reverse: false)! + } + } + } +} + +private enum ConverterServerError: LocalizedError { + case unknownSession(String) + + var errorDescription: String? { + switch self { + case .unknownSession(let sessionID): + "Unknown converter session: \(sessionID)" + } + } +} + +private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { + private let server = ConverterServer() + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + connection.exportedInterface = NSXPCInterface(with: ConverterServerXPCProtocol.self) + connection.exportedObject = server + connection.resume() + return true + } +} + +let listener = NSXPCListener(machServiceName: ConverterServerXPC.machServiceName) +private let delegate = ServiceDelegate() +listener.delegate = delegate +listener.resume() +RunLoop.current.run() diff --git a/Core/Sources/Core/Configs/CustomInputTableStore.swift b/Core/Sources/Core/Configs/CustomInputTableStore.swift index 8129ed87..50197236 100644 --- a/Core/Sources/Core/Configs/CustomInputTableStore.swift +++ b/Core/Sources/Core/Configs/CustomInputTableStore.swift @@ -49,11 +49,13 @@ public enum CustomInputTableStore { /// Load and register the custom input table if it exists. /// Safe to call multiple times; later calls override previous registration. - public static func registerIfExists() { + @discardableResult + public static func registerIfExists() -> Bool { guard exists(), let table = try? InputStyleManager.loadTable(from: fileURL) else { - return + return false } InputStyleManager.registerInputStyle(table: table, for: tableName) + return true } public static func exists() -> Bool { diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 8b9769c5..c79eae18 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -17,11 +17,13 @@ public final class SegmentsManager { /// テストなどの設定注入のための型。外部には設定を露出させない。 public struct Context { public init() {} - init(useZenzai: Bool) { + public init(useZenzai: Bool, resourcesDirectoryURL: URL? = nil) { self.useZenzai = useZenzai + self.resourcesDirectoryURL = resourcesDirectoryURL } var useZenzai: Bool = true + var resourcesDirectoryURL: URL? } public weak var delegate: (any SegmentManagerDelegate)? @@ -97,7 +99,7 @@ public final class SegmentsManager { return nil } - let base = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/", isDirectory: false).path + "/lm" + let base = self.resourcesDirectoryURL.appendingPathComponent("lm", isDirectory: false).path let personal = containerURL.appendingPathComponent("Library/Application Support/p13n_v1").path + "/lm" // check personal lm existence guard [ @@ -141,7 +143,7 @@ public final class SegmentsManager { return .off } return .on( - weight: Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/ggml-model-Q5_K_M.gguf", isDirectory: false), + weight: self.resourcesDirectoryURL.appendingPathComponent("ggml-model-Q5_K_M.gguf", isDirectory: false), inferenceLimit: Config.ZenzaiInferenceLimit().value, requestRichCandidates: requestRichCandidates, personalizationMode: self.zenzaiPersonalizationMode, @@ -155,6 +157,16 @@ public final class SegmentsManager { ) } + private var resourcesDirectoryURL: URL { + if let resourcesDirectoryURL = self.context.resourcesDirectoryURL { + return resourcesDirectoryURL + } + if let resourceURL = Bundle.main.resourceURL { + return resourceURL + } + return Bundle.main.bundleURL.appendingPathComponent("Contents/Resources", isDirectory: true) + } + private var metadata: ConvertRequestOptions.Metadata { if let tag = PackageMetadata.gitTag { .init(versionString: "azooKey on macOS (\(tag))") @@ -657,6 +669,11 @@ public final class SegmentsManager { public var selectionRange: NSRange + public init(text: [Element], selectionRange: NSRange) { + self.text = text + self.selectionRange = selectionRange + } + public func makeIterator() -> Array.Iterator { text.makeIterator() } diff --git a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift new file mode 100644 index 00000000..6269669e --- /dev/null +++ b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift @@ -0,0 +1,416 @@ +import Foundation +import KanaKanjiConverterModule + +public enum ConverterServerXPC { + public static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" +} + +@objc public protocol ConverterServerXPCProtocol { + func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) + 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) +} + +public enum ConverterServerProtocol { + public static let currentVersion = 1 + public static let minimumSupportedClientVersion = 1 +} + +public enum ConverterServerCodec { + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + public static func encode(_ info: ConverterServerInfo) throws -> Data { + try encoder.encode(info) + } + + public static func decodeServerInfo(from data: Data) throws -> ConverterServerInfo { + try decoder.decode(ConverterServerInfo.self, from: data) + } + + public static func encode(_ command: ConverterServerCommand) throws -> Data { + try encoder.encode(command) + } + + public static func decodeCommand(from data: Data) throws -> ConverterServerCommand { + try decoder.decode(ConverterServerCommand.self, from: data) + } + + public static func encode(_ response: ConverterServerResponse) throws -> Data { + try encoder.encode(response) + } + + public static func decodeResponse(from data: Data) throws -> ConverterServerResponse { + try decoder.decode(ConverterServerResponse.self, from: data) + } +} + +public struct ConverterServerInfo: Codable, Sendable, Equatable { + public var protocolVersion: Int + public var minimumClientProtocolVersion: Int + public var supportedCommands: [String] + public var serverKind: String + public var buildIdentifier: String? + + public init( + protocolVersion: Int, + minimumClientProtocolVersion: Int, + supportedCommands: [String], + serverKind: String, + buildIdentifier: String? = nil + ) { + self.protocolVersion = protocolVersion + self.minimumClientProtocolVersion = minimumClientProtocolVersion + self.supportedCommands = supportedCommands + self.serverKind = serverKind + self.buildIdentifier = buildIdentifier + } + + public func isCompatibleWithClient(protocolVersion clientProtocolVersion: Int) -> Bool { + minimumClientProtocolVersion <= clientProtocolVersion + } + + public func supports(_ commandName: ConverterServerCommandName) -> Bool { + supportedCommands.contains(commandName.rawValue) + } +} + +public enum ConverterServerCommandName: String, Codable, Sendable, CaseIterable { + case activate + case deactivate + case snapshot + case stopComposition + case insertText + case insertCompositionSeparator + case updateCandidates + case deleteBackward + case editSegment + case setCandidateWindowVisible + case selectNextCandidate + case selectPreviousCandidate + case selectCandidate + case resetSelection + case submitSelectedCandidate + case submitTransformedCandidate + case commitMarkedText + case forgetMemory +} + +public enum ConverterServerCommand: Codable, Sendable { + case activate(sessionID: String) + case deactivate(sessionID: String) + case snapshot(sessionID: String, inputState: ConverterInputState) + case stopComposition(sessionID: String) + case insertText(sessionID: String, text: String, inputStyle: ConverterInputStyle, leftSideContext: String?) + case insertCompositionSeparator(sessionID: String, inputStyle: ConverterInputStyle, skipUpdate: Bool) + case updateCandidates(sessionID: String, requestRichCandidates: Bool) + case deleteBackward(sessionID: String, count: Int, leftSideContext: String?) + case editSegment(sessionID: String, count: Int) + case setCandidateWindowVisible(sessionID: String, visible: Bool, inputState: ConverterInputState) + case selectNextCandidate(sessionID: String) + case selectPreviousCandidate(sessionID: String) + case selectCandidate(sessionID: String, index: Int) + case resetSelection(sessionID: String) + case submitSelectedCandidate(sessionID: String, leftSideContext: String?) + case submitTransformedCandidate(sessionID: String, transform: ConverterCandidateTransform, inputState: ConverterInputState, leftSideContext: String?) + case commitMarkedText(sessionID: String, inputState: ConverterInputState) + case forgetMemory(sessionID: String) + + public var commandName: ConverterServerCommandName { + switch self { + case .activate: + .activate + case .deactivate: + .deactivate + case .snapshot: + .snapshot + case .stopComposition: + .stopComposition + case .insertText: + .insertText + case .insertCompositionSeparator: + .insertCompositionSeparator + case .updateCandidates: + .updateCandidates + case .deleteBackward: + .deleteBackward + case .editSegment: + .editSegment + case .setCandidateWindowVisible: + .setCandidateWindowVisible + case .selectNextCandidate: + .selectNextCandidate + case .selectPreviousCandidate: + .selectPreviousCandidate + case .selectCandidate: + .selectCandidate + case .resetSelection: + .resetSelection + case .submitSelectedCandidate: + .submitSelectedCandidate + case .submitTransformedCandidate: + .submitTransformedCandidate + case .commitMarkedText: + .commitMarkedText + case .forgetMemory: + .forgetMemory + } + } +} + +public enum ConverterCandidateTransform: Codable, Sendable { + case hiragana + case katakana + case halfWidthKatakana + case fullWidthRoman + case halfWidthRoman +} + +public struct ConverterServerResponse: Codable, Sendable { + public var sessionID: String + public var committedText: String? + public var snapshot: ConverterSessionSnapshot + + public init(sessionID: String, committedText: String? = nil, snapshot: ConverterSessionSnapshot) { + self.sessionID = sessionID + self.committedText = committedText + self.snapshot = snapshot + } +} + +public struct ConverterSessionSnapshot: Codable, Sendable { + public var markedText: ConverterMarkedText + public var candidateWindow: ConverterCandidateWindow + public var isEmpty: Bool + public var convertTarget: String + + public init( + markedText: ConverterMarkedText, + candidateWindow: ConverterCandidateWindow, + isEmpty: Bool, + convertTarget: String + ) { + self.markedText = markedText + self.candidateWindow = candidateWindow + self.isEmpty = isEmpty + self.convertTarget = convertTarget + } +} + +public extension ConverterSessionSnapshot { + static var empty: ConverterSessionSnapshot { + ConverterSessionSnapshot( + markedText: ConverterMarkedText( + SegmentsManager.MarkedText( + text: [], + selectionRange: NSRange(location: NSNotFound, length: NSNotFound) + ) + ), + candidateWindow: .hidden, + isEmpty: true, + convertTarget: "" + ) + } + + var inputStateFromCandidateWindow: InputState? { + switch candidateWindow { + case .selecting: + .selecting + case .composing: + .composing + case .hidden: + nil + } + } +} + +public enum ConverterInputState: Codable, Sendable, Equatable { + case none + case attachDiacritic(String) + case composing + case previewing + case selecting + case replaceSuggestion + case unicodeInput(String) + + public init(_ inputState: InputState) { + switch inputState { + case .none: + self = .none + case .attachDiacritic(let value): + self = .attachDiacritic(value) + case .composing: + self = .composing + case .previewing: + self = .previewing + case .selecting: + self = .selecting + case .replaceSuggestion: + self = .replaceSuggestion + case .unicodeInput(let value): + self = .unicodeInput(value) + } + } + + public var inputState: InputState { + switch self { + case .none: + .none + case .attachDiacritic(let value): + .attachDiacritic(value) + case .composing: + .composing + case .previewing: + .previewing + case .selecting: + .selecting + case .replaceSuggestion: + .replaceSuggestion + case .unicodeInput(let value): + .unicodeInput(value) + } + } +} + +public enum ConverterInputStyle: Codable, Sendable, Equatable { + case direct + case roman2kana + case defaultRomanToKana + case defaultAZIK + case defaultKanaUS + case defaultKanaJIS + case empty + case tableName(String) + + public init(_ inputStyle: InputStyle) { + switch inputStyle { + case .direct: + self = .direct + case .roman2kana: + self = .roman2kana + case .mapped(let id): + switch id { + case .defaultRomanToKana: + self = .defaultRomanToKana + case .defaultAZIK: + self = .defaultAZIK + case .defaultKanaUS: + self = .defaultKanaUS + case .defaultKanaJIS: + self = .defaultKanaJIS + case .empty: + self = .empty + case .tableName(let name): + self = .tableName(name) + } + } + } + + public var inputStyle: InputStyle { + switch self { + case .direct: + .direct + case .roman2kana: + .roman2kana + case .defaultRomanToKana: + .mapped(id: .defaultRomanToKana) + case .defaultAZIK: + .mapped(id: .defaultAZIK) + case .defaultKanaUS: + .mapped(id: .defaultKanaUS) + case .defaultKanaJIS: + .mapped(id: .defaultKanaJIS) + case .empty: + .mapped(id: .empty) + case .tableName(let name): + .mapped(id: .tableName(name)) + } + } +} + +public struct ConverterMarkedText: Codable, Sendable, Equatable { + public var elements: [Element] + public var selectionRange: ConverterRange + + public init(_ markedText: SegmentsManager.MarkedText) { + self.elements = markedText.map(Element.init) + self.selectionRange = ConverterRange(markedText.selectionRange) + } + + public struct Element: Codable, Sendable, Equatable { + public var content: String + public var focus: FocusState + + public init(_ element: SegmentsManager.MarkedText.Element) { + self.content = element.content + self.focus = FocusState(element.focus) + } + } + + public enum FocusState: Codable, Sendable, Equatable { + case focused + case unfocused + case none + + public init(_ focusState: SegmentsManager.MarkedText.FocusState) { + switch focusState { + case .focused: + self = .focused + case .unfocused: + self = .unfocused + case .none: + self = .none + } + } + } +} + +public struct ConverterRange: Codable, Sendable, Equatable { + public var location: Int + public var length: Int + + public init(_ range: NSRange) { + self.location = range.location + self.length = range.length + } + + public var nsRange: NSRange { + NSRange(location: location, length: length) + } +} + +public enum ConverterCandidateWindow: Codable, Sendable, Equatable { + case hidden + case composing([ConverterCandidatePresentation], selectionIndex: Int?) + case selecting([ConverterCandidatePresentation], selectionIndex: Int?) +} + +public struct ConverterCandidatePresentation: Codable, Sendable, Equatable { + public var text: String + public var annotationText: String? + public var extraValues: [String: String] + + public init(_ presentation: CandidatePresentation) { + self.text = presentation.candidate.text + self.annotationText = presentation.displayContext.annotationText + self.extraValues = presentation.displayContext.extraValues + } + + public var candidatePresentation: CandidatePresentation { + CandidatePresentation( + candidate: Candidate( + text: text, + value: 0, + composingCount: .surfaceCount(text.count), + lastMid: 0, + data: [] + ), + displayContext: CandidatePresentationContext( + annotationText: annotationText, + extraValues: extraValues + ) + ) + } +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/ControlShortcutRoutingTests.swift b/Core/Tests/CoreTests/InputUtilsTests/ControlShortcutRoutingTests.swift index e3461975..a842d98e 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/ControlShortcutRoutingTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/ControlShortcutRoutingTests.swift @@ -286,3 +286,56 @@ private func makeControlEvent( return } } + +@Test func testShiftArrowRoutesToEditSegmentInCompositionStates() { + let shiftLeftEvent = makeControlEvent( + logicalKey: nil, + characters: nil, + modifiers: [.shift], + keyCode: 123 + ) + let shiftRightEvent = makeControlEvent( + logicalKey: nil, + characters: nil, + modifiers: [.shift], + keyCode: 124 + ) + + guard case .navigation(.left) = UserAction.getUserAction(eventCore: shiftLeftEvent, inputLanguage: .japanese) else { + Issue.record("Expected Shift+Left to be navigation(.left)") + return + } + guard case .navigation(.right) = UserAction.getUserAction(eventCore: shiftRightEvent, inputLanguage: .japanese) else { + Issue.record("Expected Shift+Right to be navigation(.right)") + return + } + + let states: [InputState] = [.composing, .previewing, .selecting] + for state in states { + let (leftAction, _) = state.event( + eventCore: shiftLeftEvent, + userAction: .navigation(.left), + inputLanguage: .japanese, + liveConversionEnabled: false, + enableDebugWindow: false, + enableSuggestion: false + ) + guard case .editSegment(-1) = leftAction else { + Issue.record("Expected Shift+Left in \(state) to edit segment left, got \(leftAction)") + return + } + + let (rightAction, _) = state.event( + eventCore: shiftRightEvent, + userAction: .navigation(.right), + inputLanguage: .japanese, + liveConversionEnabled: false, + enableDebugWindow: false, + enableSuggestion: false + ) + guard case .editSegment(1) = rightAction else { + Issue.record("Expected Shift+Right in \(state) to edit segment right, got \(rightAction)") + return + } + } +} diff --git a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift new file mode 100644 index 00000000..c078b845 --- /dev/null +++ b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift @@ -0,0 +1,74 @@ +import Core +import Foundation +import Testing + +private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> ConverterSessionSnapshot { + ConverterSessionSnapshot( + markedText: ConverterSessionSnapshot.empty.markedText, + candidateWindow: candidateWindow, + isEmpty: false, + convertTarget: "あい" + ) +} + +@Test func converterServerEmptySnapshotHasNoVisibleComposition() { + let snapshot = ConverterSessionSnapshot.empty + + #expect(snapshot.isEmpty) + #expect(snapshot.convertTarget.isEmpty) + #expect(snapshot.markedText.elements.isEmpty) + #expect(snapshot.markedText.selectionRange.nsRange.location == NSNotFound) + #expect(snapshot.markedText.selectionRange.nsRange.length == NSNotFound) + #expect(snapshot.inputStateFromCandidateWindow == nil) + + guard case .hidden = snapshot.candidateWindow else { + Issue.record("Expected hidden candidate window, got \(snapshot.candidateWindow)") + return + } +} + +@Test func converterServerSnapshotCandidateWindowRestoresClientInputState() { + let selecting = makeSnapshot(candidateWindow: .selecting([], selectionIndex: 0)) + #expect(selecting.inputStateFromCandidateWindow == .selecting) + + let composing = makeSnapshot(candidateWindow: .composing([], selectionIndex: nil)) + #expect(composing.inputStateFromCandidateWindow == .composing) + + let hidden = makeSnapshot(candidateWindow: .hidden) + #expect(hidden.inputStateFromCandidateWindow == nil) +} + +@Test func converterServerEditSegmentCommandCodableShape() throws { + let expectedJSON = #"{"editSegment":{"sessionID":"session-1","count":-1}}"# + let command = try ConverterServerCodec.decodeCommand(from: Data(expectedJSON.utf8)) + + guard case .editSegment(let sessionID, let count) = command else { + Issue.record("Expected editSegment command, got \(command)") + return + } + #expect(sessionID == "session-1") + #expect(count == -1) + #expect(command.commandName == .editSegment) + + let roundTrip = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(command)) + guard case .editSegment(let roundTripSessionID, let roundTripCount) = roundTrip else { + Issue.record("Expected editSegment command after round trip, got \(roundTrip)") + return + } + #expect(roundTripSessionID == "session-1") + #expect(roundTripCount == -1) +} + +@Test func converterServerInfoAdvertisesAllKnownCommands() { + let info = ConverterServerInfo( + protocolVersion: ConverterServerProtocol.currentVersion, + minimumClientProtocolVersion: ConverterServerProtocol.minimumSupportedClientVersion, + supportedCommands: ConverterServerCommandName.allCases.map(\.rawValue), + serverKind: "test" + ) + + #expect(info.isCompatibleWithClient(protocolVersion: ConverterServerProtocol.currentVersion)) + for commandName in ConverterServerCommandName.allCases { + #expect(info.supports(commandName)) + } +} diff --git a/Tools/install_converter_server_launch_agent.sh b/Tools/install_converter_server_launch_agent.sh new file mode 100755 index 00000000..669b18e3 --- /dev/null +++ b/Tools/install_converter_server_launch_agent.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +service_name="dev.ensan.inputmethod.azooKeyMac.ConverterServer" +default_app_path="${BUILT_PRODUCTS_DIR:-/tmp/azooKeyDesktopDerivedData/Build/Products/Debug}/azooKeyMac.app" +app_path="${1:-${default_app_path}}" +server_path="${app_path}/Contents/MacOS/ConverterServer" +agent_dir="${HOME}/Library/LaunchAgents" +agent_path="${agent_dir}/${service_name}.plist" +gui_domain="gui/$(id -u)" + +if [ ! -x "${server_path}" ]; then + echo "ConverterServer not found: ${server_path}" >&2 + echo "Build azooKeyMac first, or pass the app bundle path as the first argument." >&2 + exit 1 +fi + +mkdir -p "${agent_dir}" +cat > "${agent_path}" < + + + + Label + ${service_name} + ProgramArguments + + ${server_path} + + MachServices + + ${service_name} + + + KeepAlive + + RunAtLoad + + StandardOutPath + /tmp/${service_name}.stdout.log + StandardErrorPath + /tmp/${service_name}.stderr.log + + +PLIST + +launchctl bootout "${gui_domain}" "${agent_path}" >/dev/null 2>&1 || true +launchctl bootstrap "${gui_domain}" "${agent_path}" +launchctl kickstart -k "${gui_domain}/${service_name}" +launchctl print "${gui_domain}/${service_name}" >/dev/null + +echo "Installed and started ${service_name}" +echo "${agent_path}" diff --git a/azooKeyMac.xcodeproj/project.pbxproj b/azooKeyMac.xcodeproj/project.pbxproj index 8cccdf0f..78e13f2e 100644 --- a/azooKeyMac.xcodeproj/project.pbxproj +++ b/azooKeyMac.xcodeproj/project.pbxproj @@ -159,6 +159,7 @@ 1A41E61026E745D9009B65D7 /* Sources */, 1A41E61126E745D9009B65D7 /* Frameworks */, 1A41E61226E745D9009B65D7 /* Resources */, + 55C0DEC02EFD000000000001 /* Build ConverterServer */, ); buildRules = ( ); @@ -302,6 +303,29 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 55C0DEC02EFD000000000001 /* Build ConverterServer */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build ConverterServer"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/MacOS/ConverterServer", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -euo pipefail\n\nswift_configuration=debug\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n swift_configuration=release\nfi\n\nswift build --package-path \"${SRCROOT}/Core\" --product ConverterServer -c \"${swift_configuration}\"\n\nserver_source=\"${SRCROOT}/Core/.build/${swift_configuration}/ConverterServer\"\nserver_destination=\"${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/MacOS/ConverterServer\"\nmkdir -p \"$(dirname \"${server_destination}\")\"\ncp \"${server_source}\" \"${server_destination}\"\n\nif ! otool -l \"${server_destination}\" | grep -q \"@executable_path/../Frameworks\"; then\n install_name_tool -add_rpath \"@executable_path/../Frameworks\" \"${server_destination}\"\nfi\n\nif [ -n \"${EXPANDED_CODE_SIGN_IDENTITY:-}\" ] && [ \"${EXPANDED_CODE_SIGN_IDENTITY}\" != \"-\" ]; then\n codesign --force --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" --options runtime --timestamp=none \"${server_destination}\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 1A41E61026E745D9009B65D7 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -492,6 +516,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = azooKeyMac/Info.plist; @@ -526,6 +551,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GCC_OPTIMIZATION_LEVEL = fast; GENERATE_INFOPLIST_FILE = YES; diff --git a/azooKeyMac/InputController/ConverterServerClient.swift b/azooKeyMac/InputController/ConverterServerClient.swift new file mode 100644 index 00000000..60872bdb --- /dev/null +++ b/azooKeyMac/InputController/ConverterServerClient.swift @@ -0,0 +1,345 @@ +import Core +import Foundation + +final class ConverterServerClient { + private var connection: NSXPCConnection? + private var sessionID: String? + private var serverInfo: ConverterServerInfo? + private let syncTimeout: TimeInterval = 0.8 + private var hasOpenedSession = false + private var shouldAttemptReconnect = false + private var nextReconnectAttemptDate = Date.distantPast + + var onLog: ((String) -> Void)? + var hasOpenSession: Bool { + sessionID != nil + } + var canSendOrReconnect: Bool { + sessionID != nil || (shouldAttemptReconnect && Date() >= nextReconnectAttemptDate) + } + + func openSession(completion: ((String?) -> Void)? = nil) { + if let sessionID { + completion?(sessionID) + return + } + refreshServerInfo { [weak self] info in + guard let self, let info, self.isCompatible(info) else { + completion?(nil) + return + } + self.openCompatibleSession(completion: completion) + } + } + + func openSessionSync() -> String? { + if let sessionID { + return sessionID + } + guard let info = serverInfoSync(), isCompatible(info) else { + recordReconnectFailure() + return nil + } + let sessionID = waitForResult(timeout: syncTimeout) { [weak self] complete in + self?.openCompatibleSession(completion: complete) + } + if sessionID == nil { + recordReconnectFailure() + } + return sessionID + } + + func refreshServerInfo(completion: @escaping (ConverterServerInfo?) -> Void) { + if let serverInfo { + completion(serverInfo) + return + } + remoteObjectProxy { [weak self] proxy in + guard let self, let proxy else { + completion(nil) + return + } + proxy.serverInfo { data, errorMessage in + if let errorMessage { + self.onLog?("ConverterServer info failed: \(errorMessage)") + completion(nil) + return + } + guard let data, let info = try? ConverterServerCodec.decodeServerInfo(from: data) else { + self.onLog?("ConverterServer info decode failed") + completion(nil) + return + } + self.serverInfo = info + self.onLog?("ConverterServer protocol v\(info.protocolVersion), kind=\(info.serverKind)") + completion(info) + } + } + } + + func closeSession() { + guard let sessionID else { + invalidateConnection() + return + } + remoteObjectProxy { [weak self] proxy in + proxy?.closeSession(sessionID) { _ in + self?.invalidateConnection() + } + } + } + + func ping(_ message: String, completion: @escaping (String?) -> Void) { + remoteObjectProxy { proxy in + proxy?.ping(message) { response in + completion(response) + } + if proxy == nil { + completion(nil) + } + } + } + + func send( + _ commandBuilder: @escaping (String) -> ConverterServerCommand, + completion: @escaping (ConverterServerResponse?) -> Void + ) { + openSession { [weak self] sessionID in + guard let self, let sessionID else { + completion(nil) + return + } + self.sendResolved(commandBuilder(sessionID), completion: completion) + } + } + + func sendSync(_ commandBuilder: (String) -> ConverterServerCommand) -> ConverterServerResponse? { + guard let sessionID = openSessionSync() else { + return nil + } + let command = commandBuilder(sessionID) + guard supports(command) else { + onLog?("ConverterServer command unsupported: \(command.commandName.rawValue)") + return nil + } + return sendResolvedSync(command) + } + + func sendIfSessionOpenSync(_ commandBuilder: (String) -> ConverterServerCommand) -> ConverterServerResponse? { + guard let sessionID else { + return nil + } + let command = commandBuilder(sessionID) + guard supports(command) else { + onLog?("ConverterServer command unsupported: \(command.commandName.rawValue)") + return nil + } + return sendResolvedSync(command) + } + + func sendIfSessionOpen( + _ commandBuilder: @escaping (String) -> ConverterServerCommand, + completion: @escaping (ConverterServerResponse?) -> Void + ) { + guard let sessionID else { + completion(nil) + return + } + sendResolved(commandBuilder(sessionID), completion: completion) + } + + private func remoteObjectProxy(completion: @escaping (ConverterServerXPCProtocol?) -> Void) { + let connection = ensureConnection() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] error in + self?.onLog?("ConverterServer XPC error: \(error.localizedDescription)") + self?.resetConnection() + completion(nil) + }) as? ConverterServerXPCProtocol else { + completion(nil) + return + } + completion(proxy) + } + + private func sendResolved( + _ command: ConverterServerCommand, + completion: @escaping (ConverterServerResponse?) -> Void + ) { + guard supports(command) else { + onLog?("ConverterServer command unsupported: \(command.commandName.rawValue)") + completion(nil) + return + } + do { + let data = try ConverterServerCodec.encode(command) + self.remoteObjectProxy { proxy in + guard let proxy else { + completion(nil) + return + } + proxy.handleCommand(data) { [weak self] responseData, errorMessage in + if let errorMessage { + self?.onLog?("ConverterServer command failed: \(errorMessage)") + completion(nil) + return + } + guard let responseData else { + completion(nil) + return + } + completion(try? ConverterServerCodec.decodeResponse(from: responseData)) + } + } + } catch { + self.onLog?("ConverterServer encode failed: \(error.localizedDescription)") + completion(nil) + } + } + + private func openCompatibleSession(completion: ((String?) -> Void)? = nil) { + remoteObjectProxy { [weak self] proxy in + guard let self, let proxy else { + completion?(nil) + return + } + proxy.openSession { sessionID in + self.sessionID = sessionID + self.hasOpenedSession = true + self.shouldAttemptReconnect = false + self.nextReconnectAttemptDate = .distantPast + self.onLog?("ConverterServer session opened: \(sessionID)") + completion?(sessionID) + } + } + } + + private func serverInfoSync() -> ConverterServerInfo? { + if let serverInfo { + return serverInfo + } + return waitForResult(timeout: syncTimeout) { [weak self] complete in + self?.refreshServerInfo(completion: complete) + } + } + + private func sendResolvedSync(_ command: ConverterServerCommand) -> ConverterServerResponse? { + do { + let data = try ConverterServerCodec.encode(command) + return waitForResult(timeout: syncTimeout) { [weak self] complete in + self?.remoteObjectProxy { proxy in + guard let proxy else { + complete(nil) + return + } + proxy.handleCommand(data) { responseData, errorMessage in + if let errorMessage { + self?.onLog?("ConverterServer command failed: \(errorMessage)") + complete(nil) + return + } + guard let responseData else { + complete(nil) + return + } + complete(try? ConverterServerCodec.decodeResponse(from: responseData)) + } + } + } + } catch { + onLog?("ConverterServer encode failed: \(error.localizedDescription)") + return nil + } + } + + private func isCompatible(_ info: ConverterServerInfo) -> Bool { + guard info.isCompatibleWithClient(protocolVersion: ConverterServerProtocol.currentVersion) else { + onLog?( + "ConverterServer protocol incompatible: server min client v\(info.minimumClientProtocolVersion), client v\(ConverterServerProtocol.currentVersion)" + ) + return false + } + return true + } + + private func supports(_ command: ConverterServerCommand) -> Bool { + guard let serverInfo else { + return false + } + return serverInfo.supports(command.commandName) + } + + private func ensureConnection() -> NSXPCConnection { + if let connection { + return connection + } + let connection = NSXPCConnection(machServiceName: ConverterServerXPC.machServiceName, options: []) + connection.remoteObjectInterface = NSXPCInterface(with: ConverterServerXPCProtocol.self) + connection.interruptionHandler = { [weak self] in + self?.onLog?("ConverterServer connection interrupted") + self?.resetConnection() + } + connection.invalidationHandler = { [weak self] in + self?.onLog?("ConverterServer connection invalidated") + self?.resetConnection() + } + connection.resume() + self.connection = connection + return connection + } + + private func resetConnection() { + self.connection = nil + if sessionID != nil || hasOpenedSession { + shouldAttemptReconnect = true + } + self.sessionID = nil + self.serverInfo = nil + } + + private func invalidateConnection() { + connection?.invalidate() + resetConnection() + } + + private func recordReconnectFailure() { + guard shouldAttemptReconnect else { + return + } + nextReconnectAttemptDate = Date().addingTimeInterval(2) + } +} + +private final class SyncResult: @unchecked Sendable { + private let lock = NSLock() + private var value: Value? + + func set(_ value: Value?) { + lock.lock() + self.value = value + lock.unlock() + } + + func get() -> Value? { + lock.lock() + defer { + lock.unlock() + } + return value + } +} + +private func waitForResult( + timeout: TimeInterval, + start: (@escaping @Sendable (Value?) -> Void) -> Void +) -> Value? { + let semaphore = DispatchSemaphore(value: 0) + let result = SyncResult() + start { value in + result.set(value) + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return nil + } + return result.get() +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index b1a6db8f..86839914 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -6,6 +6,8 @@ import KanaKanjiConverterModuleWithDefaultDictionary @objc(azooKeyMacInputController) class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // swiftlint:disable:this type_name var segmentsManager: SegmentsManager + let converterServerClient = ConverterServerClient() + private var converterServerSnapshot: ConverterSessionSnapshot? private(set) var inputState: InputState = .none private var inputLanguage: InputLanguage = .japanese var liveConversionEnabled: Bool { @@ -145,6 +147,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.candidatesViewController.delegate = self self.replaceSuggestionsViewController.delegate = self self.segmentsManager.delegate = self + self.converterServerClient.onLog = { [weak self] message in + self?.segmentsManager.appendDebugMessage(message) + } self.setupMenu() } @@ -160,6 +165,17 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // ピン留めプロンプトのキャッシュを更新 self.reloadPinnedPromptsCache() self.segmentsManager.activate() + self.converterServerClient.openSession { [weak self] sessionID in + guard let self, sessionID != nil else { + return + } + self.converterServerClient.sendIfSessionOpen({ .activate(sessionID: $0) }, completion: { [weak self] response in + guard let response else { + return + } + self?.converterServerSnapshot = response.snapshot + }) + } if let client = sender as? IMKTextInput { client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) @@ -180,6 +196,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s @MainActor override func deactivateServer(_ sender: Any!) { self.segmentsManager.deactivate() + self.converterServerClient.sendIfSessionOpen({ .deactivate(sessionID: $0) }, completion: { _ in }) + self.converterServerSnapshot = nil self.candidatesWindow.orderOut(nil) self.predictionWindow.orderOut(nil) self.replaceSuggestionWindow.orderOut(nil) @@ -196,6 +214,19 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return } if self.segmentsManager.isEmpty { + if self.converterServerSnapshot?.isEmpty == false, + let response = self.converterServerClient.sendIfSessionOpenSync({ + .commitMarkedText(sessionID: $0, inputState: ConverterInputState(self.inputState)) + }) { + self.converterServerSnapshot = response.snapshot + if let client = sender as? IMKTextInput, let text = response.committedText, !text.isEmpty { + client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + self.inputState = .none + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + } return } let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) @@ -226,6 +257,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if self.inputLanguage == .japanese { self.inputLanguage = .english self.segmentsManager.stopJapaneseInput() + self.discardConverterServerComposition() self.refreshCandidateWindow() self.refreshPredictionWindow() } @@ -234,7 +266,12 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if self.inputLanguage == .english { self.inputLanguage = .japanese let (clientAction, clientActionCallback) = self.inputState.event( - eventCore: .init(modifierFlags: [], characters: nil, charactersIgnoringModifiers: nil, keyCode: 0x00), + eventCore: .init( + modifierFlags: [], + characters: nil, + charactersIgnoringModifiers: nil, + keyCode: 0x00 + ), userAction: .かな, inputLanguage: self.inputLanguage, liveConversionEnabled: false, @@ -388,6 +425,15 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // この種のコードは複雑にしかならないので、lintを無効にする // swiftlint:disable:next cyclomatic_complexity @MainActor func handleClientAction(_ clientAction: ClientAction, clientActionCallback: ClientActionCallback, client: IMKTextInput) -> Bool { + if let serverResponse = self.handleClientActionWithConverterServer(clientAction, client: client) { + self.applyClientActionCallback(clientActionCallback, client: client, compositionIsEmpty: serverResponse.snapshot.isEmpty) + self.applyConverterServerSnapshotState(serverResponse.snapshot) + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + return true + } + // return only false switch clientAction { case .showCandidateWindow: @@ -535,6 +581,28 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return false } + self.applyClientActionCallback( + clientActionCallback, + client: client, + compositionIsEmpty: self.currentCompositionIsEmpty + ) + + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + return true + } + + private var currentCompositionIsEmpty: Bool { + self.converterServerSnapshot?.isEmpty ?? self.segmentsManager.isEmpty + } + + @MainActor + private func applyClientActionCallback( + _ clientActionCallback: ClientActionCallback, + client: IMKTextInput, + compositionIsEmpty: Bool + ) { switch clientActionCallback { case .fallthrough: break @@ -548,13 +616,221 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } self.inputState = inputState case .basedOnBackspace(let ifIsEmpty, let ifIsNotEmpty), .basedOnSubmitCandidate(let ifIsEmpty, let ifIsNotEmpty): - self.inputState = self.segmentsManager.isEmpty ? ifIsEmpty : ifIsNotEmpty + self.inputState = compositionIsEmpty ? ifIsEmpty : ifIsNotEmpty } + } - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - return true + private func applyConverterServerSnapshotState(_ snapshot: ConverterSessionSnapshot) { + if let inputState = snapshot.inputStateFromCandidateWindow { + self.inputState = inputState + } + } + + @MainActor + // swiftlint:disable:next cyclomatic_complexity + private func handleClientActionWithConverterServer( + _ clientAction: ClientAction, + client: IMKTextInput + ) -> ConverterServerResponse? { + guard self.converterServerClient.canSendOrReconnect else { + return nil + } + + func send(_ commandBuilder: (String) -> ConverterServerCommand) -> ConverterServerResponse? { + guard let response = self.converterServerClient.sendSync(commandBuilder) else { + return nil + } + self.converterServerSnapshot = response.snapshot + return response + } + + func insertCommittedText(_ response: ConverterServerResponse?) { + guard let text = response?.committedText, !text.isEmpty else { + return + } + client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + + let serverInputStyle = ConverterInputStyle(self.inputLanguage == .english ? .direct : self.inputStyle) + let serverInputState = ConverterInputState(self.inputState) + func leftSideContext() -> String? { + self.getLeftSideContext(maxCount: 30) + } + + switch clientAction { + case .showCandidateWindow: + return send { + .setCandidateWindowVisible(sessionID: $0, visible: true, inputState: serverInputState) + } + case .hideCandidateWindow: + return send { + .setCandidateWindowVisible(sessionID: $0, visible: false, inputState: serverInputState) + } + case .enterFirstCandidatePreviewMode: + guard let response = send({ + .insertCompositionSeparator(sessionID: $0, inputStyle: serverInputStyle, skipUpdate: false) + }) else { + return nil + } + _ = send { + .setCandidateWindowVisible(sessionID: $0, visible: false, inputState: .previewing) + } + return response + case .enterCandidateSelectionMode: + guard send({ + .insertCompositionSeparator(sessionID: $0, inputStyle: serverInputStyle, skipUpdate: true) + }) != nil else { + return nil + } + return send { + .updateCandidates(sessionID: $0, requestRichCandidates: true) + } + case .appendToMarkedText(let string): + return send { + .insertText(sessionID: $0, text: string, inputStyle: serverInputStyle, leftSideContext: leftSideContext()) + } + case .appendPieceToMarkedText(let pieces): + return send { + .insertText( + sessionID: $0, + text: pieces.inputString(preferIntention: true), + inputStyle: serverInputStyle, + leftSideContext: leftSideContext() + ) + } + case .editSegment(let count): + return send { + .editSegment(sessionID: $0, count: count) + } + case .commitMarkedText: + let response = send { + .commitMarkedText(sessionID: $0, inputState: serverInputState) + } + insertCommittedText(response) + return response + case .commitMarkedTextAndAppendToMarkedText(let string): + let commitResponse = send { + .commitMarkedText(sessionID: $0, inputState: serverInputState) + } + insertCommittedText(commitResponse) + return send { + .insertText(sessionID: $0, text: string, inputStyle: serverInputStyle, leftSideContext: leftSideContext()) + } ?? commitResponse + case .commitMarkedTextAndAppendPieceToMarkedText(let pieces): + let commitResponse = send { + .commitMarkedText(sessionID: $0, inputState: serverInputState) + } + insertCommittedText(commitResponse) + return send { + .insertText( + sessionID: $0, + text: pieces.inputString(preferIntention: true), + inputStyle: serverInputStyle, + leftSideContext: leftSideContext() + ) + } ?? commitResponse + case .submitSelectedCandidate: + let response = send { + .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext()) + } + insertCommittedText(response) + return response + case .removeLastMarkedText: + guard send({ + .deleteBackward(sessionID: $0, count: 1, leftSideContext: leftSideContext()) + }) != nil else { + return nil + } + return send { + .resetSelection(sessionID: $0) + } + case .selectPrevCandidate: + return send { + .selectPreviousCandidate(sessionID: $0) + } + case .selectNextCandidate: + return send { + .selectNextCandidate(sessionID: $0) + } + case .selectNumberCandidate(let num): + guard send({ + .selectCandidate(sessionID: $0, index: self.candidatesViewController.getNumberCandidate(num: num)) + }) != nil else { + return nil + } + let response = send { + .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext()) + } + insertCommittedText(response) + return response + case .submitHiraganaCandidate: + return self.submitTransformedCandidateWithConverterServer(.hiragana, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) + case .submitKatakanaCandidate: + return self.submitTransformedCandidateWithConverterServer(.katakana, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) + case .submitHankakuKatakanaCandidate: + return self.submitTransformedCandidateWithConverterServer(.halfWidthKatakana, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) + case .submitFullWidthRomanCandidate: + return self.submitTransformedCandidateWithConverterServer(.fullWidthRoman, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) + case .submitHalfWidthRomanCandidate: + return self.submitTransformedCandidateWithConverterServer(.halfWidthRoman, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) + case .stopComposition: + return send { + .stopComposition(sessionID: $0) + } + case .forgetMemory: + return send { + .forgetMemory(sessionID: $0) + } + case .selectInputLanguage(let language): + let response = self.converterServerSnapshot?.isEmpty == false ? send { + .stopComposition(sessionID: $0) + } : self.converterServerSnapshot.map { + ConverterServerResponse(sessionID: "", snapshot: $0) + } + self.switchInputLanguage(language, client: client) + return response + case .commitMarkedTextAndSelectInputLanguage(let language): + let response = send { + .commitMarkedText(sessionID: $0, inputState: serverInputState) + } + insertCommittedText(response) + self.switchInputLanguage(language, client: client) + return response + case .submitSelectedCandidateAndEnterUnicodeInputMode: + let response = send { + .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext()) + } + insertCommittedText(response) + if let snapshot = response?.snapshot, !snapshot.isEmpty { + client.insertText(snapshot.convertTarget, replacementRange: NSRange(location: NSNotFound, length: 0)) + _ = send { + .stopComposition(sessionID: $0) + } + } + return response + default: + return nil + } + } + + @MainActor + private func submitTransformedCandidateWithConverterServer( + _ transform: ConverterCandidateTransform, + inputState: ConverterInputState, + leftSideContext: String?, + send: ((String) -> ConverterServerCommand) -> ConverterServerResponse?, + insertCommittedText: (ConverterServerResponse?) -> Void + ) -> ConverterServerResponse? { + let response = send { + .submitTransformedCandidate( + sessionID: $0, + transform: transform, + inputState: inputState, + leftSideContext: leftSideContext + ) + } + insertCommittedText(response) + return response } @MainActor func switchInputLanguage(_ language: InputLanguage, client: IMKTextInput) { @@ -564,12 +840,25 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s case .english: client.selectMode("dev.ensan.inputmethod.azooKeyMac.Roman") self.segmentsManager.stopJapaneseInput() + self.discardConverterServerComposition() case .japanese: client.selectMode("dev.ensan.inputmethod.azooKeyMac.Japanese") } } + private func discardConverterServerComposition() { + self.converterServerSnapshot = nil + self.converterServerClient.sendIfSessionOpen( + { .stopComposition(sessionID: $0) }, + completion: { _ in } + ) + } + func refreshCandidateWindow() { + if let converterServerSnapshot { + self.refreshCandidateWindow(converterServerSnapshot.candidateWindow) + return + } switch self.segmentsManager.getCurrentCandidateWindow(inputState: self.inputState) { case .selecting(let candidates, let selectionIndex): var rect: NSRect = .zero @@ -600,7 +889,40 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } + private func refreshCandidateWindow(_ candidateWindow: ConverterCandidateWindow) { + switch candidateWindow { + case .selecting(let candidates, let selectionIndex): + var rect: NSRect = .zero + self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) + self.candidatesViewController.showCandidateIndex = true + self.candidatesViewController.updateCandidatePresentations( + candidates.map(\.candidatePresentation), + selectionIndex: selectionIndex, + cursorLocation: rect.origin + ) + self.candidatesWindow.orderFront(nil) + case .composing(let candidates, let selectionIndex): + var rect: NSRect = .zero + self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) + self.candidatesViewController.showCandidateIndex = false + self.candidatesViewController.updateCandidatePresentations( + candidates.map(\.candidatePresentation), + selectionIndex: selectionIndex, + cursorLocation: rect.origin + ) + self.candidatesWindow.orderFront(nil) + case .hidden: + self.candidatesWindow.setIsVisible(false) + self.candidatesWindow.orderOut(nil) + self.candidatesViewController.hide() + } + } + func refreshPredictionWindow() { + if self.converterServerSnapshot != nil { + self.hidePredictionWindow() + return + } guard self.inputState == .composing else { self.hidePredictionWindow() return @@ -769,8 +1091,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s at: NSRange(location: NSNotFound, length: 0) ) as? [NSAttributedString.Key: Any] let text = NSMutableAttributedString(string: "") - let currentMarkedText = self.segmentsManager.getCurrentMarkedText(inputState: self.inputState) - for part in currentMarkedText where !part.content.isEmpty { + let currentMarkedText = self.currentMarkedText() + for part in currentMarkedText.elements where !part.content.isEmpty { let attributes: [NSAttributedString.Key: Any]? = switch part.focus { case .focused: highlight case .unfocused: underline @@ -785,11 +1107,18 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } self.client()?.setMarkedText( text, - selectionRange: currentMarkedText.selectionRange, + selectionRange: currentMarkedText.selectionRange.nsRange, replacementRange: NSRange(location: NSNotFound, length: 0) ) } + private func currentMarkedText() -> ConverterMarkedText { + if let converterServerSnapshot { + return converterServerSnapshot.markedText + } + return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) + } + @MainActor func submitCandidate(_ candidate: Candidate) { if let client = self.client() { @@ -813,12 +1142,36 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s extension azooKeyMacInputController: CandidatesViewControllerDelegate { func candidateSubmitted() { Task { @MainActor in + if self.converterServerSnapshot != nil { + let leftSideContext = self.getLeftSideContext(maxCount: 30) + if let response = self.converterServerClient.sendIfSessionOpenSync({ + .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext) + }) { + self.converterServerSnapshot = response.snapshot + if let text = response.committedText, !text.isEmpty { + self.client()?.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + self.inputState = response.snapshot.isEmpty ? .none : .previewing + self.applyConverterServerSnapshotState(response.snapshot) + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + return + } + } self.submitSelectedCandidate() } } func candidateSelectionChanged(_ row: Int) { Task { @MainActor in + if self.converterServerSnapshot != nil, + let response = self.converterServerClient.sendIfSessionOpenSync({ + .selectCandidate(sessionID: $0, index: row) + }) { + self.converterServerSnapshot = response.snapshot + return + } self.segmentsManager.requestSelectingRow(row) } } diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index c9346d83..cc927042 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -14,6 +14,7 @@ extension azooKeyMacInputController { self.transformSelectedTextMenuItem.target = self self.appMenu.addItem(self.transformSelectedTextMenuItem) self.appMenu.addItem(NSMenuItem.separator()) + self.appMenu.addItem(NSMenuItem(title: "Converter Processと通信", action: #selector(self.callConverterProcess(_:)), keyEquivalent: "")) self.appMenu.addItem(NSMenuItem(title: "設定…", action: #selector(self.openConfigWindow(_:)), keyEquivalent: "")) self.appMenu.addItem(NSMenuItem(title: "View on GitHub…", action: #selector(self.openGitHubRepository(_:)), keyEquivalent: "")) self.updateTransformSelectedTextMenuItemEnabledState() @@ -104,6 +105,12 @@ extension azooKeyMacInputController { (NSApplication.shared.delegate as? AppDelegate)!.openConfigWindow() } + @objc func callConverterProcess(_ sender: Any) { + self.converterServerClient.ping("azooKeyMac") { [weak self] response in + self?.segmentsManager.appendDebugMessage("ConverterServer ping: \(response ?? "failed")") + } + } + // MARK: - Application Support Directory func prepareApplicationSupportDirectory() { do { diff --git a/azooKeyMac/azooKeyMac.entitlements b/azooKeyMac/azooKeyMac.entitlements index c4080206..b54a5c38 100644 --- a/azooKeyMac/azooKeyMac.entitlements +++ b/azooKeyMac/azooKeyMac.entitlements @@ -12,5 +12,9 @@ com.apple.security.temporary-exception.mach-register.global-name $(PRODUCT_BUNDLE_IDENTIFIER)_Connection + com.apple.security.temporary-exception.mach-lookup.global-name + + dev.ensan.inputmethod.azooKeyMac.ConverterServer + diff --git a/install.sh b/install.sh index 58c1baf7..cf57a0d8 100755 --- a/install.sh +++ b/install.sh @@ -3,12 +3,16 @@ set -xe -o pipefail IGNORE_LINT=false DRY_RUN=false +NO_PKILL=false +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_APP_PATH="/Library/Input Methods/azooKeyMac.app" # Parse command-line options while [[ "$#" -gt 0 ]]; do case $1 in --ignore-lint) IGNORE_LINT=true ;; --dry-run) DRY_RUN=true ;; + --no-pkill) NO_PKILL=true ;; *) echo "Unknown parameter passed: $1"; exit 1 ;; esac shift @@ -45,10 +49,16 @@ if [ "$DRY_RUN" = true ]; then echo "DRY RUN: Would execute the following commands:" echo " sudo rm -rf /Library/Input\ Methods/azooKeyMac.app" echo " sudo cp -r build/archive.xcarchive/Products/Applications/azooKeyMac.app /Library/Input\ Methods/" - echo " pkill azooKeyMac" + echo " ${REPO_ROOT}/Tools/install_converter_server_launch_agent.sh \"${INSTALL_APP_PATH}\"" + if [ "$NO_PKILL" = false ]; then + echo " pkill azooKeyMac || true" + fi echo "Build completed successfully. Use without --dry-run to actually install." else sudo rm -rf /Library/Input\ Methods/azooKeyMac.app sudo cp -r build/archive.xcarchive/Products/Applications/azooKeyMac.app /Library/Input\ Methods/ - pkill azooKeyMac + "${REPO_ROOT}/Tools/install_converter_server_launch_agent.sh" "${INSTALL_APP_PATH}" + if [ "$NO_PKILL" = false ]; then + pkill azooKeyMac || true + fi fi From 7e31f094e7d41902cae0cee620c130255a77d386 Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Mon, 18 May 2026 16:48:08 +0900 Subject: [PATCH 2/5] fix: keep XPC server target macOS-only --- Core/Package.swift | 99 ++++++++++--------- Core/Sources/ConverterServer/main.swift | 12 +++ .../Core/XPC/ConverterServerXPCProtocol.swift | 12 --- .../ConverterServerClient.swift | 12 +++ 4 files changed, 79 insertions(+), 56 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index fa401b91..f60cf366 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -10,56 +10,67 @@ let kanaKanjiConverterTraits: Set = ["Zenzai"] let kanaKanjiConverterTraits: Set = [] #endif +var products: [Product] = [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "Core", + targets: ["Core"] + ) +] + +var targets: [Target] = [ + .executableTarget( + name: "git-info-generator" + ), + .plugin( + name: "GitInfoPlugin", + capability: .buildTool(), + dependencies: [.target(name: "git-info-generator")] + ), + .target( + name: "Core", + dependencies: [ + .product(name: "SwiftUtils", package: "AzooKeyKanaKanjiConverter"), + .product(name: "KanaKanjiConverterModuleWithDefaultDictionary", package: "AzooKeyKanaKanjiConverter"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "ZIPFoundation", package: "ZIPFoundation") + ], + swiftSettings: [.interoperabilityMode(.Cxx)], + plugins: [ + .plugin(name: "GitInfoPlugin") + ] + ), + .testTarget( + name: "CoreTests", + dependencies: ["Core"], + swiftSettings: [.interoperabilityMode(.Cxx)] + ) +] + +#if os(macOS) +products.append( + .executable( + name: "ConverterServer", + targets: ["ConverterServer"] + ) +) +targets.append( + .executableTarget( + name: "ConverterServer", + dependencies: ["Core"], + swiftSettings: [.interoperabilityMode(.Cxx)] + ) +) +#endif + let package = Package( name: "Core", platforms: [.macOS(.v13)], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "Core", - targets: ["Core"] - ), - .executable( - name: "ConverterServer", - targets: ["ConverterServer"] - ) - ], + products: products, dependencies: [ .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "bbef9d2d99a2e9e69ac3f7e2e07b08474de59a81", 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") ], - targets: [ - .executableTarget( - name: "git-info-generator" - ), - .executableTarget( - name: "ConverterServer", - dependencies: ["Core"], - swiftSettings: [.interoperabilityMode(.Cxx)] - ), - .plugin( - name: "GitInfoPlugin", - capability: .buildTool(), - dependencies: [.target(name: "git-info-generator")] - ), - .target( - name: "Core", - dependencies: [ - .product(name: "SwiftUtils", package: "AzooKeyKanaKanjiConverter"), - .product(name: "KanaKanjiConverterModuleWithDefaultDictionary", package: "AzooKeyKanaKanjiConverter"), - .product(name: "Crypto", package: "swift-crypto"), - .product(name: "ZIPFoundation", package: "ZIPFoundation") - ], - swiftSettings: [.interoperabilityMode(.Cxx)], - plugins: [ - .plugin(name: "GitInfoPlugin") - ] - ), - .testTarget( - name: "CoreTests", - dependencies: ["Core"], - swiftSettings: [.interoperabilityMode(.Cxx)] - ) - ] + targets: targets ) diff --git a/Core/Sources/ConverterServer/main.swift b/Core/Sources/ConverterServer/main.swift index 6b21f511..487368f2 100644 --- a/Core/Sources/ConverterServer/main.swift +++ b/Core/Sources/ConverterServer/main.swift @@ -2,6 +2,18 @@ import Core import Foundation import KanaKanjiConverterModuleWithDefaultDictionary +private enum ConverterServerXPC { + static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" +} + +@objc private protocol ConverterServerXPCProtocol { + func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) + 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) +} + private final class ConverterSession: SegmentManagerDelegate { let manager: SegmentsManager private var leftSideContext: String? diff --git a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift index 6269669e..917e4306 100644 --- a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift +++ b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift @@ -1,18 +1,6 @@ import Foundation import KanaKanjiConverterModule -public enum ConverterServerXPC { - public static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" -} - -@objc public protocol ConverterServerXPCProtocol { - func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) - 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) -} - public enum ConverterServerProtocol { public static let currentVersion = 1 public static let minimumSupportedClientVersion = 1 diff --git a/azooKeyMac/InputController/ConverterServerClient.swift b/azooKeyMac/InputController/ConverterServerClient.swift index 60872bdb..039b67ed 100644 --- a/azooKeyMac/InputController/ConverterServerClient.swift +++ b/azooKeyMac/InputController/ConverterServerClient.swift @@ -1,6 +1,18 @@ import Core import Foundation +private enum ConverterServerXPC { + static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" +} + +@objc private protocol ConverterServerXPCProtocol { + func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) + 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 ConverterServerClient { private var connection: NSXPCConnection? private var sessionID: String? From 3f309c567f02e5bba8858039750853747a6837c4 Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Mon, 25 May 2026 00:24:21 +0900 Subject: [PATCH 3/5] fix: preserve xpc composition state --- Core/Sources/ConverterServer/main.swift | 26 +++--- Core/Sources/Core/Configs/AppGroup.swift | 27 ++++++ .../Core/XPC/ConverterServerXPCProtocol.swift | 21 +++++ .../ConverterServerContractTests.swift | 27 ++++++ azooKeyMac.xcodeproj/project.pbxproj | 2 +- azooKeyMac/AppDelegate.swift | 13 +-- azooKeyMac/ConverterServer.entitlements | 10 +++ .../azooKeyMacInputController.swift | 85 ++++++++++++++----- 8 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 azooKeyMac/ConverterServer.entitlements diff --git a/Core/Sources/ConverterServer/main.swift b/Core/Sources/ConverterServer/main.swift index 487368f2..86d29522 100644 --- a/Core/Sources/ConverterServer/main.swift +++ b/Core/Sources/ConverterServer/main.swift @@ -247,9 +247,19 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch selectionIndex: selectionIndex ) } + let predictionCandidates: [ConverterPredictionCandidate] + if inputState == .composing { + predictionCandidates = SegmentsManager.preferredPredictionCandidates( + typoCorrectionCandidates: manager.requestTypoCorrectionPredictionCandidates(), + predictionCandidates: manager.requestPredictionCandidates() + ).map(ConverterPredictionCandidate.init) + } else { + predictionCandidates = [] + } return ConverterSessionSnapshot( markedText: markedText, candidateWindow: candidateWindow, + predictionCandidates: predictionCandidates, isEmpty: manager.isEmpty, convertTarget: manager.convertTarget ) @@ -258,10 +268,11 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch @MainActor private static func makeSegmentsManager() -> SegmentsManager { CustomInputTableStore.registerIfExists() + let containerURL = AppGroup.containerURL() return SegmentsManager( kanaKanjiConverter: KanaKanjiConverter.withDefaultDictionary(), - applicationDirectoryURL: applicationSupportDirectoryURL(), - containerURL: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.azooKeyMacIdentifier), + applicationDirectoryURL: AppGroup.memoryDirectoryURL(), + containerURL: containerURL, context: .init(useZenzai: true, resourcesDirectoryURL: appResourcesDirectoryURL()) ) } @@ -288,17 +299,6 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch return inputStyle.inputStyle } - private static func applicationSupportDirectoryURL() -> URL { - if #available(macOS 13, *) { - return URL.applicationSupportDirectory - .appending(path: "azooKey", directoryHint: .isDirectory) - .appending(path: "memory", directoryHint: .isDirectory) - } - return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("azooKey", isDirectory: true) - .appendingPathComponent("memory", isDirectory: true) - } - @MainActor private static func transformedCandidate( _ transform: ConverterCandidateTransform, diff --git a/Core/Sources/Core/Configs/AppGroup.swift b/Core/Sources/Core/Configs/AppGroup.swift index 25a0ab62..e149e463 100644 --- a/Core/Sources/Core/Configs/AppGroup.swift +++ b/Core/Sources/Core/Configs/AppGroup.swift @@ -2,4 +2,31 @@ import Foundation public enum AppGroup { public static let azooKeyMacIdentifier = "group.dev.ensan.inputmethod.azooKeyMac" + + #if os(macOS) + public static func containerURL(fileManager: FileManager = .default) -> URL? { + fileManager.containerURL(forSecurityApplicationGroupIdentifier: Self.azooKeyMacIdentifier) + } + + public static func applicationSupportDirectoryURL(fileManager: FileManager = .default) -> URL { + if let containerURL = Self.containerURL(fileManager: fileManager) { + return containerURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("azooKey", isDirectory: true) + } + + if #available(macOS 13, *) { + return URL.applicationSupportDirectory + .appending(path: "azooKey", directoryHint: .isDirectory) + } + return fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("azooKey", isDirectory: true) + } + + public static func memoryDirectoryURL(fileManager: FileManager = .default) -> URL { + Self.applicationSupportDirectoryURL(fileManager: fileManager) + .appendingPathComponent("memory", isDirectory: true) + } + #endif } diff --git a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift index 917e4306..7f4a3680 100644 --- a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift +++ b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift @@ -171,17 +171,20 @@ public struct ConverterServerResponse: Codable, Sendable { public struct ConverterSessionSnapshot: Codable, Sendable { public var markedText: ConverterMarkedText public var candidateWindow: ConverterCandidateWindow + public var predictionCandidates: [ConverterPredictionCandidate] public var isEmpty: Bool public var convertTarget: String public init( markedText: ConverterMarkedText, candidateWindow: ConverterCandidateWindow, + predictionCandidates: [ConverterPredictionCandidate] = [], isEmpty: Bool, convertTarget: String ) { self.markedText = markedText self.candidateWindow = candidateWindow + self.predictionCandidates = predictionCandidates self.isEmpty = isEmpty self.convertTarget = convertTarget } @@ -375,6 +378,24 @@ public enum ConverterCandidateWindow: Codable, Sendable, Equatable { case selecting([ConverterCandidatePresentation], selectionIndex: Int?) } +public struct ConverterPredictionCandidate: Codable, Sendable, Equatable { + public var displayText: String + public var appendText: String + public var deleteCount: Int + + public init(_ prediction: SegmentsManager.PredictionCandidate) { + self.displayText = prediction.displayText + self.appendText = prediction.appendText + self.deleteCount = prediction.deleteCount + } + + public init(displayText: String, appendText: String, deleteCount: Int = 0) { + self.displayText = displayText + self.appendText = appendText + self.deleteCount = deleteCount + } +} + public struct ConverterCandidatePresentation: Codable, Sendable, Equatable { public var text: String public var annotationText: String? diff --git a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift index c078b845..e7ade4bb 100644 --- a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift +++ b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift @@ -38,6 +38,33 @@ private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> Converte #expect(hidden.inputStateFromCandidateWindow == nil) } +@Test func converterServerSnapshotCarriesPredictionCandidates() throws { + let snapshot = ConverterSessionSnapshot( + markedText: ConverterSessionSnapshot.empty.markedText, + candidateWindow: .composing([], selectionIndex: nil), + predictionCandidates: [ + .init(displayText: "ありがとう", appendText: "がとう", deleteCount: 0), + .init(displayText: "明日", appendText: "した", deleteCount: 1) + ], + isEmpty: false, + convertTarget: "あり" + ) + + let decoded = try ConverterServerCodec.decodeResponse( + from: ConverterServerCodec.encode( + ConverterServerResponse(sessionID: "session-1", snapshot: snapshot) + ) + ) + + #expect(decoded.snapshot.predictionCandidates.count == 2) + #expect(decoded.snapshot.predictionCandidates[0].displayText == "ありがとう") + #expect(decoded.snapshot.predictionCandidates[0].appendText == "がとう") + #expect(decoded.snapshot.predictionCandidates[0].deleteCount == 0) + #expect(decoded.snapshot.predictionCandidates[1].displayText == "明日") + #expect(decoded.snapshot.predictionCandidates[1].appendText == "した") + #expect(decoded.snapshot.predictionCandidates[1].deleteCount == 1) +} + @Test func converterServerEditSegmentCommandCodableShape() throws { let expectedJSON = #"{"editSegment":{"sessionID":"session-1","count":-1}}"# let command = try ConverterServerCodec.decodeCommand(from: Data(expectedJSON.utf8)) diff --git a/azooKeyMac.xcodeproj/project.pbxproj b/azooKeyMac.xcodeproj/project.pbxproj index 78e13f2e..74e3c37a 100644 --- a/azooKeyMac.xcodeproj/project.pbxproj +++ b/azooKeyMac.xcodeproj/project.pbxproj @@ -322,7 +322,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\n\nswift_configuration=debug\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n swift_configuration=release\nfi\n\nswift build --package-path \"${SRCROOT}/Core\" --product ConverterServer -c \"${swift_configuration}\"\n\nserver_source=\"${SRCROOT}/Core/.build/${swift_configuration}/ConverterServer\"\nserver_destination=\"${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/MacOS/ConverterServer\"\nmkdir -p \"$(dirname \"${server_destination}\")\"\ncp \"${server_source}\" \"${server_destination}\"\n\nif ! otool -l \"${server_destination}\" | grep -q \"@executable_path/../Frameworks\"; then\n install_name_tool -add_rpath \"@executable_path/../Frameworks\" \"${server_destination}\"\nfi\n\nif [ -n \"${EXPANDED_CODE_SIGN_IDENTITY:-}\" ] && [ \"${EXPANDED_CODE_SIGN_IDENTITY}\" != \"-\" ]; then\n codesign --force --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" --options runtime --timestamp=none \"${server_destination}\"\nfi\n"; + shellScript = "set -euo pipefail\n\nswift_configuration=debug\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n swift_configuration=release\nfi\n\nswift build --package-path \"${SRCROOT}/Core\" --product ConverterServer -c \"${swift_configuration}\"\n\nserver_source=\"${SRCROOT}/Core/.build/${swift_configuration}/ConverterServer\"\nserver_destination=\"${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/MacOS/ConverterServer\"\nmkdir -p \"$(dirname \"${server_destination}\")\"\ncp \"${server_source}\" \"${server_destination}\"\n\nif ! otool -l \"${server_destination}\" | grep -q \"@executable_path/../Frameworks\"; then\n install_name_tool -add_rpath \"@executable_path/../Frameworks\" \"${server_destination}\"\nfi\n\nif [ -n \"${EXPANDED_CODE_SIGN_IDENTITY:-}\" ] && [ \"${EXPANDED_CODE_SIGN_IDENTITY}\" != \"-\" ]; then\n codesign --force --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" --entitlements \"${SRCROOT}/azooKeyMac/ConverterServer.entitlements\" --options runtime --timestamp=none \"${server_destination}\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/azooKeyMac/AppDelegate.swift b/azooKeyMac/AppDelegate.swift index 59b324b5..5ff7f873 100644 --- a/azooKeyMac/AppDelegate.swift +++ b/azooKeyMac/AppDelegate.swift @@ -36,18 +36,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var kanaKanjiConverter = KanaKanjiConverter.withDefaultDictionary() private var userDictionaryMemoryDirectoryURL: URL { - let applicationSupportDirectoryURL: URL - if #available(macOS 13, *) { - applicationSupportDirectoryURL = URL.applicationSupportDirectory - .appending(path: "azooKey", directoryHint: .isDirectory) - } else { - applicationSupportDirectoryURL = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - .appendingPathComponent("azooKey", isDirectory: true) - } - return applicationSupportDirectoryURL.appendingPathComponent("memory", isDirectory: true) + AppGroup.memoryDirectoryURL() } private func exportInitialUserDictionaryIfNeeded() { diff --git a/azooKeyMac/ConverterServer.entitlements b/azooKeyMac/ConverterServer.entitlements new file mode 100644 index 00000000..ed689f8e --- /dev/null +++ b/azooKeyMac/ConverterServer.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.ensan.inputmethod.azooKeyMac + + + diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 86839914..0ca3170c 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -105,17 +105,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { - let applicationDirectoryURL = if #available(macOS 13, *) { - URL.applicationSupportDirectory - .appending(path: "azooKey", directoryHint: .isDirectory) - .appending(path: "memory", directoryHint: .isDirectory) - } else { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("azooKey", isDirectory: true) - .appendingPathComponent("memory", isDirectory: true) - } - - let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.azooKeyMacIdentifier) + let applicationDirectoryURL = AppGroup.memoryDirectoryURL() + let containerURL = AppGroup.containerURL() self.segmentsManager = SegmentsManager( kanaKanjiConverter: (NSApplication.shared.delegate as? AppDelegate)!.kanaKanjiConverter, applicationDirectoryURL: applicationDirectoryURL, @@ -170,10 +161,10 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return } self.converterServerClient.sendIfSessionOpen({ .activate(sessionID: $0) }, completion: { [weak self] response in - guard let response else { + guard let self, let response, self.segmentsManager.isEmpty else { return } - self?.converterServerSnapshot = response.snapshot + self.converterServerSnapshot = response.snapshot }) } @@ -426,8 +417,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // swiftlint:disable:next cyclomatic_complexity @MainActor func handleClientAction(_ clientAction: ClientAction, clientActionCallback: ClientActionCallback, client: IMKTextInput) -> Bool { if let serverResponse = self.handleClientActionWithConverterServer(clientAction, client: client) { - self.applyClientActionCallback(clientActionCallback, client: client, compositionIsEmpty: serverResponse.snapshot.isEmpty) self.applyConverterServerSnapshotState(serverResponse.snapshot) + self.applyClientActionCallback(clientActionCallback, client: client, compositionIsEmpty: serverResponse.snapshot.isEmpty) + self.refreshConverterServerSnapshotIfNeeded(after: clientActionCallback) self.refreshMarkedText() self.refreshCandidateWindow() self.refreshPredictionWindow() @@ -626,6 +618,27 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } + private func refreshConverterServerSnapshotIfNeeded(after clientActionCallback: ClientActionCallback) { + guard self.converterServerSnapshot != nil else { + return + } + switch clientActionCallback { + case .fallthrough: + return + case .transition, .basedOnBackspace, .basedOnSubmitCandidate: + self.refreshConverterServerSnapshotForCurrentInputState() + } + } + + private func refreshConverterServerSnapshotForCurrentInputState() { + guard let response = self.converterServerClient.sendIfSessionOpenSync({ + .snapshot(sessionID: $0, inputState: ConverterInputState(self.inputState)) + }) else { + return + } + self.converterServerSnapshot = response.snapshot + } + @MainActor // swiftlint:disable:next cyclomatic_complexity private func handleClientActionWithConverterServer( @@ -635,6 +648,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s guard self.converterServerClient.canSendOrReconnect else { return nil } + guard self.segmentsManager.isEmpty else { + return nil + } func send(_ commandBuilder: (String) -> ConverterServerCommand) -> ConverterServerResponse? { guard let response = self.converterServerClient.sendSync(commandBuilder) else { @@ -803,11 +819,32 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s insertCommittedText(response) if let snapshot = response?.snapshot, !snapshot.isEmpty { client.insertText(snapshot.convertTarget, replacementRange: NSRange(location: NSNotFound, length: 0)) - _ = send { + return send { .stopComposition(sessionID: $0) - } + } ?? response } return response + case .acceptPredictionCandidate: + guard let prediction = self.converterServerSnapshot?.predictionCandidates.first else { + return self.converterServerSnapshot.map { + ConverterServerResponse(sessionID: "", snapshot: $0) + } + } + + var response: ConverterServerResponse? + if prediction.deleteCount > 0 { + response = send { + .deleteBackward(sessionID: $0, count: prediction.deleteCount, leftSideContext: leftSideContext()) + } + } + guard !prediction.appendText.isEmpty else { + return response ?? self.converterServerSnapshot.map { + ConverterServerResponse(sessionID: "", snapshot: $0) + } + } + return send { + .insertText(sessionID: $0, text: prediction.appendText, inputStyle: .direct, leftSideContext: leftSideContext()) + } ?? response default: return nil } @@ -919,16 +956,13 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } func refreshPredictionWindow() { - if self.converterServerSnapshot != nil { - self.hidePredictionWindow() - return - } guard self.inputState == .composing else { self.hidePredictionWindow() return } - let predictions = self.requestPreferredPredictionCandidates() + let predictions = self.converterServerSnapshot?.predictionCandidates + ?? self.requestPreferredPredictionCandidates().map(ConverterPredictionCandidate.init) if predictions.isEmpty { let now = Date().timeIntervalSince1970 let elapsed = now - self.lastPredictionUpdateTime @@ -1113,6 +1147,12 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } private func currentMarkedText() -> ConverterMarkedText { + switch self.inputState { + case .attachDiacritic, .replaceSuggestion, .unicodeInput: + return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) + case .none, .composing, .previewing, .selecting: + break + } if let converterServerSnapshot { return converterServerSnapshot.markedText } @@ -1151,8 +1191,9 @@ extension azooKeyMacInputController: CandidatesViewControllerDelegate { if let text = response.committedText, !text.isEmpty { self.client()?.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) } - self.inputState = response.snapshot.isEmpty ? .none : .previewing self.applyConverterServerSnapshotState(response.snapshot) + self.inputState = response.snapshot.isEmpty ? .none : .previewing + self.refreshConverterServerSnapshotForCurrentInputState() self.refreshMarkedText() self.refreshCandidateWindow() self.refreshPredictionWindow() From 93e1368727dc2c1796a17197e6056d99b7cbd0cd Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Mon, 25 May 2026 01:47:07 +0900 Subject: [PATCH 4/5] refactor: route input handling through converter server --- Core/Sources/ConverterServer/main.swift | 431 ++++++++--- .../Core/InputUtils/InputLanguage.swift | 2 +- .../Core/InputUtils/KeyEventCore.swift | 10 +- .../Core/XPC/ConverterServerXPCProtocol.swift | 220 ++---- .../ConverterServerContractTests.swift | 88 +-- .../ConverterServerClient.swift | 98 +-- .../azooKeyMacInputController.swift | 720 ++++-------------- .../azooKeyMacInputControllerHelper.swift | 6 +- 8 files changed, 596 insertions(+), 979 deletions(-) diff --git a/Core/Sources/ConverterServer/main.swift b/Core/Sources/ConverterServer/main.swift index 86d29522..39116a6f 100644 --- a/Core/Sources/ConverterServer/main.swift +++ b/Core/Sources/ConverterServer/main.swift @@ -6,8 +6,15 @@ private enum ConverterServerXPC { static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" } +private enum ConverterCandidateTransform { + case hiragana + case katakana + case halfWidthKatakana + case fullWidthRoman + case halfWidthRoman +} + @objc private protocol ConverterServerXPCProtocol { - func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) 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) @@ -38,21 +45,6 @@ private final class ConverterSession: SegmentManagerDelegate { private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Sendable { private var sessions: [String: ConverterSession] = [:] - func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) { - do { - let info = ConverterServerInfo( - protocolVersion: ConverterServerProtocol.currentVersion, - minimumClientProtocolVersion: ConverterServerProtocol.minimumSupportedClientVersion, - supportedCommands: ConverterServerCommandName.allCases.map(\.rawValue), - serverKind: "launchd-mach-service", - buildIdentifier: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - ) - reply(try ConverterServerCodec.encode(info), nil) - } catch { - reply(nil, error.localizedDescription as NSString) - } - } - func openSession(with reply: @escaping @Sendable (String) -> Void) { DispatchQueue.main.async { MainActor.assumeIsolated { @@ -91,137 +83,334 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch } @MainActor - // swiftlint:disable:next cyclomatic_complexity private func handle(_ command: ConverterServerCommand) throws -> ConverterServerResponse { switch command { case .activate(let sessionID): - return try withSession(sessionID, inputState: .none) { session in - session.manager.activate() - return nil - } + let session = try getSession(sessionID) + session.manager.activate() + return makeResponse(for: session, inputState: .none) case .deactivate(let sessionID): - return try withSession(sessionID, inputState: .none) { session in - session.manager.deactivate() - return nil - } + let session = try getSession(sessionID) + session.manager.deactivate() + return makeResponse(for: session, inputState: .none) case .snapshot(let sessionID, let inputState): - return makeResponse(sessionID: sessionID, inputState: inputState.inputState) + return makeResponse(for: try getSession(sessionID), inputState: inputState.inputState) case .stopComposition(let sessionID): - return try withSession(sessionID, inputState: .none) { session in - session.manager.stopComposition() - return nil - } - case .insertText(let sessionID, let text, let inputStyle, let leftSideContext): - return try withSession(sessionID, inputState: .composing) { session in - session.setLeftSideContext(leftSideContext) - session.manager.insertAtCursorPosition(text, inputStyle: Self.resolveInputStyle(inputStyle)) - return nil - } - case .insertCompositionSeparator(let sessionID, let inputStyle, let skipUpdate): - return try withSession(sessionID, inputState: .previewing) { session in - session.manager.insertCompositionSeparator(inputStyle: Self.resolveInputStyle(inputStyle), skipUpdate: skipUpdate) - return nil - } - case .updateCandidates(let sessionID, let requestRichCandidates): - return try withSession(sessionID, inputState: .selecting) { session in - session.manager.update(requestRichCandidates: requestRichCandidates) - return nil - } - case .deleteBackward(let sessionID, let count, let leftSideContext): - return try withSession(sessionID, inputState: .composing) { session in - session.setLeftSideContext(leftSideContext) - session.manager.deleteBackwardFromCursorPosition(count: count) - return nil - } - case .editSegment(let sessionID, let count): - return try withSession(sessionID, inputState: .selecting) { session in - session.manager.editSegment(count: count) - return nil - } - case .setCandidateWindowVisible(let sessionID, let visible, let inputState): - return try withSession(sessionID, inputState: inputState.inputState) { session in - session.manager.requestSetCandidateWindowState(visible: visible) - return nil - } - case .selectNextCandidate(let sessionID): - return try withSession(sessionID, inputState: .selecting) { session in - session.manager.requestSelectingNextCandidate() - return nil - } - case .selectPreviousCandidate(let sessionID): - return try withSession(sessionID, inputState: .selecting) { session in - session.manager.requestSelectingPrevCandidate() - return nil - } + let session = try getSession(sessionID) + session.manager.stopComposition() + return makeResponse(for: session, inputState: .none) + case .forgetMemory(let sessionID): + let session = try getSession(sessionID) + session.manager.forgetMemory() + return makeResponse(for: session, inputState: .none) + case .handleKeyEvent(let sessionID, let request): + return try handleKeyEvent(sessionID: sessionID, request: request) case .selectCandidate(let sessionID, let index): - return try withSession(sessionID, inputState: .selecting) { session in - session.manager.requestSelectingRow(index) - return nil - } - case .resetSelection(let sessionID): - return try withSession(sessionID, inputState: .composing) { session in - session.manager.requestResettingSelection() - return nil - } + let session = try getSession(sessionID) + session.manager.requestSelectingRow(index) + return makeResponse(for: session, inputState: .selecting) case .submitSelectedCandidate(let sessionID, let leftSideContext): - return try withSession(sessionID, inputState: .selecting) { session in - guard let candidate = session.manager.selectedCandidate else { - return nil - } - session.manager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext ?? "") - return candidate.text + let session = try getSession(sessionID) + var effects: [ConverterClientEffect] = [] + submitSelectedCandidate(manager: session.manager, leftSideContext: leftSideContext, effects: &effects) + let nextInputState: InputState = session.manager.isEmpty ? .none : .previewing + return makeResponse( + for: session, + inputState: nextInputState, + effects: effects, + responseInputState: ConverterInputState(nextInputState) + ) + case .commitComposition(let sessionID, let inputState): + let session = try getSession(sessionID) + let text = session.manager.commitMarkedText(inputState: inputState.inputState) + let effects: [ConverterClientEffect] = text.isEmpty ? [] : [.insertText(text)] + return makeResponse(for: session, inputState: .none, effects: effects, responseInputState: ConverterInputState.none) + } + } + + @MainActor + private func handleKeyEvent( + sessionID: String, + request: ConverterKeyEventRequest + ) throws -> ConverterServerResponse { + guard let session = sessions[sessionID] else { + throw ConverterServerError.unknownSession(sessionID) + } + session.setLeftSideContext(request.leftSideContext) + Config.DebugPredictiveTyping().value = request.enablePredictiveTyping + Config.DebugTypoCorrection().value = request.enableTypoCorrection + + let userAction = UserAction.getUserAction( + eventCore: request.event, + inputLanguage: request.inputLanguage + ) + let (clientAction, clientActionCallback) = request.inputState.inputState.event( + eventCore: request.event, + userAction: userAction, + inputLanguage: request.inputLanguage, + liveConversionEnabled: request.liveConversionEnabled, + enableDebugWindow: request.enableDebugWindow, + enableSuggestion: request.enableSuggestion + ) + + var effects: [ConverterClientEffect] = [] + var inputLanguage = request.inputLanguage + let actionHandled = perform( + clientAction, + request: request, + session: session, + inputLanguage: &inputLanguage, + effects: &effects + ) + guard actionHandled else { + return ConverterServerResponse( + handled: false, + effects: effects, + inputState: request.inputState, + inputLanguage: inputLanguage, + snapshot: snapshot(for: session.manager, inputState: request.inputState.inputState) + ) + } + + let nextInputState = apply( + clientActionCallback, + currentInputState: request.inputState.inputState, + compositionIsEmpty: session.manager.isEmpty + ) + return ConverterServerResponse( + handled: !effects.contains(.fallthroughToApplication), + effects: effects, + inputState: ConverterInputState(nextInputState), + inputLanguage: inputLanguage, + snapshot: snapshot(for: session.manager, inputState: nextInputState) + ) + } + + @MainActor + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func perform( + _ action: ClientAction, + request: ConverterKeyEventRequest, + session: ConverterSession, + inputLanguage: inout InputLanguage, + effects: inout [ConverterClientEffect] + ) -> Bool { + let manager = session.manager + let inputState = request.inputState.inputState + let inputStyle = Self.resolveInputStyle(request.inputLanguage == .english ? .direct : request.inputStyle) + switch action { + case .consume: + return true + case .fallthrough: + effects.append(.fallthroughToApplication) + return true + case .showCandidateWindow: + manager.requestSetCandidateWindowState(visible: true) + case .hideCandidateWindow: + manager.requestSetCandidateWindowState(visible: false) + case .appendToMarkedText(let text): + manager.insertAtCursorPosition(text, inputStyle: inputStyle) + case .appendPieceToMarkedText(let pieces): + manager.insertAtCursorPosition(pieces: pieces, inputStyle: inputStyle) + case .insertWithoutMarkedText(let text): + effects.append(.insertText(text)) + case .removeLastMarkedText: + manager.deleteBackwardFromCursorPosition() + manager.requestResettingSelection() + case .commitMarkedText: + let text = manager.commitMarkedText(inputState: inputState) + if !text.isEmpty { + effects.append(.insertText(text)) } - case .submitTransformedCandidate(let sessionID, let transform, let inputState, let leftSideContext): - return try withSession(sessionID, inputState: .selecting) { session in - let candidate = Self.transformedCandidate( - transform, - manager: session.manager, - inputState: inputState.inputState - ) - session.manager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext ?? "") - return candidate.text + case .editSegment(let count): + manager.editSegment(count: count) + case .enterFirstCandidatePreviewMode: + manager.insertCompositionSeparator(inputStyle: inputStyle, skipUpdate: false) + manager.requestSetCandidateWindowState(visible: false) + case .enterCandidateSelectionMode: + manager.insertCompositionSeparator(inputStyle: inputStyle, skipUpdate: true) + manager.update(requestRichCandidates: true) + case .submitSelectedCandidate: + submitSelectedCandidate(manager: manager, leftSideContext: request.leftSideContext, effects: &effects) + case .selectNextCandidate: + manager.requestSelectingNextCandidate() + case .selectPrevCandidate: + manager.requestSelectingPrevCandidate() + case .selectNumberCandidate(let number): + manager.requestSelectingRow(request.visibleCandidateStartIndex + number - 1) + submitSelectedCandidate(manager: manager, leftSideContext: request.leftSideContext, effects: &effects) + manager.requestResettingSelection() + case .selectInputLanguage(let language): + manager.stopComposition() + inputLanguage = language + effects.append(.switchInputLanguage(language)) + case .commitMarkedTextAndSelectInputLanguage(let language): + let text = manager.commitMarkedText(inputState: inputState) + if !text.isEmpty { + effects.append(.insertText(text)) } - case .commitMarkedText(let sessionID, let inputState): - return try withSession(sessionID, inputState: .none) { session in - session.manager.commitMarkedText(inputState: inputState.inputState) + inputLanguage = language + effects.append(.switchInputLanguage(language)) + case .commitMarkedTextAndAppendToMarkedText(let text): + commitMarkedTextAndContinue( + manager: manager, + inputState: inputState, + effects: &effects + ) + manager.insertAtCursorPosition(text, inputStyle: inputStyle) + case .commitMarkedTextAndAppendPieceToMarkedText(let pieces): + commitMarkedTextAndContinue( + manager: manager, + inputState: inputState, + effects: &effects + ) + manager.insertAtCursorPosition(pieces: pieces, inputStyle: inputStyle) + case .enableDebugWindow: + manager.requestDebugWindowMode(enabled: true) + case .disableDebugWindow: + manager.requestDebugWindowMode(enabled: false) + case .forgetMemory: + manager.forgetMemory() + case .submitKatakanaCandidate: + submitTransformedCandidate(.katakana, manager: manager, inputState: inputState, leftSideContext: request.leftSideContext, effects: &effects) + case .submitHiraganaCandidate: + submitTransformedCandidate(.hiragana, manager: manager, inputState: inputState, leftSideContext: request.leftSideContext, effects: &effects) + case .submitHankakuKatakanaCandidate: + submitTransformedCandidate(.halfWidthKatakana, manager: manager, inputState: inputState, leftSideContext: request.leftSideContext, effects: &effects) + case .submitFullWidthRomanCandidate: + submitTransformedCandidate(.fullWidthRoman, manager: manager, inputState: inputState, leftSideContext: request.leftSideContext, effects: &effects) + case .submitHalfWidthRomanCandidate: + submitTransformedCandidate(.halfWidthRoman, manager: manager, inputState: inputState, leftSideContext: request.leftSideContext, effects: &effects) + case .requestPredictiveSuggestion: + manager.insertAtCursorPosition("つづき", inputStyle: inputStyle) + effects.append(.requestReplaceSuggestion) + case .acceptPredictionCandidate: + acceptPredictionCandidate(manager: manager, leftSideContext: request.leftSideContext) + case .requestReplaceSuggestion: + effects.append(.requestReplaceSuggestion) + case .selectNextReplaceSuggestionCandidate: + effects.append(.selectNextReplaceSuggestionCandidate) + case .selectPrevReplaceSuggestionCandidate: + effects.append(.selectPreviousReplaceSuggestionCandidate) + case .submitReplaceSuggestionCandidate: + effects.append(.submitReplaceSuggestionCandidate) + case .hideReplaceSuggestionWindow: + effects.append(.hideReplaceSuggestionWindow) + case .showPromptInputWindow: + effects.append(.showPromptInputWindow) + case .transformSelectedText(let selectedText, let prompt): + effects.append(.transformSelectedText(selectedText, prompt)) + case .enterUnicodeInputMode, .appendToUnicodeInput, .removeLastUnicodeInput, .cancelUnicodeInput: + return true + case .submitUnicodeInput(let codePoint): + if let scalar = UInt32(codePoint, radix: 16), let unicodeScalar = Unicode.Scalar(scalar) { + effects.append(.insertText(String(Character(unicodeScalar)))) } - case .forgetMemory(let sessionID): - return try withSession(sessionID, inputState: .none) { session in - session.manager.forgetMemory() - return nil + case .submitSelectedCandidateAndEnterUnicodeInputMode: + submitSelectedCandidate(manager: manager, leftSideContext: request.leftSideContext, effects: &effects) + if !manager.isEmpty { + effects.append(.insertText(manager.convertTarget)) + manager.stopComposition() } + case .stopComposition: + manager.stopComposition() } + return true } @MainActor - private func withSession( - _ sessionID: String, + private func apply( + _ callback: ClientActionCallback, + currentInputState: InputState, + compositionIsEmpty: Bool + ) -> InputState { + switch callback { + case .fallthrough: + return currentInputState + case .transition(let inputState): + return inputState + case .basedOnBackspace(let ifIsEmpty, let ifIsNotEmpty), + .basedOnSubmitCandidate(let ifIsEmpty, let ifIsNotEmpty): + return compositionIsEmpty ? ifIsEmpty : ifIsNotEmpty + } + } + + @MainActor + private func commitMarkedTextAndContinue( + manager: SegmentsManager, inputState: InputState, - body: (ConverterSession) throws -> String? - ) throws -> ConverterServerResponse { + effects: inout [ConverterClientEffect] + ) { + let text = manager.commitMarkedText(inputState: inputState) + if !text.isEmpty { + effects.append(.insertText(text)) + } + } + + @MainActor + private func submitSelectedCandidate( + manager: SegmentsManager, + leftSideContext: String?, + effects: inout [ConverterClientEffect] + ) { + guard let candidate = manager.selectedCandidate else { + return + } + manager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext ?? "") + effects.append(.insertText(candidate.text)) + } + + @MainActor + private func submitTransformedCandidate( + _ transform: ConverterCandidateTransform, + manager: SegmentsManager, + inputState: InputState, + leftSideContext: String?, + effects: inout [ConverterClientEffect] + ) { + let candidate = Self.transformedCandidate(transform, manager: manager, inputState: inputState) + manager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext ?? "") + effects.append(.insertText(candidate.text)) + } + + @MainActor + private func acceptPredictionCandidate(manager: SegmentsManager, leftSideContext _: String?) { + let prediction = SegmentsManager.preferredPredictionCandidates( + typoCorrectionCandidates: manager.requestTypoCorrectionPredictionCandidates(), + predictionCandidates: manager.requestPredictionCandidates() + ).first + guard let prediction else { + return + } + if prediction.deleteCount > 0 { + manager.deleteBackwardFromCursorPosition(count: prediction.deleteCount) + } + guard !prediction.appendText.isEmpty else { + return + } + manager.insertAtCursorPosition(prediction.appendText, inputStyle: .direct) + } + + @MainActor + private func getSession(_ sessionID: String) throws -> ConverterSession { guard let session = sessions[sessionID] else { throw ConverterServerError.unknownSession(sessionID) } - let committedText = try body(session) - return makeResponse(sessionID: sessionID, inputState: inputState, committedText: committedText) + return session } @MainActor private func makeResponse( - sessionID: String, + for session: ConverterSession, inputState: InputState, - committedText: String? = nil + handled: Bool = true, + effects: [ConverterClientEffect] = [], + responseInputState: ConverterInputState? = nil ) -> ConverterServerResponse { - guard let session = sessions[sessionID] else { - return ConverterServerResponse( - sessionID: sessionID, - committedText: committedText, - snapshot: .empty - ) - } - return ConverterServerResponse( - sessionID: sessionID, - committedText: committedText, + ConverterServerResponse( + handled: handled, + effects: effects, + inputState: responseInputState ?? ConverterInputState(inputState), snapshot: snapshot(for: session.manager, inputState: inputState) ) } diff --git a/Core/Sources/Core/InputUtils/InputLanguage.swift b/Core/Sources/Core/InputUtils/InputLanguage.swift index 15a19afa..4acfee85 100644 --- a/Core/Sources/Core/InputUtils/InputLanguage.swift +++ b/Core/Sources/Core/InputUtils/InputLanguage.swift @@ -1,6 +1,6 @@ import Foundation -public enum InputLanguage: Sendable, Equatable, Hashable { +public enum InputLanguage: Codable, Sendable, Equatable, Hashable { case japanese case english } diff --git a/Core/Sources/Core/InputUtils/KeyEventCore.swift b/Core/Sources/Core/InputUtils/KeyEventCore.swift index bfdc3930..84472729 100644 --- a/Core/Sources/Core/InputUtils/KeyEventCore.swift +++ b/Core/Sources/Core/InputUtils/KeyEventCore.swift @@ -1,4 +1,4 @@ -public struct KeyEventCore: Sendable, Equatable { +public struct KeyEventCore: Codable, Sendable, Equatable { public struct ModifierFlag: OptionSet, Codable, Sendable, Hashable { public let rawValue: Int @@ -18,8 +18,8 @@ public struct KeyEventCore: Sendable, Equatable { self.charactersIgnoringModifiers = charactersIgnoringModifiers self.keyCode = keyCode } - var modifierFlags: ModifierFlag - var characters: String? - var charactersIgnoringModifiers: String? - var keyCode: UInt16 + public var modifierFlags: ModifierFlag + public var characters: String? + public var charactersIgnoringModifiers: String? + public var keyCode: UInt16 } diff --git a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift index 7f4a3680..f770c0cb 100644 --- a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift +++ b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift @@ -1,23 +1,10 @@ import Foundation import KanaKanjiConverterModule -public enum ConverterServerProtocol { - public static let currentVersion = 1 - public static let minimumSupportedClientVersion = 1 -} - public enum ConverterServerCodec { private static let encoder = JSONEncoder() private static let decoder = JSONDecoder() - public static func encode(_ info: ConverterServerInfo) throws -> Data { - try encoder.encode(info) - } - - public static func decodeServerInfo(from data: Data) throws -> ConverterServerInfo { - try decoder.decode(ConverterServerInfo.self, from: data) - } - public static func encode(_ command: ConverterServerCommand) throws -> Data { try encoder.encode(command) } @@ -35,135 +22,90 @@ public enum ConverterServerCodec { } } -public struct ConverterServerInfo: Codable, Sendable, Equatable { - public var protocolVersion: Int - public var minimumClientProtocolVersion: Int - public var supportedCommands: [String] - public var serverKind: String - public var buildIdentifier: String? - - public init( - protocolVersion: Int, - minimumClientProtocolVersion: Int, - supportedCommands: [String], - serverKind: String, - buildIdentifier: String? = nil - ) { - self.protocolVersion = protocolVersion - self.minimumClientProtocolVersion = minimumClientProtocolVersion - self.supportedCommands = supportedCommands - self.serverKind = serverKind - self.buildIdentifier = buildIdentifier - } - - public func isCompatibleWithClient(protocolVersion clientProtocolVersion: Int) -> Bool { - minimumClientProtocolVersion <= clientProtocolVersion - } - - public func supports(_ commandName: ConverterServerCommandName) -> Bool { - supportedCommands.contains(commandName.rawValue) - } -} - -public enum ConverterServerCommandName: String, Codable, Sendable, CaseIterable { - case activate - case deactivate - case snapshot - case stopComposition - case insertText - case insertCompositionSeparator - case updateCandidates - case deleteBackward - case editSegment - case setCandidateWindowVisible - case selectNextCandidate - case selectPreviousCandidate - case selectCandidate - case resetSelection - case submitSelectedCandidate - case submitTransformedCandidate - case commitMarkedText - case forgetMemory -} - public enum ConverterServerCommand: Codable, Sendable { case activate(sessionID: String) case deactivate(sessionID: String) case snapshot(sessionID: String, inputState: ConverterInputState) case stopComposition(sessionID: String) - case insertText(sessionID: String, text: String, inputStyle: ConverterInputStyle, leftSideContext: String?) - case insertCompositionSeparator(sessionID: String, inputStyle: ConverterInputStyle, skipUpdate: Bool) - case updateCandidates(sessionID: String, requestRichCandidates: Bool) - case deleteBackward(sessionID: String, count: Int, leftSideContext: String?) - case editSegment(sessionID: String, count: Int) - case setCandidateWindowVisible(sessionID: String, visible: Bool, inputState: ConverterInputState) - case selectNextCandidate(sessionID: String) - case selectPreviousCandidate(sessionID: String) + case forgetMemory(sessionID: String) + case handleKeyEvent(sessionID: String, request: ConverterKeyEventRequest) case selectCandidate(sessionID: String, index: Int) - case resetSelection(sessionID: String) case submitSelectedCandidate(sessionID: String, leftSideContext: String?) - case submitTransformedCandidate(sessionID: String, transform: ConverterCandidateTransform, inputState: ConverterInputState, leftSideContext: String?) - case commitMarkedText(sessionID: String, inputState: ConverterInputState) - case forgetMemory(sessionID: String) + case commitComposition(sessionID: String, inputState: ConverterInputState) +} - public var commandName: ConverterServerCommandName { - switch self { - case .activate: - .activate - case .deactivate: - .deactivate - case .snapshot: - .snapshot - case .stopComposition: - .stopComposition - case .insertText: - .insertText - case .insertCompositionSeparator: - .insertCompositionSeparator - case .updateCandidates: - .updateCandidates - case .deleteBackward: - .deleteBackward - case .editSegment: - .editSegment - case .setCandidateWindowVisible: - .setCandidateWindowVisible - case .selectNextCandidate: - .selectNextCandidate - case .selectPreviousCandidate: - .selectPreviousCandidate - case .selectCandidate: - .selectCandidate - case .resetSelection: - .resetSelection - case .submitSelectedCandidate: - .submitSelectedCandidate - case .submitTransformedCandidate: - .submitTransformedCandidate - case .commitMarkedText: - .commitMarkedText - case .forgetMemory: - .forgetMemory - } +public struct ConverterKeyEventRequest: Codable, Sendable, Equatable { + public var event: KeyEventCore + public var inputState: ConverterInputState + public var inputLanguage: InputLanguage + public var inputStyle: ConverterInputStyle + public var liveConversionEnabled: Bool + public var enableDebugWindow: Bool + public var enableSuggestion: Bool + public var enablePredictiveTyping: Bool + public var enableTypoCorrection: Bool + public var leftSideContext: String? + public var visibleCandidateStartIndex: Int + + public init( + event: KeyEventCore, + inputState: ConverterInputState, + inputLanguage: InputLanguage, + inputStyle: ConverterInputStyle, + liveConversionEnabled: Bool, + enableDebugWindow: Bool, + enableSuggestion: Bool, + enablePredictiveTyping: Bool = false, + enableTypoCorrection: Bool = false, + leftSideContext: String?, + visibleCandidateStartIndex: Int = 0 + ) { + self.event = event + self.inputState = inputState + self.inputLanguage = inputLanguage + self.inputStyle = inputStyle + self.liveConversionEnabled = liveConversionEnabled + self.enableDebugWindow = enableDebugWindow + self.enableSuggestion = enableSuggestion + self.enablePredictiveTyping = enablePredictiveTyping + self.enableTypoCorrection = enableTypoCorrection + self.leftSideContext = leftSideContext + self.visibleCandidateStartIndex = visibleCandidateStartIndex } } -public enum ConverterCandidateTransform: Codable, Sendable { - case hiragana - case katakana - case halfWidthKatakana - case fullWidthRoman - case halfWidthRoman +public enum ConverterClientEffect: Codable, Sendable, Equatable { + case insertText(String) + case switchInputLanguage(InputLanguage) + case requestPredictiveSuggestion + case requestReplaceSuggestion + case selectNextReplaceSuggestionCandidate + case selectPreviousReplaceSuggestionCandidate + case submitReplaceSuggestionCandidate + case hideReplaceSuggestionWindow + case showPromptInputWindow + case transformSelectedText(String, String) + case fallthroughToApplication } public struct ConverterServerResponse: Codable, Sendable { - public var sessionID: String - public var committedText: String? + public var handled: Bool + public var effects: [ConverterClientEffect] + public var inputState: ConverterInputState + public var inputLanguage: InputLanguage? public var snapshot: ConverterSessionSnapshot - public init(sessionID: String, committedText: String? = nil, snapshot: ConverterSessionSnapshot) { - self.sessionID = sessionID - self.committedText = committedText + public init( + handled: Bool = true, + effects: [ConverterClientEffect] = [], + inputState: ConverterInputState = .none, + inputLanguage: InputLanguage? = nil, + snapshot: ConverterSessionSnapshot + ) { + self.handled = handled + self.effects = effects + self.inputState = inputState + self.inputLanguage = inputLanguage self.snapshot = snapshot } } @@ -204,17 +146,6 @@ public extension ConverterSessionSnapshot { convertTarget: "" ) } - - var inputStateFromCandidateWindow: InputState? { - switch candidateWindow { - case .selecting: - .selecting - case .composing: - .composing - case .hidden: - nil - } - } } public enum ConverterInputState: Codable, Sendable, Equatable { @@ -325,6 +256,11 @@ public struct ConverterMarkedText: Codable, Sendable, Equatable { public var elements: [Element] public var selectionRange: ConverterRange + public init(elements: [Element], selectionRange: ConverterRange) { + self.elements = elements + self.selectionRange = selectionRange + } + public init(_ markedText: SegmentsManager.MarkedText) { self.elements = markedText.map(Element.init) self.selectionRange = ConverterRange(markedText.selectionRange) @@ -338,6 +274,11 @@ public struct ConverterMarkedText: Codable, Sendable, Equatable { self.content = element.content self.focus = FocusState(element.focus) } + + public init(content: String, focus: FocusState) { + self.content = content + self.focus = focus + } } public enum FocusState: Codable, Sendable, Equatable { @@ -362,6 +303,11 @@ public struct ConverterRange: Codable, Sendable, Equatable { public var location: Int public var length: Int + public init(location: Int, length: Int) { + self.location = location + self.length = length + } + public init(_ range: NSRange) { self.location = range.location self.length = range.length diff --git a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift index e7ade4bb..9c13d785 100644 --- a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift +++ b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift @@ -2,15 +2,6 @@ import Core import Foundation import Testing -private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> ConverterSessionSnapshot { - ConverterSessionSnapshot( - markedText: ConverterSessionSnapshot.empty.markedText, - candidateWindow: candidateWindow, - isEmpty: false, - convertTarget: "あい" - ) -} - @Test func converterServerEmptySnapshotHasNoVisibleComposition() { let snapshot = ConverterSessionSnapshot.empty @@ -19,7 +10,6 @@ private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> Converte #expect(snapshot.markedText.elements.isEmpty) #expect(snapshot.markedText.selectionRange.nsRange.location == NSNotFound) #expect(snapshot.markedText.selectionRange.nsRange.length == NSNotFound) - #expect(snapshot.inputStateFromCandidateWindow == nil) guard case .hidden = snapshot.candidateWindow else { Issue.record("Expected hidden candidate window, got \(snapshot.candidateWindow)") @@ -27,17 +17,6 @@ private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> Converte } } -@Test func converterServerSnapshotCandidateWindowRestoresClientInputState() { - let selecting = makeSnapshot(candidateWindow: .selecting([], selectionIndex: 0)) - #expect(selecting.inputStateFromCandidateWindow == .selecting) - - let composing = makeSnapshot(candidateWindow: .composing([], selectionIndex: nil)) - #expect(composing.inputStateFromCandidateWindow == .composing) - - let hidden = makeSnapshot(candidateWindow: .hidden) - #expect(hidden.inputStateFromCandidateWindow == nil) -} - @Test func converterServerSnapshotCarriesPredictionCandidates() throws { let snapshot = ConverterSessionSnapshot( markedText: ConverterSessionSnapshot.empty.markedText, @@ -52,7 +31,7 @@ private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> Converte let decoded = try ConverterServerCodec.decodeResponse( from: ConverterServerCodec.encode( - ConverterServerResponse(sessionID: "session-1", snapshot: snapshot) + ConverterServerResponse(snapshot: snapshot) ) ) @@ -65,37 +44,52 @@ private func makeSnapshot(candidateWindow: ConverterCandidateWindow) -> Converte #expect(decoded.snapshot.predictionCandidates[1].deleteCount == 1) } -@Test func converterServerEditSegmentCommandCodableShape() throws { - let expectedJSON = #"{"editSegment":{"sessionID":"session-1","count":-1}}"# - let command = try ConverterServerCodec.decodeCommand(from: Data(expectedJSON.utf8)) +@Test func converterServerHandleKeyEventCommandRoundTrips() throws { + let request = ConverterKeyEventRequest( + event: KeyEventCore( + modifierFlags: [.shift], + characters: "A", + charactersIgnoringModifiers: "a", + keyCode: 0 + ), + inputState: .none, + inputLanguage: .japanese, + inputStyle: .defaultRomanToKana, + liveConversionEnabled: true, + enableDebugWindow: false, + enableSuggestion: true, + enablePredictiveTyping: true, + enableTypoCorrection: true, + leftSideContext: "左文脈", + visibleCandidateStartIndex: 3 + ) + let command = ConverterServerCommand.handleKeyEvent(sessionID: "session-1", request: request) + let roundTrip = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(command)) - guard case .editSegment(let sessionID, let count) = command else { - Issue.record("Expected editSegment command, got \(command)") + guard case .handleKeyEvent(let sessionID, let roundTripRequest) = roundTrip else { + Issue.record("Expected handleKeyEvent command after round trip, got \(roundTrip)") return } #expect(sessionID == "session-1") - #expect(count == -1) - #expect(command.commandName == .editSegment) - - let roundTrip = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(command)) - guard case .editSegment(let roundTripSessionID, let roundTripCount) = roundTrip else { - Issue.record("Expected editSegment command after round trip, got \(roundTrip)") - return - } - #expect(roundTripSessionID == "session-1") - #expect(roundTripCount == -1) + #expect(roundTripRequest == request) } -@Test func converterServerInfoAdvertisesAllKnownCommands() { - let info = ConverterServerInfo( - protocolVersion: ConverterServerProtocol.currentVersion, - minimumClientProtocolVersion: ConverterServerProtocol.minimumSupportedClientVersion, - supportedCommands: ConverterServerCommandName.allCases.map(\.rawValue), - serverKind: "test" +@Test func converterServerResponseCarriesClientEffects() throws { + let response = ConverterServerResponse( + handled: true, + effects: [ + .insertText("あ"), + .switchInputLanguage(.english), + .requestReplaceSuggestion + ], + inputState: .composing, + inputLanguage: .english, + snapshot: .empty ) + let decoded = try ConverterServerCodec.decodeResponse(from: ConverterServerCodec.encode(response)) - #expect(info.isCompatibleWithClient(protocolVersion: ConverterServerProtocol.currentVersion)) - for commandName in ConverterServerCommandName.allCases { - #expect(info.supports(commandName)) - } + #expect(decoded.handled) + #expect(decoded.effects == response.effects) + #expect(decoded.inputState == .composing) + #expect(decoded.inputLanguage == .english) } diff --git a/azooKeyMac/InputController/ConverterServerClient.swift b/azooKeyMac/InputController/ConverterServerClient.swift index 039b67ed..48f5caec 100644 --- a/azooKeyMac/InputController/ConverterServerClient.swift +++ b/azooKeyMac/InputController/ConverterServerClient.swift @@ -6,7 +6,6 @@ private enum ConverterServerXPC { } @objc private protocol ConverterServerXPCProtocol { - func serverInfo(with reply: @escaping @Sendable (Data?, NSString?) -> Void) 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) @@ -16,7 +15,6 @@ private enum ConverterServerXPC { final class ConverterServerClient { private var connection: NSXPCConnection? private var sessionID: String? - private var serverInfo: ConverterServerInfo? private let syncTimeout: TimeInterval = 0.8 private var hasOpenedSession = false private var shouldAttemptReconnect = false @@ -27,7 +25,7 @@ final class ConverterServerClient { sessionID != nil } var canSendOrReconnect: Bool { - sessionID != nil || (shouldAttemptReconnect && Date() >= nextReconnectAttemptDate) + sessionID != nil || !hasOpenedSession || (shouldAttemptReconnect && Date() >= nextReconnectAttemptDate) } func openSession(completion: ((String?) -> Void)? = nil) { @@ -35,25 +33,15 @@ final class ConverterServerClient { completion?(sessionID) return } - refreshServerInfo { [weak self] info in - guard let self, let info, self.isCompatible(info) else { - completion?(nil) - return - } - self.openCompatibleSession(completion: completion) - } + openSessionOnServer(completion: completion) } func openSessionSync() -> String? { if let sessionID { return sessionID } - guard let info = serverInfoSync(), isCompatible(info) else { - recordReconnectFailure() - return nil - } let sessionID = waitForResult(timeout: syncTimeout) { [weak self] complete in - self?.openCompatibleSession(completion: complete) + self?.openSessionOnServer(completion: complete) } if sessionID == nil { recordReconnectFailure() @@ -61,34 +49,6 @@ final class ConverterServerClient { return sessionID } - func refreshServerInfo(completion: @escaping (ConverterServerInfo?) -> Void) { - if let serverInfo { - completion(serverInfo) - return - } - remoteObjectProxy { [weak self] proxy in - guard let self, let proxy else { - completion(nil) - return - } - proxy.serverInfo { data, errorMessage in - if let errorMessage { - self.onLog?("ConverterServer info failed: \(errorMessage)") - completion(nil) - return - } - guard let data, let info = try? ConverterServerCodec.decodeServerInfo(from: data) else { - self.onLog?("ConverterServer info decode failed") - completion(nil) - return - } - self.serverInfo = info - self.onLog?("ConverterServer protocol v\(info.protocolVersion), kind=\(info.serverKind)") - completion(info) - } - } - } - func closeSession() { guard let sessionID else { invalidateConnection() @@ -129,24 +89,14 @@ final class ConverterServerClient { guard let sessionID = openSessionSync() else { return nil } - let command = commandBuilder(sessionID) - guard supports(command) else { - onLog?("ConverterServer command unsupported: \(command.commandName.rawValue)") - return nil - } - return sendResolvedSync(command) + return sendResolvedSync(commandBuilder(sessionID)) } func sendIfSessionOpenSync(_ commandBuilder: (String) -> ConverterServerCommand) -> ConverterServerResponse? { guard let sessionID else { return nil } - let command = commandBuilder(sessionID) - guard supports(command) else { - onLog?("ConverterServer command unsupported: \(command.commandName.rawValue)") - return nil - } - return sendResolvedSync(command) + return sendResolvedSync(commandBuilder(sessionID)) } func sendIfSessionOpen( @@ -177,11 +127,6 @@ final class ConverterServerClient { _ command: ConverterServerCommand, completion: @escaping (ConverterServerResponse?) -> Void ) { - guard supports(command) else { - onLog?("ConverterServer command unsupported: \(command.commandName.rawValue)") - completion(nil) - return - } do { let data = try ConverterServerCodec.encode(command) self.remoteObjectProxy { proxy in @@ -208,7 +153,7 @@ final class ConverterServerClient { } } - private func openCompatibleSession(completion: ((String?) -> Void)? = nil) { + private func openSessionOnServer(completion: ((String?) -> Void)? = nil) { remoteObjectProxy { [weak self] proxy in guard let self, let proxy else { completion?(nil) @@ -225,15 +170,6 @@ final class ConverterServerClient { } } - private func serverInfoSync() -> ConverterServerInfo? { - if let serverInfo { - return serverInfo - } - return waitForResult(timeout: syncTimeout) { [weak self] complete in - self?.refreshServerInfo(completion: complete) - } - } - private func sendResolvedSync(_ command: ConverterServerCommand) -> ConverterServerResponse? { do { let data = try ConverterServerCodec.encode(command) @@ -263,23 +199,6 @@ final class ConverterServerClient { } } - private func isCompatible(_ info: ConverterServerInfo) -> Bool { - guard info.isCompatibleWithClient(protocolVersion: ConverterServerProtocol.currentVersion) else { - onLog?( - "ConverterServer protocol incompatible: server min client v\(info.minimumClientProtocolVersion), client v\(ConverterServerProtocol.currentVersion)" - ) - return false - } - return true - } - - private func supports(_ command: ConverterServerCommand) -> Bool { - guard let serverInfo else { - return false - } - return serverInfo.supports(command.commandName) - } - private func ensureConnection() -> NSXPCConnection { if let connection { return connection @@ -305,7 +224,6 @@ final class ConverterServerClient { shouldAttemptReconnect = true } self.sessionID = nil - self.serverInfo = nil } private func invalidateConnection() { @@ -314,9 +232,7 @@ final class ConverterServerClient { } private func recordReconnectFailure() { - guard shouldAttemptReconnect else { - return - } + shouldAttemptReconnect = true nextReconnectAttemptDate = Date().addingTimeInterval(2) } } diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 0ca3170c..62205f44 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -161,7 +161,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return } self.converterServerClient.sendIfSessionOpen({ .activate(sessionID: $0) }, completion: { [weak self] response in - guard let self, let response, self.segmentsManager.isEmpty else { + guard let self, let response else { return } self.converterServerSnapshot = response.snapshot @@ -204,26 +204,18 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.inputState = .none return } - if self.segmentsManager.isEmpty { - if self.converterServerSnapshot?.isEmpty == false, - let response = self.converterServerClient.sendIfSessionOpenSync({ - .commitMarkedText(sessionID: $0, inputState: ConverterInputState(self.inputState)) - }) { - self.converterServerSnapshot = response.snapshot - if let client = sender as? IMKTextInput, let text = response.committedText, !text.isEmpty { - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + if self.converterServerSnapshot?.isEmpty == false, + let response = self.converterServerClient.sendIfSessionOpenSync({ + .commitComposition(sessionID: $0, inputState: ConverterInputState(self.inputState)) + }) { + self.converterServerSnapshot = response.snapshot + if let client = sender as? IMKTextInput { + for effect in response.effects { + self.apply(effect, client: client) } - self.inputState = .none - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() } - return - } - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) - if let client = sender as? IMKTextInput { - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) } + self.segmentsManager.stopComposition() self.inputState = .none self.refreshMarkedText() self.refreshCandidateWindow() @@ -256,24 +248,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // 日本語モードへの切り替え if self.inputLanguage == .english { self.inputLanguage = .japanese - let (clientAction, clientActionCallback) = self.inputState.event( - eventCore: .init( - modifierFlags: [], - characters: nil, - charactersIgnoringModifiers: nil, - keyCode: 0x00 - ), - userAction: .かな, - inputLanguage: self.inputLanguage, - liveConversionEnabled: false, - enableDebugWindow: false, - enableSuggestion: false - ) - _ = self.handleClientAction( - clientAction, - clientActionCallback: clientActionCallback, - client: self.client() - ) } } } @@ -334,11 +308,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return true } } - if !self.segmentsManager.isEmpty { - _ = self.handleClientAction(.submitHalfWidthRomanCandidate, clientActionCallback: .transition(.none), client: client) - self.switchInputLanguage(.english, client: client) - return true - } } } @@ -377,256 +346,133 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if selectedRange.length > 0 { self.segmentsManager.appendDebugMessage("Selected text found, showing prompt input window") // There is selected text, show prompt input window - return self.handleClientAction(.showPromptInputWindow, clientActionCallback: .fallthrough, client: client) + self.showPromptInputWindow() + return true } else { self.segmentsManager.appendDebugMessage("No selected text, using normal suggest behavior") } } - let (clientAction, clientActionCallback) = inputState.event( - eventCore: event.keyEventCore, - userAction: userAction, + if let handled = self.handleKeyEventWithConverterServer( + event: event.keyEventCore, + client: client, + enableSuggestion: aiBackendEnabled + ) { + return handled + } + + return false + } + + @MainActor + private func handleKeyEventWithConverterServer( + event: KeyEventCore, + client: IMKTextInput, + enableSuggestion: Bool + ) -> Bool? { + guard self.converterServerClient.canSendOrReconnect else { + return nil + } + if !self.segmentsManager.isEmpty { + self.segmentsManager.stopComposition() + } + + let request = ConverterKeyEventRequest( + event: event, + inputState: ConverterInputState(self.inputState), inputLanguage: self.inputLanguage, + inputStyle: ConverterInputStyle(self.inputStyle), liveConversionEnabled: Config.LiveConversion().value, enableDebugWindow: Config.DebugWindow().value, - enableSuggestion: aiBackendEnabled + enableSuggestion: enableSuggestion, + enablePredictiveTyping: Config.DebugPredictiveTyping().value, + enableTypoCorrection: Config.DebugTypoCorrection().value, + leftSideContext: self.getLeftSideContext(maxCount: 30) ) - return handleClientAction(clientAction, clientActionCallback: clientActionCallback, client: client) - } + guard let response = self.converterServerClient.sendSync({ + .handleKeyEvent(sessionID: $0, request: request) + }) else { + return nil + } - private var inputStyle: InputStyle { - switch Config.InputStyle().value { - case .default: - .mapped(id: .defaultRomanToKana) - case .defaultAZIK: - .mapped(id: .defaultAZIK) - case .defaultKanaUS: - .mapped(id: .defaultKanaUS) - case .defaultKanaJIS: - .mapped(id: .defaultKanaJIS) - case .custom: - if CustomInputTableStore.exists() { - .mapped(id: .tableName(CustomInputTableStore.tableName)) - } else { - .mapped(id: .defaultRomanToKana) - } + if response.effects.contains(.fallthroughToApplication), !response.handled { + return false } - } - // この種のコードは複雑にしかならないので、lintを無効にする - // swiftlint:disable:next cyclomatic_complexity - @MainActor func handleClientAction(_ clientAction: ClientAction, clientActionCallback: ClientActionCallback, client: IMKTextInput) -> Bool { - if let serverResponse = self.handleClientActionWithConverterServer(clientAction, client: client) { - self.applyConverterServerSnapshotState(serverResponse.snapshot) - self.applyClientActionCallback(clientActionCallback, client: client, compositionIsEmpty: serverResponse.snapshot.isEmpty) - self.refreshConverterServerSnapshotIfNeeded(after: clientActionCallback) - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - return true + if let inputLanguage = response.inputLanguage { + self.inputLanguage = inputLanguage } + self.inputState = response.inputState.inputState + self.converterServerSnapshot = response.snapshot + for effect in response.effects { + self.apply(effect, client: client) + } + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + return response.handled + } - // return only false - switch clientAction { - case .showCandidateWindow: - self.segmentsManager.requestSetCandidateWindowState(visible: true) - case .hideCandidateWindow: - self.segmentsManager.requestSetCandidateWindowState(visible: false) - case .enterFirstCandidatePreviewMode: - self.segmentsManager.insertCompositionSeparator(inputStyle: self.inputStyle, skipUpdate: false) - self.segmentsManager.requestSetCandidateWindowState(visible: false) - case .enterCandidateSelectionMode: - self.segmentsManager.insertCompositionSeparator(inputStyle: self.inputStyle, skipUpdate: true) - self.segmentsManager.update(requestRichCandidates: true) - case .appendToMarkedText(let string): - // 英語モードの場合は.directでローマ字変換せずそのまま入力 - let inputStyle: InputStyle = self.inputLanguage == .english ? .direct : self.inputStyle - self.segmentsManager.insertAtCursorPosition(string, inputStyle: inputStyle) - case .appendPieceToMarkedText(let pieces): - // 英語モードの場合は.directでローマ字変換せずそのまま入力 - let inputStyle: InputStyle = self.inputLanguage == .english ? .direct : self.inputStyle - self.segmentsManager.insertAtCursorPosition(pieces: pieces, inputStyle: inputStyle) - case .insertWithoutMarkedText(let string): - client.insertText(string, replacementRange: NSRange(location: NSNotFound, length: 0)) - case .editSegment(let count): - self.segmentsManager.editSegment(count: count) - case .commitMarkedText: - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - case .commitMarkedTextAndAppendToMarkedText(let string): - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - // 英語モードの場合は.directでローマ字変換せずそのまま入力 - let inputStyle: InputStyle = self.inputLanguage == .english ? .direct : self.inputStyle - self.segmentsManager.insertAtCursorPosition(string, inputStyle: inputStyle) - case .commitMarkedTextAndAppendPieceToMarkedText(let pieces): - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - // 英語モードの場合は.directでローマ字変換せずそのまま入力 - let inputStyle: InputStyle = self.inputLanguage == .english ? .direct : self.inputStyle - self.segmentsManager.insertAtCursorPosition(pieces: pieces, inputStyle: inputStyle) - case .submitSelectedCandidate: - self.submitSelectedCandidate() - case .removeLastMarkedText: - self.segmentsManager.deleteBackwardFromCursorPosition() - self.segmentsManager.requestResettingSelection() - case .selectPrevCandidate: - self.segmentsManager.requestSelectingPrevCandidate() - case .selectNextCandidate: - self.segmentsManager.requestSelectingNextCandidate() - case .selectNumberCandidate(let num): - self.segmentsManager.requestSelectingRow(self.candidatesViewController.getNumberCandidate(num: num)) - self.submitSelectedCandidate() - self.segmentsManager.requestResettingSelection() - case .submitHiraganaCandidate: - self.submitCandidate(self.segmentsManager.getModifiedRubyCandidate(inputState: self.inputState) { - $0.toHiragana() - }) - case .submitKatakanaCandidate: - self.submitCandidate(self.segmentsManager.getModifiedRubyCandidate(inputState: self.inputState) { - $0.toKatakana() - }) - case .submitHankakuKatakanaCandidate: - self.submitCandidate(self.segmentsManager.getModifiedRubyCandidate(inputState: self.inputState) { - $0.toKatakana().applyingTransform(.fullwidthToHalfwidth, reverse: false)! - }) - case .submitFullWidthRomanCandidate: - self.submitCandidate(self.segmentsManager.getModifiedRomanCandidate { - $0.applyingTransform(.fullwidthToHalfwidth, reverse: true)! - }) - case .submitHalfWidthRomanCandidate: - self.submitCandidate(self.segmentsManager.getModifiedRomanCandidate { - $0.applyingTransform(.fullwidthToHalfwidth, reverse: false)! - }) - case .enableDebugWindow: - self.segmentsManager.requestDebugWindowMode(enabled: true) - case .disableDebugWindow: - self.segmentsManager.requestDebugWindowMode(enabled: false) - case .stopComposition: - self.segmentsManager.stopComposition() - case .forgetMemory: - self.segmentsManager.forgetMemory() - case .selectInputLanguage(let language): - self.switchInputLanguage(language, client: client) - case .commitMarkedTextAndSelectInputLanguage(let language): - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) + @MainActor + func requestPredictiveSuggestionWithConverterServer(client: IMKTextInput) -> Bool { + self.handleKeyEventWithConverterServer( + event: KeyEventCore( + modifierFlags: [.control], + characters: "s", + charactersIgnoringModifiers: "s", + keyCode: 1 + ), + client: client, + enableSuggestion: Config.AIBackendPreference().value != .off + ) ?? false + } + + @MainActor + // swiftlint:disable:next cyclomatic_complexity + private func apply(_ effect: ConverterClientEffect, client: IMKTextInput) { + switch effect { + case .insertText(let text): client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + case .switchInputLanguage(let language): self.switchInputLanguage(language, client: client) - // PredictiveSuggestion case .requestPredictiveSuggestion: - // 「つづき」を直接入力し、コンテキストを渡す - self.segmentsManager.insertAtCursorPosition("つづき", inputStyle: self.inputStyle) self.requestReplaceSuggestion() - case .acceptPredictionCandidate: - self.acceptPredictionCandidate() - // ReplaceSuggestion case .requestReplaceSuggestion: self.requestReplaceSuggestion() case .selectNextReplaceSuggestionCandidate: self.replaceSuggestionsViewController.selectNextCandidate() - case .selectPrevReplaceSuggestionCandidate: + case .selectPreviousReplaceSuggestionCandidate: self.replaceSuggestionsViewController.selectPrevCandidate() case .submitReplaceSuggestionCandidate: self.submitSelectedSuggestionCandidate() case .hideReplaceSuggestionWindow: self.replaceSuggestionWindow.setIsVisible(false) self.replaceSuggestionWindow.orderOut(nil) - // Selected Text Transform case .showPromptInputWindow: - self.segmentsManager.appendDebugMessage("Executing showPromptInputWindow") self.showPromptInputWindow() case .transformSelectedText(let selectedText, let prompt): - self.segmentsManager.appendDebugMessage("Executing transformSelectedText with text: '\(selectedText)' and prompt: '\(prompt)'") self.transformSelectedText(selectedText: selectedText, prompt: prompt) - // Unicode Input (Shift+Ctrl+U) - case .enterUnicodeInputMode: - // 状態遷移は clientActionCallback で行われるので、ここでは何もしない - break - case .appendToUnicodeInput: - // markedText の更新は refreshMarkedText で行われる + case .fallthroughToApplication: break - case .removeLastUnicodeInput: - // markedText の更新は refreshMarkedText で行われる - break - case .submitUnicodeInput(let codePoint): - if let scalar = UInt32(codePoint, radix: 16), let unicodeScalar = Unicode.Scalar(scalar) { - let character = String(Character(unicodeScalar)) - client.insertText(character, replacementRange: NSRange(location: NSNotFound, length: 0)) - } - case .cancelUnicodeInput: - // 状態遷移は clientActionCallback で行われるので、ここでは何もしない - break - case .submitSelectedCandidateAndEnterUnicodeInputMode: - // 選択中の候補を確定 - self.submitSelectedCandidate() - // 残りのテキストがあればひらがなのまま確定 - if !self.segmentsManager.isEmpty { - let text = self.segmentsManager.convertTarget - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - self.segmentsManager.stopComposition() - } - // MARK: 特殊ケース - case .consume: - // 何もせず先に進む - break - case .fallthrough: - return false } - - self.applyClientActionCallback( - clientActionCallback, - client: client, - compositionIsEmpty: self.currentCompositionIsEmpty - ) - - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - return true - } - - private var currentCompositionIsEmpty: Bool { - self.converterServerSnapshot?.isEmpty ?? self.segmentsManager.isEmpty } - @MainActor - private func applyClientActionCallback( - _ clientActionCallback: ClientActionCallback, - client: IMKTextInput, - compositionIsEmpty: Bool - ) { - switch clientActionCallback { - case .fallthrough: - break - case .transition(let inputState): - // 遷移した時にreplaceSuggestionWindowをhideする - if inputState != .replaceSuggestion { - self.replaceSuggestionWindow.orderOut(nil) - } - if inputState == .none { - self.switchInputLanguage(self.inputLanguage, client: client) + private var inputStyle: InputStyle { + switch Config.InputStyle().value { + case .default: + .mapped(id: .defaultRomanToKana) + case .defaultAZIK: + .mapped(id: .defaultAZIK) + case .defaultKanaUS: + .mapped(id: .defaultKanaUS) + case .defaultKanaJIS: + .mapped(id: .defaultKanaJIS) + case .custom: + if CustomInputTableStore.exists() { + .mapped(id: .tableName(CustomInputTableStore.tableName)) + } else { + .mapped(id: .defaultRomanToKana) } - self.inputState = inputState - case .basedOnBackspace(let ifIsEmpty, let ifIsNotEmpty), .basedOnSubmitCandidate(let ifIsEmpty, let ifIsNotEmpty): - self.inputState = compositionIsEmpty ? ifIsEmpty : ifIsNotEmpty - } - } - - private func applyConverterServerSnapshotState(_ snapshot: ConverterSessionSnapshot) { - if let inputState = snapshot.inputStateFromCandidateWindow { - self.inputState = inputState - } - } - - private func refreshConverterServerSnapshotIfNeeded(after clientActionCallback: ClientActionCallback) { - guard self.converterServerSnapshot != nil else { - return - } - switch clientActionCallback { - case .fallthrough: - return - case .transition, .basedOnBackspace, .basedOnSubmitCandidate: - self.refreshConverterServerSnapshotForCurrentInputState() } } @@ -639,237 +485,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.converterServerSnapshot = response.snapshot } - @MainActor - // swiftlint:disable:next cyclomatic_complexity - private func handleClientActionWithConverterServer( - _ clientAction: ClientAction, - client: IMKTextInput - ) -> ConverterServerResponse? { - guard self.converterServerClient.canSendOrReconnect else { - return nil - } - guard self.segmentsManager.isEmpty else { - return nil - } - - func send(_ commandBuilder: (String) -> ConverterServerCommand) -> ConverterServerResponse? { - guard let response = self.converterServerClient.sendSync(commandBuilder) else { - return nil - } - self.converterServerSnapshot = response.snapshot - return response - } - - func insertCommittedText(_ response: ConverterServerResponse?) { - guard let text = response?.committedText, !text.isEmpty else { - return - } - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - } - - let serverInputStyle = ConverterInputStyle(self.inputLanguage == .english ? .direct : self.inputStyle) - let serverInputState = ConverterInputState(self.inputState) - func leftSideContext() -> String? { - self.getLeftSideContext(maxCount: 30) - } - - switch clientAction { - case .showCandidateWindow: - return send { - .setCandidateWindowVisible(sessionID: $0, visible: true, inputState: serverInputState) - } - case .hideCandidateWindow: - return send { - .setCandidateWindowVisible(sessionID: $0, visible: false, inputState: serverInputState) - } - case .enterFirstCandidatePreviewMode: - guard let response = send({ - .insertCompositionSeparator(sessionID: $0, inputStyle: serverInputStyle, skipUpdate: false) - }) else { - return nil - } - _ = send { - .setCandidateWindowVisible(sessionID: $0, visible: false, inputState: .previewing) - } - return response - case .enterCandidateSelectionMode: - guard send({ - .insertCompositionSeparator(sessionID: $0, inputStyle: serverInputStyle, skipUpdate: true) - }) != nil else { - return nil - } - return send { - .updateCandidates(sessionID: $0, requestRichCandidates: true) - } - case .appendToMarkedText(let string): - return send { - .insertText(sessionID: $0, text: string, inputStyle: serverInputStyle, leftSideContext: leftSideContext()) - } - case .appendPieceToMarkedText(let pieces): - return send { - .insertText( - sessionID: $0, - text: pieces.inputString(preferIntention: true), - inputStyle: serverInputStyle, - leftSideContext: leftSideContext() - ) - } - case .editSegment(let count): - return send { - .editSegment(sessionID: $0, count: count) - } - case .commitMarkedText: - let response = send { - .commitMarkedText(sessionID: $0, inputState: serverInputState) - } - insertCommittedText(response) - return response - case .commitMarkedTextAndAppendToMarkedText(let string): - let commitResponse = send { - .commitMarkedText(sessionID: $0, inputState: serverInputState) - } - insertCommittedText(commitResponse) - return send { - .insertText(sessionID: $0, text: string, inputStyle: serverInputStyle, leftSideContext: leftSideContext()) - } ?? commitResponse - case .commitMarkedTextAndAppendPieceToMarkedText(let pieces): - let commitResponse = send { - .commitMarkedText(sessionID: $0, inputState: serverInputState) - } - insertCommittedText(commitResponse) - return send { - .insertText( - sessionID: $0, - text: pieces.inputString(preferIntention: true), - inputStyle: serverInputStyle, - leftSideContext: leftSideContext() - ) - } ?? commitResponse - case .submitSelectedCandidate: - let response = send { - .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext()) - } - insertCommittedText(response) - return response - case .removeLastMarkedText: - guard send({ - .deleteBackward(sessionID: $0, count: 1, leftSideContext: leftSideContext()) - }) != nil else { - return nil - } - return send { - .resetSelection(sessionID: $0) - } - case .selectPrevCandidate: - return send { - .selectPreviousCandidate(sessionID: $0) - } - case .selectNextCandidate: - return send { - .selectNextCandidate(sessionID: $0) - } - case .selectNumberCandidate(let num): - guard send({ - .selectCandidate(sessionID: $0, index: self.candidatesViewController.getNumberCandidate(num: num)) - }) != nil else { - return nil - } - let response = send { - .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext()) - } - insertCommittedText(response) - return response - case .submitHiraganaCandidate: - return self.submitTransformedCandidateWithConverterServer(.hiragana, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) - case .submitKatakanaCandidate: - return self.submitTransformedCandidateWithConverterServer(.katakana, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) - case .submitHankakuKatakanaCandidate: - return self.submitTransformedCandidateWithConverterServer(.halfWidthKatakana, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) - case .submitFullWidthRomanCandidate: - return self.submitTransformedCandidateWithConverterServer(.fullWidthRoman, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) - case .submitHalfWidthRomanCandidate: - return self.submitTransformedCandidateWithConverterServer(.halfWidthRoman, inputState: serverInputState, leftSideContext: leftSideContext(), send: send, insertCommittedText: insertCommittedText) - case .stopComposition: - return send { - .stopComposition(sessionID: $0) - } - case .forgetMemory: - return send { - .forgetMemory(sessionID: $0) - } - case .selectInputLanguage(let language): - let response = self.converterServerSnapshot?.isEmpty == false ? send { - .stopComposition(sessionID: $0) - } : self.converterServerSnapshot.map { - ConverterServerResponse(sessionID: "", snapshot: $0) - } - self.switchInputLanguage(language, client: client) - return response - case .commitMarkedTextAndSelectInputLanguage(let language): - let response = send { - .commitMarkedText(sessionID: $0, inputState: serverInputState) - } - insertCommittedText(response) - self.switchInputLanguage(language, client: client) - return response - case .submitSelectedCandidateAndEnterUnicodeInputMode: - let response = send { - .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext()) - } - insertCommittedText(response) - if let snapshot = response?.snapshot, !snapshot.isEmpty { - client.insertText(snapshot.convertTarget, replacementRange: NSRange(location: NSNotFound, length: 0)) - return send { - .stopComposition(sessionID: $0) - } ?? response - } - return response - case .acceptPredictionCandidate: - guard let prediction = self.converterServerSnapshot?.predictionCandidates.first else { - return self.converterServerSnapshot.map { - ConverterServerResponse(sessionID: "", snapshot: $0) - } - } - - var response: ConverterServerResponse? - if prediction.deleteCount > 0 { - response = send { - .deleteBackward(sessionID: $0, count: prediction.deleteCount, leftSideContext: leftSideContext()) - } - } - guard !prediction.appendText.isEmpty else { - return response ?? self.converterServerSnapshot.map { - ConverterServerResponse(sessionID: "", snapshot: $0) - } - } - return send { - .insertText(sessionID: $0, text: prediction.appendText, inputStyle: .direct, leftSideContext: leftSideContext()) - } ?? response - default: - return nil - } - } - - @MainActor - private func submitTransformedCandidateWithConverterServer( - _ transform: ConverterCandidateTransform, - inputState: ConverterInputState, - leftSideContext: String?, - send: ((String) -> ConverterServerCommand) -> ConverterServerResponse?, - insertCommittedText: (ConverterServerResponse?) -> Void - ) -> ConverterServerResponse? { - let response = send { - .submitTransformedCandidate( - sessionID: $0, - transform: transform, - inputState: inputState, - leftSideContext: leftSideContext - ) - } - insertCommittedText(response) - return response - } - @MainActor func switchInputLanguage(_ language: InputLanguage, client: IMKTextInput) { self.inputLanguage = language client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) @@ -896,34 +511,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.refreshCandidateWindow(converterServerSnapshot.candidateWindow) return } - switch self.segmentsManager.getCurrentCandidateWindow(inputState: self.inputState) { - case .selecting(let candidates, let selectionIndex): - var rect: NSRect = .zero - self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - self.candidatesViewController.showCandidateIndex = true - let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) - self.candidatesViewController.updateCandidatePresentations( - candidatePresentations, - selectionIndex: selectionIndex, - cursorLocation: rect.origin - ) - self.candidatesWindow.orderFront(nil) - case .composing(let candidates, let selectionIndex): - var rect: NSRect = .zero - self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - self.candidatesViewController.showCandidateIndex = false - let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) - self.candidatesViewController.updateCandidatePresentations( - candidatePresentations, - selectionIndex: selectionIndex, - cursorLocation: rect.origin - ) - self.candidatesWindow.orderFront(nil) - case .hidden: - self.candidatesWindow.setIsVisible(false) - self.candidatesWindow.orderOut(nil) - self.candidatesViewController.hide() - } + self.candidatesWindow.setIsVisible(false) + self.candidatesWindow.orderOut(nil) + self.candidatesViewController.hide() } private func refreshCandidateWindow(_ candidateWindow: ConverterCandidateWindow) { @@ -961,8 +551,10 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return } - let predictions = self.converterServerSnapshot?.predictionCandidates - ?? self.requestPreferredPredictionCandidates().map(ConverterPredictionCandidate.init) + guard let predictions = self.converterServerSnapshot?.predictionCandidates else { + self.hidePredictionWindow() + return + } if predictions.isEmpty { let now = Date().timeIntervalSince1970 let elapsed = now - self.lastPredictionUpdateTime @@ -1074,32 +666,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.predictionHideWorkItem = nil } - @MainActor - private func acceptPredictionCandidate() { - let predictions = self.requestPreferredPredictionCandidates() - guard let prediction = predictions.first else { - return - } - let deleteCount = prediction.deleteCount - if deleteCount > 0 { - self.segmentsManager.deleteBackwardFromCursorPosition(count: deleteCount) - } - let appendText = prediction.appendText - - guard !appendText.isEmpty else { - return - } - - self.segmentsManager.insertAtCursorPosition(appendText, inputStyle: .direct) - } - - private func requestPreferredPredictionCandidates() -> [SegmentsManager.PredictionCandidate] { - SegmentsManager.preferredPredictionCandidates( - typoCorrectionCandidates: self.segmentsManager.requestTypoCorrectionPredictionCandidates(), - predictionCandidates: self.segmentsManager.requestPredictionCandidates() - ) - } - var retryCount = 0 let maxRetries = 3 @@ -1148,34 +714,22 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s private func currentMarkedText() -> ConverterMarkedText { switch self.inputState { - case .attachDiacritic, .replaceSuggestion, .unicodeInput: + case .attachDiacritic, .unicodeInput: return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) + case .replaceSuggestion: + if let candidate = self.replaceSuggestionsViewController.getSelectedCandidate() { + return ConverterMarkedText( + elements: [.init(content: candidate.text, focus: .focused)], + selectionRange: .init(location: candidate.text.count, length: 0) + ) + } case .none, .composing, .previewing, .selecting: break } if let converterServerSnapshot { return converterServerSnapshot.markedText } - return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) - } - - @MainActor - func submitCandidate(_ candidate: Candidate) { - if let client = self.client() { - // インサートを行う前にコンテキストを取得する - let cleanLeftSideContext = self.segmentsManager.getCleanLeftSideContext(maxCount: 30) - client.insertText(candidate.text, replacementRange: NSRange(location: NSNotFound, length: 0)) - // アプリケーションサポートのディレクトリを準備しておく - self.segmentsManager.prefixCandidateCommited(candidate, leftSideContext: cleanLeftSideContext ?? "") - } - } - - @MainActor - func submitSelectedCandidate() { - if let candidate = self.segmentsManager.selectedCandidate { - self.submitCandidate(candidate) - self.segmentsManager.requestResettingSelection() - } + return ConverterSessionSnapshot.empty.markedText } } @@ -1188,11 +742,12 @@ extension azooKeyMacInputController: CandidatesViewControllerDelegate { .submitSelectedCandidate(sessionID: $0, leftSideContext: leftSideContext) }) { self.converterServerSnapshot = response.snapshot - if let text = response.committedText, !text.isEmpty { - self.client()?.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + if let client = self.client() { + for effect in response.effects { + self.apply(effect, client: client) + } } - self.applyConverterServerSnapshotState(response.snapshot) - self.inputState = response.snapshot.isEmpty ? .none : .previewing + self.inputState = response.inputState.inputState self.refreshConverterServerSnapshotForCurrentInputState() self.refreshMarkedText() self.refreshCandidateWindow() @@ -1200,7 +755,6 @@ extension azooKeyMacInputController: CandidatesViewControllerDelegate { return } } - self.submitSelectedCandidate() } } @@ -1211,9 +765,9 @@ extension azooKeyMacInputController: CandidatesViewControllerDelegate { .selectCandidate(sessionID: $0, index: row) }) { self.converterServerSnapshot = response.snapshot + self.refreshMarkedText() return } - self.segmentsManager.requestSelectingRow(row) } } } @@ -1231,8 +785,8 @@ extension azooKeyMacInputController: SegmentManagerDelegate { } extension azooKeyMacInputController: ReplaceSuggestionsViewControllerDelegate { - @MainActor func replaceSuggestionSelectionChanged(_ row: Int) { - self.segmentsManager.requestSelectingSuggestionRow(row) + @MainActor func replaceSuggestionSelectionChanged(_: Int) { + self.refreshMarkedText() } func replaceSuggestionSubmitted() { @@ -1244,8 +798,7 @@ extension azooKeyMacInputController: ReplaceSuggestionsViewControllerDelegate { // サジェスト候補ウィンドウを非表示にする self.replaceSuggestionWindow.setIsVisible(false) self.replaceSuggestionWindow.orderOut(nil) - // 変換状態をリセット - self.segmentsManager.stopComposition() + self.finishReplaceSuggestionComposition() } } } @@ -1260,6 +813,7 @@ extension azooKeyMacInputController { // リクエスト開始時に前回の候補をクリアし、ウィンドウを非表示にする self.segmentsManager.setReplaceSuggestions([]) + self.replaceSuggestionsViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) self.replaceSuggestionWindow.setIsVisible(false) self.replaceSuggestionWindow.orderOut(nil) @@ -1272,7 +826,11 @@ extension azooKeyMacInputController { return } - let composingText = self.segmentsManager.convertTarget + guard let converterServerSnapshot, !converterServerSnapshot.isEmpty else { + self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: skipped because converter server composition is empty") + return + } + let composingText = converterServerSnapshot.convertTarget // プロンプトを取得 let prompt = self.getLeftSideContext(maxCount: 100) ?? "" @@ -1330,6 +888,10 @@ extension azooKeyMacInputController { // 候補をウィンドウに更新 await MainActor.run { + guard self.converterServerSnapshot?.convertTarget == composingText else { + self.segmentsManager.appendDebugMessage("候補ウィンドウ更新をスキップ: composition changed") + return + } self.segmentsManager.appendDebugMessage("候補ウィンドウ更新中...") if !candidates.isEmpty { self.segmentsManager.setReplaceSuggestions(candidates) @@ -1373,11 +935,21 @@ extension azooKeyMacInputController { client.insertText(candidate.text, replacementRange: NSRange(location: NSNotFound, length: 0)) self.replaceSuggestionWindow.setIsVisible(false) self.replaceSuggestionWindow.orderOut(nil) - self.segmentsManager.stopComposition() + self.finishReplaceSuggestionComposition() } } } + @MainActor private func finishReplaceSuggestionComposition() { + if self.converterServerSnapshot != nil { + self.discardConverterServerComposition() + } + self.inputState = .none + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + } + // MARK: - Helper Methods private func retrySuggestionRequestIfNeeded(cursorPosition: CGPoint) { if retryCount < maxRetries { diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index cc927042..cd1b94fa 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -51,14 +51,14 @@ extension azooKeyMacInputController { } let hasSelection = client.selectedRange().length > 0 if hasSelection { - _ = self.handleClientAction(.showPromptInputWindow, clientActionCallback: .fallthrough, client: client) + self.showPromptInputWindow() return } switch self.inputState { case .composing, .replaceSuggestion: - _ = self.handleClientAction(.requestReplaceSuggestion, clientActionCallback: .transition(.replaceSuggestion), client: client) + self.requestReplaceSuggestion() case .none: - _ = self.handleClientAction(.requestPredictiveSuggestion, clientActionCallback: .transition(.replaceSuggestion), client: client) + _ = self.requestPredictiveSuggestionWithConverterServer(client: client) default: break } From 07ced5b4f90601e7068d48d397b6ccefb904300c Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Mon, 25 May 2026 11:27:26 +0900 Subject: [PATCH 5/5] refactor: move replace suggestions to converter server --- Core/Sources/ConverterServer/main.swift | 200 +++++++++++-- .../Core/XPC/ConverterServerXPCProtocol.swift | 57 ++++ .../ConverterServerContractTests.swift | 56 ++++ .../azooKeyMacInputController.swift | 279 +++++++++--------- .../azooKeyMacInputControllerHelper.swift | 9 +- 5 files changed, 432 insertions(+), 169 deletions(-) diff --git a/Core/Sources/ConverterServer/main.swift b/Core/Sources/ConverterServer/main.swift index 39116a6f..69be599d 100644 --- a/Core/Sources/ConverterServer/main.swift +++ b/Core/Sources/ConverterServer/main.swift @@ -24,6 +24,15 @@ private enum ConverterCandidateTransform { private final class ConverterSession: SegmentManagerDelegate { let manager: SegmentsManager private var leftSideContext: String? + var config = ConverterSessionConfig( + aiBackendPreference: .off, + openAIModelName: Config.OpenAiModelName.default, + openAIEndpoint: Config.OpenAiApiEndpoint.default, + openAIAPIKey: .init(""), + includeContextInAITransform: true + ) + var replaceSuggestions: [Candidate] = [] + var replaceSuggestionSelectionIndex: Int? init(manager: SegmentsManager) { self.manager = manager @@ -40,6 +49,44 @@ private final class ConverterSession: SegmentManagerDelegate { } return String(leftSideContext.suffix(maxCount)) } + + func clearReplaceSuggestions() { + self.replaceSuggestions = [] + self.replaceSuggestionSelectionIndex = nil + } + + func selectReplaceSuggestion(at index: Int) { + guard !replaceSuggestions.isEmpty else { + replaceSuggestionSelectionIndex = nil + return + } + replaceSuggestionSelectionIndex = min(max(0, index), replaceSuggestions.count - 1) + } + + func selectNextReplaceSuggestion() { + guard !replaceSuggestions.isEmpty else { + replaceSuggestionSelectionIndex = nil + return + } + replaceSuggestionSelectionIndex = ((replaceSuggestionSelectionIndex ?? -1) + 1) % replaceSuggestions.count + } + + func selectPreviousReplaceSuggestion() { + guard !replaceSuggestions.isEmpty else { + replaceSuggestionSelectionIndex = nil + return + } + let current = replaceSuggestionSelectionIndex ?? 0 + replaceSuggestionSelectionIndex = (current - 1 + replaceSuggestions.count) % replaceSuggestions.count + } + + var selectedReplaceSuggestion: Candidate? { + guard let replaceSuggestionSelectionIndex, + replaceSuggestions.indices.contains(replaceSuggestionSelectionIndex) else { + return nil + } + return replaceSuggestions[replaceSuggestionSelectionIndex] + } } private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Sendable { @@ -69,21 +116,20 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch } func handleCommand(_ data: Data, with reply: @escaping @Sendable (Data?, NSString?) -> Void) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - do { - let command = try ConverterServerCodec.decodeCommand(from: data) - let response = try self.handle(command) - reply(try ConverterServerCodec.encode(response), nil) - } catch { - reply(nil, error.localizedDescription as NSString) - } + Task { @MainActor in + do { + let command = try ConverterServerCodec.decodeCommand(from: data) + let response = try await self.handle(command) + reply(try ConverterServerCodec.encode(response), nil) + } catch { + reply(nil, error.localizedDescription as NSString) } } } @MainActor - private func handle(_ command: ConverterServerCommand) throws -> ConverterServerResponse { + // swiftlint:disable:next cyclomatic_complexity + private func handle(_ command: ConverterServerCommand) async throws -> ConverterServerResponse { switch command { case .activate(let sessionID): let session = try getSession(sessionID) @@ -103,6 +149,10 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch let session = try getSession(sessionID) session.manager.forgetMemory() return makeResponse(for: session, inputState: .none) + case .updateSessionConfig(let sessionID, let config): + let session = try getSession(sessionID) + session.config = config + return makeResponse(for: session, inputState: .none) case .handleKeyEvent(let sessionID, let request): return try handleKeyEvent(sessionID: sessionID, request: request) case .selectCandidate(let sessionID, let index): @@ -125,6 +175,25 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch let text = session.manager.commitMarkedText(inputState: inputState.inputState) let effects: [ConverterClientEffect] = text.isEmpty ? [] : [.insertText(text)] return makeResponse(for: session, inputState: .none, effects: effects, responseInputState: ConverterInputState.none) + case .requestReplaceSuggestion(let sessionID, let leftSideContext): + let session = try getSession(sessionID) + try await requestReplaceSuggestion(session: session, leftSideContext: leftSideContext) + return makeResponse(for: session, inputState: .replaceSuggestion, responseInputState: .replaceSuggestion) + case .selectReplaceSuggestionCandidate(let sessionID, let index): + let session = try getSession(sessionID) + session.selectReplaceSuggestion(at: index) + return makeResponse(for: session, inputState: .replaceSuggestion, responseInputState: .replaceSuggestion) + case .submitSelectedReplaceSuggestion(let sessionID): + let session = try getSession(sessionID) + var effects: [ConverterClientEffect] = [] + let didSubmit = submitSelectedReplaceSuggestion(session: session, effects: &effects) + let nextInputState: InputState = didSubmit ? .none : .replaceSuggestion + return makeResponse( + for: session, + inputState: nextInputState, + effects: effects, + responseInputState: ConverterInputState(nextInputState) + ) } } @@ -140,6 +209,22 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch Config.DebugPredictiveTyping().value = request.enablePredictiveTyping Config.DebugTypoCorrection().value = request.enableTypoCorrection + if request.enableOptionDirectFullWidthInput, + let text = OptionDirectInputResolver.resolve( + characters: request.optionDirectInputText, + modifierFlags: request.event.modifierFlags, + inputLanguage: request.inputLanguage, + inputState: request.inputState.inputState, + typeBackSlash: request.typeBackSlash + ) { + return ConverterServerResponse( + effects: [.insertText(text)], + inputState: request.inputState, + inputLanguage: request.inputLanguage, + snapshot: snapshot(for: session, inputState: request.inputState.inputState) + ) + } + let userAction = UserAction.getUserAction( eventCore: request.event, inputLanguage: request.inputLanguage @@ -168,7 +253,7 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch effects: effects, inputState: request.inputState, inputLanguage: inputLanguage, - snapshot: snapshot(for: session.manager, inputState: request.inputState.inputState) + snapshot: snapshot(for: session, inputState: request.inputState.inputState) ) } @@ -182,7 +267,7 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch effects: effects, inputState: ConverterInputState(nextInputState), inputLanguage: inputLanguage, - snapshot: snapshot(for: session.manager, inputState: nextInputState) + snapshot: snapshot(for: session, inputState: nextInputState) ) } @@ -287,14 +372,16 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch case .acceptPredictionCandidate: acceptPredictionCandidate(manager: manager, leftSideContext: request.leftSideContext) case .requestReplaceSuggestion: + session.clearReplaceSuggestions() effects.append(.requestReplaceSuggestion) case .selectNextReplaceSuggestionCandidate: - effects.append(.selectNextReplaceSuggestionCandidate) + session.selectNextReplaceSuggestion() case .selectPrevReplaceSuggestionCandidate: - effects.append(.selectPreviousReplaceSuggestionCandidate) + session.selectPreviousReplaceSuggestion() case .submitReplaceSuggestionCandidate: - effects.append(.submitReplaceSuggestionCandidate) + _ = submitSelectedReplaceSuggestion(session: session, effects: &effects) case .hideReplaceSuggestionWindow: + session.clearReplaceSuggestions() effects.append(.hideReplaceSuggestionWindow) case .showPromptInputWindow: effects.append(.showPromptInputWindow) @@ -373,6 +460,70 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch effects.append(.insertText(candidate.text)) } + @MainActor + private func requestReplaceSuggestion( + session: ConverterSession, + leftSideContext: String? + ) async throws { + session.clearReplaceSuggestions() + guard !session.manager.isEmpty else { + return + } + let backend: AIBackend + switch session.config.aiBackendPreference { + case .off: + return + case .foundationModels: + backend = .foundationModels + case .openAI: + backend = .openAI + } + let composingText = session.manager.convertTarget + let prompt = session.config.includeContextInAITransform ? (leftSideContext ?? "") : "" + let request = OpenAIRequest( + prompt: prompt, + target: composingText, + modelName: session.config.openAIModelName.isEmpty ? Config.OpenAiModelName.default : session.config.openAIModelName + ) + let predictions = try await AIClient.sendRequest( + request, + backend: backend, + apiKey: session.config.openAIAPIKey.value, + apiEndpoint: session.config.openAIEndpoint.isEmpty ? Config.OpenAiApiEndpoint.default : session.config.openAIEndpoint + ) + guard session.manager.convertTarget == composingText else { + return + } + session.replaceSuggestions = predictions.map { text in + Candidate( + text: text, + value: PValue(0), + composingCount: .surfaceCount(composingText.count), + lastMid: 0, + data: [], + actions: [], + inputable: true + ) + } + if !session.replaceSuggestions.isEmpty { + session.replaceSuggestionSelectionIndex = 0 + } + } + + @MainActor + private func submitSelectedReplaceSuggestion( + session: ConverterSession, + effects: inout [ConverterClientEffect] + ) -> Bool { + guard let candidate = session.selectedReplaceSuggestion else { + return false + } + effects.append(.insertText(candidate.text)) + session.manager.stopComposition() + session.clearReplaceSuggestions() + return true + } + @MainActor private func acceptPredictionCandidate(manager: SegmentsManager, leftSideContext _: String?) { let prediction = SegmentsManager.preferredPredictionCandidates( @@ -411,16 +562,25 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch handled: handled, effects: effects, inputState: responseInputState ?? ConverterInputState(inputState), - snapshot: snapshot(for: session.manager, inputState: inputState) + snapshot: snapshot(for: session, inputState: inputState) ) } @MainActor - private func snapshot(for manager: SegmentsManager, inputState: InputState) -> ConverterSessionSnapshot { + private func snapshot(for session: ConverterSession, inputState: InputState) -> ConverterSessionSnapshot { + let manager = session.manager if manager.isEmpty { return .empty } - let markedText = ConverterMarkedText(manager.getCurrentMarkedText(inputState: inputState)) + let markedText: ConverterMarkedText + if inputState == .replaceSuggestion, let candidate = session.selectedReplaceSuggestion { + markedText = ConverterMarkedText( + elements: [.init(content: candidate.text, focus: .focused)], + selectionRange: .init(location: candidate.text.count, length: 0) + ) + } else { + markedText = ConverterMarkedText(manager.getCurrentMarkedText(inputState: inputState)) + } let candidateWindow: ConverterCandidateWindow switch manager.getCurrentCandidateWindow(inputState: inputState) { case .hidden: @@ -449,6 +609,10 @@ private final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unch markedText: markedText, candidateWindow: candidateWindow, predictionCandidates: predictionCandidates, + replaceSuggestionCandidates: session.replaceSuggestions.map { + ConverterCandidatePresentation(CandidatePresentation(candidate: $0)) + }, + replaceSuggestionSelectionIndex: session.replaceSuggestionSelectionIndex, isEmpty: manager.isEmpty, convertTarget: manager.convertTarget ) diff --git a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift index f770c0cb..a2169c53 100644 --- a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift +++ b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift @@ -28,10 +28,52 @@ public enum ConverterServerCommand: Codable, Sendable { case snapshot(sessionID: String, inputState: ConverterInputState) case stopComposition(sessionID: String) case forgetMemory(sessionID: String) + case updateSessionConfig(sessionID: String, config: ConverterSessionConfig) case handleKeyEvent(sessionID: String, request: ConverterKeyEventRequest) case selectCandidate(sessionID: String, index: Int) case submitSelectedCandidate(sessionID: String, leftSideContext: String?) case commitComposition(sessionID: String, inputState: ConverterInputState) + case requestReplaceSuggestion(sessionID: String, leftSideContext: String?) + case selectReplaceSuggestionCandidate(sessionID: String, index: Int) + case submitSelectedReplaceSuggestion(sessionID: String) +} + +public struct ConverterSessionConfig: Codable, Sendable { + public var aiBackendPreference: Config.AIBackendPreference.Value + public var openAIModelName: String + public var openAIEndpoint: String + public var openAIAPIKey: ConverterSecretString + public var includeContextInAITransform: Bool + + public init( + aiBackendPreference: Config.AIBackendPreference.Value, + openAIModelName: String, + openAIEndpoint: String, + openAIAPIKey: ConverterSecretString, + includeContextInAITransform: Bool + ) { + self.aiBackendPreference = aiBackendPreference + self.openAIModelName = openAIModelName + self.openAIEndpoint = openAIEndpoint + self.openAIAPIKey = openAIAPIKey + self.includeContextInAITransform = includeContextInAITransform + } +} + +public struct ConverterSecretString: Codable, Sendable, CustomStringConvertible, CustomDebugStringConvertible { + public var value: String + + public init(_ value: String) { + self.value = value + } + + public var description: String { + value.isEmpty ? "" : "" + } + + public var debugDescription: String { + description + } } public struct ConverterKeyEventRequest: Codable, Sendable, Equatable { @@ -44,6 +86,9 @@ public struct ConverterKeyEventRequest: Codable, Sendable, Equatable { public var enableSuggestion: Bool public var enablePredictiveTyping: Bool public var enableTypoCorrection: Bool + public var enableOptionDirectFullWidthInput: Bool + public var typeBackSlash: Bool + public var optionDirectInputText: String? public var leftSideContext: String? public var visibleCandidateStartIndex: Int @@ -57,6 +102,9 @@ public struct ConverterKeyEventRequest: Codable, Sendable, Equatable { enableSuggestion: Bool, enablePredictiveTyping: Bool = false, enableTypoCorrection: Bool = false, + enableOptionDirectFullWidthInput: Bool = false, + typeBackSlash: Bool = false, + optionDirectInputText: String? = nil, leftSideContext: String?, visibleCandidateStartIndex: Int = 0 ) { @@ -69,6 +117,9 @@ public struct ConverterKeyEventRequest: Codable, Sendable, Equatable { self.enableSuggestion = enableSuggestion self.enablePredictiveTyping = enablePredictiveTyping self.enableTypoCorrection = enableTypoCorrection + self.enableOptionDirectFullWidthInput = enableOptionDirectFullWidthInput + self.typeBackSlash = typeBackSlash + self.optionDirectInputText = optionDirectInputText self.leftSideContext = leftSideContext self.visibleCandidateStartIndex = visibleCandidateStartIndex } @@ -114,6 +165,8 @@ public struct ConverterSessionSnapshot: Codable, Sendable { public var markedText: ConverterMarkedText public var candidateWindow: ConverterCandidateWindow public var predictionCandidates: [ConverterPredictionCandidate] + public var replaceSuggestionCandidates: [ConverterCandidatePresentation] + public var replaceSuggestionSelectionIndex: Int? public var isEmpty: Bool public var convertTarget: String @@ -121,12 +174,16 @@ public struct ConverterSessionSnapshot: Codable, Sendable { markedText: ConverterMarkedText, candidateWindow: ConverterCandidateWindow, predictionCandidates: [ConverterPredictionCandidate] = [], + replaceSuggestionCandidates: [ConverterCandidatePresentation] = [], + replaceSuggestionSelectionIndex: Int? = nil, isEmpty: Bool, convertTarget: String ) { self.markedText = markedText self.candidateWindow = candidateWindow self.predictionCandidates = predictionCandidates + self.replaceSuggestionCandidates = replaceSuggestionCandidates + self.replaceSuggestionSelectionIndex = replaceSuggestionSelectionIndex self.isEmpty = isEmpty self.convertTarget = convertTarget } diff --git a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift index 9c13d785..65c12c1b 100644 --- a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift +++ b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift @@ -60,6 +60,9 @@ import Testing enableSuggestion: true, enablePredictiveTyping: true, enableTypoCorrection: true, + enableOptionDirectFullWidthInput: true, + typeBackSlash: true, + optionDirectInputText: "a", leftSideContext: "左文脈", visibleCandidateStartIndex: 3 ) @@ -74,6 +77,59 @@ import Testing #expect(roundTripRequest == request) } +@Test func converterServerSessionConfigCommandRoundTrips() throws { + let config = ConverterSessionConfig( + aiBackendPreference: .openAI, + openAIModelName: "gpt-test", + openAIEndpoint: "https://api.example.test/v1", + openAIAPIKey: .init("secret"), + includeContextInAITransform: false + ) + let command = ConverterServerCommand.updateSessionConfig(sessionID: "session-1", config: config) + let roundTrip = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(command)) + + guard case .updateSessionConfig(let sessionID, let roundTripConfig) = roundTrip else { + Issue.record("Expected updateSessionConfig command after round trip, got \(roundTrip)") + return + } + #expect(sessionID == "session-1") + #expect(roundTripConfig.aiBackendPreference == .openAI) + #expect(roundTripConfig.openAIModelName == "gpt-test") + #expect(roundTripConfig.openAIEndpoint == "https://api.example.test/v1") + #expect(roundTripConfig.openAIAPIKey.value == "secret") + #expect(roundTripConfig.openAIAPIKey.description == "") + #expect(!roundTripConfig.includeContextInAITransform) +} + +@Test func converterServerReplaceSuggestionCommandsRoundTrip() throws { + let request = ConverterServerCommand.requestReplaceSuggestion(sessionID: "session-1", leftSideContext: "左文脈") + let select = ConverterServerCommand.selectReplaceSuggestionCandidate(sessionID: "session-1", index: 2) + let submit = ConverterServerCommand.submitSelectedReplaceSuggestion(sessionID: "session-1") + + guard case .requestReplaceSuggestion(let requestSessionID, let leftSideContext) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(request)) else { + Issue.record("Expected requestReplaceSuggestion command after round trip") + return + } + #expect(requestSessionID == "session-1") + #expect(leftSideContext == "左文脈") + + guard case .selectReplaceSuggestionCandidate(let selectSessionID, let index) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(select)) else { + Issue.record("Expected selectReplaceSuggestionCandidate command after round trip") + return + } + #expect(selectSessionID == "session-1") + #expect(index == 2) + + guard case .submitSelectedReplaceSuggestion(let submitSessionID) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(submit)) else { + Issue.record("Expected submitSelectedReplaceSuggestion command after round trip") + return + } + #expect(submitSessionID == "session-1") +} + @Test func converterServerResponseCarriesClientEffects() throws { let response = ConverterServerResponse( handled: true, diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 62205f44..750e7d56 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -160,6 +160,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s guard let self, sessionID != nil else { return } + self.syncConverterServerSessionConfig() self.converterServerClient.sendIfSessionOpen({ .activate(sessionID: $0) }, completion: { [weak self] response in guard let self, let response else { return @@ -281,20 +282,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return true } - let eventModifiers = KeyEventCore.ModifierFlag(from: event.modifierFlags) - let charactersForOptionDirectInput = event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.option)) - if Config.OptionDirectFullWidthInput().value, - let text = OptionDirectInputResolver.resolve( - characters: charactersForOptionDirectInput, - modifierFlags: eventModifiers, - inputLanguage: inputLanguage, - inputState: inputState, - typeBackSlash: Config.TypeBackSlash().value - ) { - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - return true - } - let userAction = UserAction.getUserAction(eventCore: event.keyEventCore, inputLanguage: inputLanguage) // 英数キー(keyCode 102)の処理 @@ -329,12 +316,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // Handle suggest action with selected text check (prevent recursive calls) if case .suggest = userAction { - // If AI backend is off, ignore the suggest action - if !aiBackendEnabled { - self.segmentsManager.appendDebugMessage("Suggest action ignored: AI backend is off") - return false - } - // Prevent recursive window calls if self.isPromptWindowVisible { self.segmentsManager.appendDebugMessage("Suggest action ignored: prompt window already visible") @@ -344,6 +325,10 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s let selectedRange = client.selectedRange() self.segmentsManager.appendDebugMessage("Suggest action detected. Selected range: \(selectedRange)") if selectedRange.length > 0 { + guard aiBackendEnabled else { + self.segmentsManager.appendDebugMessage("Suggest action ignored: AI backend is off") + return false + } self.segmentsManager.appendDebugMessage("Selected text found, showing prompt input window") // There is selected text, show prompt input window self.showPromptInputWindow() @@ -356,7 +341,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if let handled = self.handleKeyEventWithConverterServer( event: event.keyEventCore, client: client, - enableSuggestion: aiBackendEnabled + enableSuggestion: aiBackendEnabled, + optionDirectInputText: event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.option)) ) { return handled } @@ -368,7 +354,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s private func handleKeyEventWithConverterServer( event: KeyEventCore, client: IMKTextInput, - enableSuggestion: Bool + enableSuggestion: Bool, + optionDirectInputText: String? = nil ) -> Bool? { guard self.converterServerClient.canSendOrReconnect else { return nil @@ -387,6 +374,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s enableSuggestion: enableSuggestion, enablePredictiveTyping: Config.DebugPredictiveTyping().value, enableTypoCorrection: Config.DebugTypoCorrection().value, + enableOptionDirectFullWidthInput: Config.OptionDirectFullWidthInput().value, + typeBackSlash: Config.TypeBackSlash().value, + optionDirectInputText: optionDirectInputText, leftSideContext: self.getLeftSideContext(maxCount: 30) ) guard let response = self.converterServerClient.sendSync({ @@ -410,6 +400,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.refreshMarkedText() self.refreshCandidateWindow() self.refreshPredictionWindow() + self.refreshReplaceSuggestionWindow() return response.handled } @@ -440,9 +431,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s case .requestReplaceSuggestion: self.requestReplaceSuggestion() case .selectNextReplaceSuggestionCandidate: - self.replaceSuggestionsViewController.selectNextCandidate() + self.selectReplaceSuggestionCandidate(offset: 1) case .selectPreviousReplaceSuggestionCandidate: - self.replaceSuggestionsViewController.selectPrevCandidate() + self.selectReplaceSuggestionCandidate(offset: -1) case .submitReplaceSuggestionCandidate: self.submitSelectedSuggestionCandidate() case .hideReplaceSuggestionWindow: @@ -476,6 +467,32 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } + private var converterServerSessionConfig: ConverterSessionConfig { + ConverterSessionConfig( + aiBackendPreference: Config.AIBackendPreference().value, + openAIModelName: Config.OpenAiModelName().value, + openAIEndpoint: Config.OpenAiApiEndpoint().value, + openAIAPIKey: .init(Config.OpenAiApiKey().value), + includeContextInAITransform: Config.IncludeContextInAITransform().value + ) + } + + private func syncConverterServerSessionConfig() { + let config = self.converterServerSessionConfig + self.converterServerClient.sendIfSessionOpen( + { .updateSessionConfig(sessionID: $0, config: config) }, + completion: { _ in } + ) + } + + @discardableResult + private func syncConverterServerSessionConfigSync() -> Bool { + let config = self.converterServerSessionConfig + return self.converterServerClient.sendIfSessionOpenSync({ + .updateSessionConfig(sessionID: $0, config: config) + }) != nil + } + private func refreshConverterServerSnapshotForCurrentInputState() { guard let response = self.converterServerClient.sendIfSessionOpenSync({ .snapshot(sessionID: $0, inputState: ConverterInputState(self.inputState)) @@ -545,6 +562,52 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } + @MainActor private func refreshReplaceSuggestionWindow() { + guard self.inputState == .replaceSuggestion, + let converterServerSnapshot, + !converterServerSnapshot.replaceSuggestionCandidates.isEmpty else { + self.replaceSuggestionsViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + self.replaceSuggestionWindow.setIsVisible(false) + self.replaceSuggestionWindow.orderOut(nil) + return + } + self.replaceSuggestionsViewController.updateCandidatePresentations( + converterServerSnapshot.replaceSuggestionCandidates.map(\.candidatePresentation), + selectionIndex: converterServerSnapshot.replaceSuggestionSelectionIndex, + cursorLocation: self.getCursorLocation() + ) + self.replaceSuggestionWindow.setIsVisible(true) + self.replaceSuggestionWindow.makeKeyAndOrderFront(nil) + } + + @MainActor private func selectReplaceSuggestionCandidate(offset: Int) { + guard let snapshot = self.converterServerSnapshot, + !snapshot.replaceSuggestionCandidates.isEmpty else { + return + } + let count = snapshot.replaceSuggestionCandidates.count + let current = snapshot.replaceSuggestionSelectionIndex ?? (offset > 0 ? -1 : 0) + let next = (current + offset + count) % count + if let response = self.converterServerClient.sendIfSessionOpenSync({ + .selectReplaceSuggestionCandidate(sessionID: $0, index: next) + }) { + self.converterServerSnapshot = response.snapshot + self.inputState = response.inputState.inputState + self.refreshMarkedText() + self.refreshReplaceSuggestionWindow() + } + } + + @MainActor private func showReplaceSuggestionError(message: String) { + self.segmentsManager.appendDebugMessage("APIリクエストエラー: \(message)") + let alert = NSAlert() + alert.messageText = "変換に失敗しました" + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } + func refreshPredictionWindow() { guard self.inputState == .composing else { self.hidePredictionWindow() @@ -716,14 +779,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s switch self.inputState { case .attachDiacritic, .unicodeInput: return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) - case .replaceSuggestion: - if let candidate = self.replaceSuggestionsViewController.getSelectedCandidate() { - return ConverterMarkedText( - elements: [.init(content: candidate.text, focus: .focused)], - selectionRange: .init(location: candidate.text.count, length: 0) - ) - } - case .none, .composing, .previewing, .selecting: + case .none, .composing, .previewing, .selecting, .replaceSuggestion: break } if let converterServerSnapshot { @@ -785,22 +841,23 @@ extension azooKeyMacInputController: SegmentManagerDelegate { } extension azooKeyMacInputController: ReplaceSuggestionsViewControllerDelegate { - @MainActor func replaceSuggestionSelectionChanged(_: Int) { - self.refreshMarkedText() + @MainActor func replaceSuggestionSelectionChanged(_ row: Int) { + guard self.converterServerSnapshot?.replaceSuggestionSelectionIndex != row else { + return + } + if let response = self.converterServerClient.sendIfSessionOpenSync({ + .selectReplaceSuggestionCandidate(sessionID: $0, index: row) + }) { + self.converterServerSnapshot = response.snapshot + self.inputState = response.inputState.inputState + self.refreshMarkedText() + self.refreshReplaceSuggestionWindow() + } } func replaceSuggestionSubmitted() { Task { @MainActor in - if let candidate = self.replaceSuggestionsViewController.getSelectedCandidate() { - if let client = self.client() { - // 選択された候補をテキストとして挿入 - client.insertText(candidate.text, replacementRange: NSRange(location: NSNotFound, length: 0)) - // サジェスト候補ウィンドウを非表示にする - self.replaceSuggestionWindow.setIsVisible(false) - self.replaceSuggestionWindow.orderOut(nil) - self.finishReplaceSuggestionComposition() - } - } + self.submitSelectedSuggestionCandidate() } } } @@ -812,114 +869,41 @@ extension azooKeyMacInputController { self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: 開始") // リクエスト開始時に前回の候補をクリアし、ウィンドウを非表示にする - self.segmentsManager.setReplaceSuggestions([]) self.replaceSuggestionsViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) self.replaceSuggestionWindow.setIsVisible(false) self.replaceSuggestionWindow.orderOut(nil) - // Get selected backend preference - let preference = Config.AIBackendPreference().value - - // If backend is off, do nothing - if preference == .off { - self.segmentsManager.appendDebugMessage("AI backend is off, skipping suggestion") - return - } - guard let converterServerSnapshot, !converterServerSnapshot.isEmpty else { self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: skipped because converter server composition is empty") return } - let composingText = converterServerSnapshot.convertTarget - - // プロンプトを取得 - let prompt = self.getLeftSideContext(maxCount: 100) ?? "" - - self.segmentsManager.appendDebugMessage("プロンプト取得成功: \(prompt) << \(composingText)") - - let apiKey = Config.OpenAiApiKey().value - let modelName = Config.OpenAiModelName().value - let request = OpenAIRequest(prompt: prompt, target: composingText, modelName: modelName) - self.segmentsManager.appendDebugMessage("APIリクエスト準備完了: prompt=\(prompt), target=\(composingText), modelName=\(modelName)") - - // Get selected backend - let backend: AIBackend - switch preference { - case .off: - // Already checked above, but defensive programming - self.segmentsManager.appendDebugMessage("Unexpected .off state in backend selection") + guard self.syncConverterServerSessionConfigSync() else { + self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: skipped because session config sync failed") return - case .foundationModels: - backend = .foundationModels - case .openAI: - backend = .openAI - } - self.segmentsManager.appendDebugMessage("Using backend: \(backend.rawValue)") - - // 非同期タスクでリクエストを送信 - Task { - do { - self.segmentsManager.appendDebugMessage("APIリクエスト送信中...") - let predictions = try await AIClient.sendRequest( - request, - backend: backend, - apiKey: apiKey, - apiEndpoint: Config.OpenAiApiEndpoint().value, - logger: { [weak self] message in - self?.segmentsManager.appendDebugMessage(message) + } + let leftSideContext = self.getLeftSideContext(maxCount: 100) + self.converterServerClient.sendIfSessionOpen( + { .requestReplaceSuggestion(sessionID: $0, leftSideContext: leftSideContext) }, + completion: { [weak self] response in + Task { @MainActor in + guard let self else { + return } - ) - self.segmentsManager.appendDebugMessage("APIレスポンス受信成功: \(predictions)") - - // String配列からCandidate配列に変換 - let candidates = predictions.map { text in - Candidate( - text: text, - value: PValue(0), - composingCount: .surfaceCount(composingText.count), - lastMid: 0, - data: [], - actions: [], - inputable: true - ) - } - - self.segmentsManager.appendDebugMessage("候補変換成功: \(candidates.map { $0.text })") - - // 候補をウィンドウに更新 - await MainActor.run { - guard self.converterServerSnapshot?.convertTarget == composingText else { - self.segmentsManager.appendDebugMessage("候補ウィンドウ更新をスキップ: composition changed") + guard let response else { + self.showReplaceSuggestionError(message: "ConverterServerから候補を取得できませんでした") return } - self.segmentsManager.appendDebugMessage("候補ウィンドウ更新中...") - if !candidates.isEmpty { - self.segmentsManager.setReplaceSuggestions(candidates) - self.replaceSuggestionsViewController.updateCandidatePresentations( - candidates.map { .init(candidate: $0) }, - selectionIndex: nil, - cursorLocation: getCursorLocation() - ) - self.replaceSuggestionWindow.setIsVisible(true) - self.replaceSuggestionWindow.makeKeyAndOrderFront(nil) - self.segmentsManager.appendDebugMessage("候補ウィンドウ更新完了") + guard self.converterServerSnapshot?.convertTarget == response.snapshot.convertTarget else { + self.segmentsManager.appendDebugMessage("候補ウィンドウ更新をスキップ: composition changed") + return } - } - } catch { - let errorMessage = "APIリクエストエラー: \(error.localizedDescription)" - self.segmentsManager.appendDebugMessage(errorMessage) - - // ユーザーに通知 - await MainActor.run { - let alert = NSAlert() - alert.messageText = "変換に失敗しました" - alert.informativeText = error.localizedDescription - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() + self.converterServerSnapshot = response.snapshot + self.inputState = response.inputState.inputState + self.refreshMarkedText() + self.refreshReplaceSuggestionWindow() } } - } + ) self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: 終了") } @@ -930,14 +914,22 @@ extension azooKeyMacInputController { } @MainActor func submitSelectedSuggestionCandidate() { - if let candidate = self.replaceSuggestionsViewController.getSelectedCandidate() { - if let client = self.client() { - client.insertText(candidate.text, replacementRange: NSRange(location: NSNotFound, length: 0)) - self.replaceSuggestionWindow.setIsVisible(false) - self.replaceSuggestionWindow.orderOut(nil) - self.finishReplaceSuggestionComposition() + guard let response = self.converterServerClient.sendIfSessionOpenSync({ + .submitSelectedReplaceSuggestion(sessionID: $0) + }) else { + return + } + self.converterServerSnapshot = response.snapshot + if let client = self.client() { + for effect in response.effects { + self.apply(effect, client: client) } } + self.inputState = response.inputState.inputState + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + self.refreshReplaceSuggestionWindow() } @MainActor private func finishReplaceSuggestionComposition() { @@ -948,6 +940,7 @@ extension azooKeyMacInputController { self.refreshMarkedText() self.refreshCandidateWindow() self.refreshPredictionWindow() + self.refreshReplaceSuggestionWindow() } // MARK: - Helper Methods diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index cd1b94fa..1b881286 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -54,14 +54,7 @@ extension azooKeyMacInputController { self.showPromptInputWindow() return } - switch self.inputState { - case .composing, .replaceSuggestion: - self.requestReplaceSuggestion() - case .none: - _ = self.requestPredictiveSuggestionWithConverterServer(client: client) - default: - break - } + _ = self.requestPredictiveSuggestionWithConverterServer(client: client) } @MainActor @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {