From 83a20f4b60a6fec42cda761a3590462d241b0701 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:52:21 +0800 Subject: [PATCH] fix: apply address bar rule without page URL An empty Chrome omnibox exposes no web-area URL, so `AppKind.from` classified the browser as a normal app and the address bar default input source rule only fired after the first keystroke. Forcing the switch mid-composition left the Pinyin candidate window orphaned and the omnibox unresponsive to Enter and Delete (#99). - Make `BrowserInfo.url` optional and treat address bar focus alone as a browser context, so the rule fires on focus, before typing starts. - Keep the context stable while the address bar stays focused: URL changes then reflect omnibox edits or suggestions rather than navigation, and must not re-trigger input source switches. - Skip website ids for browser states without a URL and unwrap the now-optional URL in the browser rule menu. - Cover `makeBrowserInfo` and context comparison in `AppKindComparisonTests`. --- Input Source Pro.xcodeproj/project.pbxproj | 4 + .../Controllers/StatusItemController.swift | 7 +- Input Source Pro/Utilities/AppKind.swift | 99 ++++++++++++---- Tests/AppKindComparisonTests.swift | 107 ++++++++++++++++++ 4 files changed, 189 insertions(+), 28 deletions(-) create mode 100644 Tests/AppKindComparisonTests.swift diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index ace4d68..1c845b2 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ D60000622F20000000000062 /* AppURLAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000612F20000000000061 /* AppURLAction.swift */; }; 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -365,6 +366,7 @@ D60000612F20000000000061 /* AppURLAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLAction.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -736,6 +738,7 @@ D60000042F20000000000004 /* Tests */ = { isa = PBXGroup; children = ( + D60001012F20000000000101 /* AppKindComparisonTests.swift */, D60000512F20000000000051 /* InputSourceDeduplicationTests.swift */, D60000112F20000000000011 /* BrowserRuleSelectionTests.swift */, D60000312F20000000000031 /* BrowserRuleValidationTests.swift */, @@ -1055,6 +1058,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */, D60000522F20000000000052 /* InputSourceDeduplicationTests.swift in Sources */, D60000122F20000000000012 /* BrowserRuleSelectionTests.swift in Sources */, D60000322F20000000000032 /* BrowserRuleValidationTests.swift in Sources */, diff --git a/Input Source Pro/Controllers/StatusItemController.swift b/Input Source Pro/Controllers/StatusItemController.swift index 5ca4900..13e4474 100644 --- a/Input Source Pro/Controllers/StatusItemController.swift +++ b/Input Source Pro/Controllers/StatusItemController.swift @@ -53,7 +53,8 @@ class StatusItemController { var addBrowserRuleMenu: NSMenuItem? { guard let appKind = applicationVM.appKind, let browserInfo = appKind.getBrowserInfo(), - let host = browserInfo.url.host + let browserURL = browserInfo.url, + let host = browserURL.host else { return nil } let app = appKind.getApp() @@ -61,7 +62,7 @@ class StatusItemController { var items: [NSMenuItem] = [ BrowserRuleMenuItem( app: app, - url: browserInfo.url, + url: browserURL, preferencesVM: preferencesVM, inputSourceVM: inputSourceVM, inputSource: nil @@ -74,7 +75,7 @@ class StatusItemController { items += InputSource.sources.map { BrowserRuleMenuItem( app: app, - url: browserInfo.url, + url: browserURL, preferencesVM: preferencesVM, inputSourceVM: inputSourceVM, inputSource: $0 diff --git a/Input Source Pro/Utilities/AppKind.swift b/Input Source Pro/Utilities/AppKind.swift index 4825b68..90ba8d7 100644 --- a/Input Source Pro/Utilities/AppKind.swift +++ b/Input Source Pro/Utilities/AppKind.swift @@ -9,7 +9,7 @@ enum AppKind { focusedElement: UIElement?, isFocusOnInputContainer: Bool, - url: URL, + url: URL?, rule: BrowserRule?, isFocusedOnAddressBar: Bool ) @@ -28,9 +28,10 @@ enum AppKind { return app.bundleId() case let .browser(app, info): if !info.isFocusedOnAddressBar, - info.url != .newtab, + let url = info.url, + url != .newtab, let bundleId = app.bundleId(), - let addressId = info.rule?.id() ?? info.url.host + let addressId = info.rule?.id() ?? url.host { return "\(bundleId)_\(addressId)" } else { @@ -79,16 +80,55 @@ enum AppKind { guard let otherKind = otherKind else { return false } guard getApp() == otherKind.getApp() else { return false } - let isSameAddress = getBrowserInfo()?.url == otherKind.getBrowserInfo()?.url - let isSameAddressBar = getBrowserInfo()?.isFocusedOnAddressBar == otherKind.getBrowserInfo()?.isFocusedOnAddressBar + switch (getBrowserInfo(), otherKind.getBrowserInfo()) { + case (nil, nil): + return true + case let (current?, previous?): + if detectAddressBar, + current.isFocusedOnAddressBar != previous.isFocusedOnAddressBar + { + return false + } + + // While the address bar stays focused, URL changes reflect omnibox + // edits or suggestions rather than navigation; treating them as + // navigation switches input sources mid-composition (issue #99). + if current.isFocusedOnAddressBar, + previous.isFocusedOnAddressBar + { + return true + } - return detectAddressBar ? (isSameAddressBar && isSameAddress) : isSameAddress + return current.url == previous.url + default: + return false + } } } // MARK: - From extension AppKind { + static func makeBrowserInfo( + focusedElement: UIElement?, + isFocusOnInputContainer: Bool, + url: URL?, + rule: BrowserRule?, + isFocusedOnAddressBar: Bool + ) -> BrowserInfo? { + // An empty omnibox can expose no web-area URL, so address-bar focus + // alone constitutes a browser context (issue #99). + guard url != nil || isFocusedOnAddressBar else { return nil } + + return ( + focusedElement: focusedElement, + isFocusOnInputContainer: isFocusOnInputContainer, + url: url, + rule: rule, + isFocusedOnAddressBar: isFocusedOnAddressBar + ) + } + static func from(_ app: NSRunningApplication?, preferencesVM: PreferencesVM) -> AppKind? { if let app = app { return .from(app, preferencesVM: preferencesVM) @@ -101,29 +141,38 @@ extension AppKind { let application = app.getApplication(preferencesVM: preferencesVM) let focusedElement = app.focuedUIElement(application: application) let isFocusOnInputContainer = UIElement.isInputContainer(focusedElement) + let isBrowserEnabled = preferencesVM.isBrowserAndEnabled(app) - if let url = preferencesVM.getBrowserURL(app.bundleIdentifier, application: application)?.removeFragment() { - let rule = preferencesVM.getBrowserRule(url: url) - let isFocusOnBrowserAddress = preferencesVM.isFocusOnBrowserAddress(app: app, focusedElement: focusedElement) - - return .browser( + if isBrowserEnabled { + let isFocusOnBrowserAddress = preferencesVM.isFocusOnBrowserAddress( app: app, - info: ( - focusedElement, - isFocusOnInputContainer, - url, - rule, - isFocusOnBrowserAddress - ) + focusedElement: focusedElement ) - } else { - return .normal( - app: app, - info: ( - focusedElement, - isFocusOnInputContainer + let browserURL = preferencesVM + .getBrowserURL(app.bundleIdentifier, application: application)? + .removeFragment() + let rule = browserURL.flatMap { preferencesVM.getBrowserRule(url: $0) } + + if let browserInfo = makeBrowserInfo( + focusedElement: focusedElement, + isFocusOnInputContainer: isFocusOnInputContainer, + url: browserURL, + rule: rule, + isFocusedOnAddressBar: isFocusOnBrowserAddress + ) { + return .browser( + app: app, + info: browserInfo ) - ) + } } + + return .normal( + app: app, + info: ( + focusedElement, + isFocusOnInputContainer + ) + ) } } diff --git a/Tests/AppKindComparisonTests.swift b/Tests/AppKindComparisonTests.swift new file mode 100644 index 0000000..e386b0b --- /dev/null +++ b/Tests/AppKindComparisonTests.swift @@ -0,0 +1,107 @@ +import AppKit +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class AppKindComparisonTests: XCTestCase { + private let app = NSRunningApplication.current + + func testFocusedAddressBarCreatesBrowserInfoWithoutURL() { + let browserInfo = AppKind.makeBrowserInfo( + focusedElement: nil, + isFocusOnInputContainer: true, + url: nil, + rule: nil, + isFocusedOnAddressBar: true + ) + + XCTAssertNotNil(browserInfo) + XCTAssertNil(browserInfo?.url) + XCTAssertEqual(browserInfo?.isFocusedOnAddressBar, true) + } + + func testMissingURLOutsideAddressBarDoesNotCreateBrowserInfo() { + let browserInfo = AppKind.makeBrowserInfo( + focusedElement: nil, + isFocusOnInputContainer: true, + url: nil, + rule: nil, + isFocusedOnAddressBar: false + ) + + XCTAssertNil(browserInfo) + } + + func testFocusedAddressBarPreservesObservedPageURL() { + let url = URL(string: "https://example.com/page")! + let browserInfo = AppKind.makeBrowserInfo( + focusedElement: nil, + isFocusOnInputContainer: true, + url: url, + rule: nil, + isFocusedOnAddressBar: true + ) + + XCTAssertEqual(browserInfo?.url, url) + } + + func testAddressBarTextMutationDoesNotChangeContext() { + let previous = browser(url: URL(string: "https://example.com")!, addressBarFocused: true) + let current = browser(url: URL(string: "https://search.invalid/n")!, addressBarFocused: true) + + XCTAssertTrue(current.isSameAppOrWebsite(with: previous, detectAddressBar: true)) + } + + func testEnteringAddressBarChangesContextWhenDetectionIsEnabled() { + let url = URL(string: "https://example.com")! + let previous = browser(url: url, addressBarFocused: false) + let current = browser(url: url, addressBarFocused: true) + + XCTAssertFalse(current.isSameAppOrWebsite(with: previous, detectAddressBar: true)) + } + + func testLeavingAddressBarChangesContextWhenDetectionIsEnabled() { + let url = URL(string: "https://example.com")! + let previous = browser(url: url, addressBarFocused: true) + let current = browser(url: url, addressBarFocused: false) + + XCTAssertFalse(current.isSameAppOrWebsite(with: previous, detectAddressBar: true)) + } + + func testNavigationChangesContextOutsideAddressBar() { + let previous = browser(url: URL(string: "https://example.com")!, addressBarFocused: false) + let current = browser(url: URL(string: "https://example.com/next")!, addressBarFocused: false) + + XCTAssertFalse(current.isSameAppOrWebsite(with: previous, detectAddressBar: true)) + } + + func testNormalAndBrowserWithUnknownURLAreDifferentContexts() { + let normal = AppKind.normal( + app: app, + info: (focusedElement: nil, isFocusOnInputContainer: true) + ) + let unknownBrowser = browser(url: nil, addressBarFocused: true) + + XCTAssertFalse(unknownBrowser.isSameAppOrWebsite(with: normal, detectAddressBar: true)) + XCTAssertFalse(normal.isSameAppOrWebsite(with: unknownBrowser, detectAddressBar: true)) + } + + func testNewTabURLDoesNotCreateWebsiteId() { + let newTab = browser(url: .newtab, addressBarFocused: false) + + XCTAssertNil(newTab.getId()) + } + + private func browser(url: URL?, addressBarFocused: Bool) -> AppKind { + return .browser( + app: app, + info: ( + focusedElement: nil, + isFocusOnInputContainer: true, + url: url, + rule: nil, + isFocusedOnAddressBar: addressBarFocused + ) + ) + } +}