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 + ) + ) + } +}