diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8dd17af5..52278b98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -201,7 +201,9 @@ event payloads as complete field state. FocusSnapshotResolver finds a usable editable candidate, blocks secure/unsupported surfaces (and Mail's compose header rows, [MailHeaderFieldDetector.swift](Cotabby/Support/Accessibility/MailHeaderFieldDetector.swift): -Tab is the way from To to Subject to body there, not an accept), bounds +Tab is the way from To to Subject to body there, not an accept; and single-line sign-in and +verification fields, [CredentialFieldDetector.swift](Cotabby/Support/Accessibility/CredentialFieldDetector.swift): +a completion there is a guess at the user's identity), bounds text on both sides of the caret, resolves the focused process, and publishes stable domain values. Chromium/Electron require accessibility priming, cursor hit-test recovery, and out-of-process iframe handling. All fallbacks are revalidated and yield to a valid system-focused element. diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index c7272ef0..3238b547 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -163,6 +163,7 @@ 257302D78C5AE9951C63FCEE /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */; }; 25F7E6EC713F8F71DEEEAAA3 /* SystemUIFocusShadowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */; }; 26067524E60D738791E983CD /* SOURCES.md in Resources */ = {isa = PBXBuildFile; fileRef = 054987E76CA9D1FA4F81EA8F /* SOURCES.md */; }; + 263CF31EDC4FAD8041831291 /* CredentialFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */; }; 26EA96EB13B94A68276FA15E /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */; }; 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616CB46A8624BC4E4FBB01A /* TypefaceEvidence.swift */; }; 27A09D81E47FA601F279EF11 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B8D2232F271197468CBC11 /* FocusCapabilityResolver.swift */; }; @@ -615,6 +616,7 @@ 998168DC04A6A13D7D1F3165 /* ModelDownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CA4BBA3C54F840546033E0 /* ModelDownloadManagerTests.swift */; }; 9A2D50EF4911E45EEB4556D6 /* Aria2OutputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83457CCF1A50CE83428C363D /* Aria2OutputParser.swift */; }; 9A55EDAF0F5D5127A39351C5 /* ContextBufferNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DDE8C1CE9BF00DE055990 /* ContextBufferNavigationTests.swift */; }; + 9AAD623DEBAD8AF488C37818 /* CredentialFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */; }; 9AE3398FB0E4696C89550C04 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8311FAC345FE431FA89855 /* EmojiMatcher.swift */; }; 9B6C176547D2B6D118572E41 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1AE4120FA68524700C9324 /* BaseCompletionPromptRenderer.swift */; }; 9B7FE4C9ED6959A6D5181EF5 /* EngineAndModelPaneView+Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */; }; @@ -1004,6 +1006,7 @@ FCD81796FE4DC55778D57686 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122298AE151ECEEC175878BF /* ConfidenceSuppressionPolicy.swift */; }; FCEE05402A708C33F9719D7F /* OpenAICompatibleSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE9156494BFC0A1E12E0B6C /* OpenAICompatibleSuggestionEngineTests.swift */; }; FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA3558520A71033BBF30879 /* TypingCadenceTests.swift */; }; + FDE4A159994BDD6605AFD9E9 /* CredentialFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */; }; FDF71F83A24FBE17F5B63C68 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1E28CF46BF59ABDC3056BF /* ApplicationBundleMetadata.swift */; }; FE0922970524121DEC4EF2D9 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */; }; FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5872EC7795CC1EFF6D0D04 /* EngineAndModelPaneView+Power.swift */; }; @@ -1160,6 +1163,7 @@ 353191D1D8A1C655E1B5F562 /* CapturedInputEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturedInputEventTests.swift; sourceTree = ""; }; 35AA2C8F42B510F013D86C3C /* InsertedTextAdvanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvanceTests.swift; sourceTree = ""; }; 35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; + 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialFieldDetectorTests.swift; sourceTree = ""; }; 36652DB88C5948AA4A31524A /* ModelFileValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidator.swift; sourceTree = ""; }; 3680E1B8FA712A888F509640 /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngineTests.swift; sourceTree = ""; }; @@ -1384,6 +1388,7 @@ 8BE5F414704A8264C2946A50 /* TypoGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGateTests.swift; sourceTree = ""; }; 8C151BF4D39485E5CFACFECB /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; 8D881FED12A85FFC20F2C9D7 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; + 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialFieldDetector.swift; sourceTree = ""; }; 8DAD5637347E83DDCF515568 /* SuggestionCoordinator+HostMarkedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+HostMarkedText.swift"; sourceTree = ""; }; 8E542E57459488F3D39A9053 /* PhrasePredictionScoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoringTests.swift; sourceTree = ""; }; 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; @@ -2498,6 +2503,7 @@ 5B5D1A7D938F633C6EE094F7 /* AXHelper.swift */, 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */, 3FE7D19D22434E3E24804999 /* CalendarAccessibilityCapturePolicy.swift */, + 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */, 47F1A6BCCA20EBE7314B79D3 /* MailHeaderFieldDetector.swift */, E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */, 1EE99C84483D3A58D561C61D /* SecureFieldDetector.swift */, @@ -2709,6 +2715,7 @@ 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */, D681DDF8E81F868C1BC92CB4 /* BlockBreakAlignmentTests.swift */, 863B5A1DC3C4B9668F620245 /* CalendarAccessibilityCapturePolicyTests.swift */, + 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */, B68F9EDFE2903996F0E5524E /* MailHeaderFieldDetectorTests.swift */, AD003D4EBE530DC0E2B87C24 /* PermissionOverlayTrackerTests.swift */, B8EBC9F1890DD2FFC4884A5C /* SecureFieldDetectorTests.swift */, @@ -4011,6 +4018,7 @@ 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, A5D76116479357C29E2D8405 /* CotabbyDebugOptions.swift in Sources */, 6E978AD7E340B2795F120AC0 /* CotypistExportImporter.swift in Sources */, + 9AAD623DEBAD8AF488C37818 /* CredentialFieldDetector.swift in Sources */, B803D0491F5CF19735F23B65 /* CurrencyEvaluator.swift in Sources */, 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */, 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */, @@ -4350,6 +4358,7 @@ 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, 7A31E6395C535FF017A1EFE1 /* CotabbyDebugOptions.swift in Sources */, 5509B327A1C9E67467333B06 /* CotypistExportImporter.swift in Sources */, + 263CF31EDC4FAD8041831291 /* CredentialFieldDetector.swift in Sources */, 1F39EE1D5FA0F5D32AFFB028 /* CurrencyEvaluator.swift in Sources */, EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */, C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */, @@ -4676,6 +4685,7 @@ F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */, 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */, DC3B4CF0634704EF2ADA7C94 /* CotypistExportImporterTests.swift in Sources */, + FDE4A159994BDD6605AFD9E9 /* CredentialFieldDetectorTests.swift in Sources */, 15BE5127E4BE29F6CBEEAA0E /* CurrencyEvaluatorTests.swift in Sources */, 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */, 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */, diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 63f5c035..ff5940b0 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -45,6 +45,17 @@ struct FocusSnapshotResolver { /// fields (see `FocusSessionScopedCache`). private let secureFieldVerdictCache = FocusSessionScopedCache() private let terminalDetectionCache = FocusSessionScopedCache() + /// The label and DOM-id reads behind `CredentialFieldDetector`: up to four AX round trips that + /// would otherwise repeat on every poll of every single-line field, for values that rarely + /// change while focus stays in one field. A navigation that reuses the element changes the URL, + /// title or placeholder, which starts a new focus session and so a fresh read. A field + /// relabelled in place (a reused input whose aria-label turns it into a code box) changes none + /// of those, so a reading is also refreshed after `credentialLabelRefreshInterval`. + private let credentialLabelCache = FocusSessionScopedCache() + /// How long a credential label reading is trusted within one focus session. One second bounds + /// how long an in-place relabel goes unnoticed, while the reads run about a dozen times less + /// often than the 80 ms active poll. + static let credentialLabelRefreshInterval: TimeInterval = 1 /// The text margin the caret's paragraph wraps to, which a field's `AXFrame` does not reveal /// (Word's frame is the page edge, not the text margin). Up to three AX round trips, so each /// result is cached per focus session *and* per paragraph: the margin changes between an indented @@ -72,8 +83,15 @@ struct FocusSnapshotResolver { /// answers no width query (Chromium contenteditables, Electron composers); see /// `CaretAdvanceSampler`. One sampler follows the focused field; a new field starts a new one. private let caretAdvanceSamples = CaretAdvanceSampleStore() - init(geometryResolver: AXTextGeometryResolver? = nil) { + /// Seconds since boot, for the credential label refresh. Injected so tests can step time. + private let uptime: () -> TimeInterval + + init( + geometryResolver: AXTextGeometryResolver? = nil, + uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + ) { self.geometryResolver = geometryResolver ?? AXTextGeometryResolver() + self.uptime = uptime } /// Drops the cached static-text-run walk so the next capture pays a fresh one. Called through @@ -336,8 +354,12 @@ struct FocusSnapshotResolver { hostMarkedTextRange: resolvedCandidate.markedTextRange ?? chromiumCompletionRange ?? smartComposeRange ) - if let reason = Self.blockedReason( - for: resolvedCandidate, bundleIdentifier: bundleIdentifier, selection: selection, rawSelection: rawSelection + if let reason = blockedReason( + for: resolvedCandidate, + bundleIdentifier: bundleIdentifier, + selection: selection, + rawSelection: rawSelection, + focusChangeSequence: focusChangeSequence ) { return FocusSnapshot( applicationName: applicationName, @@ -356,12 +378,14 @@ struct FocusSnapshotResolver { } /// Why a field Cotabby can read is still one it must not complete in, or nil when it may: a - /// secure field, one of Mail's header rows, or a field with text selected. - private static func blockedReason( + /// secure field, one of Mail's header rows, a sign-in or verification field, or a field with + /// text selected. + private func blockedReason( for candidate: AXFocusCandidate, bundleIdentifier: String, selection: NSRange, - rawSelection: NSRange + rawSelection: NSRange, + focusChangeSequence: UInt64 ) -> String? { if candidate.isSecure { return "Secure text input is active." @@ -378,6 +402,21 @@ struct FocusSnapshotResolver { return MailHeaderFieldDetector.blockedReason } + // Email, username, phone and code boxes, single-line fields only. The labels are read at + // most once a second per field; the typed text is checked on every poll, since typing + // "alice@" is what reveals an unlabelled address box. + if CredentialFieldDetector.mightBeCredentialField(role: candidate.role) { + let labelReading = credentialLabelReading(for: candidate, focusChangeSequence: focusChangeSequence) + if CredentialFieldDetector.isCredentialField( + role: candidate.role, + labels: labelReading.labels, + domIdentifier: labelReading.domIdentifier, + text: candidate.textValue + ) { + return CredentialFieldDetector.blockedReason + } + } + guard selection.length > 0 else { return nil } if BrowserAppDetector.isChromiumBrowser(bundleIdentifier: bundleIdentifier) { CotabbyLogger.focus.debug( @@ -1554,6 +1593,58 @@ struct FocusSnapshotResolver { descriptionLabel: AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: element) ) } + + /// What a single-line field says about itself (title, description, placeholder, DOM id), read + /// through `credentialLabelCache`: once per focus session, refreshed after + /// `credentialLabelRefreshInterval`. + /// + /// An all-empty reading from web content is returned but not cached: a web field can be read + /// before the page has filled in its name, and caching that would leave a real sign-in box + /// unrecognized until the next refresh. Such a field keeps paying the reads until it answers. + /// A native field's attributes are there as soon as it is, so its empty reading is kept. + private func credentialLabelReading( + for candidate: AXFocusCandidate, + focusChangeSequence: UInt64 + ) -> CredentialFieldLabelReading { + let now = uptime() + if let cached = credentialLabelCache.cachedValue( + forKey: candidate.elementIdentifier, focusChangeSequence: focusChangeSequence + ), now - cached.readAt < Self.credentialLabelRefreshInterval { + return cached + } + + let reading = CredentialFieldLabelReading( + readAt: now, + labels: [ + AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXPlaceholderValueAttribute as CFString, on: candidate.element) + ], + // Only web content vends DOM ids; asking a native field is a wasted round trip. + domIdentifier: candidate.vendsDOMAttributes + ? AXHelper.stringValue(for: "AXDOMIdentifier" as CFString, on: candidate.element) + : nil + ) + if !reading.isEmpty || !candidate.vendsDOMAttributes { + credentialLabelCache.store( + reading, forKey: candidate.elementIdentifier, focusChangeSequence: focusChangeSequence + ) + } + return reading + } +} + +/// The attributes `CredentialFieldDetector` judges a field by, cached per focus session. +private struct CredentialFieldLabelReading { + /// `uptime()` when the attributes were read, so the reading can be refreshed. + let readAt: TimeInterval + let labels: [String?] + let domIdentifier: String? + + /// True when the field answered nothing usable, so a web field's reading may simply be early. + var isEmpty: Bool { + (labels + [domIdentifier]).allSatisfy { ($0 ?? "").isEmpty } + } } private struct FocusCandidateResolution { diff --git a/Cotabby/Support/Accessibility/CredentialFieldDetector.swift b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift new file mode 100644 index 00000000..4bfc8c4e --- /dev/null +++ b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift @@ -0,0 +1,148 @@ +import ApplicationServices +import Foundation + +/// File overview: +/// Recognizes sign-in and verification fields (email, username, phone, one-time codes, card +/// numbers) where Cotabby stands down, alongside password fields, which arrive as secure fields +/// and are already blocked by the resolver. +/// +/// Why: a completion in "Email or phone" guesses at the user's identity. Accepting one types a +/// wrong address into a login form, and showing one paints a guessed address beside the real one. +/// Nothing in such a field is prose a writer wants continued. Browsers mark only passwords as +/// secure, so these fields are recognized from what the page says about them: its label (title, +/// description, placeholder) and its DOM id, plus the typed text itself looking like an address. +/// +/// Scope: single-line fields only (text fields and combo boxes). A multi-line field labelled +/// "email" is an email *body*, which is exactly where completions belong. Pure: the resolver reads +/// the attributes (once per focus session) and asks here on every poll. +/// +/// Every signal is matched by whole words or whole id parts, never by raw substring. Blocking +/// costs the writer their completions, so "phonebook-search", "emailSearch" and "Email subject" +/// must stay ordinary fields even though they contain a credential word. +enum CredentialFieldDetector { + static let blockedReason = "Sign-in and verification fields are left alone." + + /// Words in a field's label that name a credential or verification input, matched as whole + /// words in the lowercased label ("pin" matches "Enter PIN", not "shipping"). + static let labelKeywords: [String] = [ + "email", "e-mail", "username", "user name", "user id", "userid", "login", "log in", "sign in", + "phone", "mobile number", "password", "passcode", "pin", "one-time", "one time code", + "verification code", "security code", "otp", "2fa", "two-factor", "authentication code", + "card number", "cvc", "cvv", "expiry", "expiration" + ] + + /// Words that make a label describe writing *about* email or phone rather than an identity + /// input: "Email subject", "Email preview text", "Search email". Such a label is not a + /// credential label even though it contains a keyword. Whole words, like the keywords. + static let proseLabelWords: [String] = [ + "subject", "message", "body", "preview", "title", "signature", "template", + "comment", "note", "notes", "reply", "search" + ] + + /// Id parts that name a credential on their own. A few compounds whose pieces are only + /// qualifiers ("identifierId" is Google's sign-in box, "onetimecode") are listed whole. + static let credentialIdentifierWords: [String] = [ + "email", "mail", "username", "userid", "login", "logon", "signin", "passwd", "password", "pwd", + "passcode", "otp", "totp", "mfa", "2fa", "phone", "telephone", "tel", "mobile", "msisdn", "pin", + "cvc", "cvv", "cardnumber", "identifierid", "onetimecode", "verificationcode", "securitycode", "authcode" + ] + + /// Id parts that commonly sit next to a credential word ("user_email", "txtPassword", + /// "phoneNumber", "login_field") but name nothing on their own. + static let qualifierIdentifierWords: [String] = [ + "user", "account", "address", "addr", "id", "identifier", "name", "number", "num", "no", "code", + "field", "input", "txt", "tb", "verification", "verify", "security", "auth", "onetime", "confirm", + "current", "new", "primary", "your", "enter" + ] + + /// Ids longer than this are generated names (framework hashes, long paths), not a sign-in + /// field's name, and are not worth segmenting on every poll. + static let maximumIdentifierLength = 64 + + /// Cheap pre-check so labels are only fetched for single-line fields. + static func mightBeCredentialField(role: String) -> Bool { + role == kAXTextFieldRole as String || role == kAXComboBoxRole as String + } + + static func isCredentialField( + role: String, + labels: [String?], + domIdentifier: String?, + text: String? + ) -> Bool { + guard mightBeCredentialField(role: role) else { return false } + + if labels.contains(where: { $0.map(labelNamesCredential) ?? false }) { + return true + } + + if let domIdentifier, domIdentifierNamesCredential(domIdentifier) { + return true + } + + return looksLikeAddressEntry(text) + } + + /// A label names a credential input when it contains a keyword as a whole word and no word + /// that turns it into a description of prose ("Email" yes, "Email subject" no). + static func labelNamesCredential(_ label: String) -> Bool { + let label = label.lowercased() + guard labelKeywords.contains(where: { containsWord($0, in: label) }) else { return false } + return !proseLabelWords.contains(where: { containsWord($0, in: label) }) + } + + /// A DOM id names a credential input when the whole id splits into known id parts and at least + /// one of them names a credential: "user_email", "loginEmail", "phonenumber" and "identifierId" + /// do; "emailSearch" and "phonebook-search" do not, because "search" and "phonebook" are not + /// sign-in vocabulary. Case, separators and digits are ignored, so "user_email", "userEmail" + /// and "useremail2" read the same; that is also why lowercase run-together ids still match. + static func domIdentifierNamesCredential(_ identifier: String) -> Bool { + let characters = Array(identifier.lowercased().filter { $0.isLetter || $0.isNumber }) + guard !characters.isEmpty, characters.count <= maximumIdentifierLength else { return false } + + // Word-break over the vocabulary. `reach[i]` is nil when no split of the first `i` + // characters exists, false when one exists, and true when one exists that includes a + // credential word. Digits are skipped one at a time (numbered ids such as "email2"). + var reach = [Bool?](repeating: nil, count: characters.count + 1) + reach[0] = false + for start in characters.indices { + guard let namesCredential = reach[start] else { continue } + if characters[start].isNumber { + reach[start + 1] = (reach[start + 1] ?? false) || namesCredential + } + for entry in identifierVocabulary where characters[start...].starts(with: entry.word) { + let end = start + entry.word.count + reach[end] = (reach[end] ?? false) || namesCredential || entry.namesCredential + } + } + return reach[characters.count] == true + } + + /// The whole value is one address-shaped token, finished or still being typed ("alice@", + /// "alice@gmail.com"), as in a login box. Matching the unfinished address matters: a field with + /// no telling label or id would otherwise get a guessed domain while the user types theirs. A + /// slash or colon before the "@" marks a URL ("medium.com/@alice"), not an address. + static func looksLikeAddressEntry(_ text: String?) -> Bool { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return false } + return text.range(of: #"^[^\s@/:]+@[^\s@/:]*$"#, options: .regularExpression) != nil + } + + private static let identifierVocabulary: [(word: [Character], namesCredential: Bool)] = + credentialIdentifierWords.map { (Array($0), true) } + qualifierIdentifierWords.map { (Array($0), false) } + + /// `keyword` appears in `label` with no letter or digit directly before or after it, so "pin" + /// matches "Enter PIN" but not "shipping". + private static func containsWord(_ keyword: String, in label: String) -> Bool { + var searchRange = label.startIndex.. Bool = { $0.map { !$0.isLetter && !$0.isNumber } ?? true } + if isBoundary(before) && isBoundary(after) { + return true + } + searchRange = found.upperBound.. AXUIElement { + let window = NSWindow( + contentRect: NSRect(x: 220, y: 220, width: 360, height: 80), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let field = NSTextField(frame: NSRect(x: 10, y: 28, width: 300, height: 24)) + field.placeholderString = placeholder + field.setAccessibilityIdentifier(Self.fieldIdentifier) + window.contentView?.addSubview(field) + window.orderFrontRegardless() + self.window = window + self.field = field + setText(text) + + let appElement = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let element = Self.findField(under: appElement, depth: 0) { + return element + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + } + throw XCTSkip("Self-process AX is unavailable in this environment") + } + + /// Replaces the text the way typing would leave it: field editor active, caret at the end. + private func setText(_ text: String) { + guard let window, let field else { return } + window.makeFirstResponder(nil) + field.stringValue = text + window.makeFirstResponder(field) + field.currentEditor()?.selectedRange = NSRange(location: (text as NSString).length, length: 0) + } + + private static func findField(under element: AXUIElement, depth: Int) -> AXUIElement? { + guard depth <= 8 else { return nil } + if AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) == (kAXTextFieldRole as String), + AXHelper.accessibilityIdentifier(of: element) == fieldIdentifier { + return element + } + for child in AXHelper.childElements(of: element) { + if let found = findField(under: child, depth: depth + 1) { + return found + } + } + return nil + } + + private func capability(of element: AXUIElement, focusChangeSequence: UInt64, resolver: FocusSnapshotResolver) -> FocusCapability { + resolver.resolveSnapshot( + focusedElement: element, + application: NSRunningApplication.current, + focusChangeSequence: focusChangeSequence + ).capability + } + + func test_signInPlaceholderBlocksTheField_andLabelsAreReadAtMostOncePerInterval() throws { + let element = try makeFieldElement(placeholder: "Email or phone", text: "realdeepdark") + var now: TimeInterval = 100 + let resolver = FocusSnapshotResolver(uptime: { now }) + let credentialBlock = FocusCapability.blocked(CredentialFieldDetector.blockedReason) + let interval = FocusSnapshotResolver.credentialLabelRefreshInterval + + XCTAssertEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) + + // Within the interval a poll reuses the reading, so a relabel is not seen yet... + field?.placeholderString = "Notes" + now += interval / 2 + XCTAssertEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) + // ...and once it has passed, the same session reads the field again. + now += interval + XCTAssertNotEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) + + // A new focus session reads at once, however fresh the last reading is. + field?.placeholderString = "Email or phone" + XCTAssertEqual(capability(of: element, focusChangeSequence: 8, resolver: resolver), credentialBlock) + } + + func test_typedAddressBlocksAnUnlabelledField_onTheNextPoll() throws { + let element = try makeFieldElement(placeholder: "Account", text: "alice") + let resolver = FocusSnapshotResolver() + let credentialBlock = FocusCapability.blocked(CredentialFieldDetector.blockedReason) + + XCTAssertNotEqual(capability(of: element, focusChangeSequence: 3, resolver: resolver), credentialBlock) + // Same focus session: the typed text is judged on every poll, unlike the cached labels. + setText("alice@") + XCTAssertEqual(capability(of: element, focusChangeSequence: 3, resolver: resolver), credentialBlock) + } +} diff --git a/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift b/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift new file mode 100644 index 00000000..d0c26705 --- /dev/null +++ b/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift @@ -0,0 +1,103 @@ +import ApplicationServices +import XCTest +@testable import Cotabby + +final class CredentialFieldDetectorTests: XCTestCase { + private let textField = kAXTextFieldRole as String + + func test_googleSignInFieldIsBlocked() { + XCTAssertTrue(CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Email or phone", nil, nil], domIdentifier: "identifierId", text: "realdeepdark" + )) + XCTAssertTrue(CredentialFieldDetector.isCredentialField( + role: textField, labels: [nil, nil, nil], domIdentifier: "identifierId", text: "" + )) + } + + func test_commonLabelsAreBlocked() { + for label in ["Username", "Enter PIN", "Verification code", "Phone number", "Card number", "Log in"] { + XCTAssertTrue( + CredentialFieldDetector.isCredentialField(role: textField, labels: [label], domIdentifier: nil, text: nil), + label + ) + } + } + + func test_comboBoxSignInFieldIsBlocked() { + XCTAssertTrue(CredentialFieldDetector.isCredentialField( + role: kAXComboBoxRole as String, labels: ["Email address"], domIdentifier: nil, text: nil + )) + } + + func test_labelsAboutEmailWritingAreNotBlocked() { + for label in ["Email subject", "Email preview text", "Search email", "Message to phone"] { + XCTAssertFalse( + CredentialFieldDetector.isCredentialField(role: textField, labels: [label], domIdentifier: nil, text: nil), + label + ) + } + } + + func test_signInDomIdentifiersAreBlocked() { + let identifiers = [ + "username", "email", "passwd", "otp", "login_field", "user_login", "user_email", "loginEmail", + "txtUserName", "phoneNumber", "phonenumber", "emailaddress", "otp-3", "OTPCode", "one-time-code" + ] + for identifier in identifiers { + XCTAssertTrue(CredentialFieldDetector.domIdentifierNamesCredential(identifier), identifier) + } + } + + func test_domIdentifiersThatOnlyContainACredentialWordAreNotBlocked() { + let identifiers = [ + "phonebook-search", "emailSearch", "email-subject", "userInput", "shipping", "mailbox", "identifier", "q", "" + ] + for identifier in identifiers { + XCTAssertFalse(CredentialFieldDetector.domIdentifierNamesCredential(identifier), identifier) + XCTAssertFalse( + CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Search"], domIdentifier: identifier, text: "hello" + ), + identifier + ) + } + } + + func test_typedAddressIsBlockedEvenWithoutLabel() { + XCTAssertTrue(CredentialFieldDetector.isCredentialField( + role: textField, labels: [], domIdentifier: nil, text: "senad@imperum.io" + )) + XCTAssertFalse(CredentialFieldDetector.looksLikeAddressEntry("mail senad@imperum.io today")) + } + + func test_addressBeingTypedIsBlockedBeforeItIsComplete() { + // A neutral "Account" box with a neutral id: only the typed text gives it away. + for text in ["alice@", "alice@gm", "alice@gmail.com"] { + XCTAssertTrue( + CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Account"], domIdentifier: "identifier", text: text + ), + text + ) + } + for text in ["@alice", "alice", "medium.com/@alice", "ping alice@", "a@b@c"] { + XCTAssertFalse(CredentialFieldDetector.looksLikeAddressEntry(text), text) + } + } + + func test_ordinaryFieldsAreNotBlocked() { + XCTAssertFalse(CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Shipping notes", "Type a message", "Search"], domIdentifier: "q", text: "hello there" + )) + XCTAssertFalse(CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Spinning"], domIdentifier: nil, text: nil + )) + } + + func test_multiLineFieldsAreNeverBlocked() { + XCTAssertFalse(CredentialFieldDetector.isCredentialField( + role: kAXTextAreaRole as String, labels: ["Email body"], domIdentifier: "email", text: "a@b.co" + )) + XCTAssertFalse(CredentialFieldDetector.mightBeCredentialField(role: kAXTextAreaRole as String)) + } +}