Compare commits

...
10 Commits
Author SHA1 Message Date
Vincent 2065d82d27 Quieten the settings lists; gather the nav buttons onto one bar
Three things, all about how much line-work the eye has to read past.

A group's app count is gone from the nav and from its row in Settings.
It was decoration: the apps are right there to be counted, and in the
nav it sat where the eye lands looking for a name.

The Settings lists framed every row and then framed all three fields
inside it — four outlines a row, forty down the Apps list, and in light
mode they carried nearly the same weight as the text. Each list is now
framed once with hairline rules between rows, and the fields inside draw
no border until you hover or focus one. Reads as a table rather than a
stack of boxes.

Back, forward, reload and collapse now share a single tinted bar instead
of floating loose in the header, and the one under the pointer lifts out
of it. They are one instrument and now look like one.

The focus tell on a bare field is the border alone. A filled background
would have been the same white — slate-900 in dark — as the panel behind
it, which says nothing.
2026-09-03 22:27:12 +02:00
Vincent 9aac28c0cf Drop the title bar; drag the window by the nav
The page now runs to the very top of the window: titleBarStyle Overlay
gives the full size content view, so there is no reserved band above the
apps and the nav reaches the top edge with the traffic lights floating
over it.

That costs the old drag strip. A native webview swallows mouse events,
so the shell cannot offer a drag region anywhere over a page — once the
page starts at y=0 the only surface left is the nav.

So the nav becomes the handle, all of it, at either width. Every
container carries the attribute and only the buttons do not, which keeps
them clickable: a drag region is decided by the element under the
cursor. The scrolling app list is included, and loses nothing by it —
mousedown is all a drag region takes, never the wheel or a trackpad
swipe.

Verified by dragging from bare points in both the collapsed rail and the
expanded panel: the window follows the delta exactly, and clicking an
app still switches to it rather than moving the window.
2026-09-03 22:19:17 +02:00
Vincent 9d69038cac Let the login test page drive itself
Verifying the password flow needs a login to actually happen, and
driving one by hand means a human at the keyboard. ?auto=1 fills the
fields and logs in on load, with user, pass, succeed, rmdom and push as
query parameters so each trigger and the failure case can be exercised
without touching a real account.

Values are written with the native setter followed by a real 'input'
event, which is what a keystroke produces and what inject.js listens
for. Worth being clear about the limit: this proves the trigger fires
for a field that reached its value the way a browser reports it, not
that physical typing works.
2026-09-03 20:31:06 +02:00
Vincent 56e161911e Make the save-password dialog actually visible
Nothing had ever been saved: every app carried savedAccount=None and the
Keychain held no Work entries at all. So right-clicking "Fill saved
password" had nothing to recall — it was never a fill bug. The offer was
firing correctly on login; its dialog just could not be seen.

Two things hid it.

The stage was parked only for settingsOpen, so the password offer left
the apps where they were — and a native webview paints over anything the
shell draws. Every shell dialog needs the park, not just Settings.

Parking alone was still not enough. show_only and set_stage each move
every webview back to the stage, and the staggered bootstrap calls
show_only once per app as it builds them, so the park was undone within
milliseconds — the dialog appeared and vanished in a flash. Nothing
recorded that the apps were *meant* to be away, so every layout pass
raced to put them back.

AppState now carries that intent and placement() applies it, so a pass
that runs while a dialog is up leaves the apps parked instead of
returning them to the stage.

Verified end to end against a local login form: the offer appears and
stays on an XHR login that fires no submit event, the credential reaches
the Keychain, right-click fill puts it back, and a failed login is
correctly not offered for saving.
2026-09-03 20:30:58 +02:00
Vincent 229dd557cc Remove the M1 marker — this project runs locally again 2026-09-03 19:56:30 +02:00
Vincent 554d4c2c19 Add test-login page (preserved from M1 before removing the M1 setup) 2026-09-03 19:55:06 +02:00
Vincent 556201288a Merge M1's work before handoff 2026-09-03 19:44:20 +02:00
Vincent 72916d3c79 Tell the M1 agent how to browse: m1-browse, not the Chrome extension
The Claude browser extension cannot work on M1 — it links to a single
persistent interactive session, and the relay runs one-shot processes. It
drives a real visible Chrome over CDP instead, so manual intervention over
screen sharing still works and logins persist.
2026-09-03 19:44:19 +02:00
Vincent Rozenberg 2e0c498fc9 Capture XHR-driven logins and offer to save password
Google, Microsoft, Okta and most modern login pages never fire a DOM
submit event — they POST via fetch/XHR and manipulate the DOM directly.
The old approach (submit listener) missed every one of them.

Three triggers now cover the common outcomes after a successful login:

  A. MutationObserver — password field removed from DOM.  Primary path.
     Most SPAs tear down the login form on success.

  B. History API interception (pushState / replaceState / popstate) —
     URL changes before the DOM settles.  100 ms grace period, then
     checks whether the field is still present before firing.

  C. Traditional form submit — kept for regressions (non-SPA sites).
     Clears pwCapture so Trigger A cannot double-fire.

Failed-login heuristic: if the password field is still in the DOM when a
trigger fires, the credential is not offered.  Avoids false positives on
wrong-password attempts.

`filling` flag prevents the capture loop from running while __workAppFill
is programmatically writing into fields.
2026-09-03 13:53:30 +02:00
Vincent Rozenberg 647c08c5b8 Fix fill-password: wrong host key, and silent failure
Two bugs made 'Fill saved password' do nothing:

1. HOST MISMATCH. Saving used location.hostname at submit time (e.g.
   accounts.google.com); filling derived the host from the app's
   configured URL (mail.google.com). The Keychain lookup always found
   nothing for any service with a separate auth domain. Fixed by storing
   the save-time hostname in App.saved_host and reading it back at fill
   time, falling back to default_scope for entries saved before this.

