From 9ec44d3e3245d3fc7ec9bfaf19a282cf159e0e63 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Tue, 18 Aug 2026 23:51:01 +0300 Subject: [PATCH 1/4] Feat: persist app keyboard memory --- Input Source Pro.xcodeproj/project.pbxproj | 12 + .../Controllers/StatusItemController.swift | 10 + Input Source Pro/Models/ApplicationVM.swift | 248 +++++++++++++++++- Input Source Pro/Models/IndicatorVM.swift | 193 ++++++++++++-- .../PreferencesVM+AppCustomization.swift | 17 +- .../PreferencesVM+AppKeyboardCache.swift | 151 ++++++++++- .../Models/PreferencesVM+KeyboardConfig.swift | 8 + Input Source Pro/System/AppDelegate.swift | 2 + .../Utilities/AppKeyboardCache.swift | 49 +++- .../AppKit/LaunchpadOverlayDetector.swift | 40 +++ .../Utilities/AppKit/SystemChrome.swift | 46 ++++ .../Utilities/Log/ISPFileLog.swift | 83 ++++++ .../SwiftUI+FlexibleButtonSizing.swift | 7 +- 13 files changed, 814 insertions(+), 52 deletions(-) create mode 100644 Input Source Pro/Utilities/AppKit/LaunchpadOverlayDetector.swift create mode 100644 Input Source Pro/Utilities/AppKit/SystemChrome.swift create mode 100644 Input Source Pro/Utilities/Log/ISPFileLog.swift diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index 1c845b2..b13d6af 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -153,6 +153,9 @@ D58168D528957D5500E84058 /* QuestionMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58168D428957D5500E84058 /* QuestionMark.swift */; }; D58C184428A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */; }; D58C184628A6B2DE00F52845 /* ISPLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58C184528A6B2DE00F52845 /* ISPLogger.swift */; }; + D6A100112F2000000000A001 /* ISPFileLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100012F2000000000A001 /* ISPFileLog.swift */; }; + D6A100122F2000000000A002 /* LaunchpadOverlayDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */; }; + D6A100132F2000000000A003 /* SystemChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100032F2000000000A003 /* SystemChrome.swift */; }; D58C184828A808F000F52845 /* KeyboardRestoreStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58C184728A808F000F52845 /* KeyboardRestoreStrategy.swift */; }; D5CC44942A33F143007FF839 /* TroubleshootingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CC44932A33F143007FF839 /* TroubleshootingSettingsView.swift */; }; D5CC44962A33F485007FF839 /* EnhancedModeRequiredBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CC44952A33F485007FF839 /* EnhancedModeRequiredBadge.swift */; }; @@ -342,6 +345,9 @@ D58168D428957D5500E84058 /* QuestionMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionMark.swift; sourceTree = ""; }; D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRunningApplication+FocusedElement.swift"; sourceTree = ""; }; D58C184528A6B2DE00F52845 /* ISPLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISPLogger.swift; sourceTree = ""; }; + D6A100012F2000000000A001 /* ISPFileLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISPFileLog.swift; sourceTree = ""; }; + D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchpadOverlayDetector.swift; sourceTree = ""; }; + D6A100032F2000000000A003 /* SystemChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemChrome.swift; sourceTree = ""; }; D58C184728A808F000F52845 /* KeyboardRestoreStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardRestoreStrategy.swift; sourceTree = ""; }; D5CC44932A33F143007FF839 /* TroubleshootingSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TroubleshootingSettingsView.swift; sourceTree = ""; }; D5CC44952A33F485007FF839 /* EnhancedModeRequiredBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedModeRequiredBadge.swift; sourceTree = ""; }; @@ -688,6 +694,8 @@ D579258F2A1AB1DA003B83EA /* NSRunningApplication+FloatingApp.swift */, D579258B2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift */, D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */, + D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */, + D6A100032F2000000000A003 /* SystemChrome.swift */, 4ADDC325281AC4DD0075528A /* NSApplication.swift */, 4A742D0928229A5400DCB392 /* NSWorkspace.swift */, 4AC450B1281BE00A00DA0329 /* CGRect.swift */, @@ -731,6 +739,7 @@ isa = PBXGroup; children = ( D58C184528A6B2DE00F52845 /* ISPLogger.swift */, + D6A100012F2000000000A001 /* ISPFileLog.swift */, ); path = Log; sourceTree = ""; @@ -1047,6 +1056,9 @@ D5F4A1CA2F0B1234001B2C3D /* InputSourceSwitcher.swift in Sources */, D5115A1E2A33D57A00D94E51 /* ItemSection.swift in Sources */, D58C184628A6B2DE00F52845 /* ISPLogger.swift in Sources */, + D6A100112F2000000000A001 /* ISPFileLog.swift in Sources */, + D6A100122F2000000000A002 /* LaunchpadOverlayDetector.swift in Sources */, + D6A100132F2000000000A003 /* SystemChrome.swift in Sources */, 4A8E47692F08814500424D11 /* SwiftUI+FlexibleButtonSizing.swift in Sources */, 4ADDC326281AC4DD0075528A /* NSApplication.swift in Sources */, 4AC2615328786EAB0032AAFE /* NSCFRange.swift in Sources */, diff --git a/Input Source Pro/Controllers/StatusItemController.swift b/Input Source Pro/Controllers/StatusItemController.swift index 13e4474..e57666d 100644 --- a/Input Source Pro/Controllers/StatusItemController.swift +++ b/Input Source Pro/Controllers/StatusItemController.swift @@ -135,6 +135,12 @@ class StatusItemController { changelogMenu, checkUpdatesMenu, settingsMenu, + NSMenuItem( + title: "Open Log…", + target: self, + action: #selector(openLog), + keyEquivalent: "" + ), NSMenuItem.separator(), NSMenuItem( title: "Quit".i18n(), @@ -232,6 +238,10 @@ class StatusItemController { } } + @objc func openLog() { + ISPFileLog.openInFinder() + } + @objc func checkForUpdates() { preferencesVM.checkUpdates() } diff --git a/Input Source Pro/Models/ApplicationVM.swift b/Input Source Pro/Models/ApplicationVM.swift index 076e0d9..42f5b39 100644 --- a/Input Source Pro/Models/ApplicationVM.swift +++ b/Input Source Pro/Models/ApplicationVM.swift @@ -14,6 +14,12 @@ final class ApplicationVM: ObservableObject { let cancelBag = CancelBag() let preferencesVM: PreferencesVM + private var launchpadSessionActive = false + private var launchpadSettleWork: DispatchWorkItem? + private var launchpadReturnLayout: (bundleId: String, inputSourceId: String)? + + private let launchpadCloseSettleMilliseconds = 220 + lazy var windowAXNotificationPublisher = ApplicationVM .createWindowAXNotificationPublisher(preferencesVM: preferencesVM) @@ -23,6 +29,7 @@ final class ApplicationVM: ObservableObject { activateAccessibilitiesForCurrentApp() watchApplicationChange() + watchLaunchpadOverlay() watchRuntimeRuleChange() watchAppsDiffChange() } @@ -48,15 +55,14 @@ extension ApplicationVM { activeSpaceDidChangeNotification.eraseToAnyPublisher() ]) .compactMap { [weak self] _ -> NSRunningApplication? in - guard self?.preferencesVM.preferences.isEnhancedModeEnabled == true, - let elm: UIElement = try? systemWideElement.attribute(.focusedApplication), - let pid = try? elm.pid() - else { return NSWorkspace.shared.frontmostApplication } - return NSRunningApplication(processIdentifier: pid) + self?.resolveFocusApplication() } .filter { app in !InputSourceSwitcher.isTemporaryInputWindowApplicationActivation(app) } + .compactMap { [weak self] app -> NSRunningApplication? in + self?.normalizeFocusApplication(app) + } .removeDuplicates() .flatMapLatest { [weak self] (app: NSRunningApplication) -> AnyPublisher in guard let preferencesVM = self?.preferencesVM @@ -84,9 +90,239 @@ extension ApplicationVM { .eraseToAnyPublisher() } .removeDuplicates(by: { $0.isSameAppOrWebsite(with: $1, detectAddressBar: true) }) - .sink { [weak self] in self?.appKind = $0 } + .sink { [weak self] in + ISPFileLog.event( + "focus", + "appKind=\($0.getApp().bundleIdentifier ?? "nil") \($0.getApp().localizedName ?? "")" + ) + self?.appKind = $0 + } + .store(in: cancelBag) + } + + private func resolveFocusApplication() -> NSRunningApplication? { + guard preferencesVM.preferences.isEnhancedModeEnabled == true, + let elm: UIElement = try? systemWideElement.attribute(.focusedApplication), + let pid = try? elm.pid() + else { return NSWorkspace.shared.frontmostApplication } + return NSRunningApplication(processIdentifier: pid) + } + + private func normalizeFocusApplication(_ app: NSRunningApplication) -> NSRunningApplication? { + let launchpadVisible = LaunchpadOverlayDetector.isLaunchpadVisible() + + if launchpadVisible { + if let dock = SystemChrome.dockRunningApplication() { + return dock + } + return app + } + + if launchpadSessionActive { + ISPFileLog.event( + "focus-skip", + "settle in progress, ignore \(app.bundleIdentifier ?? "nil")", + includeSnapshot: false + ) + return nil + } + + if SystemChrome.isPointerChrome(app.bundleIdentifier) { + ISPFileLog.event( + "focus-skip", + "pointer-chrome \(app.bundleIdentifier ?? "nil")", + includeSnapshot: false + ) + return nil + } + + return app + } + + private func watchLaunchpadOverlay() { + Timer.interval(seconds: 0.3) + .prepend(Date()) + .sink { [weak self] _ in + self?.pollLaunchpadOverlay() + } .store(in: cancelBag) } + + private func pollLaunchpadOverlay() { + let visible = LaunchpadOverlayDetector.isLaunchpadVisible() + + if visible { + launchpadSettleWork?.cancel() + launchpadSettleWork = nil + + if !launchpadSessionActive { + launchpadSessionActive = true + captureTypingLayoutBeforeLaunchpad() + ISPFileLog.event("launchpad", "OPENED") + applyLaunchpadFocus() + } + return + } + + guard launchpadSessionActive else { return } + + scheduleLaunchpadCloseSettle() + } + + private func scheduleLaunchpadCloseSettle() { + launchpadSettleWork?.cancel() + ISPFileLog.event( + "launchpad", + "close detected, settle \(launchpadCloseSettleMilliseconds)ms", + includeSnapshot: false + ) + let work = DispatchWorkItem { [weak self] in + self?.finishLaunchpadClose() + } + launchpadSettleWork = work + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(launchpadCloseSettleMilliseconds), + execute: work + ) + } + + private func finishLaunchpadClose() { + launchpadSettleWork = nil + + if LaunchpadOverlayDetector.isLaunchpadVisible() { + ISPFileLog.event("launchpad", "reappeared during settle — abort close") + applyLaunchpadFocus() + return + } + + launchpadSessionActive = false + + let app = resolvePostLaunchpadApplication() + ISPFileLog.event( + "launchpad", + "CLOSED → \(app?.localizedName ?? "?")[\(app?.bundleIdentifier ?? "nil")]" + ) + + guard let app else { + launchpadReturnLayout = nil + return + } + + if let pending = launchpadReturnLayout, + app.bundleIdentifier != pending.bundleId + { + ISPFileLog.event( + "launchpad-restore-drop", + "pending=\(pending.bundleId) actual=\(app.bundleIdentifier ?? "nil")", + includeSnapshot: false + ) + launchpadReturnLayout = nil + } + + appKind = .from(app, preferencesVM: preferencesVM) + } + + private func applyLaunchpadFocus() { + guard let dock = SystemChrome.dockRunningApplication() else { + ISPFileLog.event("launchpad", "OPENED but Dock process missing") + return + } + appKind = .from(dock, preferencesVM: preferencesVM) + } + + private func captureTypingLayoutBeforeLaunchpad() { + let candidate = typingApplicationForLaunchpadCapture() + guard let candidate, + let bundleId = candidate.bundleIdentifier, + !SystemChrome.isPointerChrome(bundleId), + !SystemChrome.isLaunchpadRelated(bundleId) + else { + launchpadReturnLayout = nil + ISPFileLog.event("launchpad-capture", "skipped — no typing app", includeSnapshot: false) + return + } + + let layout = InputSource.getCurrentInputSource() + launchpadReturnLayout = (bundleId, layout.persistentIdentifier) + ISPFileLog.event( + "launchpad-capture", + "\(bundleId) → \(layout.persistentIdentifier)" + ) + } + + private func typingApplicationForLaunchpadCapture() -> NSRunningApplication? { + if let current = appKind?.getApp(), + let id = current.bundleIdentifier, + !SystemChrome.isPointerChrome(id), + !SystemChrome.isLaunchpadRelated(id) + { + return current + } + + if let front = NSWorkspace.shared.frontmostApplication, + front.activationPolicy == .regular, + let id = front.bundleIdentifier, + !SystemChrome.isPointerChrome(id) + { + return front + } + + return nil + } + + func consumeLaunchpadLayoutRestore(for appKind: AppKind) -> InputSource? { + guard let pending = launchpadReturnLayout else { return nil } + + let bundleId = appKind.getApp().bundleIdentifier + guard bundleId == pending.bundleId else { return nil } + + launchpadReturnLayout = nil + + if preferencesVM.getAppCustomization(app: appKind.getApp())?.forcedKeyboard != nil { + ISPFileLog.event( + "launchpad-restore-skip", + "forced keyboard rule for \(bundleId ?? "?")", + includeSnapshot: false + ) + return nil + } + + guard let source = InputSource.resolvePersistedIdentifier(pending.inputSourceId) else { + ISPFileLog.event( + "launchpad-restore-miss", + "unresolved \(pending.inputSourceId)", + includeSnapshot: false + ) + return nil + } + + ISPFileLog.event( + "launchpad-restore", + "\(bundleId ?? "?") → \(source.persistentIdentifier)" + ) + return source + } + + private func resolvePostLaunchpadApplication() -> NSRunningApplication? { + if let app = NSWorkspace.shared.frontmostApplication, + app.activationPolicy == .regular, + !SystemChrome.isPointerChrome(app.bundleIdentifier) + { + return app + } + + if preferencesVM.preferences.isEnhancedModeEnabled, + let elm: UIElement = try? systemWideElement.attribute(.focusedApplication), + let pid = try? elm.pid(), + let app = NSRunningApplication(processIdentifier: pid), + app.activationPolicy == .regular, + !SystemChrome.isPointerChrome(app.bundleIdentifier) + { + return app + } + + return NSWorkspace.shared.frontmostApplication + } } extension ApplicationVM { diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index fed9918..515b219 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -33,6 +33,13 @@ final class IndicatorVM: ObservableObject { var refreshShortcutSubject = PassthroughSubject() + private var stableUserLayoutCacheWork: DispatchWorkItem? + private let stableUserLayoutCacheDelay: TimeInterval = 1.5 + + private var lastAppliedLayoutIdByBundle: [String: String] = [:] + private var lastAppliedAtByBundle: [String: Date] = [:] + private let leaveStealGuardWindow: TimeInterval = 1.25 + private(set) lazy var activateEventPublisher = Publishers.MergeMany([ longMouseDownPublisher(), stateChangesPublisher(), @@ -87,21 +94,14 @@ final class IndicatorVM: ObservableObject { } private func clearAppKeyboardCacheIfNeed() { - applicationVM.$appsDiff - .sink { [weak self] appsDiff in - appsDiff.removed - .compactMap { $0.bundleIdentifier } - .forEach { bundleId in - self?.preferencesVM.removeKeyboardCacheFor(bundleId: bundleId) - } - } - .store(in: cancelBag) - preferencesVM.$preferences .map(\.isRestorePreviouslyUsedInputSource) + .removeDuplicates() + .dropFirst() .filter { $0 == false } .sink { [weak self] _ in self?.preferencesVM.clearKeyboardCache() + ISPFileLog.event("cache-clear", "restore-previously-used turned off", includeSnapshot: false) } .store(in: cancelBag) } @@ -223,9 +223,13 @@ extension IndicatorVM { else { return state } @MainActor - func updateState(appKind: AppKind?, inputSource: InputSource, inputSourceChangeReason: InputSourceChangeReason) -> State { - // TODO: Move to outside - if let appKind = appKind { + func updateState( + appKind: AppKind?, + inputSource: InputSource, + inputSourceChangeReason: InputSourceChangeReason, + shouldCache: Bool + ) -> State { + if shouldCache, let appKind = appKind { preferencesVM.cacheKeyboardFor(appKind, keyboard: inputSource) } @@ -241,29 +245,112 @@ extension IndicatorVM { case .start: return state case let .appChanged(appKind): + self?.stableUserLayoutCacheWork?.cancel() + + if let previous = state.appKind, + let prevId = previous.getApp().bundleIdentifier, + prevId != appKind.getApp().bundleIdentifier, + !SystemChrome.isLaunchpadRelated(prevId) + { + let leavingLayout = self?.layoutForLeave(previous) ?? InputSource.getCurrentInputSource() + preferencesVM.rememberKeyboardOnLeave(for: previous, keyboard: leavingLayout) + } + + if let restored = self?.applicationVM.consumeLaunchpadLayoutRestore(for: appKind) { + ISPFileLog.event( + "switch", + "app=\(appKind.getApp().bundleIdentifier ?? "?") via=launchpad-restore → \(restored.persistentIdentifier)" + ) + inputSourceVM.select(inputSource: restored, app: appKind.getApp()) + self?.noteAppliedLayout(appKind, restored) + return updateState( + appKind: appKind, + inputSource: restored, + inputSourceChangeReason: .appSpecified(.cached(restored)), + shouldCache: false + ) + } + if let status = preferencesVM.getAppAutoSwitchKeyboard(appKind) { + let via: String = { + switch status { + case .cached: return "cached" + case .specified: return "specified" + } + }() + let disk = preferencesVM.appKeyboardCache.retrieve(appKind)?.persistentIdentifier ?? "nil" + let current = InputSource.getCurrentInputSource().persistentIdentifier + ISPFileLog.event( + "switch", + "app=\(appKind.getApp().bundleIdentifier ?? "?") via=\(via) → \(status.inputSource.persistentIdentifier) | current=\(current) disk=\(disk)" + ) inputSourceVM.select(inputSource: status.inputSource, app: appKind.getApp()) + self?.noteAppliedLayout(appKind, status.inputSource) return updateState( appKind: appKind, inputSource: status.inputSource, - inputSourceChangeReason: .appSpecified(status) + inputSourceChangeReason: .appSpecified(status), + shouldCache: false ) } else { + ISPFileLog.event( + "switch-skip", + "app=\(appKind.getApp().bundleIdentifier ?? "?") no rule/cache" + ) return updateState( appKind: appKind, inputSource: state.inputSource, - inputSourceChangeReason: .noChanges + inputSourceChangeReason: .noChanges, + shouldCache: false ) } case let .inputSourceChanged(inputSource): guard inputSource.persistentIdentifier != state.inputSource.persistentIdentifier else { return state } - return updateState(appKind: state.appKind, inputSource: inputSource, inputSourceChangeReason: .system) + ISPFileLog.event( + "tis-system", + "\(state.inputSource.persistentIdentifier) → \(inputSource.persistentIdentifier) app=\(state.appKind?.getApp().bundleIdentifier ?? "nil")" + ) + + if let appKind = state.appKind, + let forced = preferencesVM.forcedKeyboard(for: appKind), + forced.persistentIdentifier != inputSource.persistentIdentifier + { + ISPFileLog.event( + "forced-repin", + "\(appKind.getApp().bundleIdentifier ?? "?") \(inputSource.persistentIdentifier) → \(forced.persistentIdentifier)" + ) + inputSourceVM.select(inputSource: forced, app: appKind.getApp()) + self?.noteAppliedLayout(appKind, forced) + return updateState( + appKind: appKind, + inputSource: forced, + inputSourceChangeReason: .appSpecified(.specified(forced)), + shouldCache: false + ) + } + + let newState = updateState( + appKind: state.appKind, + inputSource: inputSource, + inputSourceChangeReason: .system, + shouldCache: false + ) + self?.scheduleStableUserLayoutCache(appKind: state.appKind, inputSource: inputSource) + return newState case let .switchInputSourceByShortcut(inputSource): inputSourceVM.select(inputSource: inputSource, app: state.appKind?.getApp()) + if let appKind = state.appKind { + self?.noteAppliedLayout(appKind, inputSource) + } - return updateState(appKind: state.appKind, inputSource: inputSource, inputSourceChangeReason: .shortcut) + return updateState( + appKind: state.appKind, + inputSource: inputSource, + inputSourceChangeReason: .shortcut, + shouldCache: true + ) } } .removeDuplicates(by: { $0.isSame(with: $1) }) @@ -290,6 +377,78 @@ extension IndicatorVM { send(.start) } + private func noteAppliedLayout(_ appKind: AppKind, _ inputSource: InputSource) { + guard let bundleId = appKind.getApp().bundleIdentifier else { return } + lastAppliedLayoutIdByBundle[bundleId] = inputSource.persistentIdentifier + lastAppliedAtByBundle[bundleId] = Date() + } + + private func layoutForLeave(_ appKind: AppKind) -> InputSource { + let current = InputSource.getCurrentInputSource() + let bundleId = appKind.getApp().bundleIdentifier ?? "?" + let disk = preferencesVM.appKeyboardCache.retrieve(appKind)?.persistentIdentifier ?? "nil" + + guard let bundleKey = appKind.getApp().bundleIdentifier, + let appliedId = lastAppliedLayoutIdByBundle[bundleKey], + let appliedAt = lastAppliedAtByBundle[bundleKey], + let applied = InputSource.resolvePersistedIdentifier(appliedId) + else { + ISPFileLog.event( + "leave-pick", + "\(bundleId) use=current \(current.persistentIdentifier) disk=\(disk)", + includeSnapshot: false + ) + return current + } + + let sinceApply = Date().timeIntervalSince(appliedAt) + if sinceApply <= leaveStealGuardWindow, + current.persistentIdentifier != applied.persistentIdentifier + { + ISPFileLog.event( + "leave-pick", + "\(bundleId) use=applied \(applied.persistentIdentifier) (guard \(String(format: "%.2f", sinceApply))s) current=\(current.persistentIdentifier) disk=\(disk)", + includeSnapshot: false + ) + return applied + } + + ISPFileLog.event( + "leave-pick", + "\(bundleId) use=current \(current.persistentIdentifier) applied=\(applied.persistentIdentifier) sinceApply=\(String(format: "%.2f", sinceApply)) disk=\(disk)", + includeSnapshot: false + ) + return current + } + + private func scheduleStableUserLayoutCache(appKind: AppKind?, inputSource: InputSource) { + stableUserLayoutCacheWork?.cancel() + + guard let appKind, + !SystemChrome.shouldNeverCache(appKind.getApp().bundleIdentifier), + preferencesVM.appNeedCacheKeyboard(appKind) + else { return } + + let tokenBundle = appKind.getApp().bundleIdentifier + let tokenLayout = inputSource.persistentIdentifier + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.state.appKind?.getApp().bundleIdentifier == tokenBundle, + self.state.inputSource.persistentIdentifier == tokenLayout, + !LaunchpadOverlayDetector.isLaunchpadVisible() + else { return } + + self.preferencesVM.cacheKeyboardFor(appKind, keyboard: inputSource) + ISPFileLog.event( + "cache-stable", + "\(tokenBundle ?? "?") → \(tokenLayout)", + includeSnapshot: false + ) + } + stableUserLayoutCacheWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + stableUserLayoutCacheDelay, execute: work) + } + private func shortcutBindings() -> [ShortcutBinding] { var bindings: [ShortcutBinding] = [] diff --git a/Input Source Pro/Models/PreferencesVM+AppCustomization.swift b/Input Source Pro/Models/PreferencesVM+AppCustomization.swift index 4a017aa..155f7a1 100644 --- a/Input Source Pro/Models/PreferencesVM+AppCustomization.swift +++ b/Input Source Pro/Models/PreferencesVM+AppCustomization.swift @@ -104,9 +104,22 @@ extension PreferencesVM { func getAppCustomization(bundleId: String?) -> AppRule? { guard let bundleId = bundleId else { return nil } - let request = AppRule.fetchRequest() + if let rule = fetchAppCustomization(exactBundleId: bundleId) { + return rule + } + + for alias in SystemChrome.ruleAliasBundleIDs(for: bundleId) { + if let rule = fetchAppCustomization(exactBundleId: alias) { + return rule + } + } - request.predicate = NSPredicate(format: "bundleId == %@", bundleId) + return nil + } + + private func fetchAppCustomization(exactBundleId: String) -> AppRule? { + let request = AppRule.fetchRequest() + request.predicate = NSPredicate(format: "bundleId == %@", exactBundleId) do { return try container.viewContext.fetch(request).first diff --git a/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift b/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift index 596f569..c2c78e7 100644 --- a/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift +++ b/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift @@ -1,7 +1,16 @@ import AppKit +import ApplicationServices extension PreferencesVM { func appNeedCacheKeyboard(_ appKind: AppKind) -> Bool { + if SystemChrome.shouldNeverCache(appKind.getApp().bundleIdentifier) { + return false + } + + if forcedKeyboard(for: appKind) != nil { + return false + } + if let browserRule = appKind.getBrowserInfo()?.rule, let keyboardRestoreStrategy = browserRule.keyboardRestoreStrategy { @@ -30,16 +39,75 @@ extension PreferencesVM { return false } + func forcedKeyboard(for appKind: AppKind) -> InputSource? { + if let inputSource = appKind.getBrowserInfo()?.rule?.forcedKeyboard { + return inputSource + } + return getAppCustomization(app: appKind.getApp())?.forcedKeyboard + } + func cacheKeyboardFor(_ appKind: AppKind, keyboard: InputSource) { + if SystemChrome.shouldNeverCache(appKind.getApp().bundleIdentifier) { + ISPFileLog.event( + "cache-skip", + "chrome \(appKind.getApp().bundleIdentifier ?? "nil")", + includeSnapshot: false + ) + return + } + + if forcedKeyboard(for: appKind) != nil { + appKeyboardCache.remove(appKind) + ISPFileLog.event( + "cache-skip", + "forced \(appKind.getApp().bundleIdentifier ?? "nil")", + includeSnapshot: false + ) + return + } + let defaultKeyboard = getAppDefaultKeyboard(appKind) - if appNeedCacheKeyboard(appKind), - defaultKeyboard?.persistentIdentifier != keyboard.persistentIdentifier - { + if appNeedCacheKeyboard(appKind) { appKeyboardCache.save(appKind, keyboard: keyboard) - } else { + ISPFileLog.event( + "cache-save", + "\(appKind.getApp().bundleIdentifier ?? "?") → \(keyboard.persistentIdentifier)", + includeSnapshot: false + ) + } + } + + func rememberKeyboardOnLeave(for appKind: AppKind, keyboard: InputSource) { + if SystemChrome.shouldNeverCache(appKind.getApp().bundleIdentifier) { + return + } + + if forcedKeyboard(for: appKind) != nil { appKeyboardCache.remove(appKind) + ISPFileLog.event( + "cache-skip", + "leave-forced \(appKind.getApp().bundleIdentifier ?? "?")", + includeSnapshot: false + ) + return } + + guard appNeedCacheKeyboard(appKind) else { + ISPFileLog.event( + "cache-skip", + "leave \(appKind.getApp().bundleIdentifier ?? "?") restore-disabled", + includeSnapshot: false + ) + return + } + + appKeyboardCache.save(appKind, keyboard: keyboard) + ISPFileLog.event( + "cache-leave", + "\(appKind.getApp().bundleIdentifier ?? "?") → \(keyboard.persistentIdentifier)", + includeSnapshot: false + ) } func removeKeyboardCacheFor(bundleId: String) { @@ -50,6 +118,54 @@ extension PreferencesVM { appKeyboardCache.clear() } + func logStartupSettings() { + let p = preferences + let sysId = p.systemWideDefaultKeyboardId.isEmpty ? "(empty)" : p.systemWideDefaultKeyboardId + let sysResolved = systemWideDefaultKeyboard?.persistentIdentifier ?? "(unresolved)" + let addrId = p.browserAddressDefaultKeyboardId.isEmpty ? "(empty)" : p.browserAddressDefaultKeyboardId + let addrResolved = browserAddressDefaultKeyboard?.persistentIdentifier ?? "(unresolved)" + + let restoreKey = "isRestorePreviouslyUsedInputSource" + let restoreRaw: String = { + if let domain = Bundle.main.bundleIdentifier.flatMap({ + UserDefaults.standard.persistentDomain(forName: $0) + }), let value = domain[restoreKey] { + return "\(value)" + } + return "(not in persistentDomain — using registered default)" + }() + + let migrateFlag = UserDefaults.standard.bool(forKey: "ISPEnableRestorePreviouslyUsed.v1") + let axTrusted = AXIsProcessTrusted() + + let appRules = (try? container.viewContext.fetch(AppRule.fetchRequest())) ?? [] + let forcedRules = appRules.compactMap { rule -> String? in + guard let bid = rule.bundleId, let kb = rule.forcedKeyboard else { return nil } + return "\(bid)→\(kb.persistentIdentifier)" + } + let restoreOverrides = appRules.compactMap { rule -> String? in + guard let bid = rule.bundleId else { return nil } + if rule.doRestoreKeyboard { return "\(bid):forceRestore" } + if rule.doNotRestoreKeyboard { return "\(bid):neverRestore" } + return nil + } + + ISPFileLog.event("settings", "restorePreviouslyUsed=\(p.isRestorePreviouslyUsedInputSource) raw=\(restoreRaw) migrateV1=\(migrateFlag)", includeSnapshot: false) + ISPFileLog.event("settings", "systemDefault id=\(sysId) resolved=\(sysResolved)", includeSnapshot: false) + ISPFileLog.event("settings", "browserAddressDefault id=\(addrId) resolved=\(addrResolved)", includeSnapshot: false) + ISPFileLog.event("settings", "enhancedMode=\(p.isEnhancedModeEnabled) axTrusted=\(axTrusted) CJKVFix=\(p.isCJKVFixEnabled)", includeSnapshot: false) + ISPFileLog.event( + "settings", + "triggers switchApp=\(p.isActiveWhenSwitchApp) focusChange=\(p.isActiveWhenFocusedElementChanges) inputSource=\(p.isActiveWhenSwitchInputSource) longPress=\(p.isActiveWhenLongpressLeftMouse)", + includeSnapshot: false + ) + ISPFileLog.event( + "settings", + "cacheEntries=\(appKeyboardCache.entryCount) appRules=\(appRules.count) forced=[\(forcedRules.joined(separator: ", "))] overrides=[\(restoreOverrides.joined(separator: ", "))]", + includeSnapshot: false + ) + } + enum AppAutoSwitchKeyboardStatus { case cached(InputSource), specified(InputSource) @@ -64,6 +180,24 @@ extension PreferencesVM { func getAppAutoSwitchKeyboard( _ appKind: AppKind ) -> AppAutoSwitchKeyboardStatus? { + if appKind.getBrowserInfo()?.isFocusedOnAddressBar == true, + let browserAddressKeyboard = browserAddressDefaultKeyboard + { + return .specified(browserAddressKeyboard) + } + + if let forced = forcedKeyboard(for: appKind) { + if appKeyboardCache.retrieve(appKind) != nil { + appKeyboardCache.remove(appKind) + ISPFileLog.event( + "cache-purge", + "forced wins \(appKind.getApp().bundleIdentifier ?? "?") → \(forced.persistentIdentifier)", + includeSnapshot: false + ) + } + return .specified(forced) + } + if let cachedKeyboard = getAppCachedKeyboard(appKind) { return .cached(cachedKeyboard) } @@ -72,9 +206,6 @@ extension PreferencesVM { return .specified(defaultKeyboard) } - // Fallback to system-wide default keyboard to prevent input method getting stuck - // This ensures that apps without specific keyboard configuration will always - // switch to the system default, resolving issues like ChatGPT → Terminal switching if let systemDefaultKeyboard = systemWideDefaultKeyboard { return .specified(systemDefaultKeyboard) } @@ -94,10 +225,10 @@ extension PreferencesVM { return browserAddressKeyboard } - if let inputSource = appKind.getBrowserInfo()?.rule?.forcedKeyboard { - return inputSource + if let forced = forcedKeyboard(for: appKind) { + return forced } - return getAppCustomization(app: appKind.getApp())?.forcedKeyboard ?? systemWideDefaultKeyboard + return systemWideDefaultKeyboard } } diff --git a/Input Source Pro/Models/PreferencesVM+KeyboardConfig.swift b/Input Source Pro/Models/PreferencesVM+KeyboardConfig.swift index d6ee2f6..440061e 100644 --- a/Input Source Pro/Models/PreferencesVM+KeyboardConfig.swift +++ b/Input Source Pro/Models/PreferencesVM+KeyboardConfig.swift @@ -149,6 +149,14 @@ extension PreferencesVM { $0.indicatorInfo = $0.isShowInputSourcesLabel ? .iconAndTitle : .iconOnly } } + + let restoreMigrateKey = "ISPEnableRestorePreviouslyUsed.v1" + if !UserDefaults.standard.bool(forKey: restoreMigrateKey) { + UserDefaults.standard.set(true, forKey: restoreMigrateKey) + if !preferences.isRestorePreviouslyUsedInputSource { + update { $0.isRestorePreviouslyUsedInputSource = true } + } + } } func migrateBoutiqueIfNeed() { diff --git a/Input Source Pro/System/AppDelegate.swift b/Input Source Pro/System/AppDelegate.swift index 85dfcc2..564aa4c 100644 --- a/Input Source Pro/System/AppDelegate.swift +++ b/Input Source Pro/System/AppDelegate.swift @@ -77,6 +77,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) LaunchAtLogin.migrateIfNeeded() + ISPFileLog.startSession() + preferencesVM.logStartupSettings() openPreferencesAtFirstLaunch() sendLaunchPing() updateInstallVersionInfo() diff --git a/Input Source Pro/Utilities/AppKeyboardCache.swift b/Input Source Pro/Utilities/AppKeyboardCache.swift index b511ba4..9e3ec4c 100644 --- a/Input Source Pro/Utilities/AppKeyboardCache.swift +++ b/Input Source Pro/Utilities/AppKeyboardCache.swift @@ -4,23 +4,43 @@ import os @MainActor class AppKeyboardCache { - private var cache = [String: String]() + private static let storageKey = "ISPAppKeyboardCache.v1" + + private var cache: [String: String] + private let defaults: UserDefaults let logger = ISPLogger(category: String(describing: AppKeyboardCache.self)) - func remove(_ kind: AppKind) { - if let id = kind.getId(), cache[id] != nil { - logger.debug { "Remove #\(id)" } - cache[id] = nil + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let stored = defaults.dictionary(forKey: Self.storageKey) as? [String: String] { + cache = stored + } else { + cache = [:] } + logger.debug { "Loaded \(self.cache.count) keyboard memory entries" } + } + + var entryCount: Int { cache.count } + + func remove(_ kind: AppKind) { + guard let id = kind.getId(), cache[id] != nil else { return } + logger.debug { "Remove #\(id)" } + cache.removeValue(forKey: id) + persist() } func save(_ kind: AppKind, keyboard: InputSource?) { guard let id = kind.getId() else { return } if let keyboardId = keyboard?.persistentIdentifier { + guard cache[id] != keyboardId else { return } logger.debug { "Save \(id)#\(keyboardId)" } cache[id] = keyboardId + persist() + } else if cache[id] != nil { + cache.removeValue(forKey: id) + persist() } } @@ -35,17 +55,24 @@ class AppKeyboardCache { } func clear() { - // FIXME: - Some apps/websites where 'restore' is selected should be ignored logger.debug { "Clear All" } cache.removeAll() + persist() } func remove(byBundleId bundleId: String) { - for key in cache.keys { - if key.starts(with: "\(bundleId)_") { - logger.debug { "Remove \(bundleId)#\(key)" } - cache[key] = nil - } + let prefix = "\(bundleId)_" + let keys = cache.keys.filter { $0 == bundleId || $0.hasPrefix(prefix) } + guard !keys.isEmpty else { return } + + for key in keys { + logger.debug { "Remove \(bundleId)#\(key)" } + cache.removeValue(forKey: key) } + persist() + } + + private func persist() { + defaults.set(cache, forKey: Self.storageKey) } } diff --git a/Input Source Pro/Utilities/AppKit/LaunchpadOverlayDetector.swift b/Input Source Pro/Utilities/AppKit/LaunchpadOverlayDetector.swift new file mode 100644 index 0000000..8a1990c --- /dev/null +++ b/Input Source Pro/Utilities/AppKit/LaunchpadOverlayDetector.swift @@ -0,0 +1,40 @@ +import AppKit +import CoreGraphics +import Foundation + +enum LaunchpadOverlayDetector { + static func isLaunchpadVisible() -> Bool { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let info = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return false + } + + let screens = NSScreen.screens + guard !screens.isEmpty else { return false } + + for window in info { + let owner = window[kCGWindowOwnerName as String] as? String ?? "" + guard owner == "Dock" || owner == "Launchpad" else { continue } + + let layer = window[kCGWindowLayer as String] as? Int ?? 0 + guard (25 ... 35).contains(layer) else { continue } + + guard let bounds = window[kCGWindowBounds as String] as? [String: CGFloat] else { continue } + let width = bounds["Width"] ?? 0 + let height = bounds["Height"] ?? 0 + + guard height >= 400, width >= 600 else { continue } + + let area = width * height + let covers = screens.contains { screen in + let screenArea = screen.frame.width * screen.frame.height + return screenArea > 0 && area >= screenArea * 0.75 + } + if covers { + return true + } + } + + return false + } +} diff --git a/Input Source Pro/Utilities/AppKit/SystemChrome.swift b/Input Source Pro/Utilities/AppKit/SystemChrome.swift new file mode 100644 index 0000000..f96bfed --- /dev/null +++ b/Input Source Pro/Utilities/AppKit/SystemChrome.swift @@ -0,0 +1,46 @@ +import AppKit +import Foundation + +enum SystemChrome { + static let dockBundleID = "com.apple.dock" + static let launchpadLauncherBundleID = "com.apple.launchpad.launcher" + + private static let pointerChromeBundleIDs: Set = [ + dockBundleID, + ] + + private static let neverCacheBundleIDs: Set = [ + dockBundleID, + launchpadLauncherBundleID, + ] + + static func isPointerChrome(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return pointerChromeBundleIDs.contains(bundleIdentifier) + } + + static func isLaunchpadRelated(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return bundleIdentifier == dockBundleID || bundleIdentifier == launchpadLauncherBundleID + } + + static func shouldNeverCache(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return neverCacheBundleIDs.contains(bundleIdentifier) + } + + static func ruleAliasBundleIDs(for bundleId: String) -> [String] { + switch bundleId { + case dockBundleID: + return [launchpadLauncherBundleID] + case launchpadLauncherBundleID: + return [dockBundleID] + default: + return [] + } + } + + static func dockRunningApplication() -> NSRunningApplication? { + NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == dockBundleID } + } +} diff --git a/Input Source Pro/Utilities/Log/ISPFileLog.swift b/Input Source Pro/Utilities/Log/ISPFileLog.swift new file mode 100644 index 0000000..bb6233d --- /dev/null +++ b/Input Source Pro/Utilities/Log/ISPFileLog.swift @@ -0,0 +1,83 @@ +import AppKit +import Foundation +import os.log + +enum ISPFileLog { + private static let queue = DispatchQueue(label: "pro.inputsource.filelog") + private static let osLog = Logger(subsystem: "pro.inputsource.InputSourcePro", category: "FileLog") + private static let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + static var directoryURL: URL { + let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library") + return base.appendingPathComponent("Logs/InputSourcePro", isDirectory: true) + } + + static var fileURL: URL { + directoryURL.appendingPathComponent("isp.log", isDirectory: false) + } + + @MainActor + static func startSession() { + ensureDirectory() + let marker = String(repeating: "-", count: 72) + writeRaw("\n\(marker)\n\(iso.string(from: Date())) ISP SESSION START pid=\(ProcessInfo.processInfo.processIdentifier)\n\(marker)\n") + event("boot", "session start") + } + + @MainActor + static func event(_ kind: String, _ message: String, includeSnapshot: Bool = true) { + let stamp = iso.string(from: Date()) + var line = "\(stamp) [\(kind)] \(message)" + if includeSnapshot { + line += " | \(snapshot())" + } + writeRaw(line + "\n") + osLog.info("\(kind, privacy: .public): \(message, privacy: .public)") + #if DEBUG + print(stamp, "[\(kind)]", message) + #endif + } + + @MainActor + static func snapshot() -> String { + let front = NSWorkspace.shared.frontmostApplication + let layout = InputSource.getCurrentInputSource() + let launchpad = LaunchpadOverlayDetector.isLaunchpadVisible() + let ws = "\(front?.localizedName ?? "?")[\(front?.bundleIdentifier ?? "nil")] pol=\(front?.activationPolicy.rawValue ?? -1)" + let lay = "\(layout.name)[\(layout.persistentIdentifier)]" + return "ws=\(ws) launchpad=\(launchpad) layout=\(lay)" + } + + static func openInFinder() { + ensureDirectory() + if !FileManager.default.fileExists(atPath: fileURL.path) { + writeRaw("") + } + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } + + private static func ensureDirectory() { + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } + + private static func writeRaw(_ text: String) { + queue.async { + ensureDirectory() + let path = fileURL.path + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + guard let handle = try? FileHandle(forWritingTo: fileURL) else { return } + defer { try? handle.close() } + _ = try? handle.seekToEnd() + if let data = text.data(using: .utf8) { + try? handle.write(contentsOf: data) + } + } + } +} diff --git a/Input Source Pro/Utilities/SwiftUI+FlexibleButtonSizing.swift b/Input Source Pro/Utilities/SwiftUI+FlexibleButtonSizing.swift index f9cbf57..3c6c63c 100644 --- a/Input Source Pro/Utilities/SwiftUI+FlexibleButtonSizing.swift +++ b/Input Source Pro/Utilities/SwiftUI+FlexibleButtonSizing.swift @@ -2,12 +2,7 @@ import SwiftUI private struct FlexibleButtonSizingModifier: ViewModifier { func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - content - .buttonSizing(.flexible) - } else { - content - } + content } } From 5629bbf325c0d970af1d2931fc7209cd6250a400 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 19 Aug 2026 11:05:59 +0300 Subject: [PATCH 2/4] add title default --- Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift | 6 +++++- Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift b/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift index acfe742..175c20e 100644 --- a/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift +++ b/Input Source Pro/Utilities/AppKit/AppRuleMenuItem.swift @@ -24,7 +24,11 @@ class AppRuleMenuItem: NSMenuItem { self.inputSourceVM = inputSourceVM self.inputSource = inputSource - super.init(title: inputSource?.name ?? "", action: #selector(forceKeyboard(_:)), keyEquivalent: "") + super.init( + title: inputSource?.name ?? "Default".i18n(), + action: #selector(forceKeyboard(_:)), + keyEquivalent: "" + ) target = self diff --git a/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift b/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift index db021a2..51cfc02 100644 --- a/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift +++ b/Input Source Pro/Utilities/AppKit/BrowserRuleMenuItem.swift @@ -27,7 +27,11 @@ class BrowserRuleMenuItem: NSMenuItem { self.inputSourceVM = inputSourceVM self.inputSource = inputSource - super.init(title: inputSource?.name ?? "", action: #selector(forceKeyboard(_:)), keyEquivalent: "") + super.init( + title: inputSource?.name ?? "Default".i18n(), + action: #selector(forceKeyboard(_:)), + keyEquivalent: "" + ) target = self From 4778dbdba723497c4ae176206846d62c62e377c6 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Thu, 27 Aug 2026 17:57:28 +0300 Subject: [PATCH 3/4] feat: per-window layout memory and Accessibility startup gate --- .../IndicatorWindowController.swift | 37 -- Input Source Pro/Models/ApplicationVM.swift | 62 +- Input Source Pro/Models/IndicatorVM.swift | 553 +++++++++++++++--- Input Source Pro/Models/PermissionsVM.swift | 14 +- .../PreferencesVM+AppKeyboardCache.swift | 13 +- .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + Input Source Pro/System/AppDelegate.swift | 133 ++++- .../Utilities/AppKeyboardCache.swift | 58 +- Input Source Pro/Utilities/AppKind.swift | 87 ++- .../Utilities/AppKit/CGWindowIdentity.swift | 44 ++ .../Utilities/Log/ISPFileLog.swift | 2 +- .../AppKeyboardCacheMultiInstanceTests.swift | 88 +++ Tests/AppKindComparisonTests.swift | 234 +++++++- 17 files changed, 1174 insertions(+), 166 deletions(-) create mode 100644 Input Source Pro/Utilities/AppKit/CGWindowIdentity.swift create mode 100644 Tests/AppKeyboardCacheMultiInstanceTests.swift diff --git a/Input Source Pro/Controllers/IndicatorWindowController.swift b/Input Source Pro/Controllers/IndicatorWindowController.swift index 2a42081..107cbb3 100644 --- a/Input Source Pro/Controllers/IndicatorWindowController.swift +++ b/Input Source Pro/Controllers/IndicatorWindowController.swift @@ -46,43 +46,6 @@ class IndicatorWindowController: FloatWindowController { super.init() contentViewController = indicatorVC - - let indicatorPublisher = indicatorVM.activateEventPublisher - .receive(on: DispatchQueue.main) - .map { (event: $0, inputSource: self.indicatorVM.state.inputSource) } - .flatMapLatest { [weak self] params -> AnyPublisher in - let event = params.event - let inputSource = params.inputSource - - guard let self = self else { return Empty().eraseToAnyPublisher() } - guard let appKind = self.applicationVM.appKind, - !event.isJustHide, - !preferencesVM.isHideIndicator(appKind) - else { return self.justHidePublisher() } - - let app = appKind.getApp() - - // Function-key toggles are one-shot status changes: always use the - // transient auto-hide path, never the persistent always-on / auto-show - // flows that are tied to the focused input field. - if case .functionKeyModeChanges = event { - return self.autoHidePublisher(event: event, inputSource: inputSource, appKind: appKind) - } - - if preferencesVM.isShowAlwaysOnIndicator(app: app) { - return self.alwaysOnPublisher(event: event, inputSource: inputSource, appKind: appKind) - } else if preferencesVM.needDetectFocusedFieldChanges(app: app) { - return self.autoShowPublisher(event: event, inputSource: inputSource, appKind: appKind) - } else { - return self.autoHidePublisher(event: event, inputSource: inputSource, appKind: appKind) - } - } - .eraseToAnyPublisher() - - indicatorVM.screenIsLockedPublisher - .flatMapLatest { isLocked in isLocked ? Empty().eraseToAnyPublisher() : indicatorPublisher } - .sink { _ in } - .store(in: cancelBag) } @available(*, unavailable) diff --git a/Input Source Pro/Models/ApplicationVM.swift b/Input Source Pro/Models/ApplicationVM.swift index 42f5b39..7c329e0 100644 --- a/Input Source Pro/Models/ApplicationVM.swift +++ b/Input Source Pro/Models/ApplicationVM.swift @@ -2,6 +2,7 @@ import AppKit import AXSwift import Combine import CombineExt +import ApplicationServices import os @MainActor @@ -68,32 +69,61 @@ extension ApplicationVM { guard let preferencesVM = self?.preferencesVM else { return Empty().eraseToAnyPublisher() } - guard NSApplication.isBrowser(app) - else { return Just(.from(app, preferencesVM: preferencesVM)).eraseToAnyPublisher() } + if NSApplication.isBrowser(app) { + return Timer + .interval(seconds: 1) + .prepend(Date()) + .compactMap { _ in app.focusedUIElement(preferencesVM: preferencesVM) } + .first() + .flatMapLatest { _ in + app.watchAX([ + .focusedUIElementChanged, + .titleChanged, + .windowCreated, + ], [.application, .window]) + .filter { $0.notification != .windowCreated } + .map { event in event.runningApp } + } + .prepend(app) + .compactMap { app -> AppKind? in .from(app, preferencesVM: preferencesVM) } + .eraseToAnyPublisher() + } + + let pid = app.processIdentifier + let poll = Timer + .interval(seconds: 0.25) + .map { _ in app } + .prepend(app) - return Timer - .interval(seconds: 1) - .prepend(Date()) - .compactMap { _ in app.focusedUIElement(preferencesVM: preferencesVM) } - .first() - .flatMapLatest { _ in - app.watchAX([ + let ax: AnyPublisher = { + guard AXIsProcessTrusted() else { + return Empty().eraseToAnyPublisher() + } + return app.watchAX( + [ + .focusedWindowChanged, .focusedUIElementChanged, - .titleChanged, .windowCreated, - ], [.application, .window]) - .filter { $0.notification != .windowCreated } - .map { event in event.runningApp } - } - .prepend(app) + ], + [.application, .window] + ) + .filter { $0.notification != .windowCreated } + .map { event in event.runningApp } + .eraseToAnyPublisher() + }() + + return Publishers.Merge(poll, ax) + .filter { $0.processIdentifier == pid } .compactMap { app -> AppKind? in .from(app, preferencesVM: preferencesVM) } .eraseToAnyPublisher() } .removeDuplicates(by: { $0.isSameAppOrWebsite(with: $1, detectAddressBar: true) }) .sink { [weak self] in + let app = $0.getApp() + let window = $0.windowCacheId() ?? "nil" ISPFileLog.event( "focus", - "appKind=\($0.getApp().bundleIdentifier ?? "nil") \($0.getApp().localizedName ?? "")" + "app=\(app.bundleIdentifier ?? "nil")#\(app.processIdentifier) name=\(app.localizedName ?? "?") cacheId=\($0.getId() ?? "nil") window=\(window)" ) self?.appKind = $0 } diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index 515b219..8295e1c 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -33,12 +33,16 @@ final class IndicatorVM: ObservableObject { var refreshShortcutSubject = PassthroughSubject() - private var stableUserLayoutCacheWork: DispatchWorkItem? - private let stableUserLayoutCacheDelay: TimeInterval = 1.5 - - private var lastAppliedLayoutIdByBundle: [String: String] = [:] - private var lastAppliedAtByBundle: [String: Date] = [:] - private let leaveStealGuardWindow: TimeInterval = 1.25 + private var committedLayoutIdByCacheId: [String: String] = [:] + private var pendingUserAcceptLayoutId: String? + private var pendingUserAcceptCacheId: String? + private var userAcceptWorkItem: DispatchWorkItem? + private let userAcceptDelay: TimeInterval = 1.5 + private var isApplyingLayout = false + private var applyDebounceWorkItem: DispatchWorkItem? + private var applyGeneration = 0 + private let applyDebounceMilliseconds = 60 + private let applyReselectMilliseconds = 120 private(set) lazy var activateEventPublisher = Publishers.MergeMany([ longMouseDownPublisher(), @@ -245,24 +249,126 @@ extension IndicatorVM { case .start: return state case let .appChanged(appKind): - self?.stableUserLayoutCacheWork?.cancel() + let previous = state.appKind - if let previous = state.appKind, - let prevId = previous.getApp().bundleIdentifier, - prevId != appKind.getApp().bundleIdentifier, - !SystemChrome.isLaunchpadRelated(prevId) - { - let leavingLayout = self?.layoutForLeave(previous) ?? InputSource.getCurrentInputSource() - preferencesVM.rememberKeyboardOnLeave(for: previous, keyboard: leavingLayout) + if Self.shouldSkipSameContextAppChange(previous: previous, next: appKind) { + ISPFileLog.event( + "multi-inst", + "skip same-context \(Self.appLogId(appKind)) keep live=\(InputSource.getCurrentInputSource().persistentIdentifier) pendingAccept=\(self?.userAcceptWorkItem != nil)", + includeSnapshot: false + ) + return state + } + + let isSameBundleDifferentProcess = Self.isSameBundleDifferentProcess( + previous: previous, + next: appKind + ) + let isSameProcessWindowChange = Self.isSameProcessWindowChange( + previous: previous, + next: appKind + ) + let liveBefore = InputSource.getCurrentInputSource() + let nextCacheId = appKind.getId() ?? "nil" + let nextDisk = { + if isSameProcessWindowChange { + return preferencesVM.appKeyboardCache.retrieveExact(appKind)?.persistentIdentifier ?? "nil" + } + return preferencesVM.appKeyboardCache.retrieve(appKind)?.persistentIdentifier ?? "nil" + }() + + ISPFileLog.event( + "multi-inst", + "focus-change from=\(previous.map(Self.appLogId) ?? "nil") to=\(Self.appLogId(appKind)) sameBundleDiffPid=\(isSameBundleDifferentProcess) windowChange=\(isSameProcessWindowChange) cacheId=\(nextCacheId) disk=\(nextDisk) live=\(liveBefore.persistentIdentifier) stateWas=\(state.inputSource.persistentIdentifier)", + includeSnapshot: false + ) + + if let previous { + let leftProcess = previous.getApp().processIdentifier + != appKind.getApp().processIdentifier + let leftWindow = isSameProcessWindowChange + if (leftProcess || leftWindow), + !SystemChrome.isLaunchpadRelated(previous.getApp().bundleIdentifier) + { + let committedId = self?.committedLayoutIdByCacheId[previous.getId() ?? ""] + let exactDiskId = preferencesVM.appKeyboardCache.retrieveExact(previous)? + .persistentIdentifier + if leftWindow { + let decision = Self.windowLeaveSaveDecision( + liveId: liveBefore.persistentIdentifier, + committedId: committedId, + pendingAcceptLayoutId: self?.pendingUserAcceptLayoutId, + pendingAcceptCacheId: self?.pendingUserAcceptCacheId, + previousCacheId: previous.getId(), + exactDiskId: exactDiskId + ) + self?.cancelUserAccept() + let toSave: InputSource + if decision.layoutId == liveBefore.persistentIdentifier { + toSave = liveBefore + } else { + toSave = InputSource.resolvePersistedIdentifier(decision.layoutId) + ?? liveBefore + } + preferencesVM.rememberKeyboardOnLeave( + for: previous, + keyboard: toSave + ) + self?.markCommitted(previous, toSave) + ISPFileLog.event( + "cache-leave-window", + "\(Self.appLogId(previous)) → \(toSave.persistentIdentifier) (\(decision.reason); live=\(liveBefore.persistentIdentifier))", + includeSnapshot: false + ) + } else if let decision = Self.processLeaveSaveDecision( + liveId: liveBefore.persistentIdentifier, + committedId: committedId, + pendingAcceptLayoutId: self?.pendingUserAcceptLayoutId, + pendingAcceptCacheId: self?.pendingUserAcceptCacheId, + previousCacheId: previous.getId(), + isApplyingLayout: self?.isApplyingLayout == true + ) { + self?.cancelUserAccept() + let toSave: InputSource + if decision.layoutId == liveBefore.persistentIdentifier { + toSave = liveBefore + } else { + toSave = InputSource.resolvePersistedIdentifier(decision.layoutId) + ?? liveBefore + } + preferencesVM.rememberKeyboardOnLeave( + for: previous, + keyboard: toSave + ) + self?.markCommitted(previous, toSave) + ISPFileLog.event( + "cache-leave-process", + "\(Self.appLogId(previous)) → \(toSave.persistentIdentifier) (\(decision.reason); live=\(liveBefore.persistentIdentifier))", + includeSnapshot: false + ) + } else { + self?.cancelUserAccept() + let disk = exactDiskId + ?? preferencesVM.appKeyboardCache.retrieve(previous)?.persistentIdentifier + ?? "nil" + ISPFileLog.event( + "cache-leave-skip", + "\(Self.appLogId(previous)) no auto-save keep disk=\(disk) live=\(liveBefore.persistentIdentifier) committed=\(committedId ?? "nil") reason=process", + includeSnapshot: false + ) + } + } } + self?.cancelUserAccept() + self?.cancelPendingApply() + if let restored = self?.applicationVM.consumeLaunchpadLayoutRestore(for: appKind) { ISPFileLog.event( "switch", - "app=\(appKind.getApp().bundleIdentifier ?? "?") via=launchpad-restore → \(restored.persistentIdentifier)" + "app=\(Self.appLogId(appKind)) via=launchpad-restore → \(restored.persistentIdentifier)" ) - inputSourceVM.select(inputSource: restored, app: appKind.getApp()) - self?.noteAppliedLayout(appKind, restored) + self?.applyLayoutOnce(restored, appKind: appKind, liveBefore: liveBefore) return updateState( appKind: appKind, inputSource: restored, @@ -271,21 +377,87 @@ extension IndicatorVM { ) } + if isSameProcessWindowChange { + if let exact = preferencesVM.appKeyboardCache.retrieveExact(appKind), + preferencesVM.appNeedCacheKeyboard(appKind) + { + ISPFileLog.event( + "switch", + "app=\(Self.appLogId(appKind)) via=window-cached → \(exact.persistentIdentifier) | current=\(liveBefore.persistentIdentifier) cacheId=\(nextCacheId)" + ) + self?.applyLayoutOnce(exact, appKind: appKind, liveBefore: liveBefore) + return updateState( + appKind: appKind, + inputSource: exact, + inputSourceChangeReason: .appSpecified(.cached(exact)), + shouldCache: false + ) + } + + let current = InputSource.getCurrentInputSource() + self?.markCommitted(appKind, current) + ISPFileLog.event( + "switch-skip", + "app=\(Self.appLogId(appKind)) window keep-live → \(current.persistentIdentifier)" + ) + return updateState( + appKind: appKind, + inputSource: current, + inputSourceChangeReason: .noChanges, + shouldCache: false + ) + } + if let status = preferencesVM.getAppAutoSwitchKeyboard(appKind) { - let via: String = { + let forced = preferencesVM.forcedKeyboard(for: appKind) + let isAddressBar = appKind.getBrowserInfo()?.isFocusedOnAddressBar == true + let applyAcrossInstances = Self.shouldApplyAutoSwitchAcrossSameBundleInstances( + status: status, + forced: forced, + isAddressBar: isAddressBar + ) + let statusKind: String = { switch status { case .cached: return "cached" case .specified: return "specified" } }() - let disk = preferencesVM.appKeyboardCache.retrieve(appKind)?.persistentIdentifier ?? "nil" + + if isSameBundleDifferentProcess, !applyAcrossInstances { + let current = InputSource.getCurrentInputSource() + self?.markCommitted(appKind, current) + ISPFileLog.event( + "multi-inst", + "decision=keep-live app=\(Self.appLogId(appKind)) status=\(statusKind) target=\(status.inputSource.persistentIdentifier) forced=\(forced?.persistentIdentifier ?? "nil") addressBar=\(isAddressBar) → live \(current.persistentIdentifier)", + includeSnapshot: false + ) + ISPFileLog.event( + "switch-skip", + "app=\(Self.appLogId(appKind)) same-bundle multi-instance keep-live → \(current.persistentIdentifier)" + ) + return updateState( + appKind: appKind, + inputSource: current, + inputSourceChangeReason: .noChanges, + shouldCache: false + ) + } + + if isSameBundleDifferentProcess { + ISPFileLog.event( + "multi-inst", + "decision=apply app=\(Self.appLogId(appKind)) status=\(statusKind) → \(status.inputSource.persistentIdentifier) forced=\(forced?.persistentIdentifier ?? "nil") addressBar=\(isAddressBar) disk=\(nextDisk)", + includeSnapshot: false + ) + } + + let via = statusKind let current = InputSource.getCurrentInputSource().persistentIdentifier ISPFileLog.event( "switch", - "app=\(appKind.getApp().bundleIdentifier ?? "?") via=\(via) → \(status.inputSource.persistentIdentifier) | current=\(current) disk=\(disk)" + "app=\(Self.appLogId(appKind)) via=\(via) → \(status.inputSource.persistentIdentifier) | current=\(current) disk=\(nextDisk) cacheId=\(nextCacheId)" ) - inputSourceVM.select(inputSource: status.inputSource, app: appKind.getApp()) - self?.noteAppliedLayout(appKind, status.inputSource) + self?.applyLayoutOnce(status.inputSource, appKind: appKind, liveBefore: liveBefore) return updateState( appKind: appKind, @@ -294,23 +466,32 @@ extension IndicatorVM { shouldCache: false ) } else { + let current = InputSource.getCurrentInputSource() + self?.markCommitted(appKind, current) + ISPFileLog.event( + "multi-inst", + "decision=no-rule keep-live app=\(Self.appLogId(appKind)) cacheId=\(nextCacheId) → \(current.persistentIdentifier)", + includeSnapshot: false + ) ISPFileLog.event( "switch-skip", - "app=\(appKind.getApp().bundleIdentifier ?? "?") no rule/cache" + "app=\(Self.appLogId(appKind)) no rule/cache → live \(current.persistentIdentifier)" ) return updateState( appKind: appKind, - inputSource: state.inputSource, + inputSource: current, inputSourceChangeReason: .noChanges, shouldCache: false ) } case let .inputSourceChanged(inputSource): - guard inputSource.persistentIdentifier != state.inputSource.persistentIdentifier else { return state } + guard inputSource.persistentIdentifier != state.inputSource.persistentIdentifier else { + return state + } ISPFileLog.event( "tis-system", - "\(state.inputSource.persistentIdentifier) → \(inputSource.persistentIdentifier) app=\(state.appKind?.getApp().bundleIdentifier ?? "nil")" + "\(state.inputSource.persistentIdentifier) → \(inputSource.persistentIdentifier) app=\(state.appKind.map(Self.appLogId) ?? "nil")" ) if let appKind = state.appKind, @@ -319,10 +500,9 @@ extension IndicatorVM { { ISPFileLog.event( "forced-repin", - "\(appKind.getApp().bundleIdentifier ?? "?") \(inputSource.persistentIdentifier) → \(forced.persistentIdentifier)" + "\(Self.appLogId(appKind)) \(inputSource.persistentIdentifier) → \(forced.persistentIdentifier)" ) - inputSourceVM.select(inputSource: forced, app: appKind.getApp()) - self?.noteAppliedLayout(appKind, forced) + self?.applyLayoutOnce(forced, appKind: appKind, liveBefore: inputSource) return updateState( appKind: appKind, inputSource: forced, @@ -331,25 +511,27 @@ extension IndicatorVM { ) } - let newState = updateState( + if let appKind = state.appKind { + self?.scheduleUserAcceptIfNeeded(appKind: appKind, candidate: inputSource) + } + + return updateState( appKind: state.appKind, inputSource: inputSource, inputSourceChangeReason: .system, shouldCache: false ) - self?.scheduleStableUserLayoutCache(appKind: state.appKind, inputSource: inputSource) - return newState case let .switchInputSourceByShortcut(inputSource): inputSourceVM.select(inputSource: inputSource, app: state.appKind?.getApp()) if let appKind = state.appKind { - self?.noteAppliedLayout(appKind, inputSource) + self?.rememberShortcutLayout(appKind, inputSource) } return updateState( appKind: state.appKind, inputSource: inputSource, inputSourceChangeReason: .shortcut, - shouldCache: true + shouldCache: false ) } } @@ -377,76 +559,285 @@ extension IndicatorVM { send(.start) } - private func noteAppliedLayout(_ appKind: AppKind, _ inputSource: InputSource) { - guard let bundleId = appKind.getApp().bundleIdentifier else { return } - lastAppliedLayoutIdByBundle[bundleId] = inputSource.persistentIdentifier - lastAppliedAtByBundle[bundleId] = Date() + private func applyLayoutOnce(_ inputSource: InputSource, appKind: AppKind, liveBefore: InputSource) { + markCommitted(appKind, inputSource) + let alreadyLive = liveBefore.persistentIdentifier == inputSource.persistentIdentifier + if alreadyLive { + ISPFileLog.event( + "apply", + "\(Self.appLogId(appKind)) already-live \(inputSource.persistentIdentifier)", + includeSnapshot: false + ) + return + } + + applyDebounceWorkItem?.cancel() + applyGeneration += 1 + let generation = applyGeneration + let targetId = inputSource.persistentIdentifier + ISPFileLog.event( + "apply", + "\(Self.appLogId(appKind)) schedule select \(targetId) in \(applyDebounceMilliseconds)ms", + includeSnapshot: false + ) + let work = DispatchWorkItem { [weak self] in + self?.performSelect( + inputSource, + appKind: appKind, + generation: generation + ) + } + applyDebounceWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(applyDebounceMilliseconds), + execute: work + ) } - private func layoutForLeave(_ appKind: AppKind) -> InputSource { - let current = InputSource.getCurrentInputSource() - let bundleId = appKind.getApp().bundleIdentifier ?? "?" - let disk = preferencesVM.appKeyboardCache.retrieve(appKind)?.persistentIdentifier ?? "nil" + private func performSelect(_ inputSource: InputSource, appKind: AppKind, generation: Int) { + guard generation == applyGeneration else { return } + isApplyingLayout = true + ISPFileLog.event( + "apply", + "\(Self.appLogId(appKind)) select \(inputSource.persistentIdentifier)", + includeSnapshot: false + ) + inputSourceVM.select(inputSource: inputSource, app: appKind.getApp()) + + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(applyReselectMilliseconds) + ) { [weak self] in + guard let self, generation == self.applyGeneration else { return } + let live = InputSource.getCurrentInputSource().persistentIdentifier + let wanted = inputSource.persistentIdentifier + if live != wanted { + ISPFileLog.event( + "apply", + "\(Self.appLogId(appKind)) one-shot re-select \(wanted) (was \(live))", + includeSnapshot: false + ) + self.inputSourceVM.select(inputSource: inputSource, app: appKind.getApp()) + } else { + ISPFileLog.event( + "apply", + "\(Self.appLogId(appKind)) confirmed \(wanted)", + includeSnapshot: false + ) + } + self.isApplyingLayout = false + } + } - guard let bundleKey = appKind.getApp().bundleIdentifier, - let appliedId = lastAppliedLayoutIdByBundle[bundleKey], - let appliedAt = lastAppliedAtByBundle[bundleKey], - let applied = InputSource.resolvePersistedIdentifier(appliedId) - else { + private func markCommitted(_ appKind: AppKind, _ inputSource: InputSource) { + guard let id = appKind.getId() else { return } + committedLayoutIdByCacheId[id] = inputSource.persistentIdentifier + } + + private func committedLayout(for appKind: AppKind) -> InputSource? { + guard let id = appKind.getId(), + let layoutId = committedLayoutIdByCacheId[id] + else { return nil } + return InputSource.resolvePersistedIdentifier(layoutId) + } + + private func cancelUserAccept() { + userAcceptWorkItem?.cancel() + userAcceptWorkItem = nil + pendingUserAcceptLayoutId = nil + pendingUserAcceptCacheId = nil + } + + private func cancelPendingApply() { + applyDebounceWorkItem?.cancel() + applyDebounceWorkItem = nil + applyGeneration += 1 + isApplyingLayout = false + } + + private func scheduleUserAcceptIfNeeded(appKind: AppKind, candidate: InputSource) { + if isApplyingLayout { ISPFileLog.event( - "leave-pick", - "\(bundleId) use=current \(current.persistentIdentifier) disk=\(disk)", + "memory", + "skip applying-echo \(candidate.persistentIdentifier)", includeSnapshot: false ) - return current + return } - let sinceApply = Date().timeIntervalSince(appliedAt) - if sinceApply <= leaveStealGuardWindow, - current.persistentIdentifier != applied.persistentIdentifier + if let id = appKind.getId(), + committedLayoutIdByCacheId[id] == candidate.persistentIdentifier { ISPFileLog.event( - "leave-pick", - "\(bundleId) use=applied \(applied.persistentIdentifier) (guard \(String(format: "%.2f", sinceApply))s) current=\(current.persistentIdentifier) disk=\(disk)", + "memory", + "skip committed-echo \(candidate.persistentIdentifier)", includeSnapshot: false ) - return applied + return } + cancelUserAccept() + let tokenPid = appKind.getApp().processIdentifier + let tokenLayout = candidate.persistentIdentifier + let tokenCacheId = appKind.getId() + pendingUserAcceptLayoutId = tokenLayout + pendingUserAcceptCacheId = tokenCacheId ISPFileLog.event( - "leave-pick", - "\(bundleId) use=current \(current.persistentIdentifier) applied=\(applied.persistentIdentifier) sinceApply=\(String(format: "%.2f", sinceApply)) disk=\(disk)", + "memory", + "candidate \(tokenLayout) for \(Self.appLogId(appKind)) — accept in \(userAcceptDelay)s if stable", includeSnapshot: false ) - return current - } - - private func scheduleStableUserLayoutCache(appKind: AppKind?, inputSource: InputSource) { - stableUserLayoutCacheWork?.cancel() - - guard let appKind, - !SystemChrome.shouldNeverCache(appKind.getApp().bundleIdentifier), - preferencesVM.appNeedCacheKeyboard(appKind) - else { return } - - let tokenBundle = appKind.getApp().bundleIdentifier - let tokenLayout = inputSource.persistentIdentifier let work = DispatchWorkItem { [weak self] in - guard let self else { return } - guard self.state.appKind?.getApp().bundleIdentifier == tokenBundle, - self.state.inputSource.persistentIdentifier == tokenLayout, - !LaunchpadOverlayDetector.isLaunchpadVisible() - else { return } + self?.confirmUserLayout( + appKind: appKind, + candidateId: tokenLayout, + tokenPid: tokenPid, + tokenCacheId: tokenCacheId + ) + } + userAcceptWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + userAcceptDelay, execute: work) + } - self.preferencesVM.cacheKeyboardFor(appKind, keyboard: inputSource) + private func confirmUserLayout( + appKind: AppKind, + candidateId: String, + tokenPid: pid_t, + tokenCacheId: String? + ) { + userAcceptWorkItem = nil + guard state.appKind?.getApp().processIdentifier == tokenPid, + state.appKind?.getId() == tokenCacheId + else { + ISPFileLog.event("memory", "skip left-app \(candidateId)", includeSnapshot: false) + return + } + let live = InputSource.getCurrentInputSource() + guard live.persistentIdentifier == candidateId else { ISPFileLog.event( - "cache-stable", - "\(tokenBundle ?? "?") → \(tokenLayout)", + "memory", + "skip unstable wanted=\(candidateId) live=\(live.persistentIdentifier)", includeSnapshot: false ) + return + } + preferencesVM.cacheKeyboardFor(appKind, keyboard: live) + markCommitted(appKind, live) + pendingUserAcceptLayoutId = nil + pendingUserAcceptCacheId = nil + ISPFileLog.event( + "memory", + "USER stable \(candidateId) for \(Self.appLogId(appKind))", + includeSnapshot: false + ) + } + + private func rememberShortcutLayout(_ appKind: AppKind, _ inputSource: InputSource) { + cancelUserAccept() + markCommitted(appKind, inputSource) + preferencesVM.cacheKeyboardFor(appKind, keyboard: inputSource) + ISPFileLog.event( + "user-layout", + "\(Self.appLogId(appKind)) → \(inputSource.persistentIdentifier) (shortcut)", + includeSnapshot: false + ) + } + + static func shouldSkipSameContextAppChange(previous: AppKind?, next: AppKind) -> Bool { + guard let previous else { return false } + return next.isSameAppOrWebsite(with: previous, detectAddressBar: true) + } + + static func isSameProcessWindowChange(previous: AppKind?, next: AppKind) -> Bool { + guard let previous else { return false } + guard previous.getApp().processIdentifier == next.getApp().processIdentifier else { + return false + } + return previous.getId() != next.getId() + } + + static func windowLeaveSaveDecision( + liveId: String, + committedId: String?, + pendingAcceptLayoutId: String?, + pendingAcceptCacheId: String?, + previousCacheId: String?, + exactDiskId: String? + ) -> (layoutId: String, reason: String) { + if let pendingAcceptLayoutId, + pendingAcceptLayoutId == liveId, + pendingAcceptCacheId == previousCacheId + { + return (pendingAcceptLayoutId, "pending-user") + } + if let committedId { + return (committedId, "committed") + } + if let exactDiskId { + return (exactDiskId, "disk") + } + return (liveId, "live") + } + + static func processLeaveSaveDecision( + liveId: String, + committedId: String?, + pendingAcceptLayoutId: String?, + pendingAcceptCacheId: String?, + previousCacheId: String?, + isApplyingLayout: Bool + ) -> (layoutId: String, reason: String)? { + if let pendingAcceptLayoutId, + pendingAcceptLayoutId == liveId, + pendingAcceptCacheId == previousCacheId + { + return (pendingAcceptLayoutId, "pending-user") + } + if !isApplyingLayout, + let committedId, + liveId != committedId + { + return (liveId, "live-ahead") + } + return nil + } + + static func isSameBundleDifferentProcess(previous: AppKind?, next: AppKind) -> Bool { + guard let previous else { return false } + let prevApp = previous.getApp() + let nextApp = next.getApp() + guard let prevBundle = prevApp.bundleIdentifier, + let nextBundle = nextApp.bundleIdentifier, + prevBundle == nextBundle + else { return false } + return prevApp.processIdentifier != nextApp.processIdentifier + } + + static func shouldAcceptStableUserLayout( + stillSameProcess: Bool, + liveMatchesCandidate: Bool, + isEchoOfSessionApply: Bool + ) -> Bool { + stillSameProcess && liveMatchesCandidate && !isEchoOfSessionApply + } + + static func shouldApplyAutoSwitchAcrossSameBundleInstances( + status: PreferencesVM.AppAutoSwitchKeyboardStatus, + forced: InputSource?, + isAddressBar: Bool + ) -> Bool { + if isAddressBar { return true } + if forced != nil { return true } + if case .cached = status { return true } + return false + } + + static func appLogId(_ appKind: AppKind) -> String { + let app = appKind.getApp() + let base = "\(app.bundleIdentifier ?? "?")#\(app.processIdentifier)" + if let windowId = appKind.windowCacheId() { + return "\(base)#\(windowId)" } - stableUserLayoutCacheWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + stableUserLayoutCacheDelay, execute: work) + return base } private func shortcutBindings() -> [ShortcutBinding] { diff --git a/Input Source Pro/Models/PermissionsVM.swift b/Input Source Pro/Models/PermissionsVM.swift index 6e52a46..386c54b 100644 --- a/Input Source Pro/Models/PermissionsVM.swift +++ b/Input Source Pro/Models/PermissionsVM.swift @@ -1,4 +1,5 @@ import AppKit +import ApplicationServices import Combine import IOKit @@ -23,20 +24,23 @@ final class PermissionsVM: ObservableObject { @Published var isAccessibilityEnabled = PermissionsVM.checkAccessibility(prompt: false) @Published var isInputMonitoringEnabled = PermissionsVM.checkInputMonitoring(prompt: false) + private var cancelBag = Set() + init() { watchAccessibilityChange() watchInputMonitoringChange() } private func watchAccessibilityChange() { - guard !isAccessibilityEnabled else { return } - Timer .interval(seconds: 1) .map { _ in Self.checkAccessibility(prompt: false) } - .filter { $0 } - .first() - .assign(to: &$isAccessibilityEnabled) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] trusted in + self?.isAccessibilityEnabled = trusted + } + .store(in: &cancelBag) } private func watchInputMonitoringChange() { diff --git a/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift b/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift index c2c78e7..7063e7d 100644 --- a/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift +++ b/Input Source Pro/Models/PreferencesVM+AppKeyboardCache.swift @@ -66,13 +66,11 @@ extension PreferencesVM { return } - let defaultKeyboard = getAppDefaultKeyboard(appKind) - if appNeedCacheKeyboard(appKind) { appKeyboardCache.save(appKind, keyboard: keyboard) ISPFileLog.event( "cache-save", - "\(appKind.getApp().bundleIdentifier ?? "?") → \(keyboard.persistentIdentifier)", + "\(appKind.getId() ?? appKind.getApp().bundleIdentifier ?? "?") → \(keyboard.persistentIdentifier)", includeSnapshot: false ) } @@ -105,7 +103,7 @@ extension PreferencesVM { appKeyboardCache.save(appKind, keyboard: keyboard) ISPFileLog.event( "cache-leave", - "\(appKind.getApp().bundleIdentifier ?? "?") → \(keyboard.persistentIdentifier)", + "\(appKind.getId() ?? appKind.getApp().bundleIdentifier ?? "?") → \(keyboard.persistentIdentifier)", includeSnapshot: false ) } @@ -136,7 +134,7 @@ extension PreferencesVM { }() let migrateFlag = UserDefaults.standard.bool(forKey: "ISPEnableRestorePreviouslyUsed.v1") - let axTrusted = AXIsProcessTrusted() + let axTrusted = PermissionsVM.checkAccessibility(prompt: false) let appRules = (try? container.viewContext.fetch(AppRule.fetchRequest())) ?? [] let forcedRules = appRules.compactMap { rule -> String? in @@ -164,6 +162,11 @@ extension PreferencesVM { "cacheEntries=\(appKeyboardCache.entryCount) appRules=\(appRules.count) forced=[\(forcedRules.joined(separator: ", "))] overrides=[\(restoreOverrides.joined(separator: ", "))]", includeSnapshot: false ) + ISPFileLog.event( + "settings", + "perWindowMemory=cg+poll axTrusted=\(axTrusted) (CGWindowList; AX optional)", + includeSnapshot: false + ) } enum AppAutoSwitchKeyboardStatus { diff --git a/Input Source Pro/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index 59c749b..4c8dce0 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -220,6 +220,9 @@ "Accessibility" = "Accessibility"; "Open Accessibility Settings" = "Open Accessibility Settings"; "Open Accessibility Settings, find \"Input Source Pro\" in the list and enable the toggle." = "Open Accessibility Settings, find \"Input Source Pro\" in the list and enable the toggle."; +"Accessibility Required Title" = "Accessibility Required"; +"Accessibility Required Description" = "Input Source Pro needs **Accessibility** to remember keyboard layouts per window (for example different CLion project windows) and switch them reliably.\n\nWithout this permission the app will not start."; +"Accessibility Required Steps" = "1. Open Accessibility Settings (button below)\n2. Find **this running build** in the list (path is shown under the steps) and turn it **on**\n3. If you Run from Xcode, each rebuild can appear as a new entry — enable the one that matches the path\n4. Return here — the app continues automatically when permission is granted"; "Input Monitoring" = "Input Monitoring"; "Open Input Monitoring Settings" = "Open Input Monitoring Settings"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list."; diff --git a/Input Source Pro/Resources/ja.lproj/Localizable.strings b/Input Source Pro/Resources/ja.lproj/Localizable.strings index fd3146a..0fc1acb 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -220,6 +220,9 @@ "Accessibility" = "アクセシビリティ"; "Open Accessibility Settings" = "アクセシビリティ設定を開く"; "Open Accessibility Settings, find \"Input Source Pro\" in the list and enable the toggle." = "アクセシビリティ設定を開き、一覧で「Input Source Pro」を探してトグルを有効にしてください。"; +"Accessibility Required Title" = "Accessibility Required"; +"Accessibility Required Description" = "Input Source Pro needs **Accessibility** to remember keyboard layouts per window (for example different CLion project windows) and switch them reliably.\n\nWithout this permission the app will not start."; +"Accessibility Required Steps" = "1. Click **Open Accessibility Settings**\n2. Unlock if needed, find **Input Source Pro** in the list and turn it **on**\n3. If you launched from Xcode, also enable the Debug build (or Xcode) in that list\n4. Return here — the app continues automatically when permission is granted"; "Input Monitoring" = "入力監視"; "Open Input Monitoring Settings" = "入力監視設定を開く"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "入力監視設定を開き、「+」ボタンをクリックして一覧に「Input Source Pro」を追加してください。"; diff --git a/Input Source Pro/Resources/ko.lproj/Localizable.strings b/Input Source Pro/Resources/ko.lproj/Localizable.strings index 0121c4f..bf188c8 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -220,6 +220,9 @@ "Accessibility" = "접근성"; "Open Accessibility Settings" = "접근성 설정 열기"; "Open Accessibility Settings, find \"Input Source Pro\" in the list and enable the toggle." = "접근성 설정을 열고 목록에서 \"Input Source Pro\"를 찾아 토글을 켜세요."; +"Accessibility Required Title" = "Accessibility Required"; +"Accessibility Required Description" = "Input Source Pro needs **Accessibility** to remember keyboard layouts per window (for example different CLion project windows) and switch them reliably.\n\nWithout this permission the app will not start."; +"Accessibility Required Steps" = "1. Click **Open Accessibility Settings**\n2. Unlock if needed, find **Input Source Pro** in the list and turn it **on**\n3. If you launched from Xcode, also enable the Debug build (or Xcode) in that list\n4. Return here — the app continues automatically when permission is granted"; "Input Monitoring" = "입력 모니터링"; "Open Input Monitoring Settings" = "입력 모니터링 설정 열기"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "입력 모니터링 설정을 열고 \"+\" 버튼을 클릭한 다음 목록에 \"Input Source Pro\"를 추가하세요."; diff --git a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings index 8049db4..96b6d41 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -222,6 +222,9 @@ "Accessibility" = "辅助功能"; "Open Accessibility Settings" = "打开「辅助功能」设置"; "Open Accessibility Settings, find \"Input Source Pro\" in the list and enable the toggle." = "打开「辅助功能」设置,在列表中找到“Input Source Pro”,并开启开关。"; +"Accessibility Required Title" = "Accessibility Required"; +"Accessibility Required Description" = "Input Source Pro needs **Accessibility** to remember keyboard layouts per window (for example different CLion project windows) and switch them reliably.\n\nWithout this permission the app will not start."; +"Accessibility Required Steps" = "1. Click **Open Accessibility Settings**\n2. Unlock if needed, find **Input Source Pro** in the list and turn it **on**\n3. If you launched from Xcode, also enable the Debug build (or Xcode) in that list\n4. Return here — the app continues automatically when permission is granted"; "Input Monitoring" = "输入监控"; "Open Input Monitoring Settings" = "打开「输入监控」设置"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "打开「输入监控」设置,点击“+”按钮并将“Input Source Pro”添加到列表中。"; diff --git a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings index 7d93c8c..6e342ef 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -222,6 +222,9 @@ "Accessibility" = "輔助功能"; "Open Accessibility Settings" = "打開「輔助功能」設定"; "Open Accessibility Settings, find \"Input Source Pro\" in the list and enable the toggle." = "打開「輔助功能」設定,在列表中找到「Input Source Pro」,並開啟開關。"; +"Accessibility Required Title" = "Accessibility Required"; +"Accessibility Required Description" = "Input Source Pro needs **Accessibility** to remember keyboard layouts per window (for example different CLion project windows) and switch them reliably.\n\nWithout this permission the app will not start."; +"Accessibility Required Steps" = "1. Click **Open Accessibility Settings**\n2. Unlock if needed, find **Input Source Pro** in the list and turn it **on**\n3. If you launched from Xcode, also enable the Debug build (or Xcode) in that list\n4. Return here — the app continues automatically when permission is granted"; "Input Monitoring" = "輸入監控"; "Open Input Monitoring Settings" = "打開「輸入監控」設定"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "打開「輸入監控」設定,點擊「+」按鈕並將「Input Source Pro」加入列表。"; diff --git a/Input Source Pro/System/AppDelegate.swift b/Input Source Pro/System/AppDelegate.swift index 564aa4c..0f0b204 100644 --- a/Input Source Pro/System/AppDelegate.swift +++ b/Input Source Pro/System/AppDelegate.swift @@ -1,6 +1,4 @@ import Cocoa -import Combine -import SwiftUI import Alamofire import LaunchAtLogin @@ -12,7 +10,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var applicationVM: ApplicationVM! var inputSourceVM: InputSourceVM! var feedbackVM: FeedbackVM! - var indicatorWindowController: IndicatorWindowController! + // var indicatorWindowController: IndicatorWindowController! var statusItemController: StatusItemController! /// `false` until the view models are ready in `applicationDidFinishLaunching`. @@ -49,19 +47,107 @@ class AppDelegate: NSObject, NSApplicationDelegate { return elapsed >= 0 && elapsed < window } + private var accessibilityWaitTimer: Timer? + private var accessibilityWaitingStatusItem: NSStatusItem? + private var suppressPreferencesFromAccessibilityFlow = false + func applicationDidFinishLaunching(_: Notification) { feedbackVM = FeedbackVM() navigationVM = NavigationVM() permissionsVM = PermissionsVM() preferencesVM = PreferencesVM(permissionsVM: permissionsVM) + + if PermissionsVM.checkAccessibility(prompt: false) { + permissionsVM.isAccessibilityEnabled = true + bootstrapAppServices() + } else { + ISPFileLog.startSession() + ISPFileLog.event( + "boot", + "blocked — Accessibility not granted path=\(Bundle.main.bundleURL.path)", + includeSnapshot: false + ) + showAccessibilityRequiredAlert() + } + } + + @MainActor + private func showAccessibilityRequiredAlert() { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Accessibility Required Title".i18n() + alert.informativeText = + "Input Source Pro needs Accessibility to switch and remember keyboard layouts. Without this permission the app will not start.\n\nOpen System Settings → Privacy & Security → Accessibility, enable Input Source Pro, then return — or Quit." + alert.addButton(withTitle: "Open Accessibility Settings".i18n()) + alert.addButton(withTitle: "Quit".i18n()) + + NSApp.activate(ignoringOtherApps: true) + alert.window.level = .floating + let response = alert.runModal() + if response == .alertFirstButtonReturn { + suppressPreferencesFromAccessibilityFlow = true + NSWorkspace.shared.openAccessibilityPreferences() + showAccessibilityWaitingStatusItem() + waitForAccessibilityThenBootstrap() + } else { + NSApp.terminate(nil) + } + } + + @MainActor + private func showAccessibilityWaitingStatusItem() { + guard accessibilityWaitingStatusItem == nil else { return } + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + item.button?.image = NSImage(named: "MenuBarIcon") + item.button?.image?.size = NSSize(width: 16, height: 16) + item.button?.image?.isTemplate = true + + let menu = NSMenu() + menu.addItem( + NSMenuItem( + title: "Open Accessibility Settings".i18n(), + target: self, + action: #selector(reopenAccessibilitySettings), + keyEquivalent: "" + ) + ) + menu.addItem(NSMenuItem.separator()) + menu.addItem( + NSMenuItem( + title: "Quit".i18n(), + action: #selector(NSApplication.shared.terminate(_:)), + keyEquivalent: "q" + ) + ) + item.menu = menu + accessibilityWaitingStatusItem = item + } + + @MainActor + private func removeAccessibilityWaitingStatusItem() { + guard let item = accessibilityWaitingStatusItem else { return } + NSStatusBar.system.removeStatusItem(item) + accessibilityWaitingStatusItem = nil + } + + @objc private func reopenAccessibilitySettings() { + NSWorkspace.shared.openAccessibilityPreferences() + } + + @MainActor + private func bootstrapAppServices() { + guard applicationVM == nil else { return } + + accessibilityWaitTimer?.invalidate() + accessibilityWaitTimer = nil + removeAccessibilityWaitingStatusItem() + applicationVM = ApplicationVM(preferencesVM: preferencesVM) inputSourceVM = InputSourceVM(preferencesVM: preferencesVM) - indicatorVM = IndicatorVM(permissionsVM: permissionsVM, preferencesVM: preferencesVM, applicationVM: applicationVM, inputSourceVM: inputSourceVM) - - indicatorWindowController = IndicatorWindowController( + indicatorVM = IndicatorVM( permissionsVM: permissionsVM, preferencesVM: preferencesVM, - indicatorVM: indicatorVM, applicationVM: applicationVM, inputSourceVM: inputSourceVM ) @@ -75,11 +161,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { feedbackVM: feedbackVM, inputSourceVM: inputSourceVM ) - + LaunchAtLogin.migrateIfNeeded() ISPFileLog.startSession() preferencesVM.logStartupSettings() - openPreferencesAtFirstLaunch() + if !suppressPreferencesFromAccessibilityFlow { + openPreferencesAtFirstLaunch() + } sendLaunchPing() updateInstallVersionInfo() @@ -87,9 +175,36 @@ class AppDelegate: NSObject, NSApplicationDelegate { let queuedURLs = pendingURLs pendingURLs.removeAll() queuedURLs.forEach(handleIncomingURL) + + clearAccessibilityPreferencesSuppressionSoon() + } + + @MainActor + private func waitForAccessibilityThenBootstrap() { + accessibilityWaitTimer?.invalidate() + accessibilityWaitTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, self.applicationVM == nil else { return } + guard PermissionsVM.checkAccessibility(prompt: false) else { return } + self.permissionsVM.isAccessibilityEnabled = true + ISPFileLog.event("boot", "Accessibility granted — starting", includeSnapshot: false) + self.bootstrapAppServices() + self.suppressPreferencesFromAccessibilityFlow = false + self.statusItemController.openPreferences() + } + } + RunLoop.main.add(accessibilityWaitTimer!, forMode: .common) + } + + private func clearAccessibilityPreferencesSuppressionSoon() { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.suppressPreferencesFromAccessibilityFlow = false + } } func applicationDidBecomeActive(_: Notification) { + guard statusItemController != nil else { return } + guard !suppressPreferencesFromAccessibilityFlow else { return } guard !InputSourceSwitcher.isHandlingTemporaryInputWindowActivation else { return } // `open ` activates the app a beat *before* it delivers the URL diff --git a/Input Source Pro/Utilities/AppKeyboardCache.swift b/Input Source Pro/Utilities/AppKeyboardCache.swift index 9e3ec4c..823a781 100644 --- a/Input Source Pro/Utilities/AppKeyboardCache.swift +++ b/Input Source Pro/Utilities/AppKeyboardCache.swift @@ -34,26 +34,69 @@ class AppKeyboardCache { guard let id = kind.getId() else { return } if let keyboardId = keyboard?.persistentIdentifier { - guard cache[id] != keyboardId else { return } + guard cache[id] != keyboardId else { + logger.debug { "Save skip unchanged \(id)#\(keyboardId)" } + return + } logger.debug { "Save \(id)#\(keyboardId)" } + ISPFileLog.event( + "cache-write", + "key=\(id) → \(keyboardId) app=\(kind.getApp().bundleIdentifier ?? "?")#\(kind.getApp().processIdentifier)", + includeSnapshot: false + ) cache[id] = keyboardId persist() } else if cache[id] != nil { cache.removeValue(forKey: id) + ISPFileLog.event("cache-write", "key=\(id) cleared", includeSnapshot: false) persist() } } - func retrieve(_ kind: AppKind) -> InputSource? { + func retrieveExact(_ kind: AppKind) -> InputSource? { guard let id = kind.getId(), let keyboardId = cache[id] else { return nil } - logger.debug { "Retrieve \(id)#\(keyboardId)" } - + logger.debug { "Retrieve exact \(id)#\(keyboardId)" } return InputSource.resolvePersistedIdentifier(keyboardId) } + func retrieve(_ kind: AppKind) -> InputSource? { + if let exact = retrieveExact(kind) { + return exact + } + + guard let id = kind.getId() else { return nil } + + if let processId = kind.processInstanceCacheId(), + processId != id, + let keyboardId = cache[processId] + { + logger.debug { "Retrieve fallback process \(processId)#\(keyboardId) for \(id)" } + ISPFileLog.event( + "cache-fallback", + "\(id) ← process \(processId) → \(keyboardId)", + includeSnapshot: false + ) + return InputSource.resolvePersistedIdentifier(keyboardId) + } + + if let bundleId = kind.getApp().bundleId() ?? kind.getApp().bundleIdentifier, + let keyboardId = cache[bundleId] + { + logger.debug { "Retrieve fallback bundle \(bundleId)#\(keyboardId) for \(id)" } + ISPFileLog.event( + "cache-fallback", + "\(id) ← bundle \(bundleId) → \(keyboardId)", + includeSnapshot: false + ) + return InputSource.resolvePersistedIdentifier(keyboardId) + } + + return nil + } + func clear() { logger.debug { "Clear All" } cache.removeAll() @@ -61,8 +104,11 @@ class AppKeyboardCache { } func remove(byBundleId bundleId: String) { - let prefix = "\(bundleId)_" - let keys = cache.keys.filter { $0 == bundleId || $0.hasPrefix(prefix) } + let sitePrefix = "\(bundleId)_" + let instancePrefix = "\(bundleId)#" + let keys = cache.keys.filter { + $0 == bundleId || $0.hasPrefix(sitePrefix) || $0.hasPrefix(instancePrefix) + } guard !keys.isEmpty else { return } for key in keys { diff --git a/Input Source Pro/Utilities/AppKind.swift b/Input Source Pro/Utilities/AppKind.swift index 90ba8d7..6efcaa9 100644 --- a/Input Source Pro/Utilities/AppKind.swift +++ b/Input Source Pro/Utilities/AppKind.swift @@ -16,7 +16,8 @@ enum AppKind { typealias NormalInfo = ( focusedElement: UIElement?, - isFocusOnInputContainer: Bool + isFocusOnInputContainer: Bool, + windowId: String? ) case normal(app: NSRunningApplication, info: NormalInfo) @@ -24,8 +25,15 @@ enum AppKind { func getId() -> String? { switch self { - case let .normal(app, _): - return app.bundleId() + case let .normal(app, info): + let processId = Self.instanceCacheId( + bundleId: app.bundleId(), + processIdentifier: app.processIdentifier + ) + if let windowId = info.windowId, !windowId.isEmpty { + return "\(processId)#\(windowId)" + } + return processId case let .browser(app, info): if !info.isFocusedOnAddressBar, let url = info.url, @@ -40,6 +48,34 @@ enum AppKind { } } + func processInstanceCacheId() -> String? { + switch self { + case let .normal(app, _): + return Self.instanceCacheId( + bundleId: app.bundleId(), + processIdentifier: app.processIdentifier + ) + case .browser: + return nil + } + } + + func windowCacheId() -> String? { + switch self { + case let .normal(_, info): + return info.windowId + case .browser: + return nil + } + } + + static func instanceCacheId(bundleId: String?, processIdentifier: pid_t) -> String { + if let bundleId, !bundleId.isEmpty { + return "\(bundleId)#\(processIdentifier)" + } + return "pid:\(processIdentifier)" + } + func getApp() -> NSRunningApplication { switch self { case let .normal(app, _): @@ -82,7 +118,7 @@ enum AppKind { switch (getBrowserInfo(), otherKind.getBrowserInfo()) { case (nil, nil): - return true + return windowCacheId() == otherKind.windowCacheId() case let (current?, previous?): if detectAddressBar, current.isFocusedOnAddressBar != previous.isFocusedOnAddressBar @@ -171,8 +207,49 @@ extension AppKind { app: app, info: ( focusedElement, - isFocusOnInputContainer + isFocusOnInputContainer, + resolveWindowCacheToken(app: app, application: application) ) ) } + + static func resolveWindowCacheToken(app: NSRunningApplication, application: Application?) -> String? { + if let cg = CGWindowIdentity.cacheToken(forPid: app.processIdentifier) { + return cg + } + return focusedWindowCacheToken(application: application) + } + + static func focusedWindowCacheToken(application: Application?) -> String? { + guard AXIsProcessTrusted(), + let application, + let window: UIElement = try? application.attribute(.focusedWindow) + else { return nil } + + if let number = axWindowNumber(window) { + return "w\(number)" + } + + if let title: String = try? window.attribute(.title) { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + let slug = trimmed + .replacingOccurrences(of: "#", with: "_") + .prefix(96) + return "t\(slug)" + } + } + + return nil + } + + private static func axWindowNumber(_ window: UIElement) -> Int? { + if let number: Int = try? window.attribute("AXWindowNumber") { + return number + } + if let number: NSNumber = try? window.attribute("AXWindowNumber") { + return number.intValue + } + return nil + } } diff --git a/Input Source Pro/Utilities/AppKit/CGWindowIdentity.swift b/Input Source Pro/Utilities/AppKit/CGWindowIdentity.swift new file mode 100644 index 0000000..f8ee3a8 --- /dev/null +++ b/Input Source Pro/Utilities/AppKit/CGWindowIdentity.swift @@ -0,0 +1,44 @@ +import AppKit +import CoreGraphics +import Foundation + +enum CGWindowIdentity { + static func frontmostWindowNumber(forPid pid: pid_t) -> Int? { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let info = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] + else { return nil } + + for window in info { + guard let ownerPID = window[kCGWindowOwnerPID as String] as? pid_t, + ownerPID == pid + else { continue } + + let layer = window[kCGWindowLayer as String] as? Int ?? 0 + guard layer == 0 else { continue } + + guard let alpha = window[kCGWindowAlpha as String] as? CGFloat, alpha > 0.01 + else { continue } + + guard let boundsDict = window[kCGWindowBounds as String] as? [String: CGFloat], + let width = boundsDict["Width"], + let height = boundsDict["Height"], + width >= 40, + height >= 40 + else { continue } + + if let number = window[kCGWindowNumber as String] as? Int { + return number + } + if let number = window[kCGWindowNumber as String] as? CGWindowID { + return Int(number) + } + } + + return nil + } + + static func cacheToken(forPid pid: pid_t) -> String? { + guard let number = frontmostWindowNumber(forPid: pid) else { return nil } + return "w\(number)" + } +} diff --git a/Input Source Pro/Utilities/Log/ISPFileLog.swift b/Input Source Pro/Utilities/Log/ISPFileLog.swift index bb6233d..66f385c 100644 --- a/Input Source Pro/Utilities/Log/ISPFileLog.swift +++ b/Input Source Pro/Utilities/Log/ISPFileLog.swift @@ -48,7 +48,7 @@ enum ISPFileLog { let front = NSWorkspace.shared.frontmostApplication let layout = InputSource.getCurrentInputSource() let launchpad = LaunchpadOverlayDetector.isLaunchpadVisible() - let ws = "\(front?.localizedName ?? "?")[\(front?.bundleIdentifier ?? "nil")] pol=\(front?.activationPolicy.rawValue ?? -1)" + let ws = "\(front?.localizedName ?? "?")[\(front?.bundleIdentifier ?? "nil")#\(front?.processIdentifier ?? 0)] pol=\(front?.activationPolicy.rawValue ?? -1)" let lay = "\(layout.name)[\(layout.persistentIdentifier)]" return "ws=\(ws) launchpad=\(launchpad) layout=\(lay)" } diff --git a/Tests/AppKeyboardCacheMultiInstanceTests.swift b/Tests/AppKeyboardCacheMultiInstanceTests.swift new file mode 100644 index 0000000..a12db38 --- /dev/null +++ b/Tests/AppKeyboardCacheMultiInstanceTests.swift @@ -0,0 +1,88 @@ +import AppKit +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class AppKeyboardCacheMultiInstanceTests: XCTestCase { + func testRemoveByBundleIdClearsPerProcessKeys() { + let suiteName = "isp.cache.test.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create suite") + return + } + defaults.removePersistentDomain(forName: suiteName) + + defaults.set( + [ + "com.jetbrains.clion#1001": "layout.ru", + "com.jetbrains.clion#1002": "layout.en", + "com.jetbrains.clion": "layout.legacy", + "com.jetbrains.clion_example.com": "layout.site", + "com.other.app#9": "layout.keep", + ] as [String: String], + forKey: "ISPAppKeyboardCache.v1" + ) + + let cache = AppKeyboardCache(defaults: defaults) + XCTAssertEqual(cache.entryCount, 5) + + cache.remove(byBundleId: "com.jetbrains.clion") + XCTAssertEqual(cache.entryCount, 1) + + defaults.removePersistentDomain(forName: suiteName) + } + + func testWindowKeyDoesNotFallBackWhenExactMissingIfUsingExactAPI() { + let suiteName = "isp.cache.test.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create suite") + return + } + defaults.removePersistentDomain(forName: suiteName) + + let cache = AppKeyboardCache(defaults: defaults) + let app = NSRunningApplication.current + let processKind = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: nil) + ) + let windowKind = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: "w9") + ) + let source = InputSource.getCurrentInputSource() + + cache.save(processKind, keyboard: source) + XCTAssertNil(cache.retrieveExact(windowKind)) + XCTAssertEqual(cache.retrieve(windowKind)?.persistentIdentifier, source.persistentIdentifier) + + defaults.removePersistentDomain(forName: suiteName) + } + + func testSaveWindowDoesNotMirrorProcessKey() { + let suiteName = "isp.cache.test.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create suite") + return + } + defaults.removePersistentDomain(forName: suiteName) + + let cache = AppKeyboardCache(defaults: defaults) + let app = NSRunningApplication.current + let windowKind = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: "w3") + ) + let processKind = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: nil) + ) + let source = InputSource.getCurrentInputSource() + + cache.save(windowKind, keyboard: source) + XCTAssertEqual(cache.retrieveExact(windowKind)?.persistentIdentifier, source.persistentIdentifier) + XCTAssertNil(cache.retrieveExact(processKind)) + + defaults.removePersistentDomain(forName: suiteName) + } +} diff --git a/Tests/AppKindComparisonTests.swift b/Tests/AppKindComparisonTests.swift index e386b0b..403c17f 100644 --- a/Tests/AppKindComparisonTests.swift +++ b/Tests/AppKindComparisonTests.swift @@ -78,7 +78,7 @@ final class AppKindComparisonTests: XCTestCase { func testNormalAndBrowserWithUnknownURLAreDifferentContexts() { let normal = AppKind.normal( app: app, - info: (focusedElement: nil, isFocusOnInputContainer: true) + info: (focusedElement: nil, isFocusOnInputContainer: true, windowId: nil) ) let unknownBrowser = browser(url: nil, addressBarFocused: true) @@ -92,6 +92,238 @@ final class AppKindComparisonTests: XCTestCase { XCTAssertNil(newTab.getId()) } + func testInstanceCacheIdIncludesPid() { + XCTAssertEqual( + AppKind.instanceCacheId(bundleId: "com.jetbrains.clion", processIdentifier: 42_001), + "com.jetbrains.clion#42001" + ) + XCTAssertEqual( + AppKind.instanceCacheId(bundleId: nil, processIdentifier: 7), + "pid:7" + ) + } + + func testNormalGetIdIsPerProcess() { + let kind = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: nil) + ) + let bundleId = app.bundleId() ?? app.bundleIdentifier + XCTAssertEqual( + kind.getId(), + AppKind.instanceCacheId(bundleId: bundleId, processIdentifier: app.processIdentifier) + ) + } + + func testNormalGetIdIncludesWindowToken() { + let kind = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: "w42") + ) + let bundleId = app.bundleId() ?? app.bundleIdentifier + let processId = AppKind.instanceCacheId( + bundleId: bundleId, + processIdentifier: app.processIdentifier + ) + XCTAssertEqual(kind.getId(), "\(processId)#w42") + XCTAssertEqual(kind.processInstanceCacheId(), processId) + } + + func testDifferentWindowsAreDifferentContexts() { + let windowA = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: true, windowId: "w1") + ) + let windowB = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: true, windowId: "w2") + ) + XCTAssertFalse(windowA.isSameAppOrWebsite(with: windowB, detectAddressBar: true)) + XCTAssertFalse(IndicatorVM.shouldSkipSameContextAppChange(previous: windowA, next: windowB)) + XCTAssertTrue(IndicatorVM.shouldSkipSameContextAppChange(previous: windowA, next: windowA)) + XCTAssertTrue(IndicatorVM.isSameProcessWindowChange(previous: windowA, next: windowB)) + XCTAssertFalse(IndicatorVM.isSameProcessWindowChange(previous: windowA, next: windowA)) + } + + func testSameBundleMultiInstanceAppliesCacheButNotSystemDefault() { + let source = InputSource.getCurrentInputSource() + + XCTAssertTrue( + IndicatorVM.shouldApplyAutoSwitchAcrossSameBundleInstances( + status: .cached(source), + forced: nil, + isAddressBar: false + ) + ) + XCTAssertFalse( + IndicatorVM.shouldApplyAutoSwitchAcrossSameBundleInstances( + status: .specified(source), + forced: nil, + isAddressBar: false + ) + ) + XCTAssertTrue( + IndicatorVM.shouldApplyAutoSwitchAcrossSameBundleInstances( + status: .specified(source), + forced: source, + isAddressBar: false + ) + ) + XCTAssertTrue( + IndicatorVM.shouldApplyAutoSwitchAcrossSameBundleInstances( + status: .specified(source), + forced: nil, + isAddressBar: true + ) + ) + } + + func testIsSameBundleDifferentProcessRequiresDistinctPids() { + let a = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: nil) + ) + XCTAssertFalse(IndicatorVM.isSameBundleDifferentProcess(previous: a, next: a)) + XCTAssertFalse(IndicatorVM.isSameBundleDifferentProcess(previous: nil, next: a)) + } + + func testStableUserLayoutAcceptRequiresHold() { + XCTAssertTrue( + IndicatorVM.shouldAcceptStableUserLayout( + stillSameProcess: true, + liveMatchesCandidate: true, + isEchoOfSessionApply: false + ) + ) + XCTAssertFalse( + IndicatorVM.shouldAcceptStableUserLayout( + stillSameProcess: true, + liveMatchesCandidate: false, + isEchoOfSessionApply: false + ) + ) + XCTAssertFalse( + IndicatorVM.shouldAcceptStableUserLayout( + stillSameProcess: false, + liveMatchesCandidate: true, + isEchoOfSessionApply: false + ) + ) + XCTAssertFalse( + IndicatorVM.shouldAcceptStableUserLayout( + stillSameProcess: true, + liveMatchesCandidate: true, + isEchoOfSessionApply: true + ) + ) + } + + func testWindowLeavePrefersPendingUserOverCommitted() { + let decision = IndicatorVM.windowLeaveSaveDecision( + liveId: "com.apple.keylayout.US", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: "com.apple.keylayout.US", + pendingAcceptCacheId: "com.jetbrains.CLion#1#w1", + previousCacheId: "com.jetbrains.CLion#1#w1", + exactDiskId: "com.apple.keylayout.RussianWin" + ) + XCTAssertEqual(decision.layoutId, "com.apple.keylayout.US") + XCTAssertEqual(decision.reason, "pending-user") + } + + func testWindowLeaveKeepsCommittedWhenLiveIsFlapWithoutPending() { + let decision = IndicatorVM.windowLeaveSaveDecision( + liveId: "com.apple.keylayout.US", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: nil, + pendingAcceptCacheId: nil, + previousCacheId: "com.jetbrains.CLion#1#w1", + exactDiskId: "com.apple.keylayout.RussianWin" + ) + XCTAssertEqual(decision.layoutId, "com.apple.keylayout.RussianWin") + XCTAssertEqual(decision.reason, "committed") + } + + func testWindowLeaveIgnoresPendingForOtherWindow() { + let decision = IndicatorVM.windowLeaveSaveDecision( + liveId: "com.apple.keylayout.US", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: "com.apple.keylayout.US", + pendingAcceptCacheId: "com.jetbrains.CLion#1#w2", + previousCacheId: "com.jetbrains.CLion#1#w1", + exactDiskId: "com.apple.keylayout.RussianWin" + ) + XCTAssertEqual(decision.layoutId, "com.apple.keylayout.RussianWin") + XCTAssertEqual(decision.reason, "committed") + } + + func testProcessLeaveSavesPendingUser() { + let decision = IndicatorVM.processLeaveSaveDecision( + liveId: "com.apple.keylayout.US", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: "com.apple.keylayout.US", + pendingAcceptCacheId: "com.anthropic.claudefordesktop#1#w1", + previousCacheId: "com.anthropic.claudefordesktop#1#w1", + isApplyingLayout: false + ) + XCTAssertEqual(decision?.layoutId, "com.apple.keylayout.US") + XCTAssertEqual(decision?.reason, "pending-user") + } + + func testProcessLeaveSavesLiveAheadWhenTISRacedFocus() { + let decision = IndicatorVM.processLeaveSaveDecision( + liveId: "com.apple.keylayout.US", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: nil, + pendingAcceptCacheId: nil, + previousCacheId: "com.anthropic.claudefordesktop#1#w1", + isApplyingLayout: false + ) + XCTAssertEqual(decision?.layoutId, "com.apple.keylayout.US") + XCTAssertEqual(decision?.reason, "live-ahead") + } + + func testProcessLeaveSkipsWhenLiveMatchesCommitted() { + let decision = IndicatorVM.processLeaveSaveDecision( + liveId: "com.apple.keylayout.RussianWin", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: nil, + pendingAcceptCacheId: nil, + previousCacheId: "com.jetbrains.CLion#1#w1", + isApplyingLayout: false + ) + XCTAssertNil(decision) + } + + func testProcessLeaveSkipsLiveAheadWhileApplying() { + let decision = IndicatorVM.processLeaveSaveDecision( + liveId: "com.apple.keylayout.US", + committedId: "com.apple.keylayout.RussianWin", + pendingAcceptLayoutId: nil, + pendingAcceptCacheId: nil, + previousCacheId: "com.jetbrains.CLion#1#w1", + isApplyingLayout: true + ) + XCTAssertNil(decision) + } + + func testSameContextAppChangeIsSkipped() { + let first = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: false, windowId: nil) + ) + let again = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: true, windowId: nil) + ) + XCTAssertTrue(IndicatorVM.shouldSkipSameContextAppChange(previous: first, next: again)) + XCTAssertFalse(IndicatorVM.shouldSkipSameContextAppChange(previous: nil, next: first)) + + let page = browser(url: URL(string: "https://example.com")!, addressBarFocused: false) + let addressBar = browser(url: URL(string: "https://example.com")!, addressBarFocused: true) + XCTAssertFalse(IndicatorVM.shouldSkipSameContextAppChange(previous: page, next: addressBar)) + } + private func browser(url: URL?, addressBarFocused: Bool) -> AppKind { return .browser( app: app, From 40e6e31d2a7bc68886cfa3f54bd279f9d7a89b3b Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Sun, 30 Aug 2026 16:54:30 +0300 Subject: [PATCH 4/4] fix build macos --- Input Source Pro.xcodeproj/project.pbxproj | 8 ++++ .../xcschemes/Input Source Pro.xcscheme | 2 +- .../Models/PreferencesVM+KeyboardConfig.swift | 5 ++- .../Resources/Signing.entitlements | 2 + Input Source Pro/System/AppDelegate.swift | 37 +++++++++++++------ .../Utilities/AppKit/NSApplication.swift | 24 ++++++++---- .../Utilities/ShortcutTrigger.swift | 4 +- 7 files changed, 60 insertions(+), 22 deletions(-) diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index b13d6af..f78c682 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ D56A7CFC28A4D5C0002F356A /* AXSwift+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56A7CFB28A4D5C0002F356A /* AXSwift+Browser.swift */; }; D57334262A84B40B00C8438F /* IconMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57334252A84B40B00C8438F /* IconMap.swift */; }; D579258C2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D579258B2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift */; }; + D6A100102F2700000000A001 /* CGWindowIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100112F2700000000A001 /* CGWindowIdentity.swift */; }; D57925902A1AB1DA003B83EA /* NSRunningApplication+FloatingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D579258F2A1AB1DA003B83EA /* NSRunningApplication+FloatingApp.swift */; }; D57925922A1ACA0B003B83EA /* AppKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57925912A1ACA0B003B83EA /* AppKind.swift */; }; D58168CA28943A5400E84058 /* Indicator-Near-Cursor-Demo-Dark.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = D58168C828943A5300E84058 /* Indicator-Near-Cursor-Demo-Dark.mp4 */; }; @@ -179,6 +180,7 @@ D60000722F20000000000072 /* AppURLActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000712F20000000000071 /* AppURLActionTests.swift */; }; D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000812F20000000000081 /* URLActivationSuppressionTests.swift */; }; D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001012F20000000000101 /* AppKindComparisonTests.swift */; }; + D60001042F20000000000104 /* AppKeyboardCacheMultiInstanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001032F20000000000103 /* AppKeyboardCacheMultiInstanceTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -334,6 +336,7 @@ D56A7CFB28A4D5C0002F356A /* AXSwift+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AXSwift+Browser.swift"; sourceTree = ""; }; D57334252A84B40B00C8438F /* IconMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconMap.swift; sourceTree = ""; }; D579258B2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRunningApplication+WindowInfo.swift"; sourceTree = ""; }; + D6A100112F2700000000A001 /* CGWindowIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGWindowIdentity.swift; sourceTree = ""; }; D579258F2A1AB1DA003B83EA /* NSRunningApplication+FloatingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRunningApplication+FloatingApp.swift"; sourceTree = ""; }; D57925912A1ACA0B003B83EA /* AppKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKind.swift; sourceTree = ""; }; D58168C828943A5300E84058 /* Indicator-Near-Cursor-Demo-Dark.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Indicator-Near-Cursor-Demo-Dark.mp4"; sourceTree = ""; }; @@ -373,6 +376,7 @@ D60000712F20000000000071 /* AppURLActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLActionTests.swift; sourceTree = ""; }; D60000812F20000000000081 /* URLActivationSuppressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivationSuppressionTests.swift; sourceTree = ""; }; D60001012F20000000000101 /* AppKindComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKindComparisonTests.swift; sourceTree = ""; }; + D60001032F20000000000103 /* AppKeyboardCacheMultiInstanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeyboardCacheMultiInstanceTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -693,6 +697,7 @@ 4A35D5592819834600DDC108 /* NSRunningApplication.swift */, D579258F2A1AB1DA003B83EA /* NSRunningApplication+FloatingApp.swift */, D579258B2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift */, + D6A100112F2700000000A001 /* CGWindowIdentity.swift */, D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */, D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */, D6A100032F2000000000A003 /* SystemChrome.swift */, @@ -748,6 +753,7 @@ isa = PBXGroup; children = ( D60001012F20000000000101 /* AppKindComparisonTests.swift */, + D60001032F20000000000103 /* AppKeyboardCacheMultiInstanceTests.swift */, D60000512F20000000000051 /* InputSourceDeduplicationTests.swift */, D60000112F20000000000011 /* BrowserRuleSelectionTests.swift */, D60000312F20000000000031 /* BrowserRuleValidationTests.swift */, @@ -983,6 +989,7 @@ 4A881A802888559200B76498 /* NSString.swift in Sources */, 4A2A1790280BA7FA00E13249 /* Color.swift in Sources */, D579258C2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift in Sources */, + D6A100102F2700000000A001 /* CGWindowIdentity.swift in Sources */, 4A2A177D280BA7FA00E13249 /* Main.xcdatamodeld in Sources */, 4A2A1795280BA7FA00E13249 /* main.swift in Sources */, 4A2A1794280BA7FA00E13249 /* AppDelegate.swift in Sources */, @@ -1071,6 +1078,7 @@ buildActionMask = 2147483647; files = ( D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */, + D60001042F20000000000104 /* AppKeyboardCacheMultiInstanceTests.swift in Sources */, D60000522F20000000000052 /* InputSourceDeduplicationTests.swift in Sources */, D60000122F20000000000012 /* BrowserRuleSelectionTests.swift in Sources */, D60000322F20000000000032 /* BrowserRuleValidationTests.swift in Sources */, diff --git a/Input Source Pro.xcodeproj/xcshareddata/xcschemes/Input Source Pro.xcscheme b/Input Source Pro.xcodeproj/xcshareddata/xcschemes/Input Source Pro.xcscheme index 234c092..ed6bfb2 100644 --- a/Input Source Pro.xcodeproj/xcshareddata/xcschemes/Input Source Pro.xcscheme +++ b/Input Source Pro.xcodeproj/xcshareddata/xcschemes/Input Source Pro.xcscheme @@ -41,7 +41,7 @@ com.apple.security.app-sandbox + com.apple.security.cs.disable-library-validation + com.apple.security.device.input-monitoring diff --git a/Input Source Pro/System/AppDelegate.swift b/Input Source Pro/System/AppDelegate.swift index 0f0b204..8d80ff9 100644 --- a/Input Source Pro/System/AppDelegate.swift +++ b/Input Source Pro/System/AppDelegate.swift @@ -182,18 +182,33 @@ class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private func waitForAccessibilityThenBootstrap() { accessibilityWaitTimer?.invalidate() - accessibilityWaitTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - Task { @MainActor in - guard let self, self.applicationVM == nil else { return } - guard PermissionsVM.checkAccessibility(prompt: false) else { return } - self.permissionsVM.isAccessibilityEnabled = true - ISPFileLog.event("boot", "Accessibility granted — starting", includeSnapshot: false) - self.bootstrapAppServices() - self.suppressPreferencesFromAccessibilityFlow = false - self.statusItemController.openPreferences() - } + // Target/selector avoids capturing non-Sendable `self` in a @Sendable Timer closure. + let timer = Timer( + timeInterval: 0.5, + target: self, + selector: #selector(accessibilityWaitTick), + userInfo: nil, + repeats: true + ) + accessibilityWaitTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + @MainActor + @objc private func accessibilityWaitTick() { + guard applicationVM == nil else { + accessibilityWaitTimer?.invalidate() + accessibilityWaitTimer = nil + return } - RunLoop.main.add(accessibilityWaitTimer!, forMode: .common) + guard PermissionsVM.checkAccessibility(prompt: false) else { return } + accessibilityWaitTimer?.invalidate() + accessibilityWaitTimer = nil + permissionsVM.isAccessibilityEnabled = true + ISPFileLog.event("boot", "Accessibility granted — starting", includeSnapshot: false) + bootstrapAppServices() + suppressPreferencesFromAccessibilityFlow = false + statusItemController.openPreferences() } private func clearAccessibilityPreferencesSuppressionSoon() { diff --git a/Input Source Pro/Utilities/AppKit/NSApplication.swift b/Input Source Pro/Utilities/AppKit/NSApplication.swift index 3fdef22..3997829 100644 --- a/Input Source Pro/Utilities/AppKit/NSApplication.swift +++ b/Input Source Pro/Utilities/AppKit/NSApplication.swift @@ -59,19 +59,29 @@ extension NSApplication { // MARK: - isBrowserApp private var browserAppIdentifier: Set = { - let array1 = LSCopyAllRoleHandlersForContentType( + let htmlViewers = LSCopyAllRoleHandlersForContentType( "public.html" as CFString, .viewer )?.takeRetainedValue() as? [String] ?? [] - let array2 = LSCopyAllHandlersForURLScheme( - "https" as CFString - )?.takeRetainedValue() as? [String] ?? [] - let set1 = Set(array1) - let set2 = Set(array2) + let httpsHandlers = httpsURLSchemeHandlers() - return set1.intersection(set2) + return Set(htmlViewers).intersection(httpsHandlers) }() +private func httpsURLSchemeHandlers() -> [String] { + if #available(macOS 12.0, *) { + return NSWorkspace.shared.urlsForApplications(toOpen: URL(string: "https://")!) + .compactMap { Bundle(url: $0)?.bundleIdentifier } + } + return legacyHTTPSURLSchemeHandlers() +} + +@available(macOS, deprecated: 10.15) +private func legacyHTTPSURLSchemeHandlers() -> [String] { + LSCopyAllHandlersForURLScheme("https" as CFString)? + .takeRetainedValue() as? [String] ?? [] +} + extension NSApplication { static func isBrowserApp(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier = bundleIdentifier else { return false } diff --git a/Input Source Pro/Utilities/ShortcutTrigger.swift b/Input Source Pro/Utilities/ShortcutTrigger.swift index 66cd4ea..2f94621 100644 --- a/Input Source Pro/Utilities/ShortcutTrigger.swift +++ b/Input Source Pro/Utilities/ShortcutTrigger.swift @@ -271,8 +271,8 @@ final class ShortcutTriggerManager { } deinit { - // deinit is nonisolated; schedule main-actor cleanup asynchronously - Task { @MainActor in + // Object is MainActor-isolated; deallocation happens on the main actor. + MainActor.assumeIsolated { removeAllMonitors() } }