Skip to content

Sites: secure mailboxes, chats, a dating site

Some mail never leaves its website: a bank's secure mailbox, the hospital's, the tax office's. Chats live in the browser too (Element, WhatsApp, Discord), and so does a dating site. Sites (Ctrl+4) keeps them pinned, logged in once, and turns their notifications into something that waits for you.

Sites are a stopgap: Sioul's encrypted mail exists so that such letters can come back to mail (design.md).

What a site is

Not an account: it holds no sign-in of Sioul's (the site keeps its own, in its profile). [[site]] in the configuration; files older than 4 October 2026 kept sites as [[account]] of kind "portal", read as sites and moved to [[site]] the first time Sioul starts (config::migrate_sites_at_start, a copy of the file kept as config-before-sites.toml, made once: a move that fails at each start never writes over it):

[[site]]
id = "element"
name = "Element"
url = "https://app.element.io"
site = "chat"            # its type, how it behaves: "mailbox" (the default: a secure mailbox, a client area), "chat", "video", "social", "dating", "other"
categories = ["Amis"]    # your own categories, any words ("Banque", "Santé"): the list filters by them
area = "leisure"         # what it is for: "work", "admin", "leisure", joined by "+"; unsaid, by its type (docs/areas.md)
microphone = true        # for calls; unsaid, chats, video calls and dating sites have them, no other site
camera = true
screen = true            # sharing the screen
realtime = false         # its notifications at once
muted = false            # its sounds silenced
background = true        # kept open to hear from it: chats by default
announced_by = []        # the domains whose mail announces it; the site's own domain by default

The list

  • Your order, kept in the configuration: a site's ⋮ ▸ Move up, Move down. Once your devices share each site apart (format 2, database.md), a move also writes it as site_order (the sites' ids, in order), which reaches your other devices with the sites; a site it does not name comes after, in the file's order. Before that, the list travels whole, its order with it. The sort button above the list groups them by type instead (secure mailboxes and client areas, chats, video calls, social networks, dating, other), your order kept within each.
  • Filtered (the funnel above the list): by what a site is for (work, your admin, leisure), by its type, by one of your categories, each with "any"; chosen again, a choice is undone. The filters in use are said under the title, with one button to show every site again.
  • Its own icon: asked of the site itself, never of an icon service that would learn which sites you keep (sioul_sync::favicon: the icon its page names, the larger the better, else /favicon.ico), kept in ~/.cache/sioul/favicons/ and asked again after a week; its type's icon until then, and for a site that gives none.
  • The sites for these hours first; the others fold under one line, "Other hours: 3" (areas.md).
  • Changed in place, from its menu: ⋮ for the site open, or a right click on any site of the list: name, address (https only), type and categories; its kind of thing; what it is for (work, admin, leisure, any of them together); the microphone, the camera, sharing the screen; the devices of calls; silenced, kept open; its place in your order; a link; Remove takes it out of Sioul (its sign-in stays in the profile until cleared). Sites are made, changed and removed here only, never in Accounts.
  • Tied to other things: ⋮ ▸ Link to… ties a site to a task, a note, a contact, a project, as a correspondent is (sioul:site/<id>, kept by the other thing, or in links.toml); a link to it opens the site. The link picker finds sites by name and address.

Presets

"Add a site" offers the sites people usually keep (about 400 on 4 October 2026), by country and, where it matters, by state or province (Québec in Canada; American utilities by state), and for everyone: offices (taxes, health insurance, family allowances, jobs, retirement, studies, papers and residence), banks, electricity and gas, phone and internet, chats, video calls, social networks (LinkedIn for work; Facebook, Instagram, Reddit, X, Bluesky, Mastodon… for leisure; Nextdoor for both), dating and friendship. Filtered by type or category, and found by a word ("ameli", "banque"); one click fills the name, the sign-in address, its type, its categories in your language, what it is for, and the domains whose mail announces it; or the fields are filled by hand. Your country is your language's (French in Canada shows Canada), and can be changed. Left out on purpose: services without a web version (Signal, Viber, Session, Hinge, Bumble now), retired ones (Skype).