2. SILENT FAILURE. Every error path in the fillpw sentinel called only
   eprintln!, which is invisible to the user. A feature that fails silently
   is indistinguishable from one that is not wired up. Fixed by emitting a
   fill-error event; the shell shows it as a toast in the nav column, which
   is left of the stage and therefore always above native views.
2026-09-03 13:35:26 +02:00
12 changed files with 892 additions and 135 deletions
-21
View File
@@ -1,21 +0,0 @@
## This project lives on M1
All coding, browsing, testing and compiling for this project happens on the
always-on **M1** server. First find out which side you are on — run once:
`test -f ~/.config/claude-m1.env && echo "I am M1" || echo "I am the M5"`.
**On M1 — you are the worker.** Work normally in this repo: read, edit, build,
test and commit here. Ignore the relay notes below; they are for the other Mac.
**On the M5 — this folder is a read-only git mirror.** Do not edit files here
and do not run builds or tests here.
- For **every** message about this project, run `m1-say "<the message>"` and
show M1's reply. M1's agent does the work, on M1, and keeps going even when
this Mac sleeps. You are the pipe. (Questions about the relay itself you may
answer locally.)
- To refresh this mirror with M1's latest work: `m1-back` — a git merge, never
rsync, never an overwrite. Then `git log` / `git status` show what changed.
- M1 is the git remote `m1`. If something truly must be committed from here,
push it with `git push m1 main` — but prefer telling M1 to do it.
@@ -0,0 +1,445 @@
# XHR Password Capture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Capture credentials from XHR-driven login flows (Google, Microsoft, Okta…) that never fire a DOM `submit` event, so "Save this password?" appears for the majority of real-world sites.
**Architecture:** Two-phase capture-then-offer in `inject.js`. Phase 1 snapshots username + password into page-local memory on every `input` event in a password field. Phase 2 fires when a plausible login-succeeded signal arrives — password field removed from DOM (MutationObserver), or SPA navigates via the History API — and only offers if the field is now absent (field still present means failed login). The existing `submit` listener stays for traditional forms. A `filling` flag prevents the capture from re-triggering when our own fill command writes into the fields.
**Tech Stack:** JavaScript (inject.js, runs in every app's WKWebView). No Rust or frontend changes required — the existing `savepw` sentinel and `webviews.rs` handler already do the right thing once the sentinel fires.
**Spec:** No separate spec doc — requirements are in the task description. Re-stated in Global Constraints below.
## Global Constraints
- Must NOT fire the sentinel from `beforeunload` or `pagehide` — WKWebView's navigation is already in flight and `location.href` assignment is unreliable there.
- The captured value must stay in page memory only until the offer moment; never serialise it, never log it, never send it to the shell.
- Offer at most once per captured credential; clear `pwCapture` immediately after sending the sentinel.
- Do not offer if the password field is still in the DOM when the trigger fires (failed login heuristic).
- Do not capture during programmatic fill (`__workAppFill`) — that would re-offer a credential just retrieved from the Keychain.
- Keep the existing `submit` listener for traditional forms; it must not double-fire with the new mutation/URL triggers.
- App version stays 0.0.1; no new Rust changes.
---
### Task 1: Rewrite the password section of inject.js
**Files:**
- Modify: `src-tauri/src/inject.js` — replace the passwords section (lines ~492565)
**Interfaces:**
- Consumes: `send(kind, data)` — already defined in inject.js; sends a sentinel URL
- Consumes: `lastRightClicked` — already in scope; used by `__workAppFill`
- Produces: `window.__workAppFill(account, password)` — unchanged signature, now sets/clears `filling`
- [ ] **Step 1: Replace the passwords section in inject.js**
Find and replace everything between `/* -------------------------------------------------------- passwords */` and the end of the `submit` listener (just before `/* ---------------------------------------------------------- the icon */`).
The new passwords section:
```javascript
/* -------------------------------------------------------- passwords */
/* Capture-then-offer: works for XHR-driven logins that never fire a DOM
submit — Google, Microsoft, and most of the modern web.
Two phases:
1. CAPTURE — on every keystroke in a password field, snapshot the
username and password into page-local memory only. Nothing leaves
the page yet.
2. OFFER — when a plausible login-succeeded signal fires, check whether
the password field is still in the DOM. If it is, the login
probably failed (the error form is still showing); clear and wait for
the next attempt. If it is gone, the page has moved on: offer to save.
Three signals trigger phase 2:
A. Password field disappears from the DOM (MutationObserver).
B. SPA navigates via pushState / replaceState / popstate.
C. Traditional form submit — send immediately, before the page navigates.
The value travels exactly once, cleared right after the sentinel is
queued. It never touches apps.json, the shell, or the logs. */
var pwCapture = null; // { user, pass, host } — page-local only
var filling = false; // true while __workAppFill is writing into fields
function pwSnapshot() {
var pw = document.querySelector('input[type="password"]');
if (!pw || !pw.value) return;
var user = '';
var all = document.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === pw) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i].value || user;
}
pwCapture = { user: user, pass: pw.value, host: location.hostname };
}
// Capture on every keystroke in a password field, but never while our
// own fill command is writing — that would offer to re-save what was
// just retrieved.
document.addEventListener('input', function (e) {
if (filling) return;
var el = e.target;
if (el && el.tagName === 'INPUT' &&
(el.type || '').toLowerCase() === 'password') {
pwSnapshot();
}
}, true);
// Trigger A: field disappears from DOM — the cleanest signal that the
// login flow moved past the password step.
try {
new MutationObserver(function () {
if (!pwCapture) return;
if (document.querySelector('input[type="password"]')) return;
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}).observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
// Trigger B: SPA changes URL via the History API. 100 ms grace period
// covers the race where the URL changes fractionally before the React/Vue
// component tree removes the form. pwCapture is cleared immediately so
// Trigger A cannot double-fire during the wait.
(function () {
function onNav() {
if (!pwCapture) return;
var snap = pwCapture;
pwCapture = null;
setTimeout(function () {
if (!document.querySelector('input[type="password"]')) {
send('savepw', { u: snap.user, p: snap.pass, h: snap.host });
}
// Field still present after 100 ms: failed login; snap already gone.
}, 100);
}
var origPush = history.pushState;
var origReplace = history.replaceState;
try {
history.pushState = function () { origPush.apply(this, arguments); onNav(); };
history.replaceState = function () { origReplace.apply(this, arguments); onNav(); };
} catch (e) {}
window.addEventListener('popstate', onNav);
})();
/* Called from Rust after you ask for it, never on its own. Fills the form
around the password field you right-clicked, or the first one on the page. */
window.__workAppFill = function (account, password) {
filling = true;
try {
var pw = (lastRightClicked && lastRightClicked.closest
? lastRightClicked.closest('form')
: null);
var form = pw || document.querySelector('form input[type="password"]');
if (form && form.tagName === 'INPUT') form = form.form;
if (!form) form = document;
var field = form.querySelector('input[type="password"]');
if (!field) return false;
var user = null;
var all = form.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === field) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i];
}
function fill(el, value) {
if (!el) return;
/* Assigning .value directly is invisible to React and Angular, which
track their own copy — the site would submit an empty field. This
sets it the way a keystroke would. */
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
if (account) fill(user, account);
fill(field, password);
field.focus();
return true;
} finally {
filling = false;
}
};
// Trigger C: traditional form submit. Sends immediately so the sentinel
// reaches Rust before the page navigates. Clears pwCapture so Trigger A
// does not double-fire when the navigation removes the field.
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
try {
pwSnapshot();
if (pwCapture) {
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}
} catch (err) {}
}, true);
```
- [ ] **Step 2: Verify the existing test in webviews.rs still passes**
The webviews test `the_script_carries_this_apps_own_configuration` checks that inject.js is present in the built script. No logic there that would break, but confirm:
```bash
cargo test --manifest-path src-tauri/Cargo.toml 2>&1 | grep "test result"
```
Expected: `test result: ok. 26 passed; 0 failed`
---
### Task 2: Build a local test page and verify all scenarios
**Files:**
- Create (temporary, not committed): `/tmp/workapp-login-test/index.html`
**What this tests:**
1. XHR login, DOM removal → offer APPEARS
2. Failed XHR login (field stays) → offer does NOT appear
3. XHR login, URL change via pushState only (no DOM removal) → offer APPEARS after 100 ms
4. Traditional form submit → offer APPEARS (regression)
- [ ] **Step 1: Write the test page**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Test</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 380px;
margin: 80px auto; padding: 0 20px; }
input, button { display: block; width: 100%; margin: 8px 0;
padding: 10px; box-sizing: border-box; font-size: 14px; }
button { cursor: pointer; background: #0ea5e9; color: white;
border: none; border-radius: 6px; }
button.sec { background: #64748b; }
.err { color: #dc2626; margin: 8px 0; font-size: 13px; }
.ok { color: #16a34a; margin: 8px 0; font-size: 13px; }
label { display: flex; align-items: center; gap: 8px;
font-size: 13px; margin: 4px 0; }
fieldset { border: 1px solid #e2e8f0; border-radius: 8px;
padding: 12px; margin-bottom: 16px; }
legend { font-size: 12px; font-weight: 600; color: #475569; }
</style>
</head>
<body>
<h2>Work App — login trigger test</h2>
<fieldset>
<legend>Scenario controls</legend>
<label><input type="checkbox" id="succeed" checked> Simulate successful login</label>
<label><input type="checkbox" id="removeDom" checked> Remove form from DOM on success</label>
<label><input type="checkbox" id="pushUrl"> Change URL via pushState on success</label>
</fieldset>
<div id="loginSection">
<form id="loginForm" onsubmit="return false">
<input type="email" id="email" placeholder="Email" autocomplete="username">
<input type="password" id="password" placeholder="Password"
autocomplete="current-password">
<button type="button" onclick="doXhr()">Login via XHR (no submit event)</button>
<button type="submit" class="sec" onclick="doFormSubmit(event)">
Login via form submit (classic)
</button>
</form>
<div id="msg"></div>
</div>
<div id="dashboard" style="display:none">
<div class="ok">✓ Logged in — form removed from DOM.</div>
<div class="ok" id="urlNote"></div>
<button onclick="location.reload()">Reload to reset</button>
</div>
<script>
function doXhr() {
var succeed = document.getElementById('succeed').checked;
var rmDom = document.getElementById('removeDom').checked;
var pushUrl = document.getElementById('pushUrl').checked;
var msg = document.getElementById('msg');
msg.className = '';
msg.textContent = '';
if (!succeed) {
msg.className = 'err';
msg.textContent = 'Invalid credentials. Try again.';
return; // field stays — no offer should fire
}
// Success path
if (rmDom) {
document.getElementById('loginSection').remove();
document.getElementById('dashboard').style.display = '';
}
if (pushUrl) {
history.pushState({}, '', '/dashboard');
document.getElementById('urlNote').textContent =
'✓ URL changed to /dashboard via pushState.';
}
}
function doFormSubmit(e) {
// Let the submit event bubble so inject.js sees it,
// then prevent default so we don't actually navigate.
// inject.js's listener fires first (capture phase).
}
</script>
</body>
</html>
```
- [ ] **Step 2: Serve the test page**
```bash
mkdir -p /tmp/workapp-login-test
# (write the file above to /tmp/workapp-login-test/index.html)
python3 -m http.server 9753 --directory /tmp/workapp-login-test &
```
- [ ] **Step 3: Add the test app to Work's config on M1**
The M1 config is the generic seed (Gmail, Drive, Calendar). Add a test entry:
```bash
CONF="$HOME/Library/Application Support/com.vincent.workapp/apps.json"
# Read current, add test app, write back
python3 - <<'PY'
import json, uuid, pathlib
p = pathlib.Path.home() / "Library/Application Support/com.vincent.workapp/apps.json"
cfg = json.loads(p.read_text())
test_app = {
"id": "test-login-local",
"name": "Login Test",
"url": "http://localhost:9753",
"scope": ["localhost"],
"groupId": None,
"userAgent": None,
"hidden": [],
"icon": None,
"savedAccount": None,
"savedHost": None,
"zoom": 1.0,
"order": 99
}
cfg["apps"].append(test_app)
p.write_text(json.dumps(cfg, indent=2))
print("added Login Test app")
PY
```
- [ ] **Step 4: Build and launch**
```bash
npm run ship
```
(ship quits and relaunches the app, which picks up the new config)
- [ ] **Step 5: Test scenario 1 — XHR success, DOM removal**
In the Work app, click "Login Test". Check all three boxes: ✓ Succeed, ✓ Remove DOM, ☐ pushState.
Enter any email and password, click **"Login via XHR"**.
Expected: "Save this password?" dialog appears.
- [ ] **Step 6: Test scenario 2 — Failed XHR login**
Reload the page (Back button or reload). Uncheck "Simulate successful login". Enter credentials, click XHR button.
Expected: error message appears, **no** "Save this password?" dialog.
- [ ] **Step 7: Test scenario 3 — pushState only (no DOM removal)**
Reload. Check Succeed, uncheck Remove DOM, check pushState. Enter credentials, click XHR button.
Expected: "Save this password?" dialog appears within ~100 ms (brief delay for the grace period).
- [ ] **Step 8: Test scenario 4 — traditional form submit (regression)**
Reload. Click **"Login via form submit"** with credentials typed.
Expected: "Save this password?" dialog appears.
- [ ] **Step 9: Confirm no double-offer**
In scenario 1 (DOM removal + pushState both active): check both Remove DOM and pushState. Click XHR.
Expected: exactly **one** "Save this password?" dialog, not two.
- [ ] **Step 10: Remove the test app from config**
```bash
python3 - <<'PY'
import json, pathlib
p = pathlib.Path.home() / "Library/Application Support/com.vincent.workapp/apps.json"
cfg = json.loads(p.read_text())
cfg["apps"] = [a for a in cfg["apps"] if a["id"] != "test-login-local"]
p.write_text(json.dumps(cfg, indent=2))
print("removed Login Test app")
PY
```
Kill the test server: `kill %1` (or `pkill -f "http.server 9753"`)
---
### Task 3: Commit and ship the final build
- [ ] **Step 1: Confirm 26 tests still pass**
```bash
cargo test --manifest-path src-tauri/Cargo.toml 2>&1 | grep "test result"
```
Expected: `test result: ok. 26 passed; 0 failed`
- [ ] **Step 2: Build the release**
```bash
npm run ship
```
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/inject.js
git commit -m "Capture passwords from XHR-driven logins, not only form submit
Google, Microsoft and most modern auth flows never fire a DOM submit
event. The previous listener was therefore deaf to most of the app list.
Two-phase approach: capture credentials on every 'input' event in a
password field (page-local memory only, nothing leaves the page); offer
to save when a plausible login-succeeded signal fires:
A. Password field disappears from the DOM (MutationObserver) — the
cleanest signal; React/Vue unmount the form before or alongside the
navigation.
B. SPA navigates via pushState/replaceState/popstate — 100 ms grace
period handles the race where the URL changes before the form is
removed.
C. Traditional form submit — unchanged behaviour, send immediately so
the sentinel reaches Rust before the page navigates.
A filling flag prevents the input-capture from re-firing when our own
fill command writes into the fields. The existing failed-login heuristic
(field still in DOM → don't offer) applies to all three triggers."
```
- [ ] **Step 4: Tell Vincent the build is ready to pull**
+23 -1
View File
@@ -48,6 +48,13 @@ pub struct AppState {
pub unread: Mutex<std::collections::HashMap<String, u32>>,
/// Size of the last dialog backdrop still, or why there wasn't one.
pub last_snapshot: Mutex<String>,
/// A dialog is up and the apps are parked off screen.
///
/// Repositioning is not a one-off: webviews are re-laid-out whenever the
/// stage moves and once per app as the staggered bootstrap builds them.
/// Without somewhere to record that they are meant to be away, the next
/// such pass puts them straight back over the dialog.
pub parked: Mutex<bool>,
}
impl AppState {
@@ -105,6 +112,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
last_notification: Mutex::new(String::new()),
unread: Mutex::new(std::collections::HashMap::new()),
last_snapshot: Mutex::new(String::new()),
parked: Mutex::new(false),
})
}
@@ -253,6 +261,7 @@ pub async fn stage_snapshot(app: AppHandle) -> Option<String> {
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
let stage = *state.stage.lock().unwrap();
*state.parked.lock().unwrap() = true;
webviews::hide_all(&app, &state.cfg(), stage);
}
@@ -261,6 +270,7 @@ pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
*state.parked.lock().unwrap() = false;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
@@ -333,6 +343,7 @@ pub fn add_app(
hidden: Vec::new(),
icon: None,
saved_account: None,
saved_host: None,
zoom: 1.0,
order,
};
@@ -947,6 +958,7 @@ pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
let mut cfg = state.config.lock().unwrap();
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == app_id) {
a.saved_account = Some(account);
a.saved_host = Some(host.clone());
}
}
state.persist()?;
@@ -977,7 +989,17 @@ pub fn fill_password_for(
.saved_account
.clone()
.ok_or("no password saved for this app")?;
let host = config::default_scope(&target.url).ok_or("this app has no host")?;
// Prefer the hostname that was live when the credential was saved
// (`location.hostname` at submit time). For services with a separate auth
// domain (Google, Microsoft, Okta…) that differs from the app's configured
// URL, so looking up by the configured URL's host always finds nothing.
// Fall back to deriving from the URL for entries saved before this field
// was added.
let host = target
.saved_host
.clone()
.or_else(|| config::default_scope(&target.url))
.ok_or("this app has no host")?;
#[cfg(target_os = "macos")]
let password = {
+7
View File
@@ -41,6 +41,12 @@ pub struct App {
/// the Keychain — but without it the Keychain cannot be asked for the item.
#[serde(default)]
pub saved_account: Option<String>,
/// The hostname the credential was saved under — `location.hostname` at
/// submit time, which is often different from the app's configured URL
/// (e.g. `accounts.google.com` vs `mail.google.com`). Stored so the fill
/// path can look up the same key the save path wrote.
#[serde(default)]
pub saved_host: Option<String>,
/// Page zoom, remembered per app: a dense ERP and a mail client do not
/// want the same size.
#[serde(default = "default_zoom")]
@@ -221,6 +227,7 @@ pub fn seed() -> Config {
hidden: Vec::new(),
icon: None,
saved_account: None,
saved_host: None,
zoom: 1.0,
order,
};
+124 -53
View File
@@ -491,76 +491,147 @@
/* -------------------------------------------------------- passwords */
/* Offers to remember a login you just typed, the way a browser does.
The value goes straight to Rust and into the macOS Keychain; it is never
written to this app's config, never logged, and never handed to the shell
— the shell is only told which host and which username, which is all it
needs to ask you the question. */
function credentialsIn(form) {
var pw = form.querySelector('input[type="password"]');
if (!pw || !pw.value) return null;
/* Capture-then-offer: works for XHR-driven logins that never fire a DOM
submit — Google, Microsoft, and most of the modern web.
/* The username is whatever text-like field comes before the password —
which is how every login form on the web is built, whatever it calls
its fields. */
var fields = form.querySelectorAll('input');
Two phases:
1. CAPTURE — on every keystroke in a password field, snapshot the
username and password into page-local memory only. Nothing leaves
the page yet.
2. OFFER — when a plausible login-succeeded signal fires, check whether
the password field is still in the DOM. If it is, the login
probably failed (the form is still showing an error); clear and wait
for the next attempt. If it is gone, the page has moved on: offer.
Three signals trigger phase 2:
A. Password field disappears from the DOM (MutationObserver) — the
cleanest signal; React/Vue unmount the form before the navigation.
B. SPA navigates via pushState / replaceState / popstate. 100 ms grace
period covers the race where the URL changes before the form is gone.
C. Traditional form submit — send immediately so the sentinel reaches
Rust before the page navigates away.
The value travels exactly once, cleared right after the sentinel is
queued. It never touches apps.json, the shell, or the logs. */
var pwCapture = null; // { user, pass, host } — page-local only
var filling = false; // true while __workAppFill is writing into fields
function pwSnapshot() {
var pw = document.querySelector('input[type="password"]');
if (!pw || !pw.value) return;
var user = '';
for (var i = 0; i < fields.length; i++) {
if (fields[i] === pw) break;
var t = (fields[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = fields[i].value || user;
var all = document.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === pw) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i].value || user;
}
return { user: user, pass: pw.value };
pwCapture = { user: user, pass: pw.value, host: location.hostname };
}
// Capture on every keystroke in a password field, but never while our
// own fill command is writing — that would offer to re-save what was
// just retrieved from the Keychain.
document.addEventListener('input', function (e) {
if (filling) return;
var el = e.target;
if (el && el.tagName === 'INPUT' &&
(el.type || '').toLowerCase() === 'password') {
pwSnapshot();
}
}, true);
// Trigger A: password field disappears from the DOM.
try {
new MutationObserver(function () {
if (!pwCapture) return;
if (document.querySelector('input[type="password"]')) return;
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}).observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
// Trigger B: SPA navigates via the History API. pwCapture is cleared
// immediately so Trigger A cannot double-fire during the 100 ms wait.
(function () {
function onNav() {
if (!pwCapture) return;
var snap = pwCapture;
pwCapture = null;
setTimeout(function () {
if (!document.querySelector('input[type="password"]')) {
send('savepw', { u: snap.user, p: snap.pass, h: snap.host });
}
// Field still present after 100 ms: failed login; snap already cleared.
}, 100);
}
var origPush = history.pushState;
var origReplace = history.replaceState;
try {
history.pushState = function () { origPush.apply(this, arguments); onNav(); };
history.replaceState = function () { origReplace.apply(this, arguments); onNav(); };
} catch (e) {}
window.addEventListener('popstate', onNav);
})();
/* Called from Rust after you ask for it, never on its own. Fills the form
around the password field you right-clicked, or the first one on the page. */
window.__workAppFill = function (account, password) {
var pw = (lastRightClicked && lastRightClicked.closest
? lastRightClicked.closest('form')
: null);
var form = pw || document.querySelector('form input[type="password"]');
if (form && form.tagName === 'INPUT') form = form.form;
if (!form) form = document;
filling = true;
try {
var pw = (lastRightClicked && lastRightClicked.closest
? lastRightClicked.closest('form')
: null);
var form = pw || document.querySelector('form input[type="password"]');
if (form && form.tagName === 'INPUT') form = form.form;
if (!form) form = document;
var field = form.querySelector('input[type="password"]');
if (!field) return false;
var field = form.querySelector('input[type="password"]');
if (!field) return false;
var user = null;
var all = form.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === field) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i];
var user = null;
var all = form.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === field) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i];
}
function fill(el, value) {
if (!el) return;
/* Assigning .value directly is invisible to React and Angular, which
track their own copy — the site would submit an empty field. This
sets it the way a keystroke would. */
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
if (account) fill(user, account);
fill(field, password);
field.focus();
return true;
} finally {
filling = false;
}
function fill(el, value) {
if (!el) return;
/* Assigning .value directly is invisible to React and Angular, which
track their own copy — the site would submit an empty field. This sets
it the way a keystroke would. */
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
if (account) fill(user, account);
fill(field, password);
field.focus();
return true;
};
// Trigger C: traditional form submit. Clears pwCapture so Trigger A
// does not double-fire when the navigation removes the field.
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
try {
var found = credentialsIn(form);
if (found) {
send('savepw', { u: found.user, p: found.pass, h: location.hostname });
pwSnapshot();
if (pwCapture) {
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}
} catch (err) {}
}, true);
+19 -3
View File
@@ -295,7 +295,7 @@ fn handle_sentinel(
"fillpw" => {
let state = handle.state::<crate::commands::AppState>();
if let Err(e) = crate::commands::fill_password_for(&handle, &from, &state) {
eprintln!("could not fill a password: {e}");
let _ = handle.emit("fill-error", e);
}
}
@@ -944,6 +944,19 @@ fn set_page_visibility(handle: &AppHandle, app_id: &str, visible: bool) {
));
}
/// Where the apps belong right now.
///
/// While a dialog is up they belong off screen, and every layout pass has to
/// agree — otherwise the pass that runs next puts them back over the dialog.
fn placement(handle: &AppHandle, stage: (f64, f64, f64, f64)) -> (f64, f64) {
let parked = *handle
.state::<crate::commands::AppState>()
.parked
.lock()
.unwrap();
(stage.0, if parked { stage.1 + PARKED_OFFSET } else { stage.1 })
}
/// Brings one app to the front. Every other app stays live behind it.
pub fn show_only(
handle: &AppHandle,
@@ -951,9 +964,10 @@ pub fn show_only(
cfg: &Config,
stage: (f64, f64, f64, f64),
) {
let (x, y) = placement(handle, stage);
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_position(LogicalPosition::new(x, y));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.set_zoom(app.zoom);
let _ = wv.show();
@@ -977,9 +991,10 @@ pub fn set_stage(
radius: f64,
) {
let cfg = handle.state::<crate::commands::AppState>().cfg();
let (x, y) = placement(handle, stage);
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_position(LogicalPosition::new(x, y));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
set_corner_radius(handle, &app.id, radius);
}
@@ -1084,6 +1099,7 @@ mod tests {
hidden: vec![".ad".into()],
icon: None,
saved_account: None,
saved_host: None,
zoom: 1.0,
order: 0,
};
+2 -1
View File
@@ -19,7 +19,8 @@
"minWidth": 900,
"minHeight": 600,
"center": true,
"hiddenTitle": true
"hiddenTitle": true,
"titleBarStyle": "Overlay"
}
],
"security": {
+37 -5
View File
@@ -32,6 +32,7 @@ export default function App() {
const [downloads, setDownloads] = useState<Download[]>([]);
const [offer, setOffer] = useState<PasswordOffer | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const [fillError, setFillError] = useState<string | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
const booted = useRef(false);
@@ -147,6 +148,11 @@ export default function App() {
}),
// A login was submitted. The password is held in Rust; this only asks.
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
// Fill failed — show the reason rather than doing nothing silently.
listen<string>("fill-error", (e) => {
setFillError(e.payload);
setTimeout(() => setFillError(null), 5000);
}),
listen<{ id: number; name: string; path: string }>("download-started", (e) =>
setDownloads((d) => [
@@ -183,12 +189,19 @@ export default function App() {
/* A native view paints over anything the shell draws, so a dialog needs the
app moved out of the way rather than merely a higher z-index — and once it
is moved there is nothing left behind the dialog to look at. A still taken
on the way out, blurred, puts the background back. */
on the way out, blurred, puts the background back.
This applies to EVERY shell dialog, not just settings. The save-password
offer once missed it and rendered behind the app's own view, where it was
both invisible and unclickable — so nothing was ever saved, and filling
had nothing to recall. */
const dialogUp = settingsOpen || offer !== null;
useEffect(() => {
if (!config) return;
let cancelled = false;
if (settingsOpen) {
if (dialogUp) {
void (async () => {
const shot = await api.stageSnapshot().catch(() => null);
if (cancelled) return;
@@ -202,7 +215,7 @@ export default function App() {
return () => {
cancelled = true;
};
}, [settingsOpen, config]);
}, [dialogUp, config]);
const toggleCollapse = () => {
if (!config) return;
@@ -252,9 +265,13 @@ export default function App() {
/>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
on purpose — anything drawn here would be painted over.
It runs to the very top of the window: there is no title bar to sit
under. Nothing draggable can go here either, for the same reason the
hole is empty — the webview would swallow it. Dragging lives in the
nav, which is the one column the shell still owns. */}
<div className="flex min-w-0 flex-1 flex-col">
<div data-tauri-drag-region className="shrink-0" style={{ height: chrome }} />
<div
ref={stageRef}
className="relative min-h-0 flex-1 overflow-hidden bg-slate-100 dark:bg-slate-950"
@@ -322,6 +339,21 @@ export default function App() {
</Dialog>
)}
{fillError && (
/* Positioned within the nav column (left of the stage), so it is
always above native views. The stage starts at x = collapsed ? rail
: PANEL; this toast stays well within that. */
<div
role="alert"
style={{ width: collapsed ? rail : 240 }}
className="fixed bottom-4 left-0 z-[80] px-3"
>
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] leading-snug text-red-700 shadow-md dark:border-red-900 dark:bg-red-950/60 dark:text-red-300">
{fillError}
</div>
</div>
)}
{settingsOpen && (
<Settings
config={config}
+57 -29
View File
@@ -1,7 +1,7 @@
import type { Config, Download, Group, WorkApp } from "../types";
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
import Downloads from "./Downloads";
import { Favicon, GROUP_LABEL, ICON_CHROME } from "./ui";
import { BUTTON_BAR, Favicon, GROUP_LABEL, ICON_CHROME, ICON_ON_BAR } from "./ui";
interface Props {
config: Config;
@@ -50,13 +50,13 @@ export default function Nav({
you want gone. */
const controls = (
<>
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_ON_BAR}>
<Back />
</button>
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_CHROME}>
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_ON_BAR}>
<Forward />
</button>
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_ON_BAR}>
<Reload />
</button>
</>
@@ -127,34 +127,52 @@ export default function Nav({
};
return (
<aside className={`${shell} items-center`} style={{ width: rail }}>
<aside
data-tauri-drag-region
className={`${shell} items-center`}
style={{ width: rail }}
>
{/* With no title bar left, the nav is the only surface the shell
still owns, so the whole of it drags the window — every container
here carries the attribute, and only the buttons do not. Costs
nothing on the scrolling list: a drag region intercepts mousedown,
never the wheel or a trackpad swipe. */}
<div data-tauri-drag-region className="w-full shrink-0" style={{ height: chrome }} />
{/* Only the expander survives the rail's header. Back and forward are
a two-finger swipe and reload is ⌘R, so a toolbar here would be
clutter standing in for something nobody asked for. */}
<div className="flex w-full flex-col items-center border-b border-slate-200 py-2.5 dark:border-slate-800">
<div
data-tauri-drag-region
className="flex w-full flex-col items-center border-b border-slate-200 py-2.5 dark:border-slate-800"
>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Collapse open={false} />
</button>
</div>
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
<nav
data-tauri-drag-region
className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3"
>
{ungrouped.map(railBtn)}
{ungrouped.length > 0 && groups.length > 0 && (
<span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
<span data-tauri-drag-region className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
)}
{groups.map((g, i) => {
const apps = inGroup(g.id);
if (apps.length === 0) return null;
return (
<div key={g.id} className="flex flex-col items-center gap-1">
{i > 0 && <span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
<div key={g.id} data-tauri-drag-region className="flex flex-col items-center gap-1">
{i > 0 && <span data-tauri-drag-region className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
{apps.map(railBtn)}
</div>
);
})}
</nav>
<Downloads items={downloads} collapsed onDismiss={onDismissDownload} />
<div className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800">
<div
data-tauri-drag-region
className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800"
>
{settingsButton}
</div>
</aside>
@@ -169,7 +187,7 @@ export default function Nav({
const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
const appRow = (app: WorkApp) => (
<li key={app.id}>
<li key={app.id} data-tauri-drag-region>
<button
onClick={() => onSelect(app.id)}
title={app.url}
@@ -183,26 +201,31 @@ export default function Nav({
);
return (
<aside className={shell} style={{ width: PANEL }}>
<aside data-tauri-drag-region className={shell} style={{ width: PANEL }}>
<div data-tauri-drag-region className="shrink-0" style={{ height: chrome }} />
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 py-2.5 dark:border-slate-800">
{controls}
<button
onClick={onToggleCollapse}
title="Collapse"
className={`${ICON_CHROME} ml-auto`}
>
<Collapse open />
</button>
<header
data-tauri-drag-region
className="border-b border-slate-200 px-2 py-2 dark:border-slate-800"
>
<div className={BUTTON_BAR}>
{controls}
<button
onClick={onToggleCollapse}
title="Collapse"
className={`${ICON_ON_BAR} ml-auto`}
>
<Collapse open />
</button>
</div>
</header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{ungrouped.length > 0 && <ul className="space-y-0.5">{ungrouped.map(appRow)}</ul>}
<nav data-tauri-drag-region className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{ungrouped.length > 0 && <ul data-tauri-drag-region className="space-y-0.5">{ungrouped.map(appRow)}</ul>}
{groups.map((g) => {
const apps = inGroup(g.id);
return (
<section key={g.id} className="pt-3 first:pt-0">
<section key={g.id} data-tauri-drag-region className="pt-3 first:pt-0">
<button
onClick={() => onToggleGroup(g)}
className={`${GROUP_LABEL} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
@@ -216,15 +239,17 @@ export default function Nav({
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
<span className="truncate">{g.name}</span>
<span className="ml-auto font-mono text-[10px] opacity-60">{apps.length}</span>
</button>
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
{!g.collapsed && <ul data-tauri-drag-region className="space-y-0.5">{apps.map(appRow)}</ul>}
</section>
);
})}
{config.apps.length === 0 && (
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
<p
data-tauri-drag-region
className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400"
>
No apps yet. Open Settings to add the first one.
</p>
)}
@@ -232,7 +257,10 @@ export default function Nav({
<Downloads items={downloads} collapsed={false} onDismiss={onDismissDownload} />
<footer className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800">
<footer
data-tauri-drag-region
className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800"
>
{settingsButton}
</footer>
</aside>
+15 -22
View File
@@ -16,7 +16,10 @@ import {
HELP,
ICON_CHROME,
INPUT,
INPUT_BARE,
LABEL,
ROW,
ROW_LIST,
SUBPANEL,
SectionHeading,
Segmented,
@@ -113,29 +116,26 @@ export default function Settings({
{/* ----------------------------------------------------- apps */}
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
<div className={ROW_LIST}>
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<div key={app.id} className={ROW}>
<Favicon icon={app.icon} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
className={`${INPUT_BARE} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
className={`${INPUT_BARE} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
className={`${INPUT_BARE} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
@@ -186,19 +186,15 @@ export default function Settings({
{/* ---------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
<div className={ROW_LIST}>
{groups.map((g: Group) => (
<div key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800">
<div key={g.id} className={ROW}>
<input
value={g.name}
onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value }))}
className={`${INPUT} h-[26px] w-full flex-1`}
className={`${INPUT_BARE} h-[26px] w-full flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
{config.apps.filter((a) => a.groupId === g.id).length} apps
</span>
<button
onClick={() => run(() => api.deleteGroup(g.id))}
title={`Delete ${g.name} — its apps stay, ungrouped`}
@@ -239,12 +235,9 @@ export default function Settings({
{/* ------------------------------------------------------ zoom */}
<section className="space-y-2">
<SectionHeading>Zoom</SectionHeading>
<div className="space-y-1.5">
<div className={ROW_LIST}>
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<div key={app.id} className={`${ROW} gap-3`}>
<Favicon icon={app.icon} name={app.name} />
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
<input
+25
View File
@@ -29,6 +29,23 @@ export const INPUT =
"placeholder:text-slate-400 dark:border-slate-800 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
/* A list of editable rows, framed once. Giving every row a border and every
field inside it another one stacks four outlines per row, and ten rows of
that is a thicket you have to read past to find the thing you came for. */
export const ROW_LIST =
"divide-y divide-slate-200 overflow-hidden rounded-lg border border-slate-200 " +
"dark:divide-slate-800 dark:border-slate-800";
export const ROW = "flex items-center gap-2 px-2 py-1.5";
/** A field inside a ROW: the row frames it, so it draws no outline until used.
The focus tell is the border alone — a filled background would be the same
white (or slate-900) as the panel behind it, which says nothing. */
export const INPUT_BARE =
`${CONTROL_H} min-w-0 rounded-md border border-transparent bg-transparent px-2 text-[12px] outline-none ` +
"hover:border-slate-200 focus:border-sky-500 " +
"dark:hover:border-slate-700 dark:focus:border-sky-500 " +
"placeholder:text-slate-400 dark:placeholder:text-slate-500";
const BTN_BASE =
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
"disabled:cursor-not-allowed cursor-pointer";
@@ -54,6 +71,14 @@ export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 cursor-pointer " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
/* A row of icon buttons sharing one tinted surface, so they read as a single
instrument rather than three loose targets. The hovered one lifts out of it. */
export const BUTTON_BAR =
"flex items-center gap-0.5 rounded-lg bg-slate-100/80 p-0.5 dark:bg-slate-800/60";
export const ICON_ON_BAR =
`${ICON_BTN} text-slate-500 hover:bg-white hover:text-slate-900 hover:shadow-sm ` +
"dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white";
/** Icon-button skin used throughout the chrome. */
export const ICON_CHROME =
`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 ` +
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Test</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 380px;
margin: 80px auto; padding: 0 20px; }
input, button { display: block; width: 100%; margin: 8px 0;
padding: 10px; box-sizing: border-box; font-size: 14px; }
button { cursor: pointer; background: #0ea5e9; color: white;
border: none; border-radius: 6px; }
button.sec { background: #64748b; }
.err { color: #dc2626; margin: 8px 0; font-size: 13px; }
.ok { color: #16a34a; margin: 8px 0; font-size: 13px; }
label { display: flex; align-items: center; gap: 8px;
font-size: 13px; margin: 4px 0; }
fieldset { border: 1px solid #e2e8f0; border-radius: 8px;
padding: 12px; margin-bottom: 16px; }
legend { font-size: 12px; font-weight: 600; color: #475569; }
</style>
</head>
<body>
<h2>Work App — login trigger test</h2>
<p style="font-size:13px;color:#64748b">
Each scenario toggles which triggers fire. Watch for the
"Save this password?" dialog from Work.
</p>
<fieldset>
<legend>Scenario controls</legend>
<label><input type="checkbox" id="succeed" checked>
Simulate successful login</label>
<label><input type="checkbox" id="removeDom" checked>
Remove form from DOM on success (Trigger A)</label>
<label><input type="checkbox" id="pushUrl">
Change URL via pushState on success (Trigger B)</label>
</fieldset>
<div id="loginSection">
<form id="loginForm" onsubmit="return false">
<input type="email" id="email" placeholder="Email"
autocomplete="username">
<input type="password" id="password" placeholder="Password"
autocomplete="current-password">
<button type="button" onclick="doXhr()">
Login via XHR (no submit event) — tests Triggers A &amp; B
</button>
<button type="submit" class="sec" onclick="doFormSubmit()">
Login via form submit — tests Trigger C
</button>
</form>
<div id="msg"></div>
</div>
<div id="dashboard" style="display:none">
<div class="ok">✓ Logged in — form removed from DOM.</div>
<div class="ok" id="urlNote"></div>
<button onclick="location.reload()">Reload to reset</button>
</div>
<script>
function doXhr() {
var succeed = document.getElementById('succeed').checked;
var rmDom = document.getElementById('removeDom').checked;
var pushUrl = document.getElementById('pushUrl').checked;
var msg = document.getElementById('msg');
msg.className = '';
msg.textContent = '';
if (!succeed) {
// Failed login: keep form, show error — offer must NOT fire
msg.className = 'err';
msg.textContent = 'Invalid credentials. Try again.';
return;
}
// Successful login
if (rmDom) {
document.getElementById('loginSection').remove();
document.getElementById('dashboard').style.display = '';
}
if (pushUrl) {
history.pushState({}, '', '/dashboard');
var n = document.getElementById('urlNote');
if (n) n.textContent = '✓ URL changed to /dashboard via pushState.';
}
}
function doFormSubmit() {
// inject.js's submit listener fires in capture phase before this.
// Default is already prevented by onsubmit="return false" on the form.
}
</script>
<script>
// Automation hook, so the page can drive itself when there is no human at
// the keyboard: ?auto=1&user=…&pass=…&succeed=1&rmdom=1&push=0
//
// Values are written with the native setter and followed by a real 'input'
// event, which is what a keystroke produces and what inject.js listens for.
// It does NOT prove that physical typing works — only that the trigger
// fires for a field that reached its value the way a browser reports it.
(function () {
var q = new URLSearchParams(location.search);
if (q.get('auto') !== '1') return;
function setNative(el, value) {
var proto = Object.getPrototypeOf(el);
var desc = Object.getOwnPropertyDescriptor(proto, 'value');
desc.set.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function check(id, on) {
var el = document.getElementById(id);
if (el && el.checked !== on) el.click();
}
window.addEventListener('load', function () {
check('succeed', q.get('succeed') !== '0');
check('removeDom', q.get('rmdom') !== '0');
check('pushUrl', q.get('push') === '1');
var email = document.getElementById('email');
var pw = document.getElementById('password');
if (email) { email.focus(); setNative(email, q.get('user') || 'testuser@example.com'); }
if (pw) { pw.focus(); setNative(pw, q.get('pass') || 'TestPassword123'); }
// Let the fields settle, then log in the way Google does: no submit event.
setTimeout(doXhr, 800);
});
})();
</script>
</body>
</html>