diff --git a/Input Source Pro/Models/ApplicationVM.swift b/Input Source Pro/Models/ApplicationVM.swift index 39dff1a..9d3bf22 100644 --- a/Input Source Pro/Models/ApplicationVM.swift +++ b/Input Source Pro/Models/ApplicationVM.swift @@ -13,13 +13,14 @@ final class ApplicationVM: ObservableObject { let cancelBag = CancelBag() let preferencesVM: PreferencesVM + private var lastResolvedBrowserAppKinds = [String: AppKind]() lazy var windowAXNotificationPublisher = ApplicationVM .createWindowAXNotificationPublisher(preferencesVM: preferencesVM) init(preferencesVM: PreferencesVM) { self.preferencesVM = preferencesVM - appKind = .from(NSWorkspace.shared.frontmostApplication, preferencesVM: preferencesVM) + appKind = resolveAppKind(for: NSWorkspace.shared.frontmostApplication) activateAccessibilitiesForCurrentApp() watchApplicationChange() @@ -62,10 +63,14 @@ extension ApplicationVM { else { return Empty().eraseToAnyPublisher() } guard NSApplication.isBrowser(app) - else { return Just(.from(app, preferencesVM: preferencesVM)).eraseToAnyPublisher() } + else { + return Just(app) + .compactMap { [weak self] in self?.resolveAppKind(for: $0) } + .eraseToAnyPublisher() + } return Timer - .interval(seconds: 1) + .interval(seconds: 0.05) .prepend(Date()) .compactMap { _ in app.focusedUIElement(preferencesVM: preferencesVM) } .first() @@ -79,7 +84,7 @@ extension ApplicationVM { .map { event in event.runningApp } } .prepend(app) - .compactMap { app -> AppKind? in .from(app, preferencesVM: preferencesVM) } + .compactMap { [weak self] in self?.resolveAppKind(for: $0) } .eraseToAnyPublisher() } .removeDuplicates(by: { $0.isSameAppOrWebsite(with: $1, detectAddressBar: true) }) @@ -103,4 +108,30 @@ extension ApplicationVM { .sink { $0.getApp().activateAccessibilities() } .store(in: cancelBag) } + + private func resolveAppKind(for app: NSRunningApplication?) -> AppKind? { + guard let app else { return nil } + + let resolved = AppKind.from(app, preferencesVM: preferencesVM) + + if let browserInfo = resolved.getBrowserInfo(), + !browserInfo.isFocusedOnAddressBar, + browserInfo.url != .newtab, + let bundleIdentifier = app.bundleIdentifier + { + lastResolvedBrowserAppKinds[bundleIdentifier] = resolved + return resolved + } + + guard preferencesVM.isBrowserAndEnabled(app), + let bundleIdentifier = app.bundleIdentifier, + case .normal = resolved, + let fallback = lastResolvedBrowserAppKinds[bundleIdentifier] + else { + return resolved + } + + logger.debug { "Reusing cached browser context for \(bundleIdentifier) while waiting for the focused tab to resolve." } + return fallback + } } diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index 05881c0..648a7cc 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -253,7 +253,11 @@ extension IndicatorVM { return updateState(appKind: state.appKind, inputSource: inputSource, inputSourceChangeReason: .system) case let .switchInputSourceByShortcut(inputSource): - inputSourceVM.select(inputSource: inputSource, app: state.appKind?.getApp()) + inputSourceVM.select( + inputSource: inputSource, + app: state.appKind?.getApp(), + allowShortcutFallback: false + ) return updateState(appKind: state.appKind, inputSource: inputSource, inputSourceChangeReason: .shortcut) } diff --git a/Input Source Pro/Models/InputSourceVM.swift b/Input Source Pro/Models/InputSourceVM.swift index d3a34b9..9281b28 100644 --- a/Input Source Pro/Models/InputSourceVM.swift +++ b/Input Source Pro/Models/InputSourceVM.swift @@ -10,6 +10,7 @@ class InputSourceVM: ObservableObject { private struct SelectionRequest { let inputSource: InputSource let app: NSRunningApplication? + let allowShortcutFallback: Bool } let preferencesVM: PreferencesVM @@ -35,13 +36,30 @@ class InputSourceVM: ObservableObject { selectInputSourceSubject .tap { [weak self] in if let self { - $0.inputSource.select(cJKVFixStrategy: self.preferencesVM.activeCJKVFixStrategy(for: $0.app)) + $0.inputSource.select( + cJKVFixStrategy: self.preferencesVM.activeCJKVFixStrategy(for: $0.app), + allowShortcutFallback: $0.allowShortcutFallback + ) } } .flatMapLatest({ _ in - Timer - .interval(seconds: 1) - .eraseToAnyPublisher() + Publishers.MergeMany([ + Just(()) + .eraseToAnyPublisher(), + Timer + .delay(seconds: 0.05) + .mapToVoid() + .eraseToAnyPublisher(), + Timer + .delay(seconds: 0.15) + .mapToVoid() + .eraseToAnyPublisher(), + Timer + .delay(seconds: 0.3) + .mapToVoid() + .eraseToAnyPublisher() + ]) + .eraseToAnyPublisher() }) .sink { [weak self] _ in self?.inputSourceChangesSubject.send(()) @@ -49,8 +67,18 @@ class InputSourceVM: ObservableObject { .store(in: cancelBag) } - func select(inputSource: InputSource, app: NSRunningApplication? = nil) { - selectInputSourceSubject.send(SelectionRequest(inputSource: inputSource, app: app)) + func select( + inputSource: InputSource, + app: NSRunningApplication? = nil, + allowShortcutFallback: Bool = true + ) { + selectInputSourceSubject.send( + SelectionRequest( + inputSource: inputSource, + app: app, + allowShortcutFallback: allowShortcutFallback + ) + ) } private func watchSystemNotification() { diff --git a/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift b/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift index acfe742..cd74680 100644 --- a/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift +++ b/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift @@ -50,7 +50,7 @@ class AppRuleMenuItem: NSMenuItem { } if let inputSource { - inputSourceVM.select(inputSource: inputSource, app: app) + inputSourceVM.select(inputSource: inputSource, app: app, allowShortcutFallback: false) } watchChanges() diff --git a/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift b/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift index db021a2..30d1804 100644 --- a/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift +++ b/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift @@ -66,7 +66,7 @@ class BrowserRuleMenuItem: NSMenuItem { } if let inputSource { - inputSourceVM.select(inputSource: inputSource, app: app) + inputSourceVM.select(inputSource: inputSource, app: app, allowShortcutFallback: false) } watchChanges() diff --git a/Input Source Pro/Utilities/InputSource/InputSource.swift b/Input Source Pro/Utilities/InputSource/InputSource.swift index 4000067..848ddab 100644 --- a/Input Source Pro/Utilities/InputSource/InputSource.swift +++ b/Input Source Pro/Utilities/InputSource/InputSource.swift @@ -70,8 +70,12 @@ class InputSource { }() } - func select(cJKVFixStrategy: CJKVFixStrategy?) { - InputSourceSwitcher.switchToInputSource(self, cJKVFixStrategy: cJKVFixStrategy) + func select(cJKVFixStrategy: CJKVFixStrategy?, allowShortcutFallback: Bool = true) { + InputSourceSwitcher.switchToInputSource( + self, + cJKVFixStrategy: cJKVFixStrategy, + allowShortcutFallback: allowShortcutFallback + ) } private var normalizedInputModeID: String? { diff --git a/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift b/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift index 75c7763..5e2176a 100644 --- a/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift +++ b/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift @@ -23,6 +23,14 @@ enum InputSourceSwitcher { let inputModeID: String? let isCJKV: Bool + var persistentIdentifier: String { + if let inputModeID, !inputModeID.isEmpty { + return "\(sourceID)::\(inputModeID)" + } + + return sourceID + } + @MainActor func matches(_ inputSource: InputSource) -> Bool { if let inputModeID { @@ -33,6 +41,11 @@ enum InputSourceSwitcher { } } + private struct ShortcutFallback { + let previousShortcut: HotKeyInfo + let nonCJKVSource: TISInputSource + } + private static let logger = ISPLogger(category: String(describing: InputSourceSwitcher.self)) private static var pendingWorkItems: [DispatchWorkItem] = [] private static var temporaryInputWindow: NSWindow? @@ -140,7 +153,11 @@ enum InputSourceSwitcher { ) } - static func switchToInputSource(_ inputSource: InputSource, cJKVFixStrategy: CJKVFixStrategy?) { + static func switchToInputSource( + _ inputSource: InputSource, + cJKVFixStrategy: CJKVFixStrategy?, + allowShortcutFallback: Bool = true + ) { cancelPendingWorkItems() let target = SwitchTarget( localizedName: inputSource.name, @@ -150,13 +167,16 @@ enum InputSourceSwitcher { ) if inputSource.isCJKVR, let modeID = inputSource.inputModeID { - return switchToInputMode(modeID: modeID, cJKVFixStrategy: cJKVFixStrategy) + return switchToInputMode( + modeID: modeID, + cJKVFixStrategy: allowShortcutFallback ? cJKVFixStrategy : nil + ) } switchToTarget( target, tisTarget: inputSource.tisInputSource, - cJKVFixStrategy: cJKVFixStrategy + cJKVFixStrategy: allowShortcutFallback ? cJKVFixStrategy : nil ) } @@ -165,19 +185,34 @@ enum InputSourceSwitcher { tisTarget: TISInputSource, cJKVFixStrategy: CJKVFixStrategy? ) { - guard target.isCJKV, - let cJKVFixStrategy - else { - selectInputSource(tisTarget, reason: "target") - return + let shortcutFallback: ShortcutFallback? + + if target.isCJKV, + cJKVFixStrategy == .previousInputSourceShortcut, + let previousShortcut = getPreviousInputSourceShortcut(), + let nonCJKVSource = resolveNonCJKVSource(), + canPostShortcuts() + { + syntheticEventEndTime = ProcessInfo.processInfo.systemUptime + 0.35 + shortcutFallback = ShortcutFallback( + previousShortcut: previousShortcut, + nonCJKVSource: nonCJKVSource + ) + } else { + shortcutFallback = nil } - switch cJKVFixStrategy { - case .temporaryInputWindow: + if target.isCJKV, cJKVFixStrategy == .temporaryInputWindow { switchToCJKVTargetWithTemporaryInputWindow(target, tisTarget: tisTarget) - case .previousInputSourceShortcut: - switchToCJKVTargetWithPreviousInputSourceShortcut(target, tisTarget: tisTarget) + return } + + selectInputSource(tisTarget, reason: "target") + scheduleSelectionVerification( + for: target, + tisTarget: tisTarget, + shortcutFallback: shortcutFallback + ) } private static func switchToCJKVTargetWithTemporaryInputWindow( @@ -190,39 +225,15 @@ enum InputSourceSwitcher { scheduleWorkItem(after: temporaryInputWindowDuration + 0.05) { let currentInputSource = InputSource.getCurrentInputSource() - if !target.matches(currentInputSource) { - selectInputSource(tisTarget, reason: "CJKV target mismatch fallback") - } - } - } + guard !isCurrentInputSourceMatched(currentInputSource, with: target) else { return } - private static func switchToCJKVTargetWithPreviousInputSourceShortcut( - _ target: SwitchTarget, - tisTarget: TISInputSource - ) { - guard let previousShortcut = getPreviousInputSourceShortcut(), - let nonCJKVSource = resolveNonCJKVSource(), - canPostShortcuts() - else { - selectInputSource(tisTarget, reason: "CJKV target shortcut fallback") - return + selectInputSource(tisTarget, reason: "CJKV target mismatch fallback") + scheduleSelectionVerification( + for: target, + tisTarget: tisTarget, + remainingAttempts: 2 + ) } - - // Suppress modifier event processing in ShortcutTriggerManager for the duration - // of the CJKV fix sequence (~300ms) to prevent synthetic keyboard events from - // corrupting modifier tracking state and blocking subsequent shortcut triggers. - syntheticEventEndTime = ProcessInfo.processInfo.systemUptime + 0.35 - logger.debug { "Applying CJKV fix using previous input source shortcut" } - selectInputSource(tisTarget, reason: "CJKV target") - selectInputSource(nonCJKVSource, reason: "CJKV bounce") - - scheduleWorkItem(after: 0.1, execute: { - triggerShortcut(previousShortcut, onFinish: { currentInputSouce in - if !target.matches(currentInputSouce) { - selectInputSource(tisTarget, reason: "CJKV target mismatch fallback") - } - }) - }) } @discardableResult @@ -306,6 +317,84 @@ enum InputSourceSwitcher { } } + private static func scheduleSelectionVerification( + for target: SwitchTarget, + tisTarget: TISInputSource, + shortcutFallback: ShortcutFallback? = nil, + didApplyShortcutFallback: Bool = false, + remainingAttempts: Int = 3 + ) { + guard remainingAttempts > 0 else { return } + + scheduleWorkItem(after: 0.05, execute: { + let currentInputSource = InputSource.getCurrentInputSource() + + guard !isCurrentInputSourceMatched(currentInputSource, with: target) else { return } + + if let shortcutFallback, !didApplyShortcutFallback { + logger.debug { + "Direct input source selection did not stick for \(target.localizedName) (\(target.persistentIdentifier)); applying CJKV shortcut fallback." + } + + applyShortcutFallback( + shortcutFallback, + for: target, + tisTarget: tisTarget, + remainingAttempts: remainingAttempts - 1 + ) + return + } + + logger.debug { + "Retrying input source selection for \(target.localizedName) (\(target.persistentIdentifier)); current=\(currentInputSource.persistentIdentifier)" + } + + selectInputSource(tisTarget, reason: "verification retry") + scheduleSelectionVerification( + for: target, + tisTarget: tisTarget, + shortcutFallback: nil, + didApplyShortcutFallback: didApplyShortcutFallback, + remainingAttempts: remainingAttempts - 1 + ) + }) + } + + private static func applyShortcutFallback( + _ shortcutFallback: ShortcutFallback, + for target: SwitchTarget, + tisTarget: TISInputSource, + remainingAttempts: Int + ) { + logger.debug { "Applying CJKV shortcut fallback for \(target.localizedName)" } + selectInputSource(shortcutFallback.nonCJKVSource, reason: "CJKV bounce") + + scheduleWorkItem(after: 0.1, execute: { + triggerShortcut(shortcutFallback.previousShortcut, onFinish: { currentInputSource in + if !isCurrentInputSourceMatched(currentInputSource, with: target) { + selectInputSource(tisTarget, reason: "CJKV fallback target retry") + } + + scheduleSelectionVerification( + for: target, + tisTarget: tisTarget, + shortcutFallback: nil, + didApplyShortcutFallback: true, + remainingAttempts: remainingAttempts + ) + }) + }) + } + + private static func isCurrentInputSourceMatched(_ currentInputSource: InputSource, with target: SwitchTarget) -> Bool { + if let inputModeID = target.inputModeID, !inputModeID.isEmpty { + return currentInputSource.inputModeID == inputModeID || + currentInputSource.persistentIdentifier == target.persistentIdentifier + } + + return currentInputSource.id == target.sourceID + } + /// Adapted from macism's showTemporaryInputWindow approach. /// macism is MIT licensed, copyright (c) 2023 https://github.com/laishulu. private static func showTemporaryInputWindow() {