diff --git a/Core/Package.swift b/Core/Package.swift index e42d2b6a..f60cf366 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -10,47 +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"] - ) - ], + 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" - ), - .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/ConverterServer+KeyEvent.swift b/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift new file mode 100644 index 00000000..615245e0 --- /dev/null +++ b/Core/Sources/ConverterServer/ConverterServer+KeyEvent.swift @@ -0,0 +1,348 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +extension ConverterServer { + @MainActor + func handleKeyEvent( + sessionID: String, + request: ConverterKeyEventRequest + ) throws -> ConverterServerResponse { + let session = try getSession(sessionID) + session.setContext(request.context) + 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, + typeBackSlash: request.typeBackSlash + ) + 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, 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, inputState: nextInputState) + ) + } + + @MainActor + // swiftlint:disable:next cyclomatic_complexity function_body_length + 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) + let leftSideContext = session.conversionLeftSideContext() + 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 .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: 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: leftSideContext, effects: &effects) + manager.requestResettingSelection() + case .selectInputLanguage(let language): + inputLanguage = language + effects.append(.switchInputLanguage(language)) + case .commitMarkedTextAndSelectInputLanguage(let language): + let text = manager.commitMarkedText(inputState: inputState) + if !text.isEmpty { + effects.append(.insertText(text)) + } + 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: leftSideContext, effects: &effects) + case .submitHiraganaCandidate: + submitTransformedCandidate(.hiragana, manager: manager, inputState: inputState, leftSideContext: leftSideContext, effects: &effects) + case .submitHankakuKatakanaCandidate: + submitTransformedCandidate(.halfWidthKatakana, manager: manager, inputState: inputState, leftSideContext: leftSideContext, effects: &effects) + case .submitFullWidthRomanCandidate: + submitTransformedCandidate(.fullWidthRoman, manager: manager, inputState: inputState, leftSideContext: leftSideContext, effects: &effects) + case .submitHalfWidthRomanCandidate: + submitTransformedCandidate(.halfWidthRoman, manager: manager, inputState: inputState, leftSideContext: leftSideContext, effects: &effects) + case .requestPredictiveSuggestion: + manager.insertAtCursorPosition("つづき", inputStyle: inputStyle) + effects.append(.requestReplaceSuggestion) + case .acceptPredictionCandidate: + acceptPredictionCandidate(manager: manager, leftSideContext: leftSideContext) + case .requestReplaceSuggestion: + session.clearReplaceSuggestions() + effects.append(.requestReplaceSuggestion) + case .selectNextReplaceSuggestionCandidate: + session.selectNextReplaceSuggestion() + case .selectPrevReplaceSuggestionCandidate: + session.selectPreviousReplaceSuggestion() + case .submitReplaceSuggestionCandidate: + _ = submitSelectedReplaceSuggestion(session: session, effects: &effects) + case .hideReplaceSuggestionWindow: + session.clearReplaceSuggestions() + 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 .submitSelectedCandidateAndEnterUnicodeInputMode: + submitSelectedCandidate(manager: manager, leftSideContext: leftSideContext, effects: &effects) + if !manager.isEmpty { + effects.append(.insertText(manager.convertTarget)) + manager.stopComposition() + } + case .stopComposition: + manager.stopComposition() + } + return true + } + + @MainActor + 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 + func commitMarkedTextAndContinue( + manager: SegmentsManager, + inputState: InputState, + effects: inout [ConverterClientEffect] + ) { + let text = manager.commitMarkedText(inputState: inputState) + if !text.isEmpty { + effects.append(.insertText(text)) + } + } + + @MainActor + 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 + 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 + func requestReplaceSuggestion( + session: ConverterSession + ) 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 ? session.replaceSuggestionPromptContext() : "" + 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 + 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 + 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) + } +} diff --git a/Core/Sources/ConverterServer/ConverterServer+Settings.swift b/Core/Sources/ConverterServer/ConverterServer+Settings.swift new file mode 100644 index 00000000..e5229eff --- /dev/null +++ b/Core/Sources/ConverterServer/ConverterServer+Settings.swift @@ -0,0 +1,297 @@ +import Core + +extension ConverterServer { + @MainActor + static func makeSettingDescriptors( + capabilities: ConverterSettingClientCapabilities + ) -> [ConverterSettingDescriptor] { + func descriptor( + key: String, + title: String, + section: String, + kind: ConverterSettingKind, + value: ConverterSettingValue? = nil, + isEnabled: Bool = true + ) -> ConverterSettingDescriptor { + ConverterSettingDescriptor( + key: key, + title: title, + section: section, + kind: kind, + value: value, + isEnabled: isEnabled, + requiresClientUpdate: requiresClientUpdate(kind: kind, capabilities: capabilities) + ) + } + + return [ + descriptor( + key: Config.AIBackendPreference.key, + title: "いい感じ変換", + section: "基本", + kind: .selector(options: [ + .init(title: "オフ", value: .string(Config.AIBackendPreference.Value.off.rawValue)), + .init(title: "Foundation Models", value: .string(Config.AIBackendPreference.Value.foundationModels.rawValue)), + .init(title: "OpenAI API", value: .string(Config.AIBackendPreference.Value.openAI.rawValue)) + ]), + value: .string(Config.AIBackendPreference().value.rawValue) + ), + descriptor( + key: Config.LiveConversion.key, + title: "ライブ変換", + section: "変換設定", + kind: .toggle, + value: .bool(Config.LiveConversion().value) + ), + descriptor( + key: Config.TypeBackSlash.key, + title: "円記号の代わりにバックスラッシュを入力", + section: "入力オプション", + kind: .toggle, + value: .bool(Config.TypeBackSlash().value) + ), + descriptor( + key: Config.TypeHalfSpace.key, + title: "スペースは常に半角を入力", + section: "入力オプション", + kind: .toggle, + value: .bool(Config.TypeHalfSpace().value) + ), + descriptor( + key: Config.OptionDirectFullWidthInput.key, + title: "Optionキーで直接全角英数を入力", + section: "入力オプション", + kind: .toggle, + value: .bool(Config.OptionDirectFullWidthInput().value) + ), + descriptor( + key: Config.PunctuationStyle.key, + title: "句読点の種類", + section: "入力オプション", + kind: .selector(options: [ + .init(title: "、と。", value: .int(Config.PunctuationStyle.Value.kutenAndToten.rawValue)), + .init(title: "、と.", value: .int(Config.PunctuationStyle.Value.periodAndToten.rawValue)), + .init(title: ",と。", value: .int(Config.PunctuationStyle.Value.kutenAndComma.rawValue)), + .init(title: ",と.", value: .int(Config.PunctuationStyle.Value.periodAndComma.rawValue)) + ]), + value: .int(Config.PunctuationStyle().value.rawValue) + ), + descriptor( + key: Config.Learning.key, + title: "履歴学習", + section: "履歴学習", + kind: .selector(options: [ + .init(title: "学習する", value: .string(Config.Learning.Value.inputAndOutput.rawValue)), + .init(title: "学習を停止", value: .string(Config.Learning.Value.onlyOutput.rawValue)), + .init(title: "学習を無視", value: .string(Config.Learning.Value.nothing.rawValue)) + ]), + value: .string(Config.Learning().value.rawValue) + ), + descriptor( + key: "dev.ensan.inputmethod.azooKeyMac.setting.action.resetLearningData", + title: "履歴学習データをリセット", + section: "履歴学習", + kind: .button(action: "resetLearningData") + ), + descriptor( + key: Config.InputStyle.key, + title: "入力方式", + section: "入力方式", + kind: .custom(surface: "inputStyle") + ), + descriptor( + key: Config.KeyboardLayout.key, + title: "キーボード配列", + section: "キーボード配列", + kind: .selector(options: [ + .init(title: "QWERTY", value: .string(Config.KeyboardLayout.Value.qwerty.rawValue)), + .init(title: "Australian", value: .string(Config.KeyboardLayout.Value.australian.rawValue)), + .init(title: "British", value: .string(Config.KeyboardLayout.Value.british.rawValue)), + .init(title: "Colemak", value: .string(Config.KeyboardLayout.Value.colemak.rawValue)), + .init(title: "Dvorak", value: .string(Config.KeyboardLayout.Value.dvorak.rawValue)), + .init(title: "Dvorak - QWERTY Command", value: .string(Config.KeyboardLayout.Value.dvorakQwertyCommand.rawValue)) + ]), + value: .string(Config.KeyboardLayout().value.rawValue) + ), + descriptor( + key: Config.ZenzaiProfile.key, + title: "変換プロフィール", + section: "変換設定", + kind: .textField(secure: false), + value: .string(Config.ZenzaiProfile().value) + ), + descriptor( + key: Config.ZenzaiInferenceLimit.key, + title: "Zenzaiの推論上限", + section: "変換設定", + kind: .number(min: 1, max: 50, step: 1), + value: .int(Config.ZenzaiInferenceLimit().value) + ), + descriptor( + key: Config.ZenzaiPersonalizationLevel.key, + title: "パーソナライズ", + section: "開発者向け設定", + kind: .selector(options: [ + .init(title: "オフ", value: .string(Config.ZenzaiPersonalizationLevel.Value.off.rawValue)), + .init(title: "弱く", value: .string(Config.ZenzaiPersonalizationLevel.Value.soft.rawValue)), + .init(title: "普通", value: .string(Config.ZenzaiPersonalizationLevel.Value.normal.rawValue)), + .init(title: "強く", value: .string(Config.ZenzaiPersonalizationLevel.Value.hard.rawValue)) + ]), + value: .string(Config.ZenzaiPersonalizationLevel().value.rawValue) + ), + descriptor( + key: Config.DebugPredictiveTyping.key, + title: "開発中の予測入力を有効化", + section: "開発者向け設定", + kind: .toggle, + value: .bool(Config.DebugPredictiveTyping().value) + ), + descriptor( + key: Config.DebugTypoCorrection.key, + title: "開発中の入力訂正を有効化", + section: "開発者向け設定", + kind: .toggle, + value: .bool(Config.DebugTypoCorrection().value) + ), + descriptor( + key: "dev.ensan.inputmethod.azooKeyMac.setting.action.downloadDebugTypoCorrectionWeights", + title: "入力訂正の重みをダウンロード", + section: "開発者向け設定", + kind: .button(action: "downloadDebugTypoCorrectionWeights") + ), + descriptor( + key: Config.UserDictionary.key, + title: "ユーザー辞書", + section: "辞書設定", + kind: .custom(surface: "userDictionary") + ), + descriptor( + key: Config.SystemUserDictionary.key, + title: "システム辞書", + section: "辞書設定", + kind: .custom(surface: "systemUserDictionary") + ), + descriptor( + key: "dev.ensan.inputmethod.azooKeyMac.setting.surface.foundationModelsAvailability", + title: "Foundation Models availability", + section: "基本", + kind: .custom(surface: "foundationModelsAvailability") + ), + descriptor( + key: Config.OpenAiModelName.key, + title: "OpenAIモデル名", + section: "OpenAI API", + kind: .textField(secure: false), + value: .string(Config.OpenAiModelName().value.isEmpty ? Config.OpenAiModelName.default : Config.OpenAiModelName().value) + ), + descriptor( + key: Config.OpenAiApiEndpoint.key, + title: "OpenAI APIエンドポイント", + section: "OpenAI API", + kind: .textField(secure: false), + value: .string(Config.OpenAiApiEndpoint().value) + ), + descriptor( + key: "dev.ensan.inputmethod.azooKeyMac.preference.OpenAiApiKey", + title: "OpenAI APIキー", + section: "OpenAI API", + kind: .textField(secure: true) + ) + ] + } + + private static func requiresClientUpdate( + kind: ConverterSettingKind, + capabilities: ConverterSettingClientCapabilities + ) -> Bool { + guard capabilities.supportedKinds.contains(kind.identifier) else { + return true + } + switch kind { + case .button(let action): + return !capabilities.supportedActions.contains(action) + case .custom(let surface): + return !capabilities.supportedCustomSurfaces.contains(surface) + case .toggle, .selector, .textField, .number: + return false + } + } + + @MainActor + // swiftlint:disable:next cyclomatic_complexity + static func updateSetting(key: String, value: ConverterSettingValue) throws { + switch key { + case Config.AIBackendPreference.key: + guard case .string(let rawValue) = value, + let backend = Config.AIBackendPreference.Value(rawValue: rawValue) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.AIBackendPreference().value = backend + case Config.LiveConversion.key: + Config.LiveConversion().value = try boolSettingValue(value, key: key) + case Config.TypeBackSlash.key: + Config.TypeBackSlash().value = try boolSettingValue(value, key: key) + case Config.TypeHalfSpace.key: + Config.TypeHalfSpace().value = try boolSettingValue(value, key: key) + case Config.OptionDirectFullWidthInput.key: + Config.OptionDirectFullWidthInput().value = try boolSettingValue(value, key: key) + case Config.PunctuationStyle.key: + guard case .int(let rawValue) = value, + let punctuationStyle = Config.PunctuationStyle.Value(rawValue: rawValue) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.PunctuationStyle().value = punctuationStyle + case Config.Learning.key: + guard case .string(let rawValue) = value, + let learning = Config.Learning.Value(rawValue: rawValue) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.Learning().value = learning + case Config.KeyboardLayout.key: + guard case .string(let rawValue) = value, + let layout = Config.KeyboardLayout.Value(rawValue: rawValue) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.KeyboardLayout().value = layout + case Config.ZenzaiProfile.key: + guard case .string(let value) = value else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.ZenzaiProfile().value = value + case Config.ZenzaiInferenceLimit.key: + guard case .int(let value) = value, (1 ... 50).contains(value) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.ZenzaiInferenceLimit().value = value + case Config.ZenzaiPersonalizationLevel.key: + guard case .string(let rawValue) = value, + let level = Config.ZenzaiPersonalizationLevel.Value(rawValue: rawValue) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.ZenzaiPersonalizationLevel().value = level + case Config.DebugPredictiveTyping.key: + Config.DebugPredictiveTyping().value = try boolSettingValue(value, key: key) + case Config.DebugTypoCorrection.key: + Config.DebugTypoCorrection().value = try boolSettingValue(value, key: key) + case Config.OpenAiModelName.key: + guard case .string(let value) = value else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.OpenAiModelName().value = value + case Config.OpenAiApiEndpoint.key: + guard case .string(let value) = value else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.OpenAiApiEndpoint().value = value + default: + throw ConverterServerError.unknownSetting(key) + } + } + + private static func boolSettingValue(_ value: ConverterSettingValue, key: String) throws -> Bool { + guard case .bool(let boolValue) = value else { + throw ConverterServerError.invalidSettingValue(key) + } + return boolValue + } +} diff --git a/Core/Sources/ConverterServer/ConverterServer+Snapshot.swift b/Core/Sources/ConverterServer/ConverterServer+Snapshot.swift new file mode 100644 index 00000000..1f97b4ef --- /dev/null +++ b/Core/Sources/ConverterServer/ConverterServer+Snapshot.swift @@ -0,0 +1,147 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +enum ConverterCandidateTransform { + case hiragana + case katakana + case halfWidthKatakana + case fullWidthRoman + case halfWidthRoman +} + +extension ConverterServer { + @MainActor + func makeResponse( + for session: ConverterSession, + inputState: InputState, + handled: Bool = true, + effects: [ConverterClientEffect] = [], + settings: [ConverterSettingDescriptor] = [], + responseInputState: ConverterInputState? = nil + ) -> ConverterServerResponse { + ConverterServerResponse( + handled: handled, + effects: effects, + inputState: responseInputState ?? ConverterInputState(inputState), + settings: settings, + snapshot: snapshot(for: session, inputState: inputState) + ) + } + + @MainActor + func snapshot(for session: ConverterSession, inputState: InputState) -> ConverterSessionSnapshot { + let manager = session.manager + if manager.isEmpty { + return .empty + } + 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: + 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 + ) + } + 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, + replaceSuggestionCandidates: session.replaceSuggestions.map { + ConverterCandidatePresentation(CandidatePresentation(candidate: $0)) + }, + replaceSuggestionSelectionIndex: session.replaceSuggestionSelectionIndex, + isEmpty: manager.isEmpty, + convertTarget: manager.convertTarget + ) + } + + @MainActor + static func makeSegmentsManager() -> SegmentsManager { + CustomInputTableStore.registerIfExists() + let containerURL = AppGroup.containerURL() + return SegmentsManager( + kanaKanjiConverter: KanaKanjiConverter.withDefaultDictionary(), + applicationDirectoryURL: AppGroup.memoryDirectoryURL(), + containerURL: containerURL, + context: .init(useZenzai: true, resourcesDirectoryURL: appResourcesDirectoryURL()) + ) + } + + 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 + static func resolveInputStyle(_ inputStyle: ConverterInputStyle) -> InputStyle { + if case .tableName(CustomInputTableStore.tableName) = inputStyle, + !CustomInputTableStore.registerIfExists() { + return .mapped(id: .defaultRomanToKana) + } + return inputStyle.inputStyle + } + + @MainActor + 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)! + } + } + } +} diff --git a/Core/Sources/ConverterServer/ConverterServerError.swift b/Core/Sources/ConverterServer/ConverterServerError.swift new file mode 100644 index 00000000..09d84a68 --- /dev/null +++ b/Core/Sources/ConverterServer/ConverterServerError.swift @@ -0,0 +1,18 @@ +import Foundation + +enum ConverterServerError: LocalizedError { + case unknownSession(String) + case unknownSetting(String) + case invalidSettingValue(String) + + var errorDescription: String? { + switch self { + case .unknownSession(let sessionID): + "Unknown converter session: \(sessionID)" + case .unknownSetting(let key): + "Unknown converter setting: \(key)" + case .invalidSettingValue(let key): + "Invalid converter setting value: \(key)" + } + } +} diff --git a/Core/Sources/ConverterServer/ConverterSession.swift b/Core/Sources/ConverterServer/ConverterSession.swift new file mode 100644 index 00000000..be57f0d3 --- /dev/null +++ b/Core/Sources/ConverterServer/ConverterSession.swift @@ -0,0 +1,98 @@ +import Core +import KanaKanjiConverterModuleWithDefaultDictionary + +final class ConverterSession: SegmentManagerDelegate { + static let conversionContextLength = 30 + static let replaceSuggestionContextLength = 100 + + let manager: SegmentsManager + private var context = ConverterTextContext() + 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 + self.manager.delegate = self + } + + func setContext(_ context: ConverterTextContext) { + self.context = context + } + + func getLeftSideContext(maxCount: Int) -> String? { + guard let leftSideContext = context.leftSideContext else { + return nil + } + return String(leftSideContext.suffix(maxCount)) + } + + func getRightSideContext(maxCount: Int) -> String? { + guard let rightSideContext = context.rightSideContext else { + return nil + } + return String(rightSideContext.prefix(maxCount)) + } + + func conversionLeftSideContext() -> String? { + getLeftSideContext(maxCount: Self.conversionContextLength) + } + + func replaceSuggestionPromptContext() -> String { + let leftSideContext = getLeftSideContext(maxCount: Self.replaceSuggestionContextLength) ?? "" + let rightSideContext = getRightSideContext(maxCount: Self.replaceSuggestionContextLength) ?? "" + guard !leftSideContext.isEmpty || !rightSideContext.isEmpty else { + return "" + } + return [ + leftSideContext.isEmpty ? nil : "Text before: ...\(leftSideContext)", + rightSideContext.isEmpty ? nil : "Text after: \(rightSideContext)..." + ] + .compactMap(\.self) + .joined(separator: "\n") + } + + 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] + } +} diff --git a/Core/Sources/ConverterServer/main.swift b/Core/Sources/ConverterServer/main.swift new file mode 100644 index 00000000..2cd2f64b --- /dev/null +++ b/Core/Sources/ConverterServer/main.swift @@ -0,0 +1,226 @@ +import Core +import Darwin +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +private enum ConverterServerXPC { + static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" +} + +@objc private protocol ConverterServerXPCProtocol { + func openSession(with reply: @escaping @Sendable (String) -> Void) + func closeSession(_ sessionID: String, with reply: @escaping @Sendable (Bool) -> Void) + func handleCommand(_ data: Data, with reply: @escaping @Sendable (Data?, NSString?) -> Void) + func ping(_ message: String, with reply: @escaping @Sendable (String) -> Void) +} + +final class ConverterServer: NSObject, ConverterServerXPCProtocol, @unchecked Sendable { + private var sessions: [String: ConverterSession] = [:] + + 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) { + 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) async throws -> ConverterServerResponse { + switch command { + case .shutdown: + Self.scheduleShutdown() + return ConverterServerResponse(snapshot: .empty) + case .session(let sessionID, let command): + return try await handle(command, sessionID: sessionID) + } + } + + @MainActor + private func handle(_ command: ConverterSessionCommand, sessionID: String) async throws -> ConverterServerResponse { + let session = try getSession(sessionID) + switch command { + case .lifecycle(let command): + return handle(command, session: session) + case .settings(let command): + return try handle(command, session: session) + case .updateConfig(let config): + session.config = config + return makeResponse(for: session, inputState: .none) + case .handleKeyEvent(let request): + return try handleKeyEvent(sessionID: sessionID, request: request) + case .composition(let command): + return handle(command, session: session) + case .candidate(let command): + return handle(command, session: session) + case .replaceSuggestion(let command): + return try await handle(command, session: session) + } + } + + @MainActor + private func handle( + _ command: ConverterSessionLifecycleCommand, + session: ConverterSession + ) -> ConverterServerResponse { + switch command { + case .activate: + session.manager.activate() + return makeResponse(for: session, inputState: .none) + case .deactivate: + session.manager.deactivate() + return makeResponse(for: session, inputState: .none) + } + } + + @MainActor + private func handle( + _ command: ConverterSettingsCommand, + session: ConverterSession + ) throws -> ConverterServerResponse { + switch command { + case .list(let capabilities): + return makeResponse( + for: session, + inputState: .none, + settings: Self.makeSettingDescriptors(capabilities: capabilities) + ) + case .update(let key, let value): + try Self.updateSetting(key: key, value: value) + return makeResponse(for: session, inputState: .none) + } + } + + @MainActor + private func handle( + _ command: ConverterCompositionCommand, + session: ConverterSession + ) -> ConverterServerResponse { + switch command { + case .snapshot(let inputState): + return makeResponse(for: session, inputState: inputState.inputState) + case .stopComposition: + session.manager.stopComposition() + return makeResponse(for: session, inputState: .none) + case .forgetMemory: + session.manager.forgetMemory() + return makeResponse(for: session, inputState: .none) + case .commit(let inputState): + 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 handle( + _ command: ConverterCandidateCommand, + session: ConverterSession + ) -> ConverterServerResponse { + switch command { + case .selectCandidate(let index): + session.manager.requestSelectingRow(index) + return makeResponse(for: session, inputState: .selecting) + case .submitSelectedCandidate(let context): + session.setContext(context) + var effects: [ConverterClientEffect] = [] + submitSelectedCandidate( + manager: session.manager, + leftSideContext: session.conversionLeftSideContext(), + effects: &effects + ) + let nextInputState: InputState = session.manager.isEmpty ? .none : .previewing + return makeResponse( + for: session, + inputState: nextInputState, + effects: effects, + responseInputState: ConverterInputState(nextInputState) + ) + } + } + + @MainActor + private func handle( + _ command: ConverterReplaceSuggestionCommand, + session: ConverterSession + ) async throws -> ConverterServerResponse { + switch command { + case .request(let context): + session.setContext(context) + try await requestReplaceSuggestion(session: session) + return makeResponse(for: session, inputState: .replaceSuggestion, responseInputState: .replaceSuggestion) + case .selectReplaceSuggestionCandidate(let index): + session.selectReplaceSuggestion(at: index) + return makeResponse(for: session, inputState: .replaceSuggestion, responseInputState: .replaceSuggestion) + case .submitSelectedReplaceSuggestion: + 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) + ) + } + } + + private static func scheduleShutdown() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + exit(EXIT_SUCCESS) + } + } + + @MainActor + func getSession(_ sessionID: String) throws -> ConverterSession { + guard let session = sessions[sessionID] else { + throw ConverterServerError.unknownSession(sessionID) + } + return session + } + +} + +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/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/Configs/BoolConfigItem.swift b/Core/Sources/Core/Configs/BoolConfigItem.swift index 3c0b7b20..5f92529e 100644 --- a/Core/Sources/Core/Configs/BoolConfigItem.swift +++ b/Core/Sources/Core/Configs/BoolConfigItem.swift @@ -7,14 +7,14 @@ protocol BoolConfigItem: ConfigItem { extension BoolConfigItem { public var value: Bool { get { - if let value = UserDefaults.standard.object(forKey: Self.key) { + if let value = Config.object(forKey: Self.key) { value as? Bool ?? Self.default } else { Self.default } } nonmutating set { - UserDefaults.standard.set(newValue, forKey: Self.key) + Config.set(newValue, forKey: Self.key) } } } diff --git a/Core/Sources/Core/Configs/Config.swift b/Core/Sources/Core/Configs/Config.swift index aea00864..bb29321f 100644 --- a/Core/Sources/Core/Configs/Config.swift +++ b/Core/Sources/Core/Configs/Config.swift @@ -1,7 +1,40 @@ import Foundation /// namespace for `Config` -public enum Config {} +public enum Config { + nonisolated(unsafe) public static let userDefaults: UserDefaults = { + #if os(macOS) + UserDefaults(suiteName: AppGroup.azooKeyMacIdentifier) ?? .standard + #else + .standard + #endif + }() + + public static func object(forKey key: String) -> Any? { + if let value = Self.userDefaults.object(forKey: key) { + return value + } + return UserDefaults.standard.object(forKey: key) + } + + public static func data(forKey key: String) -> Data? { + if let value = Self.userDefaults.data(forKey: key) { + return value + } + return UserDefaults.standard.data(forKey: key) + } + + public static func string(forKey key: String) -> String? { + if let value = Self.userDefaults.string(forKey: key) { + return value + } + return UserDefaults.standard.string(forKey: key) + } + + public static func set(_ value: Any?, forKey key: String) { + Self.userDefaults.set(value, forKey: key) + } +} public protocol ConfigItem { static var key: String { get } diff --git a/Core/Sources/Core/Configs/CustomCodableConfigItem.swift b/Core/Sources/Core/Configs/CustomCodableConfigItem.swift index d85462d5..a9332c8b 100644 --- a/Core/Sources/Core/Configs/CustomCodableConfigItem.swift +++ b/Core/Sources/Core/Configs/CustomCodableConfigItem.swift @@ -16,7 +16,7 @@ protocol CustomCodableConfigItem: ConfigItem { extension CustomCodableConfigItem { public var value: Value { get { - guard let data = UserDefaults.standard.data(forKey: Self.key) else { + guard let data = Config.data(forKey: Self.key) else { print(#file, #line, "data is not set yet") return Self.default } @@ -31,7 +31,7 @@ extension CustomCodableConfigItem { nonmutating set { do { let encoded = try JSONEncoder().encode(newValue) - UserDefaults.standard.set(encoded, forKey: Self.key) + Config.set(encoded, forKey: Self.key) } catch { print(#file, #line, error) } @@ -218,7 +218,7 @@ extension Config { public static var `default`: Value { // Migration: If user had OpenAI API enabled, preserve that setting let legacyKey = Config.Deprecated.EnableOpenAiApiKey.key - if let legacyValue = UserDefaults.standard.object(forKey: legacyKey) as? Bool, + if let legacyValue = Config.object(forKey: legacyKey) as? Bool, legacyValue { return .openAI } 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/Configs/IntConfigItem.swift b/Core/Sources/Core/Configs/IntConfigItem.swift index e031adb0..b871ff84 100644 --- a/Core/Sources/Core/Configs/IntConfigItem.swift +++ b/Core/Sources/Core/Configs/IntConfigItem.swift @@ -7,14 +7,14 @@ protocol IntConfigItem: ConfigItem { extension IntConfigItem { public var value: Int { get { - if let value = UserDefaults.standard.object(forKey: Self.key) { + if let value = Config.object(forKey: Self.key) { value as? Int ?? Self.default } else { Self.default } } nonmutating set { - UserDefaults.standard.set(newValue, forKey: Self.key) + Config.set(newValue, forKey: Self.key) } } } diff --git a/Core/Sources/Core/Configs/KeyboardShortcutConfigItem.swift b/Core/Sources/Core/Configs/KeyboardShortcutConfigItem.swift index 27bd76d9..d9e27cfc 100644 --- a/Core/Sources/Core/Configs/KeyboardShortcutConfigItem.swift +++ b/Core/Sources/Core/Configs/KeyboardShortcutConfigItem.swift @@ -45,7 +45,7 @@ protocol KeyboardShortcutConfigItem: ConfigItem { extension KeyboardShortcutConfigItem { public var value: KeyboardShortcut { get { - guard let data = UserDefaults.standard.data(forKey: Self.key) else { + guard let data = Config.data(forKey: Self.key) else { return Self.default } do { @@ -58,7 +58,7 @@ extension KeyboardShortcutConfigItem { nonmutating set { do { let encoded = try JSONEncoder().encode(newValue) - UserDefaults.standard.set(encoded, forKey: Self.key) + Config.set(encoded, forKey: Self.key) } catch { // エンコード失敗時は何もしない } diff --git a/Core/Sources/Core/Configs/PuntuationConfigItem.swift b/Core/Sources/Core/Configs/PuntuationConfigItem.swift index a3f0d8cd..c0d93a8f 100644 --- a/Core/Sources/Core/Configs/PuntuationConfigItem.swift +++ b/Core/Sources/Core/Configs/PuntuationConfigItem.swift @@ -18,7 +18,7 @@ extension Config { extension Config.PunctuationStyle { public var value: Value { get { - guard let data = UserDefaults.standard.data(forKey: Self.key) else { + guard let data = Config.data(forKey: Self.key) else { print(#file, #line, "data is not set yet") // この場合、過去の設定を反映する return if Config.Deprecated.TypeCommaAndPeriod().value { @@ -38,7 +38,7 @@ extension Config.PunctuationStyle { nonmutating set { do { let encoded = try JSONEncoder().encode(newValue) - UserDefaults.standard.set(encoded, forKey: Self.key) + Config.set(encoded, forKey: Self.key) } catch { print(#file, #line, error) } diff --git a/Core/Sources/Core/Configs/StringConfigItem.swift b/Core/Sources/Core/Configs/StringConfigItem.swift index 1c235aff..0da10cfa 100644 --- a/Core/Sources/Core/Configs/StringConfigItem.swift +++ b/Core/Sources/Core/Configs/StringConfigItem.swift @@ -12,10 +12,10 @@ protocol StringConfigItem: ConfigItem {} extension StringConfigItem { public var value: String { get { - UserDefaults.standard.string(forKey: Self.key) ?? "" + Config.string(forKey: Self.key) ?? "" } nonmutating set { - UserDefaults.standard.set(newValue, forKey: Self.key) + Config.set(newValue, forKey: Self.key) } } } @@ -46,11 +46,11 @@ extension Config { public var value: String { get { - let stored = UserDefaults.standard.string(forKey: Self.key) ?? "" + let stored = Config.string(forKey: Self.key) ?? "" return stored.isEmpty ? Self.default : stored } nonmutating set { - UserDefaults.standard.set(newValue, forKey: Self.key) + Config.set(newValue, forKey: Self.key) } } } diff --git a/Core/Sources/Core/InputUtils/Actions/UserAction.swift b/Core/Sources/Core/InputUtils/Actions/UserAction.swift index 90f708cb..58230ef2 100644 --- a/Core/Sources/Core/InputUtils/Actions/UserAction.swift +++ b/Core/Sources/Core/InputUtils/Actions/UserAction.swift @@ -107,7 +107,12 @@ public enum UserAction { // この種のコードは複雑にしかならないので、lintを無効にする // swiftlint:disable:next cyclomatic_complexity - public static func getUserAction(eventCore: KeyEventCore, inputLanguage: InputLanguage) -> UserAction { + public static func getUserAction( + eventCore: KeyEventCore, + inputLanguage: InputLanguage, + typeBackSlash: Bool? = nil + ) -> UserAction { + let typeBackSlash = typeBackSlash ?? Config.TypeBackSlash().value // see: https://developer.mozilla.org/ja/docs/Web/API/UI_Events/Keyboard_event_code_values#mac_%E3%81%A7%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89%E5%80%A4 func keyMap(_ string: String, invertPunctuation: Bool = false) -> [InputPiece] { switch inputLanguage { @@ -161,13 +166,13 @@ public enum UserAction { case ("¥", [.shift, .option]), ("¥", [.shift]), ("\\", [.shift, .option]), ("\\", [.shift]): return .input(keyMap("|")) case ("¥", []), ("\\", []): - return if Config.TypeBackSlash().value { + return if typeBackSlash { .input(keyMap("\\")) } else { .input(keyMap("¥")) } case ("¥", [.option]), ("\\", [.option]): - return if Config.TypeBackSlash().value { + return if typeBackSlash { .input(keyMap("¥")) } else { .input(keyMap("\\")) 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/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 8b9769c5..8ecbcddb 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 [ @@ -120,6 +122,10 @@ public final class SegmentsManager { case other } + private enum ContextLength { + static let conversion = 30 + } + public func appendDebugMessage(_ string: String) { self.debugCandidates.insert( Candidate( @@ -136,12 +142,16 @@ public final class SegmentsManager { } } - private func zenzaiMode(leftSideContext: String?, requestRichCandidates: Bool) -> ConvertRequestOptions.ZenzaiMode { + private func zenzaiMode( + leftSideContext: String?, + rightSideContext: String?, + requestRichCandidates: Bool + ) -> ConvertRequestOptions.ZenzaiMode { if !self.context.useZenzai { 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, @@ -149,12 +159,23 @@ public final class SegmentsManager { .init( profile: Config.ZenzaiProfile().value, leftSideContext: leftSideContext, + rightSideContext: rightSideContext, enableAlignmentSeparator: true, ) ) ) } + 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))") @@ -167,12 +188,12 @@ public final class SegmentsManager { private func options( leftSideContext: String?, + rightSideContext: String?, requestRichCandidates: Bool, requireJapanesePrediction: ConvertRequestOptions.PredictionMode, requireEnglishPrediction: ConvertRequestOptions.PredictionMode ) -> ConvertRequestOptions { - let canUseDebugTypoCorrection = Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() - return .init( + .init( requireJapanesePrediction: requireJapanesePrediction, requireEnglishPrediction: requireEnglishPrediction, keyboardLanguage: .ja_JP, @@ -183,7 +204,11 @@ public final class SegmentsManager { sharedContainerURL: CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: self.azooKeyMemoryDir), textReplacer: .withDefaultEmojiDictionary(), specialCandidateProviders: KanaKanjiConverter.defaultSpecialCandidateProviders, - zenzaiMode: self.zenzaiMode(leftSideContext: leftSideContext, requestRichCandidates: requestRichCandidates), + zenzaiMode: self.zenzaiMode( + leftSideContext: leftSideContext, + rightSideContext: rightSideContext, + requestRichCandidates: requestRichCandidates + ), experimentalZenzaiPredictiveInput: true, typoCorrectionMode: .automatic, metadata: self.metadata @@ -441,7 +466,7 @@ public final class SegmentsManager { } public func getCleanLeftSideContext(maxCount: Int) -> String? { - self.delegate?.getLeftSideContext(maxCount: 30).map { + self.delegate?.getLeftSideContext(maxCount: maxCount).map { var last = $0.split(separator: "\n", omittingEmptySubsequences: false).last ?? $0[...] // 前方の空白を削除する while last.first?.isWhitespace ?? false { @@ -451,6 +476,17 @@ public final class SegmentsManager { } } + public func getCleanRightSideContext(maxCount: Int) -> String? { + self.delegate?.getRightSideContext(maxCount: maxCount).map { + var first = $0.split(separator: "\n", omittingEmptySubsequences: false).first ?? $0[...] + // 後方の空白を削除する + while first.last?.isWhitespace ?? false { + first = first.dropLast() + } + return String(first) + } + } + /// Updates the `self.rawCandidates` based on the current input context. /// /// This function is responsible for handling candidate conversion, @@ -463,7 +499,11 @@ public final class SegmentsManager { /// /// - Note: /// This function is executed on the `@MainActor` to ensure UI consistency. - @MainActor private func updateRawCandidate(requestRichCandidates: Bool = false, forcedLeftSideContext: String? = nil) { + @MainActor private func updateRawCandidate( + requestRichCandidates: Bool = false, + forcedLeftSideContext: String? = nil, + forcedRightSideContext: String? = nil + ) { if self.lastOperation != .delete { self.backspaceAdjustedPredictionCandidate = nil self.backspaceTypoCorrectionLock = nil @@ -507,11 +547,13 @@ public final class SegmentsManager { self.kanaKanjiConverter.importDynamicUserDictionary([], shortcuts: dynamicShortcuts) - let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30) + let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: ContextLength.conversion) + let rightSideContext = forcedRightSideContext ?? self.getCleanRightSideContext(maxCount: ContextLength.conversion) let result = self.kanaKanjiConverter.requestCandidates( self.composingText, options: options( leftSideContext: leftSideContext, + rightSideContext: rightSideContext, requestRichCandidates: requestRichCandidates, requireJapanesePrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled, requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled @@ -657,6 +699,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() } @@ -889,12 +936,13 @@ public final class SegmentsManager { return [] } - let leftSideContext = self.getCleanLeftSideContext(maxCount: 30) ?? "" + let leftSideContext = self.getCleanLeftSideContext(maxCount: ContextLength.conversion) ?? "" let typoCandidates = self.kanaKanjiConverter.experimentalRequestTypoCorrection( leftSideContext: leftSideContext, composingText: targetComposingText, options: options( leftSideContext: leftSideContext, + rightSideContext: nil, requestRichCandidates: false, requireJapanesePrediction: .disabled, requireEnglishPrediction: .disabled @@ -929,6 +977,7 @@ public final class SegmentsManager { composingText, options: options( leftSideContext: leftSideContext, + rightSideContext: nil, requestRichCandidates: false, requireJapanesePrediction: .disabled, requireEnglishPrediction: .disabled @@ -949,11 +998,11 @@ public final class SegmentsManager { let correctedDisplayText = self.convertedText( reading: correctedReading, - leftSideContext: self.getCleanLeftSideContext(maxCount: 30) + leftSideContext: self.getCleanLeftSideContext(maxCount: ContextLength.conversion) ) ?? correctedReading let previousComposingDisplayText = self.convertedText( reading: previousComposingText.convertTarget, - leftSideContext: self.getCleanLeftSideContext(maxCount: 30) + leftSideContext: self.getCleanLeftSideContext(maxCount: ContextLength.conversion) ) ?? previousComposingText.convertTarget guard Self.shouldPresentTypoCorrectionPredictionCandidate( candidateDisplayText: correctedDisplayText, @@ -1056,6 +1105,7 @@ public final class SegmentsManager { public protocol SegmentManagerDelegate: AnyObject { func getLeftSideContext(maxCount: Int) -> String? + func getRightSideContext(maxCount: Int) -> String? } private extension ComposingText { diff --git a/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift new file mode 100644 index 00000000..6d7a1ea8 --- /dev/null +++ b/Core/Sources/Core/XPC/ConverterServerXPCProtocol.swift @@ -0,0 +1,687 @@ +import Foundation +import KanaKanjiConverterModule + +/// Converter Process との通信で使う JSON codec。 +/// +/// XPC 自体には `Data` だけを流し、この型で `ConverterServerCommand` と +/// `ConverterServerResponse` へ変換する。これにより XPC の Objective-C +/// インターフェースと、Swift 側の Codable な通信契約を分離している。 +public enum ConverterServerCodec { + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + 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) + } +} + +/// Converter Process に送るトップレベルの命令。 +/// +/// セッションに属する処理は `session(sessionID:command:)` に集約し、 +/// プロセス全体に作用する処理だけをこの enum に直接置く。 +public enum ConverterServerCommand: Codable, Sendable { + /// 応答を返したあとで Converter Process を終了する。 + /// + /// LaunchAgent が KeepAlive 付きで登録されている場合は、終了後に launchd が + /// 新しい Converter Process を起動する。 + case shutdown + + /// 指定したセッションへ命令を配送する。 + case session(sessionID: String, command: ConverterSessionCommand) +} + +/// 1つの変換セッションに対する命令。 +/// +/// Client は IMK と UI に必要な副作用だけを担当し、変換状態、候補選択、設定値、 +/// AI 置換候補などの状態は Server が保持する。この enum はそれらの責務ごとに +/// 命令をまとめており、通信仕様上の入口を読みやすく保つために平坦な case 群にはしていない。 +public enum ConverterSessionCommand: Codable, Sendable { + /// セッションの開始・停止に関する命令。 + case lifecycle(ConverterSessionLifecycleCommand) + + /// Server が列挙する設定項目と、その値の更新に関する命令。 + case settings(ConverterSettingsCommand) + + /// API key など、Client が実行時に渡すセッション設定を更新する。 + case updateConfig(ConverterSessionConfig) + + /// 1つのキーイベントを処理し、状態 snapshot と Client 側で実行する副作用を返す。 + case handleKeyEvent(ConverterKeyEventRequest) + + /// marked text の変換状態を操作・取得する命令。 + case composition(ConverterCompositionCommand) + + /// 通常の変換候補ウィンドウに対する選択・確定命令。 + case candidate(ConverterCandidateCommand) + + /// AI 置換候補の生成・選択・確定命令。 + case replaceSuggestion(ConverterReplaceSuggestionCommand) +} + +/// 変換セッションのライフサイクル操作。 +public enum ConverterSessionLifecycleCommand: Codable, Sendable { + /// セッションが持つ `SegmentsManager` を有効化する。 + case activate + + /// セッションが持つ `SegmentsManager` を無効化する。 + case deactivate +} + +/// Converter Process が公開する汎用設定の操作。 +/// +/// UI の実体は Client が描画するが、どの設定を表示するかという要求は Server が返す。 +/// Client が未対応の種類は `requiresClientUpdate` 付きの descriptor として扱う。 +public enum ConverterSettingsCommand: Codable, Sendable { + /// Server が表示したい設定項目を列挙する。 + case list(capabilities: ConverterSettingClientCapabilities) + + /// 指定した設定キーの値を更新する。 + case update(key: String, value: ConverterSettingValue) +} + +/// marked text と変換メモリに関する操作。 +public enum ConverterCompositionCommand: Codable, Sendable { + /// Client が持つ入力状態をもとに、現在の Server 状態 snapshot を返す。 + case snapshot(inputState: ConverterInputState) + + /// テキストを挿入せず、現在の composition を終了する。 + case stopComposition + + /// 現在選択中の学習候補を忘却する。 + case forgetMemory + + /// 現在の marked text を確定し、必要なら `insertText` effect を返す。 + case commit(inputState: ConverterInputState) +} + +/// 通常の変換候補ウィンドウに対する操作。 +public enum ConverterCandidateCommand: Codable, Sendable { + /// 現在の候補ウィンドウで指定行を選択する。 + case selectCandidate(index: Int) + + /// 選択中の変換候補を確定する。 + case submitSelectedCandidate(context: ConverterTextContext) +} + +/// AI 置換候補に対する操作。 +public enum ConverterReplaceSuggestionCommand: Codable, Sendable { + /// 現在の composition に対する置換候補を生成する。 + case request(context: ConverterTextContext) + + /// 置換候補ウィンドウで指定行を選択する。 + case selectReplaceSuggestionCandidate(index: Int) + + /// 選択中の置換候補を確定する。 + case submitSelectedReplaceSuggestion +} + +/// Client が描画・実行できる汎用設定 UI の能力。 +/// +/// Server はこの情報を見て、Client がそのまま表示できる設定と、Client 更新が必要な設定を +/// 区別して descriptor を返す。 +public struct ConverterSettingClientCapabilities: Codable, Sendable, Equatable { + public var supportedKinds: Set + public var supportedActions: Set + public var supportedCustomSurfaces: Set + + public init( + supportedKinds: Set = [], + supportedActions: Set = [], + supportedCustomSurfaces: Set = [] + ) { + self.supportedKinds = supportedKinds + self.supportedActions = supportedActions + self.supportedCustomSurfaces = supportedCustomSurfaces + } +} + +/// 設定 UI の種類を表す安定した識別子。 +public enum ConverterSettingKindIdentifier: String, Codable, Sendable, Hashable { + case toggle + case selector + case textField + case number + case button + case custom +} + +/// Server が要求する設定 UI の形。 +/// +/// `toggle`、`selector`、`textField`、`number` は汎用描画できることを想定している。 +/// `button` と `custom` は Client 側の明示的な対応が必要な操作・画面を表す。 +public enum ConverterSettingKind: Codable, Sendable, Equatable { + case toggle + case selector(options: [ConverterSettingOption]) + case textField(secure: Bool) + case number(min: Double?, max: Double?, step: Double?) + case button(action: String) + case custom(surface: String) + + public var identifier: ConverterSettingKindIdentifier { + switch self { + case .toggle: + .toggle + case .selector: + .selector + case .textField: + .textField + case .number: + .number + case .button: + .button + case .custom: + .custom + } + } +} + +/// 汎用設定で扱う値。 +/// +/// 設定の永続化自体は既存の `Config` 型が担い、この型は XPC で値を運ぶための +/// cross-platform な表現として使う。 +public enum ConverterSettingValue: Codable, Sendable, Equatable { + case bool(Bool) + case string(String) + case int(Int) + case double(Double) +} + +/// selector 設定に表示する1つの選択肢。 +public struct ConverterSettingOption: Codable, Sendable, Equatable { + public var title: String + public var value: ConverterSettingValue + + public init(title: String, value: ConverterSettingValue) { + self.title = title + self.value = value + } +} + +/// Server が Client に表示を要求する1つの設定項目。 +/// +/// `requiresClientUpdate` が true の項目は、現在の Client では描画または操作できない。 +/// その場合でも descriptor を返すことで、新しい Server がどの設定を追加したいのかを +/// Client 側で認識できる。 +public struct ConverterSettingDescriptor: Codable, Sendable, Equatable { + public var key: String + public var title: String + public var section: String + public var kind: ConverterSettingKind + public var value: ConverterSettingValue? + public var isEnabled: Bool + public var requiresClientUpdate: Bool + + public init( + key: String, + title: String, + section: String, + kind: ConverterSettingKind, + value: ConverterSettingValue? = nil, + isEnabled: Bool = true, + requiresClientUpdate: Bool = false + ) { + self.key = key + self.title = title + self.section = section + self.kind = kind + self.value = value + self.isEnabled = isEnabled + self.requiresClientUpdate = requiresClientUpdate + } +} + +/// Client から Server へ渡すセッション単位の実行時設定。 +/// +/// Keychain や UI 状態など Client 側に残る情報を、必要な範囲だけ Server セッションへ同期する。 +/// 永続設定の一覧・更新は `ConverterSettingsCommand` が担当する。 +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 + } +} + +/// ログ出力時に値を伏せるための秘密文字列表現。 +/// +/// Codable では実値を運ぶが、`description` と `debugDescription` は `` を返す。 +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 + } +} + +/// 変換位置の前後にあるドキュメント文脈。 +/// +/// Client は macOS の text input API から取得できた範囲をこの型に詰めて Server に渡す。 +/// どの処理で何文字使うかは Server 側が決めるため、各 command には maxCount を持たせない。 +public struct ConverterTextContext: Codable, Sendable, Equatable { + /// XPC で運ぶ文脈の上限。 + /// + /// 実際に変換で使う長さは Server 側で用途別に切り詰める。 + public static let transportCharacterLimit = 200 + + public var leftSideContext: String? + public var rightSideContext: String? + + public init(leftSideContext: String? = nil, rightSideContext: String? = nil) { + self.leftSideContext = leftSideContext + self.rightSideContext = rightSideContext + } +} + +/// Client が受け取ったキーイベントと、その時点での IMK/UI 状態。 +/// +/// Server はこの情報だけを見て変換処理を進め、Client に必要な effect と snapshot を返す。 +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 enableOptionDirectFullWidthInput: Bool + public var typeBackSlash: Bool + public var optionDirectInputText: String? + public var context: ConverterTextContext + 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, + enableOptionDirectFullWidthInput: Bool = false, + typeBackSlash: Bool = false, + optionDirectInputText: String? = nil, + context: ConverterTextContext = .init(), + 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.enableOptionDirectFullWidthInput = enableOptionDirectFullWidthInput + self.typeBackSlash = typeBackSlash + self.optionDirectInputText = optionDirectInputText + self.context = context + self.visibleCandidateStartIndex = visibleCandidateStartIndex + } +} + +/// Server が Client に実行を依頼する副作用。 +/// +/// `setMarkedText` や候補ウィンドウの描画は snapshot から Client が行う。 +/// この enum は、アプリへの文字挿入、入力モード切替、Client 固有 UI の表示など、 +/// Server プロセス内では完結できない操作だけを表す。 +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 +} + +/// Converter Process から Client へ返す共通レスポンス。 +/// +/// 通常のキー処理、候補操作、設定取得などの応答をこの型にまとめる。 +/// Client は `effects` を順に実行したあと、`snapshot` をもとに marked text と +/// 候補ウィンドウを更新する。 +public struct ConverterServerResponse: Codable, Sendable { + public var handled: Bool + public var effects: [ConverterClientEffect] + public var inputState: ConverterInputState + public var inputLanguage: InputLanguage? + public var settings: [ConverterSettingDescriptor] + public var snapshot: ConverterSessionSnapshot + + public init( + handled: Bool = true, + effects: [ConverterClientEffect] = [], + inputState: ConverterInputState = .none, + inputLanguage: InputLanguage? = nil, + settings: [ConverterSettingDescriptor] = [], + snapshot: ConverterSessionSnapshot + ) { + self.handled = handled + self.effects = effects + self.inputState = inputState + self.inputLanguage = inputLanguage + self.settings = settings + self.snapshot = snapshot + } +} + +/// Client が UI 反映に使う Server セッションの現在状態。 +/// +/// marked text、候補ウィンドウ、予測候補、AI 置換候補をまとめた読み取り専用の状態表現。 +/// Client はこの snapshot を描画へ反映するだけで、変換状態の本体は Server 側に残す。 +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 + + public init( + 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 + } +} + +public extension ConverterSessionSnapshot { + static var empty: ConverterSessionSnapshot { + ConverterSessionSnapshot( + markedText: ConverterMarkedText( + SegmentsManager.MarkedText( + text: [], + selectionRange: NSRange(location: NSNotFound, length: NSNotFound) + ) + ), + candidateWindow: .hidden, + isEmpty: true, + convertTarget: "" + ) + } +} + +/// `InputState` を XPC で運ぶための Codable 表現。 +/// +/// macOS Client の内部型に依存しすぎないよう、通信境界ではこの型に変換する。 +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) + } + } +} + +/// `InputStyle` を XPC で運ぶための Codable 表現。 +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)) + } + } +} + +/// marked text の描画に必要なテキスト断片と選択範囲。 +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) + } + + 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 init(content: String, focus: FocusState) { + self.content = content + self.focus = 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 + } + } + } +} + +/// `NSRange` を Codable にするための軽量表現。 +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 + } + + 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?) +} + +/// 予測入力候補として Client に表示する候補。 +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 + } +} + +/// 通常候補・AI 置換候補を Client に表示するための候補情報。 +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..70daa4ca 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/ControlShortcutRoutingTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/ControlShortcutRoutingTests.swift @@ -265,6 +265,54 @@ private func makeControlEvent( } } +@Test func testEisuKeepsCompositionWhenSwitchingToEnglish() { + let event = makeControlEvent( + logicalKey: nil, + characters: nil, + modifiers: [], + keyCode: 102 + ) + let composingStates: [InputState] = [.composing, .previewing, .replaceSuggestion] + for state in composingStates { + let (action, callback) = state.event( + eventCore: event, + userAction: .英数, + inputLanguage: .japanese, + liveConversionEnabled: false, + enableDebugWindow: false, + enableSuggestion: false + ) + guard case .selectInputLanguage(.english) = action, case .fallthrough = callback else { + Issue.record("Expected Eisu in \(state) to keep composition while switching, got \(action), \(callback)") + return + } + } +} + +@Test func testKanaDoesNotDropJapaneseComposition() { + let event = makeControlEvent( + logicalKey: nil, + characters: nil, + modifiers: [], + keyCode: 104 + ) + let composingStates: [InputState] = [.composing, .previewing, .selecting, .replaceSuggestion] + for state in composingStates { + let (action, callback) = state.event( + eventCore: event, + userAction: .かな, + inputLanguage: .japanese, + liveConversionEnabled: false, + enableDebugWindow: false, + enableSuggestion: false + ) + guard case .selectInputLanguage(.japanese) = action, case .fallthrough = callback else { + Issue.record("Expected Kana in Japanese \(state) to keep composition, got \(action), \(callback)") + return + } + } +} + @Test func testNonModifierUnknownStillFallsThroughDuringComposing() { // Ctrlを伴わない.unknownは従来通りfallthroughされる(既存挙動の回帰防止) let bareEvent = makeControlEvent( @@ -286,3 +334,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/InputUtilsTests/UserActionOptionPunctuationTests.swift b/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift index f9e0dd57..eabb7866 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift @@ -10,6 +10,13 @@ private func inputString(from action: UserAction) -> String? { return pieces.inputString(preferIntention: true) } +private func rawInputString(from action: UserAction) -> String? { + guard case .input(let pieces) = action else { + return nil + } + return pieces.inputString(preferIntention: false) +} + private func makeEvent( logicalKey: String, characters: String?, @@ -24,7 +31,7 @@ private func makeEvent( } @Test func testOptionPunctuationMappings() async throws { - let defaults = UserDefaults.standard + let defaults = Config.userDefaults let key = Config.PunctuationStyle.key let originalData = defaults.data(forKey: key) defer { @@ -83,3 +90,26 @@ private func makeEvent( inputLanguage: .japanese )) == "˘") } + +@Test func testTypeBackSlashCanBeInjectedWithoutReadingProcessDefaults() async throws { + #expect(rawInputString(from: UserAction.getUserAction( + eventCore: makeEvent(logicalKey: "¥", characters: "¥", modifiers: []), + inputLanguage: .japanese, + typeBackSlash: true + )) == "\\") + #expect(rawInputString(from: UserAction.getUserAction( + eventCore: makeEvent(logicalKey: "¥", characters: "¥", modifiers: [.option]), + inputLanguage: .japanese, + typeBackSlash: true + )) == "¥") + #expect(rawInputString(from: UserAction.getUserAction( + eventCore: makeEvent(logicalKey: "¥", characters: "¥", modifiers: []), + inputLanguage: .japanese, + typeBackSlash: false + )) == "¥") + #expect(rawInputString(from: UserAction.getUserAction( + eventCore: makeEvent(logicalKey: "¥", characters: "¥", modifiers: [.option]), + inputLanguage: .japanese, + typeBackSlash: false + )) == "\\") +} diff --git a/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift new file mode 100644 index 00000000..61a45fca --- /dev/null +++ b/Core/Tests/CoreTests/XPCTests/ConverterServerContractTests.swift @@ -0,0 +1,228 @@ +import Core +import Foundation +import Testing + +@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) + + guard case .hidden = snapshot.candidateWindow else { + Issue.record("Expected hidden candidate window, got \(snapshot.candidateWindow)") + return + } +} + +@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(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 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, + enableOptionDirectFullWidthInput: true, + typeBackSlash: true, + optionDirectInputText: "a", + context: .init(leftSideContext: "左文脈", rightSideContext: "右文脈"), + visibleCandidateStartIndex: 3 + ) + let command = ConverterServerCommand.session(sessionID: "session-1", command: .handleKeyEvent(request)) + let roundTrip = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(command)) + + guard case .session(let sessionID, .handleKeyEvent(let roundTripRequest)) = roundTrip else { + Issue.record("Expected handleKeyEvent command after round trip, got \(roundTrip)") + return + } + #expect(sessionID == "session-1") + #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.session(sessionID: "session-1", command: .updateConfig(config)) + let roundTrip = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(command)) + + guard case .session(let sessionID, .updateConfig(let roundTripConfig)) = roundTrip else { + Issue.record("Expected updateConfig 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.session( + sessionID: "session-1", + command: .replaceSuggestion(.request(context: .init(leftSideContext: "左文脈", rightSideContext: "右文脈"))) + ) + let select = ConverterServerCommand.session( + sessionID: "session-1", + command: .replaceSuggestion(.selectReplaceSuggestionCandidate(index: 2)) + ) + let submit = ConverterServerCommand.session( + sessionID: "session-1", + command: .replaceSuggestion(.submitSelectedReplaceSuggestion) + ) + + guard case .session(let requestSessionID, .replaceSuggestion(.request(let context))) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(request)) else { + Issue.record("Expected requestReplaceSuggestion command after round trip") + return + } + #expect(requestSessionID == "session-1") + #expect(context.leftSideContext == "左文脈") + #expect(context.rightSideContext == "右文脈") + + guard case .session(let selectSessionID, .replaceSuggestion(.selectReplaceSuggestionCandidate(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 .session(let submitSessionID, .replaceSuggestion(.submitSelectedReplaceSuggestion)) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(submit)) else { + Issue.record("Expected submitSelectedReplaceSuggestion command after round trip") + return + } + #expect(submitSessionID == "session-1") +} + +@Test func converterServerCandidateCommandsRoundTripContext() throws { + let context = ConverterTextContext(leftSideContext: "左文脈", rightSideContext: "右文脈") + let submit = ConverterServerCommand.session( + sessionID: "session-1", + command: .candidate(.submitSelectedCandidate(context: context)) + ) + + guard case .session(let sessionID, .candidate(.submitSelectedCandidate(let roundTripContext))) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(submit)) else { + Issue.record("Expected submitSelectedCandidate command after round trip") + return + } + #expect(sessionID == "session-1") + #expect(roundTripContext == context) +} + +@Test func converterServerSettingsCommandsRoundTrip() throws { + guard case .shutdown = try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(.shutdown)) else { + Issue.record("Expected shutdown command after round trip") + return + } + + let capabilities = ConverterSettingClientCapabilities( + supportedKinds: [.toggle, .selector, .button], + supportedActions: ["resetLearningData"], + supportedCustomSurfaces: ["inputStyle"] + ) + let list = ConverterServerCommand.session( + sessionID: "session-1", + command: .settings(.list(capabilities: capabilities)) + ) + let update = ConverterServerCommand.session( + sessionID: "session-1", + command: .settings(.update( + key: Config.TypeBackSlash.key, + value: .bool(true) + )) + ) + + guard case .session(let listSessionID, .settings(.list(let roundTripCapabilities))) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(list)) else { + Issue.record("Expected listSettings command after round trip") + return + } + #expect(listSessionID == "session-1") + #expect(roundTripCapabilities == capabilities) + + guard case .session(let updateSessionID, .settings(.update(let key, let value))) = + try ConverterServerCodec.decodeCommand(from: ConverterServerCodec.encode(update)) else { + Issue.record("Expected updateSetting command after round trip") + return + } + #expect(updateSessionID == "session-1") + #expect(key == Config.TypeBackSlash.key) + #expect(value == .bool(true)) +} + +@Test func converterServerResponseCarriesClientEffects() throws { + let setting = ConverterSettingDescriptor( + key: Config.TypeBackSlash.key, + title: "円記号の代わりにバックスラッシュを入力", + section: "入力オプション", + kind: .toggle, + value: .bool(true), + requiresClientUpdate: false + ) + let response = ConverterServerResponse( + handled: true, + effects: [ + .insertText("あ"), + .switchInputLanguage(.english), + .requestReplaceSuggestion + ], + inputState: .composing, + inputLanguage: .english, + settings: [setting], + snapshot: .empty + ) + let decoded = try ConverterServerCodec.decodeResponse(from: ConverterServerCodec.encode(response)) + + #expect(decoded.handled) + #expect(decoded.effects == response.effects) + #expect(decoded.inputState == .composing) + #expect(decoded.inputLanguage == .english) + #expect(decoded.settings == [setting]) +} diff --git a/Tools/install_converter_server_launch_agent.sh b/Tools/install_converter_server_launch_agent.sh new file mode 100755 index 00000000..2a6c0ae7 --- /dev/null +++ b/Tools/install_converter_server_launch_agent.sh @@ -0,0 +1,27 @@ +#!/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)" +script_dir="$(cd "$(dirname "$0")" && pwd)" + +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 + +"${script_dir}/write_converter_server_launch_agent.sh" "${agent_path}" "${server_path}" "${service_name}" + +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/Tools/write_converter_server_launch_agent.sh b/Tools/write_converter_server_launch_agent.sh new file mode 100755 index 00000000..c597cc10 --- /dev/null +++ b/Tools/write_converter_server_launch_agent.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +agent_path="$1" +server_path="$2" +service_name="${3:-dev.ensan.inputmethod.azooKeyMac.ConverterServer}" + +agent_dir="$(dirname "${agent_path}")" +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 diff --git a/azooKeyMac.xcodeproj/project.pbxproj b/azooKeyMac.xcodeproj/project.pbxproj index 8cccdf0f..74e3c37a 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}\" --entitlements \"${SRCROOT}/azooKeyMac/ConverterServer.entitlements\" --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/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/ConverterServerClient.swift b/azooKeyMac/InputController/ConverterServerClient.swift new file mode 100644 index 00000000..7d5c58a4 --- /dev/null +++ b/azooKeyMac/InputController/ConverterServerClient.swift @@ -0,0 +1,312 @@ +import Core +import Foundation + +private enum ConverterServerXPC { + static let machServiceName = "dev.ensan.inputmethod.azooKeyMac.ConverterServer" +} + +@objc private protocol ConverterServerXPCProtocol { + func openSession(with reply: @escaping @Sendable (String) -> Void) + func closeSession(_ sessionID: String, with reply: @escaping @Sendable (Bool) -> Void) + func handleCommand(_ data: Data, with reply: @escaping @Sendable (Data?, NSString?) -> Void) + func ping(_ message: String, with reply: @escaping @Sendable (String) -> Void) +} + +final class ConverterServerClient { + private var connection: NSXPCConnection? + private var sessionID: String? + 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 || !hasOpenedSession || (shouldAttemptReconnect && Date() >= nextReconnectAttemptDate) + } + + func openSession(completion: ((String?) -> Void)? = nil) { + if let sessionID { + completion?(sessionID) + return + } + openSessionOnServer(completion: completion) + } + + func openSessionSync() -> String? { + if let sessionID { + return sessionID + } + let sessionID = waitForResult(timeout: syncTimeout) { [weak self] complete in + self?.openSessionOnServer(completion: complete) + } + if sessionID == nil { + recordReconnectFailure() + } + return sessionID + } + + 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 listSettings( + capabilities: ConverterSettingClientCapabilities, + completion: @escaping ([ConverterSettingDescriptor]?) -> Void + ) { + send( + { _ in + .settings(.list(capabilities: capabilities)) + }, + completion: { response in + completion(response?.settings) + } + ) + } + + func updateSetting( + key: String, + value: ConverterSettingValue, + completion: @escaping (Bool) -> Void + ) { + send( + { _ in + .settings(.update(key: key, value: value)) + }, + completion: { response in + completion(response != nil) + } + ) + } + + func restartServer(completion: @escaping (Bool) -> Void) { + sendResolved(.shutdown) { [weak self] response in + self?.invalidateConnection() + completion(response != nil) + } + } + + func send( + _ commandBuilder: @escaping (String) -> ConverterSessionCommand, + completion: @escaping (ConverterServerResponse?) -> Void + ) { + openSession { [weak self] sessionID in + guard let self, let sessionID else { + completion(nil) + return + } + self.sendResolved( + .session(sessionID: sessionID, command: commandBuilder(sessionID)), + completion: completion + ) + } + } + + func sendSync(_ commandBuilder: (String) -> ConverterSessionCommand) -> ConverterServerResponse? { + guard let sessionID = openSessionSync() else { + return nil + } + return sendResolvedSync(.session(sessionID: sessionID, command: commandBuilder(sessionID))) + } + + func sendIfSessionOpenSync(_ commandBuilder: (String) -> ConverterSessionCommand) -> ConverterServerResponse? { + guard let sessionID else { + return nil + } + return sendResolvedSync(.session(sessionID: sessionID, command: commandBuilder(sessionID))) + } + + func sendIfSessionOpen( + _ commandBuilder: @escaping (String) -> ConverterSessionCommand, + completion: @escaping (ConverterServerResponse?) -> Void + ) { + guard let sessionID else { + completion(nil) + return + } + sendResolved(.session(sessionID: sessionID, command: 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 + ) { + 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 openSessionOnServer(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 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 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 + } + + private func invalidateConnection() { + connection?.invalidate() + resetConnection() + } + + private func recordReconnectFailure() { + shouldAttemptReconnect = true + 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..61562d32 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 currentConverterView: ConverterSessionSnapshot? private(set) var inputState: InputState = .none private var inputLanguage: InputLanguage = .japanese var liveConversionEnabled: Bool { @@ -66,7 +68,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s /// ピン留めプロンプトのキャッシュを更新 func reloadPinnedPromptsCache() { - guard let data = UserDefaults.standard.data(forKey: Config.PromptHistory.key), + guard let data = Config.data(forKey: Config.PromptHistory.key), let history = try? JSONDecoder().decode([PromptHistoryItem].self, from: data) else { self.pinnedPromptsCache = [] return @@ -103,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, @@ -145,6 +138,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 +156,18 @@ 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.syncConverterServerSessionConfig() + self.converterServerClient.sendIfSessionOpen({ _ in .lifecycle(.activate) }, completion: { [weak self] response in + guard let self, let response else { + return + } + self.currentConverterView = response.snapshot + }) + } if let client = sender as? IMKTextInput { client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) @@ -180,6 +188,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s @MainActor override func deactivateServer(_ sender: Any!) { self.segmentsManager.deactivate() + self.converterServerClient.sendIfSessionOpen({ _ in .lifecycle(.deactivate) }, completion: { _ in }) + self.currentConverterView = nil self.candidatesWindow.orderOut(nil) self.predictionWindow.orderOut(nil) self.replaceSuggestionWindow.orderOut(nil) @@ -195,13 +205,18 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.inputState = .none return } - if self.segmentsManager.isEmpty { - return - } - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) - if let client = sender as? IMKTextInput { - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + if self.currentConverterView?.isEmpty == false, + let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .composition(.commit(inputState: ConverterInputState(self.inputState))) + }) { + self.currentConverterView = response.snapshot + if let client = sender as? IMKTextInput { + for effect in response.effects { + self.apply(effect, client: client) + } + } } + self.segmentsManager.stopComposition() self.inputState = .none self.refreshMarkedText() self.refreshCandidateWindow() @@ -233,19 +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() - ) } } } @@ -279,20 +281,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)の処理 @@ -306,11 +294,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 - } } } @@ -332,12 +315,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") @@ -347,214 +324,181 @@ 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 - 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, + optionDirectInputText: event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.option)) + ) { + return handled + } + + return false + } + + @MainActor + private func handleKeyEventWithConverterServer( + event: KeyEventCore, + client: IMKTextInput, + enableSuggestion: Bool, + optionDirectInputText: String? = nil + ) -> 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, + enableOptionDirectFullWidthInput: Config.OptionDirectFullWidthInput().value, + typeBackSlash: Config.TypeBackSlash().value, + optionDirectInputText: optionDirectInputText, + context: self.currentConverterTextContext() ) - return handleClientAction(clientAction, clientActionCallback: clientActionCallback, client: client) - } + guard let response = self.converterServerClient.sendSync({ _ in + .handleKeyEvent(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 + } + + if let inputLanguage = response.inputLanguage { + self.inputLanguage = inputLanguage } + self.inputState = response.inputState.inputState + self.currentConverterView = response.snapshot + for effect in response.effects { + self.apply(effect, client: client) + } + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + self.refreshReplaceSuggestionWindow() + return response.handled + } + + @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 } - // この種のコードは複雑にしかならないので、lintを無効にする + @MainActor // swiftlint:disable:next cyclomatic_complexity - @MainActor func handleClientAction(_ clientAction: ClientAction, clientActionCallback: ClientActionCallback, client: IMKTextInput) -> Bool { - // 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) + private func apply(_ effect: ConverterClientEffect, client: IMKTextInput) { + switch effect { + case .insertText(let text): 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): + case .switchInputLanguage(let language): self.switchInputLanguage(language, client: client) - case .commitMarkedTextAndSelectInputLanguage(let language): - let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - 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: - self.replaceSuggestionsViewController.selectPrevCandidate() + self.selectReplaceSuggestionCandidate(offset: 1) + case .selectPreviousReplaceSuggestionCandidate: + self.selectReplaceSuggestionCandidate(offset: -1) 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 で行われる - 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: - // 何もせず先に進む + case .fallthroughToApplication: break - case .fallthrough: - return false } + } - 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 = self.segmentsManager.isEmpty ? ifIsEmpty : ifIsNotEmpty } + } - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - return true + 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( + { _ in .updateConfig(config) }, + completion: { _ in } + ) + } + + @discardableResult + private func syncConverterServerSessionConfigSync() -> Bool { + let config = self.converterServerSessionConfig + return self.converterServerClient.sendIfSessionOpenSync({ _ in + .updateConfig(config) + }) != nil + } + + private func refreshConverterViewForCurrentInputState() { + guard let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .composition(.snapshot(inputState: ConverterInputState(self.inputState))) + }) else { + return + } + self.currentConverterView = response.snapshot } @MainActor func switchInputLanguage(_ language: InputLanguage, client: IMKTextInput) { @@ -569,15 +513,32 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } + private func discardConverterServerComposition() { + self.currentConverterView = nil + self.converterServerClient.sendIfSessionOpen( + { _ in .composition(.stopComposition) }, + completion: { _ in } + ) + } + func refreshCandidateWindow() { - switch self.segmentsManager.getCurrentCandidateWindow(inputState: self.inputState) { + if let currentConverterView { + self.refreshCandidateWindow(currentConverterView.candidateWindow) + return + } + self.candidatesWindow.setIsVisible(false) + self.candidatesWindow.orderOut(nil) + self.candidatesViewController.hide() + } + + 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 - let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) self.candidatesViewController.updateCandidatePresentations( - candidatePresentations, + candidates.map(\.candidatePresentation), selectionIndex: selectionIndex, cursorLocation: rect.origin ) @@ -586,9 +547,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s 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, + candidates.map(\.candidatePresentation), selectionIndex: selectionIndex, cursorLocation: rect.origin ) @@ -600,13 +560,62 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } + @MainActor private func refreshReplaceSuggestionWindow() { + guard self.inputState == .replaceSuggestion, + let currentConverterView, + !currentConverterView.replaceSuggestionCandidates.isEmpty else { + self.replaceSuggestionsViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + self.replaceSuggestionWindow.setIsVisible(false) + self.replaceSuggestionWindow.orderOut(nil) + return + } + self.replaceSuggestionsViewController.updateCandidatePresentations( + currentConverterView.replaceSuggestionCandidates.map(\.candidatePresentation), + selectionIndex: currentConverterView.replaceSuggestionSelectionIndex, + cursorLocation: self.getCursorLocation() + ) + self.replaceSuggestionWindow.setIsVisible(true) + self.replaceSuggestionWindow.makeKeyAndOrderFront(nil) + } + + @MainActor private func selectReplaceSuggestionCandidate(offset: Int) { + guard let view = self.currentConverterView, + !view.replaceSuggestionCandidates.isEmpty else { + return + } + let count = view.replaceSuggestionCandidates.count + let current = view.replaceSuggestionSelectionIndex ?? (offset > 0 ? -1 : 0) + let next = (current + offset + count) % count + if let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .replaceSuggestion(.selectReplaceSuggestionCandidate(index: next)) + }) { + self.currentConverterView = 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() return } - let predictions = self.requestPreferredPredictionCandidates() + guard let predictions = self.currentConverterView?.predictionCandidates else { + self.hidePredictionWindow() + return + } if predictions.isEmpty { let now = Date().timeIntervalSince1970 let elapsed = now - self.lastPredictionUpdateTime @@ -718,32 +727,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 @@ -769,8 +752,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,48 +768,73 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } self.client()?.setMarkedText( text, - selectionRange: currentMarkedText.selectionRange, + selectionRange: currentMarkedText.selectionRange.nsRange, replacementRange: NSRange(location: NSNotFound, length: 0) ) } - @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 ?? "") + private func currentMarkedText() -> ConverterMarkedText { + switch self.inputState { + case .attachDiacritic, .unicodeInput: + return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) + case .none, .composing, .previewing, .selecting, .replaceSuggestion: + break } - } - - @MainActor - func submitSelectedCandidate() { - if let candidate = self.segmentsManager.selectedCandidate { - self.submitCandidate(candidate) - self.segmentsManager.requestResettingSelection() + if let currentConverterView { + return currentConverterView.markedText } + return ConverterSessionSnapshot.empty.markedText } } extension azooKeyMacInputController: CandidatesViewControllerDelegate { func candidateSubmitted() { Task { @MainActor in - self.submitSelectedCandidate() + if self.currentConverterView != nil { + if let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .candidate(.submitSelectedCandidate(context: self.currentConverterTextContext())) + }) { + self.currentConverterView = response.snapshot + if let client = self.client() { + for effect in response.effects { + self.apply(effect, client: client) + } + } + self.inputState = response.inputState.inputState + self.refreshConverterViewForCurrentInputState() + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + return + } + } } } func candidateSelectionChanged(_ row: Int) { Task { @MainActor in - self.segmentsManager.requestSelectingRow(row) + if self.currentConverterView != nil, + let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .candidate(.selectCandidate(index: row)) + }) { + self.currentConverterView = response.snapshot + self.refreshMarkedText() + return + } } } } extension azooKeyMacInputController: SegmentManagerDelegate { - func getLeftSideContext(maxCount: Int) -> String? { - let endIndex = client().markedRange().location + private func currentConverterTextContext() -> ConverterTextContext { + ConverterTextContext( + leftSideContext: self.getLeftSideContext(), + rightSideContext: self.getRightSideContext() + ) + } + + func getLeftSideContext(maxCount: Int = ConverterTextContext.transportCharacterLimit) -> String? { + let endIndex = self.contextRange().location let leftRange = NSRange(location: max(endIndex - maxCount, 0), length: min(endIndex, maxCount)) var actual = NSRange() // 同じ行の文字のみコンテキストに含める @@ -834,26 +842,52 @@ extension azooKeyMacInputController: SegmentManagerDelegate { self.segmentsManager.appendDebugMessage("\(#function): leftSideContext=\(leftSideContext ?? "nil")") return leftSideContext } + + func getRightSideContext(maxCount: Int = ConverterTextContext.transportCharacterLimit) -> String? { + let range = self.contextRange() + let startIndex = range.location + range.length + let documentLength = self.client().length() + guard startIndex < documentLength else { + return nil + } + let rightRange = NSRange(location: startIndex, length: min(documentLength - startIndex, maxCount)) + var actual = NSRange() + let rightSideContext = self.client().string(from: rightRange, actualRange: &actual) + self.segmentsManager.appendDebugMessage("\(#function): rightSideContext=\(rightSideContext ?? "nil")") + return rightSideContext + } + + private func contextRange() -> NSRange { + let markedRange = self.client().markedRange() + if markedRange.location != NSNotFound { + return markedRange + } + let selectedRange = self.client().selectedRange() + if selectedRange.location != NSNotFound { + return selectedRange + } + return NSRange(location: 0, length: 0) + } } extension azooKeyMacInputController: ReplaceSuggestionsViewControllerDelegate { @MainActor func replaceSuggestionSelectionChanged(_ row: Int) { - self.segmentsManager.requestSelectingSuggestionRow(row) + guard self.currentConverterView?.replaceSuggestionSelectionIndex != row else { + return + } + if let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .replaceSuggestion(.selectReplaceSuggestionCandidate(index: row)) + }) { + self.currentConverterView = 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.segmentsManager.stopComposition() - } - } + self.submitSelectedSuggestionCandidate() } } } @@ -865,105 +899,40 @@ 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") + guard let currentConverterView, !currentConverterView.isEmpty else { + self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: skipped because converter server composition is empty") return } - - let composingText = self.segmentsManager.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) + } + self.converterServerClient.sendIfSessionOpen( + { _ in .replaceSuggestion(.request(context: self.currentConverterTextContext())) }, + 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 { - 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 let response else { + self.showReplaceSuggestionError(message: "ConverterServerから候補を取得できませんでした") + 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() + guard self.currentConverterView?.convertTarget == response.snapshot.convertTarget else { + self.segmentsManager.appendDebugMessage("候補ウィンドウ更新をスキップ: composition changed") + return + } + self.currentConverterView = response.snapshot + self.inputState = response.inputState.inputState + self.refreshMarkedText() + self.refreshReplaceSuggestionWindow() } } - } + ) self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: 終了") } @@ -974,14 +943,33 @@ 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.segmentsManager.stopComposition() + guard let response = self.converterServerClient.sendIfSessionOpenSync({ _ in + .replaceSuggestion(.submitSelectedReplaceSuggestion) + }) else { + return + } + self.currentConverterView = 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() { + if self.currentConverterView != nil { + self.discardConverterServerComposition() + } + self.inputState = .none + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + self.refreshReplaceSuggestionWindow() } // MARK: - Helper Methods diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index c9346d83..7e1a89b0 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -50,17 +50,10 @@ 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) - case .none: - _ = self.handleClientAction(.requestPredictiveSuggestion, clientActionCallback: .transition(.replaceSuggestion), client: client) - default: - break - } + _ = self.requestPredictiveSuggestionWithConverterServer(client: client) } @MainActor @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { diff --git a/azooKeyMac/Windows/ConfigState.swift b/azooKeyMac/Windows/ConfigState.swift index 4ce1246b..0ecaf792 100644 --- a/azooKeyMac/Windows/ConfigState.swift +++ b/azooKeyMac/Windows/ConfigState.swift @@ -51,7 +51,7 @@ private final class ConfigStateStore: ObservableObject { // and rely on SwiftUI / @Published to coalesce updates per run loop tick. self.observer = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, - object: UserDefaults.standard, + object: Config.userDefaults, queue: .main ) { [weak self] _ in Task { @MainActor in diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index 3dfd884f..c68b8d73 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -5,10 +5,6 @@ import SwiftUI struct ConfigWindow: View { @ConfigState private var liveConversion = Config.LiveConversion() @ConfigState private var inputStyle = Config.InputStyle() - @ConfigState private var typeBackSlash = Config.TypeBackSlash() - @ConfigState private var punctuationStyle = Config.PunctuationStyle() - @ConfigState private var typeHalfSpace = Config.TypeHalfSpace() - @ConfigState private var optionDirectFullWidthInput = Config.OptionDirectFullWidthInput() @ConfigState private var zenzaiProfile = Config.ZenzaiProfile() @ConfigState private var zenzaiPersonalizationLevel = Config.ZenzaiPersonalizationLevel() @ConfigState private var openAiApiKey = Config.OpenAiApiKey() @@ -21,9 +17,12 @@ struct ConfigWindow: View { @ConfigState private var debugTypoCorrection = Config.DebugTypoCorrection() @ConfigState private var userDictionary = Config.UserDictionary() @ConfigState private var systemUserDictionary = Config.SystemUserDictionary() - @ConfigState private var keyboardLayout = Config.KeyboardLayout() @ConfigState private var aiBackend = Config.AIBackendPreference() + @State private var converterServerClient = ConverterServerClient() + @State private var converterSettingDescriptors: [String: ConverterSettingDescriptor] = [:] + @State private var converterSettingsLoading = false + @State private var converterSettingsErrorMessage: String? @State private var selectedTab: Tab = .basic @State private var zenzaiProfileHelpPopover = false @State private var zenzaiInferenceLimitHelpPopover = false @@ -39,6 +38,8 @@ struct ConfigWindow: View { @State private var debugTypoCorrectionState: DebugTypoCorrectionState = .notDownloaded @State private var debugTypoCorrectionDownloadInProgress = false @State private var debugTypoCorrectionErrorMessage: String? + @State private var converterProcessRestartInProgress = false + @State private var converterProcessRestartMessage: String? private enum Tab: String, CaseIterable, Hashable { case basic = "基本" @@ -65,6 +66,10 @@ struct ConfigWindow: View { } private var azooKeyApplicationSupportDirectoryURL: URL { + AppGroup.applicationSupportDirectoryURL() + } + + private var legacyAzooKeyApplicationSupportDirectoryURL: URL { if #available(macOS 13, *) { URL.applicationSupportDirectory .appending(path: "azooKey", directoryHint: .isDirectory) @@ -80,6 +85,12 @@ struct ConfigWindow: View { ) } + private var legacyDebugTypoCorrectionModelDirectoryURL: URL { + DebugTypoCorrectionWeights.modelDirectoryURL( + azooKeyApplicationSupportDirectoryURL: self.legacyAzooKeyApplicationSupportDirectoryURL + ) + } + private var debugTypoCorrectionStatusText: String { if self.debugTypoCorrectionDownloadInProgress { return "ダウンロード中..." @@ -94,11 +105,81 @@ struct ConfigWindow: View { } } + private var converterSettingClientCapabilities: ConverterSettingClientCapabilities { + ConverterSettingClientCapabilities( + supportedKinds: [.toggle, .selector, .textField, .number], + supportedActions: [], + supportedCustomSurfaces: [] + ) + } + + @MainActor + private func loadConverterSettingsIfNeeded() { + guard self.converterSettingDescriptors.isEmpty, !self.converterSettingsLoading else { + return + } + self.reloadConverterSettings() + } + + @MainActor + private func reloadConverterSettings() { + self.converterSettingsLoading = true + self.converterSettingsErrorMessage = nil + self.converterServerClient.listSettings(capabilities: self.converterSettingClientCapabilities) { settings in + DispatchQueue.main.async { + guard let settings else { + self.converterSettingsLoading = false + self.converterSettingsErrorMessage = "Converter Processから設定を取得できませんでした" + return + } + self.converterSettingDescriptors = Dictionary(uniqueKeysWithValues: settings.map { ($0.key, $0) }) + self.converterSettingsLoading = false + } + } + } + + @MainActor + private func updateConverterSetting(key: String, value: ConverterSettingValue) { + self.converterServerClient.updateSetting(key: key, value: value) { success in + DispatchQueue.main.async { + guard success else { + self.converterSettingsErrorMessage = "Converter Processに設定を保存できませんでした" + self.reloadConverterSettings() + return + } + if var descriptor = self.converterSettingDescriptors[key] { + descriptor.value = value + self.converterSettingDescriptors[key] = descriptor + } + } + } + } + + private func converterSettingValueID(_ value: ConverterSettingValue?) -> String { + switch value { + case .bool(let value): + "bool:\(value)" + case .string(let value): + "string:\(value)" + case .int(let value): + "int:\(value)" + case .double(let value): + "double:\(value)" + case .none: + "" + } + } + @MainActor private func refreshDebugTypoCorrectionState() async { let modelDirectoryURL = self.debugTypoCorrectionModelDirectoryURL - let state = await Task.detached(priority: .utility) { - DebugTypoCorrectionWeights.state(modelDirectoryURL: modelDirectoryURL) + let legacyModelDirectoryURL = self.legacyDebugTypoCorrectionModelDirectoryURL + let state = await Task.detached(priority: .utility) { () -> DebugTypoCorrectionState in + Self.migrateLegacyDebugTypoCorrectionWeightsIfNeeded( + from: legacyModelDirectoryURL, + to: modelDirectoryURL + ) + return DebugTypoCorrectionWeights.state(modelDirectoryURL: modelDirectoryURL) }.value self.debugTypoCorrectionState = state if state != .failed { @@ -136,6 +217,44 @@ struct ConfigWindow: View { } } + nonisolated private static func migrateLegacyDebugTypoCorrectionWeightsIfNeeded(from sourceURL: URL, to targetURL: URL) { + guard sourceURL.standardizedFileURL != targetURL.standardizedFileURL else { + return + } + guard !DebugTypoCorrectionWeights.hasRequiredWeightFiles(modelDirectoryURL: targetURL), + DebugTypoCorrectionWeights.hasRequiredWeightFiles(modelDirectoryURL: sourceURL) else { + return + } + do { + let fileManager = FileManager.default + try fileManager.createDirectory( + at: targetURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: targetURL.path) { + try fileManager.removeItem(at: targetURL) + } + try fileManager.copyItem(at: sourceURL, to: targetURL) + } catch { + // The status check below will surface a notDownloaded/failed state. + } + } + + @MainActor + private func restartConverterProcess() { + guard !self.converterProcessRestartInProgress else { + return + } + self.converterProcessRestartInProgress = true + self.converterProcessRestartMessage = nil + self.converterServerClient.restartServer { success in + DispatchQueue.main.async { + self.converterProcessRestartMessage = success ? "再起動しました" : "Converter Processに再起動を依頼できませんでした" + self.converterProcessRestartInProgress = false + } + } + } + private func openAzooKeyDataDirectoryInFinder() { do { try FileManager.default.createDirectory( @@ -251,6 +370,157 @@ struct ConfigWindow: View { } } + @ViewBuilder + private func converterSettingSection(title: String, systemImage: String, keys: [String]) -> some View { + Section { + if self.converterSettingsLoading && self.converterSettingDescriptors.isEmpty { + ProgressView() + .controlSize(.small) + } + if let converterSettingsErrorMessage { + Text(converterSettingsErrorMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(keys, id: \.self) { key in + if let setting = self.converterSettingDescriptors[key] { + converterSettingRow(setting) + } + } + } header: { + Label(title, systemImage: systemImage) + } + } + + @ViewBuilder + private func converterSettingRow(_ setting: ConverterSettingDescriptor) -> some View { + let isDisabled = !setting.isEnabled || setting.requiresClientUpdate + switch setting.kind { + case .toggle: + converterToggleSettingRow(setting, isDisabled: isDisabled) + case .selector(let options): + converterSelectorSettingRow(setting, options: options, isDisabled: isDisabled) + case .textField(let secure): + converterTextSettingRow(setting, secure: secure, isDisabled: isDisabled) + case .number(let min, let max, let step): + converterNumberSettingRow(setting, min: min, max: max, step: step, isDisabled: isDisabled) + case .button, .custom: + EmptyView() + } + } + + private func converterToggleSettingRow(_ setting: ConverterSettingDescriptor, isDisabled: Bool) -> some View { + Toggle( + setting.title, + isOn: Binding( + get: { + if case .bool(let value) = self.converterSettingDescriptors[setting.key]?.value { + return value + } + return false + }, + set: { + self.updateConverterSetting(key: setting.key, value: .bool($0)) + } + ) + ) + .disabled(isDisabled) + } + + private func converterSelectorSettingRow( + _ setting: ConverterSettingDescriptor, + options: [ConverterSettingOption], + isDisabled: Bool + ) -> some View { + Picker( + setting.title, + selection: Binding( + get: { + self.converterSettingValueID(self.converterSettingDescriptors[setting.key]?.value) + }, + set: { selectedID in + guard let option = options.first(where: { self.converterSettingValueID($0.value) == selectedID }) else { + return + } + self.updateConverterSetting(key: setting.key, value: option.value) + } + ) + ) { + ForEach(Array(options.enumerated()), id: \.offset) { _, option in + Text(option.title) + .tag(self.converterSettingValueID(option.value)) + } + } + .disabled(isDisabled) + } + + @ViewBuilder + private func converterTextSettingRow( + _ setting: ConverterSettingDescriptor, + secure: Bool, + isDisabled: Bool + ) -> some View { + let binding = Binding( + get: { + if case .string(let value) = self.converterSettingDescriptors[setting.key]?.value { + return value + } + return "" + }, + set: { + self.updateConverterSetting(key: setting.key, value: .string($0)) + } + ) + if secure { + SecureField(setting.title, text: binding) + .disabled(isDisabled) + } else { + TextField(setting.title, text: binding) + .disabled(isDisabled) + } + } + + private func converterNumberSettingRow( + _ setting: ConverterSettingDescriptor, + min: Double?, + max: Double?, + step: Double?, + isDisabled: Bool + ) -> some View { + let range = (min ?? 0) ... (max ?? 100) + return LabeledContent(setting.title) { + Stepper( + value: Binding( + get: { + if case .int(let value) = self.converterSettingDescriptors[setting.key]?.value { + return Double(value) + } + if case .double(let value) = self.converterSettingDescriptors[setting.key]?.value { + return value + } + return min ?? 0 + }, + set: { value in + if case .int = setting.value { + self.updateConverterSetting(key: setting.key, value: .int(Int(value))) + } else { + self.updateConverterSetting(key: setting.key, value: .double(value)) + } + } + ), + in: range, + step: step ?? 1 + ) { + Text(self.converterSettingDisplayValue(for: setting.key)) + } + .disabled(isDisabled) + } + } + + private func converterSettingDisplayValue(for key: String) -> String { + self.converterSettingValueID(self.converterSettingDescriptors[key]?.value).split(separator: ":").last.map(String.init) ?? "" + } + var body: some View { VStack(spacing: 0) { // カスタムタブバー @@ -320,6 +590,11 @@ struct ConfigWindow: View { } } } + .task { + await MainActor.run { + self.loadConverterSettingsIfNeeded() + } + } } // MARK: - 基本タブ @@ -342,13 +617,13 @@ struct ConfigWindow: View { foundationModelsAvailability = FoundationModelsClientCompat.checkAvailability() availabilityCheckDone = true - let hasSetAIBackend = UserDefaults.standard.bool(forKey: "hasSetAIBackendManually") + let hasSetAIBackend = Config.object(forKey: "hasSetAIBackendManually") as? Bool ?? false if !hasSetAIBackend, aiBackend.value == .off, let availability = foundationModelsAvailability, availability.isAvailable { aiBackend.value = .foundationModels - UserDefaults.standard.set(true, forKey: "hasSetAIBackendManually") + Config.set(true, forKey: "hasSetAIBackendManually") } if aiBackend.value == .foundationModels, @@ -359,7 +634,7 @@ struct ConfigWindow: View { } } .onChange(of: aiBackend.value) { _ in - UserDefaults.standard.set(true, forKey: "hasSetAIBackendManually") + Config.set(true, forKey: "hasSetAIBackendManually") } } @@ -479,19 +754,16 @@ struct ConfigWindow: View { @ViewBuilder private var customizeTabView: some View { Form { - Section { - Toggle("円記号の代わりにバックスラッシュを入力", isOn: $typeBackSlash) - Toggle("スペースは常に半角を入力", isOn: $typeHalfSpace) - Toggle("Optionキーで直接全角英数を入力", isOn: $optionDirectFullWidthInput) - Picker("句読点の種類", selection: $punctuationStyle) { - Text("、と。").tag(Config.PunctuationStyle.Value.`kutenAndToten`) - Text("、と.").tag(Config.PunctuationStyle.Value.periodAndToten) - Text(",と。").tag(Config.PunctuationStyle.Value.kutenAndComma) - Text(",と.").tag(Config.PunctuationStyle.Value.periodAndComma) - } - } header: { - Label("入力オプション", systemImage: "character.cursor.ibeam") - } + converterSettingSection( + title: "入力オプション", + systemImage: "character.cursor.ibeam", + keys: [ + Config.TypeBackSlash.key, + Config.TypeHalfSpace.key, + Config.OptionDirectFullWidthInput.key, + Config.PunctuationStyle.key + ] + ) Section { Picker("履歴学習", selection: $learning) { @@ -554,18 +826,11 @@ struct ConfigWindow: View { Label("入力方式", systemImage: "keyboard") } - Section { - Picker("キーボード配列", selection: $keyboardLayout) { - Text("QWERTY").tag(Config.KeyboardLayout.Value.qwerty) - Text("Australian").tag(Config.KeyboardLayout.Value.australian) - Text("British").tag(Config.KeyboardLayout.Value.british) - Text("Colemak").tag(Config.KeyboardLayout.Value.colemak) - Text("Dvorak").tag(Config.KeyboardLayout.Value.dvorak) - Text("Dvorak - QWERTY ⌘").tag(Config.KeyboardLayout.Value.dvorakQwertyCommand) - } - } header: { - Label("キーボード配列", systemImage: "keyboard.badge.ellipsis") - } + converterSettingSection( + title: "キーボード配列", + systemImage: "keyboard.badge.ellipsis", + keys: [Config.KeyboardLayout.key] + ) } .formStyle(.grouped) .scrollContentBackground(.hidden) @@ -680,6 +945,24 @@ struct ConfigWindow: View { } } } + LabeledContent("Converter Process") { + VStack(alignment: .trailing, spacing: 4) { + Button("再起動") { + self.restartConverterProcess() + } + .disabled(self.converterProcessRestartInProgress) + if self.converterProcessRestartInProgress { + ProgressView() + .controlSize(.small) + } + if let converterProcessRestartMessage { + Text(converterProcessRestartMessage) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } } header: { Label("開発者向け設定", systemImage: "hammer") } diff --git a/azooKeyMac/Windows/PromptInput/PromptInputView.swift b/azooKeyMac/Windows/PromptInput/PromptInputView.swift index 75b640c6..d0cde44d 100644 --- a/azooKeyMac/Windows/PromptInput/PromptInputView.swift +++ b/azooKeyMac/Windows/PromptInput/PromptInputView.swift @@ -444,7 +444,7 @@ struct PromptInputView: View { private func loadPromptHistory() { // Try to load as Data first (new format) - if let data = UserDefaults.standard.data(forKey: Config.PromptHistory.key) { + if let data = Config.data(forKey: Config.PromptHistory.key) { if let history = try? JSONDecoder().decode([PromptHistoryItem].self, from: data) { promptHistory = history return @@ -457,7 +457,7 @@ struct PromptInputView: View { } // Fallback to string format (legacy) - let historyString = UserDefaults.standard.string(forKey: Config.PromptHistory.key) ?? "" + let historyString = Config.string(forKey: Config.PromptHistory.key) ?? "" if !historyString.isEmpty, let data = historyString.data(using: .utf8) { if let history = try? JSONDecoder().decode([PromptHistoryItem].self, from: data) { @@ -548,7 +548,7 @@ struct PromptInputView: View { private func savePinnedHistory() { if let data = try? JSONEncoder().encode(promptHistory) { - UserDefaults.standard.set(data, forKey: Config.PromptHistory.key) + Config.set(data, forKey: Config.PromptHistory.key) } } 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 diff --git a/pkg-scripts/postinstall b/pkg-scripts/postinstall new file mode 100755 index 00000000..56d3b6e8 --- /dev/null +++ b/pkg-scripts/postinstall @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +service_name="dev.ensan.inputmethod.azooKeyMac.ConverterServer" +app_path="/Library/Input Methods/azooKeyMac.app" +server_path="${app_path}/Contents/MacOS/ConverterServer" +agent_dir="/Library/LaunchAgents" +agent_path="${agent_dir}/${service_name}.plist" +script_dir="$(cd "$(dirname "$0")" && pwd)" + +if [ ! -x "${server_path}" ]; then + echo "ConverterServer not found: ${server_path}" >&2 + exit 1 +fi + +"${script_dir}/write_converter_server_launch_agent.sh" "${agent_path}" "${server_path}" "${service_name}" +chmod 644 "${agent_path}" +chown root:wheel "${agent_path}" + +console_user="$(stat -f %Su /dev/console)" +if [ -z "${console_user}" ] || [ "${console_user}" = "root" ] || [ "${console_user}" = "_mbsetupuser" ]; then + echo "No active console user; ${service_name} will start on next login." + exit 0 +fi + +console_uid="$(id -u "${console_user}")" +gui_domain="gui/${console_uid}" +legacy_agent_path="$(eval echo "~${console_user}")/Library/LaunchAgents/${service_name}.plist" + +launchctl bootout "${gui_domain}/${service_name}" >/dev/null 2>&1 || true +if [ -f "${legacy_agent_path}" ]; then + launchctl bootout "${gui_domain}" "${legacy_agent_path}" >/dev/null 2>&1 || true + rm -f "${legacy_agent_path}" +fi +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}" diff --git a/pkgbuild.sh b/pkgbuild.sh index 3d17be98..f7572e61 100755 --- a/pkgbuild.sh +++ b/pkgbuild.sh @@ -6,6 +6,8 @@ CONFIGURATION="Release" ARCHIVE_PATH="./build/archive.xcarchive" EXPORT_PATH="./build/export" EXPORT_OPTIONS_PLIST="./exportOptions.plist" +PKG_SCRIPTS_SOURCE_PATH="./pkg-scripts" +PKG_SCRIPTS_PATH="./build/pkg-scripts" # 1. Clean Build rm -rf ./build @@ -52,12 +54,18 @@ rm ${EXPORT_PATH}/Packaging.log rm ${EXPORT_PATH}/DistributionSummary.plist rm ${EXPORT_PATH}/ExportOptions.plist +mkdir -p "${PKG_SCRIPTS_PATH}" +cp "${PKG_SCRIPTS_SOURCE_PATH}/postinstall" "${PKG_SCRIPTS_PATH}/postinstall" +cp "./Tools/write_converter_server_launch_agent.sh" "${PKG_SCRIPTS_PATH}/write_converter_server_launch_agent.sh" +chmod +x "${PKG_SCRIPTS_PATH}/postinstall" "${PKG_SCRIPTS_PATH}/write_converter_server_launch_agent.sh" + # Suppose we have build/azooKeyMac.app # Use this script to create a plist package for distribution # pkgbuild --analyze --root ./build/ pkg.plist # Create a temporary package pkgbuild --root ${EXPORT_PATH} \ + --scripts ${PKG_SCRIPTS_PATH} \ --component-plist pkg.plist --identifier dev.ensan.inputmethod.azooKeyMac \ --version 0 \ --install-location /Library/Input\ Methods \