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/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 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 } }