Offer to save a password when a login is submitted

Submitting a form containing a password now offers to remember it.

It goes into the macOS Keychain, through the Security framework rather
than the `security` binary - a password passed as a command-line argument
is visible in `ps` to anyone on the machine, however briefly. Never
apps.json, never a log.

The value travels as little as it can: the injected script hands it
straight to Rust, which holds it in memory and tells the shell only which
host and which username, since that is all the shell needs to ask the
question. It is written on Save and dropped on anything else.

Autofill is deliberately not built. Reading a password back out and
injecting it into a page is a materially larger surface than offering to
store one, and deserves its own decision.
This commit is contained in:
2026-09-01 15:26:53 +02:00
parent 3aa9d9c3f7
commit 0567a4b7c7
10 changed files with 212 additions and 2 deletions
+54 -2
View File
@@ -4,9 +4,16 @@ import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import Nav from "./components/Nav";
import Settings from "./components/Settings";
import { BTN_PRIMARY } from "./components/ui";
import { BTN_PRIMARY, Dialog } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance";
import type { Config, Group, HiddenEvent, NotificationClick, SwitchEvent } from "./types";
import type {
Config,
Group,
HiddenEvent,
NotificationClick,
PasswordOffer,
SwitchEvent,
} from "./types";
export default function App() {
const [config, setConfig] = useState<Config | null>(null);
@@ -16,6 +23,8 @@ export default function App() {
const [theme, setTheme] = useAppearance("system");
const [unread, setUnread] = useState<Record<string, number>>({});
const [backdrop, setBackdrop] = useState<string | null>(null);
const [offer, setOffer] = useState<PasswordOffer | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
const booted = useRef(false);
@@ -110,6 +119,8 @@ export default function App() {
listen<[string, number][]>("unread-changed", (e) => {
setUnread(Object.fromEntries(e.payload));
}),
// A login was submitted. The password is held in Rust; this only asks.
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
];
return () => {
unlisten.forEach((p) => p.then((f) => f()));
@@ -204,6 +215,47 @@ export default function App() {
)}
</div>
{offer && (
<Dialog
title="Save this password?"
onCancel={() => {
void api.discardPassword();
setOffer(null);
}}
confirmLabel="Save"
onConfirm={async () => {
try {
setSaved(await api.savePassword());
} catch {
setSaved(null);
}
setOffer(null);
}}
>
{offer.account ? (
<>
<span className="font-medium">{offer.account}</span> at{" "}
<span className="font-mono text-[12px]">{offer.host}</span>.
</>
) : (
<>
The login you just entered at{" "}
<span className="font-mono text-[12px]">{offer.host}</span>.
</>
)}{" "}
It goes into your macOS Keychain not into this app's settings file, and
nowhere else on disk.
</Dialog>
)}
{saved && (
<Dialog title="Saved" onCancel={() => setSaved(null)}>
The password for <span className="font-mono text-[12px]">{saved}</span> is in
your Keychain. You can see and remove it in Keychain Access, under{" "}
<span className="font-mono text-[12px]">Work {saved}</span>.
</Dialog>
)}
{settingsOpen && (
<Settings
config={config}