Compare commits
10
Commits
d26b1f7b29
...
2065d82d27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2065d82d27 | ||
|
|
9aac28c0cf | ||
|
|
9d69038cac | ||
|
|
56e161911e | ||
|
|
229dd557cc | ||
|
|
554d4c2c19 | ||
|
|
556201288a | ||
|
|
72916d3c79 | ||
|
|
2e0c498fc9 | ||
|
|
647c08c5b8 |
@@ -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 ~492–565)
|
||||
|
||||
**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**
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"hiddenTitle": true
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+37
-5
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ` +
|
||||
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user