Adding Password Autofill to a WKWebView App
The most repeated line in Overlay's feedback for the past year was some version of "why do I have
to type my password again". Fair. Overlay is a floating panel full of web apps, each tab a
WKWebView, and WKWebView gives you Safari's engine but not Safari's password manager. That part
lives in the Safari app. Apple doesn't expose it, and there is no API into the website passwords in
iCloud Keychain either (ASAuthorizationPasswordProvider is only the Sign in with Apple flow, I
checked). So every login form in Overlay was a blank text box, forever.
I put it off for a long time because the honest version of this feature is a password manager, and a password manager is the kind of thing you do not want to get slightly wrong. This is a write-up of how it ended up, written mostly so I remember why things are the way they are.
What I decided up front
Before writing code I made myself answer a few questions, because I knew I'd otherwise change my mind halfway through.
Storage: one file, encrypted with AES-GCM, and the 256-bit key goes in the login Keychain. Not the data-protection keychain, which I tried first and which does not work in a directly distributed build without a provisioning profile (error -34018, then the process gets killed if you add the entitlement anyway). The file is useless without the key and the key is useless without the file, which is about the level of protection a local password store can offer.
Filling: never automatic. The dropdown shows up when you click into a login field, and nothing gets written into the page until you pick a row. I know Chrome fills on page load when there is exactly one match. I did not want to be responsible for a credential landing in a form the user never meant to submit, so every fill is a click or a Return.
Isolated-session tabs, the ones with their own cookie jar so you can be two people on one site, see the same vault as everything else. Passwords belong to a site, not to a cookie jar.
And it's free, in the non-Pro tier. Charging for the ability to log in felt wrong.
One more, technical: the JavaScript I inject only describes the page. Deciding which field is the username, whether a form is login or sign-up, whether a login succeeded, all of that runs in Swift where I can write tests. I've written enough untestable page scripts to know how that goes.
How it's put together
A WKUserScript runs in every frame, in its own content world, at document end. It walks the
inputs, gives each one a number, and sends back a list of descriptors: type, name, id,
autocomplete, placeholder, label text, whether it's visible, which form it's in. No values.
Values cross the bridge in exactly two places. When a form is submitted, the script sends the password that was just typed, because that is the moment I can offer to save it. When the user picks a row in the dropdown, native sends the password to the script to fill. That's it. Nothing else carries a secret.
On the native side each form gets classified (login, sign-up, change-password, username-only,
password-only) and the frame's origin is matched against the vault. The origin comes from
WKFrameInfo.securityOrigin, not from anything in the message. A page can put whatever URL it
likes in a message body; it can't fake which frame the message arrived from.
Filling a React form
My first fill implementation set input.value and dispatched an input event. It worked on the
plain HTML forms I tested with and did nothing on the first real site I tried, which was React.
React remembers the last value it set and ignores changes that didn't go through its own setter,
so its state still said the field was empty.
What ended up working everywhere I tried (React 18, Vue 3, plain forms): call the prototype
setter, HTMLInputElement.prototype.value, then dispatch an InputEvent with inputType: "insertText" and a change after it. That is what a keyboard produces. There's a well-known trick
where you poke React's value tracker directly, but the tracker lives on the page-world wrapper
object and a content-world script can't see it, so I don't rely on it.
Which site a password belongs to
A login saved on login.example.com should be offered on app.example.com. One saved on
example.co.uk must not show up on evil.co.uk. The thing that draws that line is the Public
Suffix List, so Overlay ships a copy (both the ICANN and private sections, about ten thousand
rules) and works out the registrable domain natively. I looked for a way around bundling it and
there isn't one.
Matching is tiered: exact origin, then same host, then same registrable domain, with recent use breaking ties. Two rules I won't relax: a password saved over HTTPS is never offered on an HTTP page, and ports have to match. When a match comes from a different subdomain the dropdown shows that host on the right of the row, greyed out, so you can see it before clicking.
When to ask "save this password?"
Asking at submit time is the obvious thing and it's wrong, because wrong passwords get submitted too. I ended up copying Chrome's heuristic: a submit creates a pending draft; if the next navigation finishes and the new page has no empty password field, the login probably worked, ask. If the login form is still sitting there, it probably failed, drop the draft. Sites that never navigate get a second signal, the password field disappearing from the DOM. After thirty seconds with neither, the draft expires.
It's a heuristic and it has holes. A site that keeps the login form on the page after a successful sign-in looks like a failure to Overlay and you never get the card. I haven't found a general fix for that; Chrome has the same blind spot.
Multi-step logins, the Google style where the email is on one page and the password on the next, needed a small addition. When a form with only a username field is submitted, I remember that username for five minutes, keyed by registrable domain, and glue it onto the password-only submit that follows.
If the vault already has that username for that origin with the same password, nothing is shown and I just bump the last-used date. Different password, and the card asks to update instead.
The dropdown
Overlay's panel is a non-activating window: it floats over your work without taking the
foreground. The dropdown has to behave the same, so it's a borderless NSPanel attached as a child
window, positioned under the field by taking the field's CSS rect, multiplying by pageZoom,
converting through the web view to the window and then to screen coordinates.
A window that never becomes key breaks two things you take for granted. SwiftUI's onHover
doesn't fire, so hover highlighting is done with AppKit tracking areas set to activeAlways.
Key events don't arrive at all, so while the dropdown is open the injected script captures arrow
keys, Return, Escape and Tab and forwards them; the page never sees them. I also learned that
when the list scrolls under a stationary mouse, AppKit fires a mouse-entered on whatever row ends
up under the pointer, which yanked the highlight away from the keyboard selection until I started
ignoring hover events until the mouse actually moves.
The case that took the longest was Apple's own sign-in. App Store Connect embeds the login form in
an iframe from idmsa.apple.com, and a script inside a cross-origin frame can't walk up to the top
document to find out where it is on screen. My first version gave up and showed a bar at the top of
the page, which was ugly and, as a user pointed out, had no hover and wasn't anywhere near the
field. The fix came from the other direction: in the top document, when focus is inside an
iframe, document.activeElement is that iframe element, and you can read its bounding rect even
though you can't read its contents. Field position inside the frame plus iframe position in the
top document gives an exact anchor. The bar is still there as a fallback but I haven't seen it
since.
Touch ID, the clipboard, and no export
Revealing or copying a password in the manager asks for Touch ID (or the login password on Macs without it), and then leaves you alone for five minutes. Copies are marked concealed so clipboard managers that respect that convention won't record them, and the clipboard is cleared after sixty seconds if you haven't copied anything else.
Filling can require the same authentication. It's on by default on Macs with Touch ID and off on Macs without, because a password prompt on every fill is unusable without a sensor. I added it after realising that locking reveal but not fill leaves an obvious hole: anyone at your unlocked Mac who can fill a password can also switch the field to plain text and read it.
There's CSV import from Chrome, Edge, Safari, Firefox, Bitwarden and 1Password, and I decided against export. A plaintext CSV doesn't protect you from anyone who can already pass Touch ID on your machine, it just leaves a file with every password in it lying around after you're done with it. Backup is Time Machine, which restores the Keychain along with the file. If I do portable backup later it'll be encrypted with a passphrase.
Things that went wrong
The app crashed on launch six times in a row one afternoon. The cause was a cleanup routine that
called WKWebsiteDataStore.allDataStoreIdentifiers five seconds after launch, before the process
had created any web view, which is when WebKit's main run loop gets set up. It only happened when
the active tab was the settings page. Every time I'd tested it, a web page was active.
Weibo's old login form sets value="邮箱/会员帐号/手机号" as a fake placeholder and clears it on
focus. I saved that string as a username. Captured usernames that match the field's placeholder,
label, aria-label or default value are now treated as empty.
An early version of the dropdown showed "from api.weibo.com" under every row. The user I was working with wanted the domain on the right, greyed, without the word "from", and wanted the password dots that Chrome shows. He was right on all three.
What's missing
Nothing syncs between Macs; the vault is local and the key doesn't travel. No generated passwords on sign-up forms yet. No passkeys, because WebKit's WebAuthn isn't exposed to embedders in a form I can use. Each of those is a smaller job than what's described here, and I'll get to at least the first two.
The user-facing version of all this is in Saving and filling passwords in Overlay. Version notes are on the changelog.