"Usual sites ▾", beside "Pin a site", opens them as a menu: each country, then its groups (offices, banks, energy, phone and internet; chats, video calls, social networks, dating for everyone), then the sites; a country's states or provinces in a menu of their own ("By state", "By province"), each with its groups, or its sites at once when it has one group. Each country, state and group opens with "All of them (N)", which pins all of that group not pinned yet at once (a country's own, not its states'); a site already pinned is ticked and left as it is. Clicking a group's name opens it rather than pinning it all, so that nothing is pinned by a slip. Sites already pinned are known by their address (host and path), so two services on one host (canada.ca's accounts, a network and its messages) stay two.

The list is a plain JSON file anyone can update without building Sioul: presets/sites.json in the sources, { "tags": { "health": { "name": { "en": "Health", "fr": "Santé" } } }, "countries": { "CA": { "sites": […], "regions": { "QC": { "sites": […] } } } }, "international": […] }, each site { "name", "url", "type", "tags", "area", "announced_by", "about", "calls" }; a country may say what its regions are called in a menu ("regions_name": { "en": "By state", "fr": "Par État" }). Of the copy built in, one in $XDG_DATA_DIRS/sioul/presets/sites.json and yours in ~/.local/share/sioul/presets/sites.json, the one with the latest checked date is used. tools/check-presets.py asks every address as a browser would and says, for each: ok; moved (it answers from another address: worth updating); blocked (it refuses robots: check by hand); broken (not found, no such host: fix it). It exits with 1 when one is broken, so it can run on a schedule; --only FR, --json.

The browser

  • Qt WebEngine (Chromium), started before the application (cpp/webengine.cpp).
  • One persistent profile for every site (sioul-sites): cookies kept, so logins last; files cached on disk (512 MB at most).
  • A site's view is made the first time it opens and kept for the session; a chat's at start, to receive its notifications. It goes only closed as a browser closes a tab: as Sioul quits, or when the site is taken out (below, "Closing").
  • Permissions: notifications granted to every site before it asks, and kept (persistentPermissionsPolicy: StoreOnDisk, profile.queryPermission(…).grant()): Sioul catches them and gathers them (below), so no site asks for them again (Proton did, at each visit, while nothing was kept). The microphone, the camera and sharing the screen (you choose a screen or a window, or nothing) as each site's switches say: on for chats, video calls and dating sites, off for the others until you turn them on; a site asking while off is told in the status line, with where to turn it on; a yes kept from before is forgotten as soon as its switch is off. Geolocation never.
  • Sharing the screen needs the view's settings.screenCaptureEnabled, set on every site's view and pop-up: Qt WebEngine keeps it off ("Disabled by default"), and then refuses every capture after the choice (Qt 6.11, src/core/media_capture_devices_dispatcher.cpp): you chose a screen, the call got AbortError: Invalid state (until 6 October 2026). The site's switch and your choice still decide. Checked by tools/check-sites.py share: a local page asks for the screen three times while Weston, headless, gives Xwayland a screen nobody sees; capture off, refused after the choice; on, a video track; the site's switch off, refused at once, the status line saying where to turn it on.
  • The devices of calls: the camera, the microphone and the speaker chosen, given to every call and changed during one (below, "The devices of calls").
  • Colours: every site and pop-up is shown in the screen's own colours, from its ICC profile, on X11 (Wayland and macOS convert by themselves), and "Calmer colours" (Settings ▸ Display) softens loud colours on every computer: a shader over the view, through a lookup table made for each screen (colour.md).
  • Pop-ups (sign-in windows, call windows) open in a window of their own. Closed, by you or by the page once a sign-in is done, the window goes at once and its page closes behind it as a browser closes a tab.
  • Downloads go to your downloads folder.
  • The user agent leaves out Qt's name: some chats refuse browsers they do not know.

The devices of calls

You choose them in a site's menu ▸ Devices for calls…, or, during a call, from the camera button in the row above the site, which shows while that site or a pop-up it opened has a call. Both list the camera, the microphone and the speaker from the system's own list (Qt Multimedia's: the same on Linux, Windows and macOS), read from a site's first call or the first time the list shows, since Qt Multimedia, started for it, loads FFmpeg and asks the graphics drivers what they decode; "The system's own" when unsaid ([calls] in the configuration). The choice is the same for every site, and a change applies at once to every call open in Sioul and to the next ones. The research behind it: research/calls.md.

  • Why a script. Qt WebEngine 6.11 has no way to choose a site's devices or to change them under a call: it gives the device the page asked for, else the first of Chromium's list (src/core/media_capture_devices_dispatcher.cpp, getDefaultDevices), and a permission is only granted or denied. So the choice is made inside the page, by crates/sioul-app/src/calls.js, run in every page and frame of every site's view and pop-up, in the page's own world, before the page's scripts (webFixesFor in SitesPage.qml; Rust gives the file, calls_script).
  • A call keeps its tracks; Sioul changes what feeds them. For each camera or microphone track, the page gets a relay: Chromium's MediaStreamTrackGenerator, fed from the real device's track through a MediaStreamTrackProcessor. Its label, settings, abilities, constraints, stop, clone and muted answer as the real device's; copies made by MediaStream.clone() are dressed too, and an ImageCapture is made on the real track. A change asks for a new real track with the call's own settings (those the page asked for, with what applyConstraints changed since; looser if the device cannot meet them, echo cancellation, noise suppression and gain kept), feeds the relay from it, and stops the old one. The site's mute button, its voice meter (Web Audio binds its source to the track it was given), its effects and hanging up keep working; swapping tracks with replaceTrack broke all four in the lab. Hanging up closes every real device: once the page has stopped every copy of a relay, its device stops.
  • Without the relay, should a later Chromium drop MediaStreamTrackGenerator from pages, the script swaps the call's tracks in its senders (replaceTrack) and its streams instead, and what the page does to its old track (stop, enabled) reaches the new one; a voice meter or an effect may then stop. The check below would fail first.
  • Which device. At a call's start, the device chosen in Sioul, found by its name (the browser's device ids are each site's own), asked for as ideal, so that a call still starts when it cannot open. Before a site was first allowed a device, the browser gives no names: the call is moved to the chosen device right after that first grant, before the page gets its tracks (until 8 October 2026, a site's first call ignored the choice). During a call, a device the page asks for by its id (its own menu) is given: the latest choice wins. With "The system's own", the page's own choice stands; on Linux the call then uses Chromium's "default" device, which PulseAudio and PipeWire move when the system's default changes (Chromium opens it with no device name and without PA_STREAM_DONT_MOVE: media/audio/pulse/pulse_util.cc).
  • The speaker: given with setSinkId to every media element and audio context the page plays through, those playing by themselves (an autoplay element given a srcObject, which never calls play(): missed until 8 October 2026) included. An element the page sent elsewhere itself keeps that until the next change in Sioul.
  • The old callback forms navigator.getUserMedia and navigator.webkitGetUserMedia, still in Chromium 140, go the same way.
  • A change reaches every frame: setCallDevice dispatches a sioul-devices event in each frame of each view and pop-up (everyFrame: mainFrame, its children, runJavaScript in world 0), since a call may be inside a frame of another site, which the view's own runJavaScript never reaches. Each view and pop-up is also given its scripts again (holder.fixes, SitePopup.webFixes), so that a page loaded after a change starts with the new names (until 8 October 2026, a reloaded page started with the old ones).
  • Only sites allowed a device get the names, in the script or in the event: a site allowed no microphone and no camera gets an empty choice. Those allowed one can read the names from their browser anyway.
  • What the script tells Sioul, through console.debug kept before the page runs, each line marked sioul:<token>:, the token a secret of the run (callToken, from Qt's Math.random, which draws from QRandomGenerator::global()): "call" and "end" (every device closed, or the page gone: pagehide), which show and hide the call button (calls, calling; a pop-up counts its own); "changed"; "kept", with only the device's kind and the error's name, which the line above the site turns into a sentence naming the site and the device by the names Sioul has, never words from the page.
  • Costs, measured in the lab (Qt WebEngine 6.11.2, Chromium's fake devices, 640 × 480 at 20 fps): about 4 % of one core more in the page's process during a call; on a page whose main thread is busy 30 % to 60 % of the time, about 25 ms more sound delay, and in one run in five at 60 %, half the video frames. The relay runs on the page's main thread: in Qt WebEngine 6.11 a track cannot be sent to a worker.
  • Limits: a page can tell a relay from a camera (its constructor's name); a page that opens its device another way (another frame's getUserMedia) keeps it, unseen; the site's own menu may still name the old device after a change in Sioul; on a site's first call, the default device may open for an instant before the chosen one. Android's Sites use Android's WebView, which chooses devices itself: none of this applies there.
  • Checked by tools/check-sites.py devices, in bubblewrap with a /dev of its own (no camera, no sound card), no user's runtime folder (no sound server) and a network of its own, on Chromium's fake devices (--use-fake-device-for-media-stream=device-count=3 --allow-loopback-in-peer-connection): no real camera or microphone can be opened. Four test sites: a loopback call in the page (through a canvas effect, with a voice meter), one in a frame of another host, one in a pop-up, and a mailbox allowed no device. The calls start on the camera and microphone chosen before Sioul started, before any grant; follow a change of camera (fake camera 1 is grey: the previews and the effect's remote picture turn grey), of speaker (every element's sinkId) and of microphone (the sound still received, the meter reading); the page's own camera button blacks the picture and brings it back; a camera that is not there is said in the line above the site while the calls keep theirs; the mailbox hears nothing; after hanging up every track has ended and no site has a call; the page reloaded after one more change starts its new call on the camera chosen last. 26 checks, all passing on 9 October 2026.

Closing

Some sites keep a login only in the open page. Discord, as reported publicly (no real account was used here), takes its token out of its storage (localStorage) once the page has loaded and writes it back as the page goes: a reload keeps you logged in, a page destroyed without going logs you out. A browser lets a page go when it closes the tab or quits: it runs the page's beforeunload, then its pagehide and unload. Qt WebEngine does not when a view is destroyed, and quitting Sioul destroyed them: Discord asked to log in at every start (reported on 6 October 2026), while Chromium kept it on the same computer.

  • As Sioul quits, once its windows are gone, every site's page is closed as a browser closes its tabs (WebEngineView.RequestClose), the pop-ups' too; a page's "Leave the site?" is answered yes. Then Qt WebEngine shuts down with the application and writes what the sites keep (SitesPage.qml, closeAll; cpp/webengine.cpp, sioul_close_sites, called once exec() returns in lib.rs). The pages close in a few milliseconds (7 to 10 ms measured); Sioul waits two seconds at most, out of sight, its window gone (Chromium gives a page that does not answer half a second for each step).
  • Every way of quitting comes there: the window's close (its button, Alt+F4, the taskbar's Close); a logout or a shutdown (on X11, the session manager's "Die" is Qt's quit, which closes the windows; Plasma on Wayland closes them); SIGTERM, SIGINT (Ctrl+C) and SIGHUP, which ended Sioul on the spot with nothing written, and now quit it as its window does (cpp/signals.cpp: a pipe read by Qt's event loop; a second signal ends it at once, and so do thirty seconds).
  • While Sioul runs: a site taken out has its page closed so before its view goes (its login stays in the profile, whole); a pop-up closed, by you or by its page, closes its page behind it; a site's view, once made, stays for the session, so unticking "Keep it open" on a chat never opened no longer destroys its page.
  • What cannot be kept: Sioul killed (SIGKILL, a crash, the power cut) loses what the pages hold in memory, and what they wrote last: Chromium writes localStorage to the disk some seconds after a page writes it, then less often (a test killed 35 s after the load lost its last 30 s), cookies every thirty seconds. Its web processes killed with it may too: systemd stopping the unit Plasma starts Sioul in (app-sioul@….service, KillMode=control-group), as when the session ends without a logout, signals every process of it at once; in two tests the token written back at beforeunload was kept, those of pagehide and unload once.
  • Checked by tools/check-sites.py quit: a local page stands in for Discord (three tokens taken out of localStorage, each written back in one of the three handlers; a value written once; one written four times a second, in localStorage and in a cookie; a cookie from the server), Sioul run twice offscreen on a demo profile. Before: the window closed or SIGTERM, no handler ran, the three tokens lost; SIGTERM killed Sioul in 0.1 s. Now: the window closed, SIGTERM 1, 3 or 35 s after the load, a pop-up closed and the site taken out while Sioul runs: every token, value and cookie found, the last writes within a second and a half of the close; Sioul gone 0.3 to 0.9 s after the close (2.5 s on a machine loaded by six builds).

Notifications at your pace

Every site's notifications are accepted, then caught by Sioul, never shown by the site itself:

  • from the site in front of you: nothing more;
  • from a site in real time (its checkbox, always in view; or real time for everything): a desktop notification at once;
  • a call ("incoming call", "vous appelle"…): at once, unless the site is silenced; a missed call waits like the rest;
  • else it waits, under " has news" in the Porch, in $XDG_STATE_HOME/sioul/site-notices.toml (a notification told twice is kept once; the newest two hundred). Opening the site clears its news.
  • Gathered: at set times (Settings ▸ Reminders, 9:00, 13:00 and 18:00 unless you set others; three a day helped most in a field trial, Fitz et al. 2019), one notification says which sites have news, "WhatsApp (3) · Discord (1)", with Open the Porch. Only the sites of those hours, on the computer you are at (sioul_sync::lease, as the medicines).
  • A site whose area does not fit the hours (areas.md) keeps its notifications, real time and calls included, until its hours come. While you sleep, every site keeps them: no gathered notification, no real time, no call (health.md, "Do not disturb").

Mail that announces a site

A message whose sender belongs to a site's announced_by ("you have a new message in your secure space") gets a button above its headers: "Open ".

Security keys and one-time codes

  • A security key (WebAuthn, FIDO2: a YubiKey for GitHub, Proton Mail, Google…): when a site asks, Sioul's dialog says which of the key's accounts, asks its PIN (or a new one when the key has none or asks to change it, with how many tries are left), says to touch it again, and says in plain words why it failed (not registered, PIN blocked, timeout…), with Retry. A plain touch shows nothing: the key blinks and the page waits (Qt 6.11 says nothing then). Passkeys kept on the key work; passkeys in a phone or in the system do not on Linux and macOS (Qt WebEngine offers none); on Windows, Windows' own dialog takes all of it.
  • A fix for Qt 6.10–6.11: sites that ask PublicKeyCredential.getClientCapabilities() waited forever for an answer that never came (QTBUG-149575); a one-line script, in every page before its own, takes that question away, and the site asks the key directly.
  • Linux: works with Qt WebEngine built with udev (Fedora's is) and systemd's rules for keys (no udev rule to add). The Flatpak needs --device=all to reach /dev/hidraw*.
  • One-time codes: "Fill the login" on a page asking for a code writes the code the vault's secret gives now, only on the site its login belongs to, only when you click.

Bitwarden

Logins are filled from Bitwarden, read by Sioul itself (sioul_sync::bitwarden): nothing to install, on every system. The account (its e-mail, and its server when it is not bitwarden.com: bitwarden.eu, or your own, Vaultwarden too, always over HTTPS) goes in Sites ⚙. The key settings a server gives are held to Bitwarden's own bounds, so a server cannot weaken them. "Fill the login" asks for the master password once per session; it derives the master key as Bitwarden's clients do (PBKDF2-SHA256 or Argon2id, as the account says), logs in with its hash, opens the user key (HKDF-stretched master key; AES-256-CBC checked by HMAC-SHA256 before anything is decrypted), an organisation's key through the RSA key, an item's own key when it has one, and newer accounts' COSE messages (XChaCha20-Poly1305, AES-256-GCM; XAES-256-GCM not yet). The password and the keys are dropped; the logins stay in memory, wiped when the vault closes. Read only: nothing is ever written to the vault. Bitwarden refuses clients too far behind its own version ("Please update your app to continue using Bitwarden": a declared 2024.12.0 was refused in October 2026), so Sioul declares itself Bitwarden's desktop app at the server's own version, read from its /config once a session (2026.9.0 when the server does not say).

  • The security key alone (Bitwarden's "log in with passkey", for a key whose passkey is used for encryption: Settings ▸ Security ▸ Master password in Bitwarden's web vault): "Open with my security key", above the password; what opened the vault last time is proposed first. Sioul asks Bitwarden for a login's options (accounts/webauthn/assertion-options, asked of no account: the key says whose it is), then asks the key at once, as for a second step (below), for the passkey (its PIN in Sioul's dialog) and its secret (WebAuthn PRF, salted with SHA-256 of "passwordless-login", as Bitwarden's apps salt it). The answer waits in the page until Sioul takes it from there; the secret never goes in an address, which would carry it out. Bitwarden checks the key's signature (grant_type=webauthn) and gives back an RSA key sealed by the secret and the user key sealed by that RSA key (WebAuthnPrfOption): the secret, stretched as a master key is (HKDF "enc" and "mac"), opens the RSA key, which opens the user key (RSA-OAEP-SHA1); the rest is as with the password. No second step and no new-device code: Bitwarden counts the key's PIN as a second factor. A passkey of another Bitwarden account than the one in Sites ⚙ is refused (the access token names its e-mail). When the key gives no secret, when its encryption is off, or when Bitwarden does not know the key, the dialog says so and what to do in Bitwarden.
  • A second step, the account's own first, in the order Bitwarden's apps propose them, any other one choice away:
    • a security key (a YubiKey or any FIDO2 key, as Bitwarden registers them now), asked at once when its step comes. A key signs only for the page it is asked from, and Bitwarden takes signatures for its vault's address only (ServerDomain and Origins): so the vault's own light security-key page (webauthn-connector.html, nothing shown unasked) is loaded, held unseen in the dialog (a one-point view, given the focus: a page asks a key only while it has it), and Sioul's script (bitwarden-key.js) asks the key there with Bitwarden's options as Bitwarden's page passes them (the AppID extension of keys registered as U2F included), then writes the answer word for word as that page writes it (clientDataJson, the extension results): the step's code. The PIN, when the key has one, comes in Sioul's own dialog, above; a plain touch shows nothing, the key blinks. "Stop" drops the page; "Use the security key" asks again. No separate window: one would cover the PIN's dialog;
    • a YubiKey's code (YubiKey OTP: it types it), an authenticator app, e-mail (the code is sent when this step is chosen, "Send the code again" beside it), a recovery code.
    • Which steps come is the server's choice: a security key registered as FIDO2 is offered to every account; "YubiKey OTP" and Duo only while the account has Premium (Bitwarden hides them from every app otherwise, TwoFactorProvider.RequiresPremium). When no key is offered, the dialog says so and why.
    • "Remember this device" is kept in the system keyring, so the next sessions skip the step. Duo cannot be taken here. A new device's code, mailed by Bitwarden, is asked the same way. This computer's identifier is kept ($XDG_STATE_HOME/sioul/bitwarden-device), so it is a new device once.
    • A security key as the only step, where the dialog cannot ask one (opened from Accounts ▸ Password… ▸ From Bitwarden…, which holds no page; a phone, which has no Qt WebEngine): the dialog says so instead of naming Duo. On a computer, the vault opened once from the Sites page stays open for Accounts too, until Sioul closes; on a phone, an authenticator app or e-mail codes as a second step in Bitwarden, or the password typed.
  • Bitwarden's server is asked off the window's thread: the unlock, a passkey's options and its answer, the e-mail's code (sites::bitwarden_later). Each call gives the dialog a ticket at once, and its answer comes with the signal bitwarden_answered(ticket, answer); the dialog waits for its own ticket only, says "Waiting for Bitwarden…" meanwhile, greys Open, and forgets the answer once closed (the vault opens all the same). The window stays free while the server answers. Checked by tests/qml/tst_vault.qml on a stand-in, and in the runner (SIOUL_GRAB_STEPS=bitwarden-wait, no account set, so no server is asked).
  • Which login: the site's only one is filled at once, unless another login was chosen there last; several, none, or another chosen there last open the chooser (LoginChooser.qml); ⋮ ▸ "Choose a login…" opens it any time. The login chosen last for a site comes first (kept in $XDG_STATE_HOME/sioul/bitwarden-chosen.json, the site's host → Bitwarden's item id). The chooser holds names, user names and sites only: a password leaves the vault for the one login chosen. A login made for another domain than the page's says which, in the warm colour ("for bank.example.net"), so a look-alike site shows.
  • The search (bitwarden::Logins::find): two fields, Site and User name, each optional, both matching when both are filled, each with a button at its end that empties it. Until 6 October 2026 one field searched names, user names and sites at once: "gmail" found every login with a Gmail address rather than Gmail's own.
    • Site matches the login's sites, never its user name, best first: the same host; the same registrable domain (sites::domain_of; an address in numbers, 192.168.1.1, is its own); a host whose labels, or their parts between hyphens, begin with the words typed ("ameli": ameli.fr and assure.ameli.fr, not camelia.com; "agricole": credit-agricole.fr; "ameli.f", on the way to "ameli.fr", already); then the login's name, its words beginning with the words typed, or for an address typed, the domain's own name as a whole word ("ameli" for ameli.fr; "x" for x.com, not "Xbox"). A login's sites are its web addresses ("https://", "http://" or none): an app's ("androidapp://…") and a match pattern ("^https?://…") are no site.
    • User name matches the user name only, holding every word typed.
    • Case and accents aside (text::fold_char). Neither field reads a password, a note or a one-time code's secret: the search reads an index made as the vault opens, each login's name, user name, sites and their domains, folded, and nothing else.
    • The order: the login chosen last for the page; then by how its site matched (host, domain, label, name); the page's own (its host, then its domain); the user name typed whole before a part of one; the name. Fifty are listed, the others counted ("12 more logins: narrow the search"); with both fields filled and nothing found, the chooser says to empty one.
    • Opened for a site: Site holds its domain (the page's host first among its logins), or the domain of the login chosen there last when that one was made for another domain (a site that signs in with Google). Changed for a sign-in on another domain, or emptied to search by user name alone.
    • Opened for a mail account (Accounts ▸ Password… ▸ From Bitwarden…): User name holds the address; Site stays empty, unless a login was chosen for that server before (its domain then). A mail server's domain is seldom where its provider's login is kept (imap.gmail.com and accounts.google.com, mail.ecloud.global and murena.io, imap.mail.me.com and icloud.com, ssl0.ovh.net and ovh.com), and with both fields matching it would hide the very login looked for; the server's own logins still come first. Another domain is no warning there: the password goes to the account's own server, which refuses a wrong one.
    • Keys: Tab goes from one field to the other (the buttons that empty them are no Tab stop); the arrows move in the list; Enter takes the highlighted login, the first unless moved, after searching what was just typed; Escape closes. Searched as you type, once typing pauses for 150 ms. Side by side, the two fields stack on a phone (under 400 px of dialog).
    • Speed: the index is made once, as the vault opens (58 ms for 20,000 made-up logins), and only the fifty kept are sorted: a search of those 20,000 took 1 to 5.5 ms on a desktop computer busy with builds (cargo test -p sioul-sync a_large_vault_is_searched_quickly -- --ignored --nocapture). Up to 5,000 logins the search runs on the window's thread, so the chooser opens filled; above, off it (bitwarden_search answers {"later": n}, then the signal bitwarden_found(n, …)): an older answer is dropped for a newer one, and Enter pressed before the answer takes its first login once it comes.
  • The form: the login is written into the page's password field and the name field before it, as typing would (the values travel as JSON, so nothing in them can change the script); on a page asking for a one-time code, the code the vault's secret gives now (RFC 6238: base32, otpauth:// with its digits, period and SHA-1/256/512, Steam's five characters).
  • Checked against Bitwarden's own key-derivation test vectors (PBKDF2, Argon2id, HKDF), RFC 6238's codes, a changed byte refused before decryption, and a COSE message; against the live cloud with a made-up account (refused for its password, not its version: cargo test -p sioul-sync the_cloud_takes_the_version -- --ignored); a passkey's keys made as Bitwarden's web vault makes them, opened by its secret and refused for another key's; the cloud's passkey options (the_cloud_asks_a_passkey, ignored: it asks Bitwarden); offscreen, Bitwarden's page held unseen in the dialog, focused as a key needs, the key asked at once, for both ways; a refusal and an answer without secret taken back to the dialog; the search on a made-up vault of twelve logins (cargo test -p sioul-sync bitwarden): by host then domain, label starts (ameli, not camelia), names when no site matches, user names alone, both fields together, "gmail" as a site finding none of the Gmail addresses, case and accents, passwords and one-time secrets never matched; offscreen, the chooser on a stand-in backend (Qt's test runner): what the fields hold as it opens for a site and for an account, the pause before searching, Tab, Enter right after typing, the arrows, Escape, emptying a field, the status line, the warning, a large vault's answers coming later and out of order, Enter before them, side by side at 1000 px and stacked at 412 px in English and French. The window's own steps on a made-up vault (SIOUL_GRAB_STEPS=bitwarden-choose with SIOUL_TEST_VAULT, in a test build only) follow the two fields (a site's logins, a user name alone, both fields, the last chosen first) but have not run with them yet. Not yet with a real key and vault: Qt WebEngine's support of the PRF extension is the first thing a real key shows.