Theseus UX batch: tabs, address history, password autofill MVP, home cards

Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.

Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
  overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
  side chosen by pointer x within target (Chrome UX). New move-tab IPC
  splices the tabs array + re-emits.

Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
  by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
  under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
  picker via address-cursor IPC; Enter fires goURL; blur closes after
  160ms so click-through registers. New files address-picker.html +
  address-picker-preload.js. Cleared by existing clearHistoryOnQuit.

Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
  and the active tab's host has matching credentials (exact hostname
  match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
  runs a small script in the active tab: finds first visible
  input[type=password]:not([disabled]), walks the same form for a
  visible text/email/tel/url/search input whose name/id/autocomplete
  matches /username|user|email|login|account|id/, fills both via the
  native value setter + dispatches input/change so React/Vue-controlled
  inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
  the chip's visibility + count stays accurate.

Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
  "Add current page" (or "Remove current page" if already saved). On a
  specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
  the add/remove-current entry. Uses a shared .ctxmenu style mirroring
  the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.

Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
  breakpoints at 600/900/1200. Cards have a subtitle line, a colored
  badge (on-chain / Sia / server / custom), and edit affordances that
  reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
  card" tile. Modal for add/edit with title / URL / subtitle / badge.
  Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
  window.home = { getCards, setCards, resetCards, navigate }. IPC
  handlers in main.js validate sender.getURL() matches our own
  home.html — third-party pages see the API shape via the preload but
  can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
  (e.g. opening home.html directly outside Electron for preview) so
  the grid is never blank.

Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
  the two independent strands (password manager A.2/3, browser wallet
  B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
  kickoff for the next session picking up either strand.

Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
This commit is contained in:
Local Dev 2026-08-17 02:17:12 +02:00
parent 6f2d32b9d5
commit 2be859267e
12 changed files with 1076 additions and 51 deletions

198
ROADMAP-identity-wallet.md Normal file
View file

@ -0,0 +1,198 @@
# Identity + wallet roadmap
Two independent strands, both rooted in the same BCH-style seed-with-hardened-
derivation model. Order them however makes sense to the session picking this
up; strands don't block each other.
**Strand A — Password manager.** Local-first vault, deterministic derivation
per site+username, optional Sia backup for cross-device.
**Strand B — Browser-integrated BCH wallet.** MetaMask-style provider so
Silent Mode pages (and any BCH-native dApp) can request signatures,
payments, and BCNR name operations from the user's wallet.
Both use the SAME seed via distinct hardened purpose subtrees so one
compromise can't leak the other:
```
m / 44' / 145' BCH wallet (SLIP-44) ← wallet already ships
m / 1381' / 0' passwords ← phase 1 shipped
m / 1414' / 0' messenger (Nostr etc.) ← future
```
Same seed, three purposes, no cross-derivation. See
[DESIGN-password-manager.md](DESIGN-password-manager.md) for the discipline.
## Strand A — Password manager
### A.1 — SHIPPED (0.0.3, 9c09955e)
- Local encrypted vault at `<userData>/passwords.vault`
- AES-256-GCM under PBKDF2 (200k iters, SHA-256) master-password key
- BIP39 mnemonic → seed → HKDF purpose root
- Deterministic derivation per (domain, username, version, rules)
- Settings UI: setup / locked / unlocked panels, add/edit/reveal/copy/delete
- IPC surface: pwStatus/Setup/Unlock/Lock/List/Get/Add/Update/Remove/Generate
- 12 crypto unit tests, all passing
### A.2 — Autofill (~ 1 week)
The user-visible next step. Right now users must copy from Settings.
- **ContentScript** injected on every page (Electron
`session.defaultSession.setPreloads` OR per-webContents preload extension)
- Watch `input[type=password]` + adjacent username field on load AND on
DOM mutation (SPAs). Debounce.
- **Origin binding via eTLD+1** using the public suffix list. Ship a
bundled snapshot of https://publicsuffix.org/list/public_suffix_list.dat
under `Argus/data/` so `evil-google.com` can't fill `google.com`.
- **Toolbar affordance**: a key icon in the address bar OR the shield's
overlay grows a "Passwords" chip when entries exist for the current
origin. Click → pick which credential to fill.
- **Keyboard shortcut**: Ctrl+Shift+L to fill the current form.
- **Save prompt**: when a form submit yields a password field the vault
hasn't seen, prompt "Save this password? (Generate strong one instead?)"
- **Per-origin permission** cached: sites that were already refused don't
re-prompt.
- Tests: origin-binding table (eTLD+1 for suffix corner cases —
`*.co.uk`, `*.compute.amazonaws.com`, IDN), autofill DOM injection
vector coverage.
### A.3 — Sia backup / cross-device restore (~ 3-5 days)
Opt-in cloud sync of the encrypted vault blob.
- New setting: "Sync vault to Sia". Off by default.
- User provides their own Sia S3 endpoint + credentials (like the operator's
`sia-s3.json`), OR opts to use the operator's public relay at
`sia://silentmode.st/vault/<vaultId>` (metadata leaks: only "this
vaultId exists", nothing else — vault is encrypted end-to-end).
- On save: upload the encrypted-blob bytes to `bucket/vault-<id>.bin`.
Include an integrity hash in the local vault header.
- On new-device setup: user picks "Restore from Sia" → enters vaultId +
master password → download blob → decrypt.
- **Explicit non-goal**: no operational sync (last-write-wins across
devices, no merge). Phase A.3 is "clone the vault to another laptop",
not "keep two laptops perfectly in step". Multi-device concurrent-edit
is A.4 if we ever want it.
### A.4 — Nice-to-haves
Order these however makes sense; each is small and independent.
- Import from Bitwarden JSON / KeePass CSV / Firefox CSV
- Export to same
- Password strength meter on the paste-a-password entry
- Auto-lock after N minutes of Theseus idle
- Password-history per entry (previous versions kept, so a rotation
doesn't lose the old password until the user confirms it's no longer
needed anywhere)
- Compromised-password check via HIBP k-anonymity API (opt-in, sends
a 5-char SHA-1 prefix only)
- Password-strength / composition rules from the site's `passwordrules`
attribute (WICG spec) so generated passwords satisfy site policy
## Strand B — Browser-integrated BCH wallet
MetaMask-style `window.bcnr` (or `window.bch`) provider that lets a page
request signatures, payments, and name operations from the user's wallet.
The wallet already exists in `site/js/` for the registrar flow; this
strand generalises it to a per-origin API for any page.
### B.1 — Design (start here) (~ 2-3 days)
Ship the doc before writing code. Two hard problems to nail:
1. **Permission model**. Chrome dapp providers have taught us how easily
this becomes malware infrastructure. The doc must lock down:
- Permission granularity: per-origin, per-method, per-amount
- First-use always shows a modal (never auto-approves)
- Persistent permissions revocable in Settings > Wallet > Sites
- "You're on a BCNR name backed by an on-chain contract" vs "You're
on random-site.com" — trust badges the user can see before signing
- Anti-phishing: display the recipient's cash address in the
confirmation dialog with a big warning if the address changes
mid-session
2. **API surface**. Match existing convention where possible so BCH dApp
developers don't learn a Silent Mode-only dialect:
- `bcnr.getAccounts()` — returns cash addresses the user has approved
for this origin
- `bcnr.requestAccount()` — first-use modal
- `bcnr.signMessage({message, address})` — BIP-137 style, used for
BCNR name registration and auth flows
- `bcnr.sendPayment({to, amount, memo})` — modal-confirmed BCH send
- `bcnr.resolveName(name)` — no permission needed; BCNR lookup
- `bcnr.registerName({name, records})`, `bcnr.updateName(name, records)`
— modal-confirmed; ties into the site/register.html flow
Deliverable: `TheseusNavigator/DESIGN-integrated-wallet.md` in the same
shape as `DESIGN-password-manager.md`.
### B.2 — Injection scaffold + read-only APIs (~ 1 week)
- ContentScript that injects `window.bcnr` on every page load. Isolated
world (Electron's `contextIsolation: true` already enforces this) so
page JS can't tamper with the provider.
- Provider is an event-emitting object that speaks JSON-RPC over
postMessage to the preload, which forwards to main via IPC.
- Read-only methods (no permission needed): `resolveName`, `getBcnrTlds`,
`isRegistered`, `getRecordVersion`.
- Tests: page-script → provider → main round trip; provider surface
matches the spec.
### B.3 — Signature + payment APIs (~ 2-3 weeks)
- Permission-gated: `requestAccount`, `signMessage`, `sendPayment`.
- Reuse the existing wallet in `site/js/` (libauth-based) as the signing
core; wrap it in a main-process handler that reads the wallet's
`wallets.json` (already encrypted with the wallet passphrase).
- Modal confirmations rendered as a new WebContentsView overlay (same
pattern as popover/engine-picker/downloads).
- Site permissions persisted in `userData/wallet-permissions.json`.
- Settings → Wallet section: list connected sites, revoke per-site or
per-method.
### B.4 — BCNR name operations (~ 1 week)
- `registerName`, `updateName` — thin wrappers over the existing
`Argus/src/register.js` / `update.js` flows.
- Sanity: same modal-first pattern; show the exact record payload before
signing (users need to see what's going on-chain permanently).
### B.5 — Nostr messenger integration (~ 2-3 weeks)
Bring the messenger strand in as a parallel purpose. Same seed under
`m/1414'/0'`, so users who set up passwords in strand A already have
messenger keys.
- Derive Nostr keys (secp256k1, npub/nsec encoding) from the messenger
purpose root
- `window.nostr` NIP-07 provider (widely-supported protocol, dozens of
compatible clients)
- Chrome integration: address bar chip when a Nostr-native site connects
- Threat model: Nostr keys don't hold funds, but do bind identity —
compromise = impersonation. Same modal-first permission model.
### B.6 — Advanced: connect-wallet UX
- QR-pairing so a mobile Ariadne can act as a hardware-wallet-like
signer for a desktop Theseus
- Multisig confirmations (2-of-3 with a hardware wallet + Theseus +
Nostr-relayed remote signer)
## Cross-strand dependencies
- Both strands rely on the crypto module at
`Argus/src/lib/password-vault.js`. Extract `bip39ToSeed` +
`seedToPurposeRoot` into a `seed-derive.js` module the wallet strand
can also import cleanly.
- Both strands should share the master-password unlock. If a user has
the vault unlocked and the wallet locked (or vice versa), the second
unlock should not re-prompt.
## Non-goals
- No third-party cloud sync. Sia is the only sync target we bless.
- No Chromium password-manager UI reuse. Ergonomics don't fit our threat
model (Google Sync-shaped).
- No Ethereum / Solana / other-chain support in Strand B. This is BCH-
specific; multi-chain is a separate future decision.
- No hardware-wallet abstraction layer in phase 1. Ledger/Trezor
integration is a phase 4 conversation.
- No autofill for credit cards, addresses, or contact forms. Each has
security nuances that deserve their own design pass. Password-only.

View file

@ -0,0 +1,105 @@
# Session kickoff — identity + wallet work
Paste the block below into a fresh Claude Code session started in
`D:\Dev\SilentMode`. Zero prior context needed.
---
You are picking up **identity + wallet** work on Silent Mode's **Theseus
Navigator** — the Electron browser with in-process BCNR resolution. Two
independent strands: **password manager** (phase 1 shipped, needs
autofill + Sia sync next) and **browser-integrated BCH wallet** (design
+ scaffold, then permissioned APIs). Both derive from the same BCH-style
seed via distinct hardened purpose subtrees.
**Read these first, in order, before touching anything:**
1. `HANDOFF.md` — session hand-off protocol + subproject index. Learn
the `PENDING.md` per-subproject convention, the session-naming rule,
and coordination guidance for concurrent sessions.
2. `TheseusNavigator/README.md` — scope + status of the browser.
3. `TheseusNavigator/PENDING.md` — current uncommitted work, grouped by
session. Read the other session's group FIRST so you don't stomp.
4. `TheseusNavigator/GOTCHAS.md` — non-obvious traps (`build.files`,
admin-terminal-for-first-build, native-select popup theme, DNS
hosts-pin for Sia upload, etc.). Save yourself an hour.
5. `TheseusNavigator/ROADMAP-identity-wallet.md` — the full roadmap for
both strands, sorted into small deliverables.
6. `TheseusNavigator/DESIGN-password-manager.md` — the crypto discipline
+ threat model. Any new derivation MUST reuse the "purpose subtree"
pattern documented there.
7. If you'll touch the wallet strand: read
`Argus/src/lib/register-tx.js` + `Argus/src/lib/wallet-web.js` to see
how the existing signing works, and `site/register.html` for how the
built-in wallet UI is currently invoked from a page.
8. `SESSION-CONTEXT.md` — the product context (BCNR terminology,
resolver rules, hosting model, on-chain invariants).
**What is already shipped (Theseus 0.0.3, `9c09955e…`):**
- Password vault + deterministic derivation (`Argus/src/lib/password-vault.js`,
12 tests passing).
- Settings > Passwords section: setup / locked / unlocked panels; add,
reveal, copy, delete entries. No autofill yet — users copy from
Settings and paste into sites.
- Everything from earlier UX rounds: shield security badge, three-tier
engine catalog, download tracker, cache/history storage settings,
fingerprint spoofing selects, theme cards.
**What has NOT been designed yet — flag these decisions before starting:**
- Wallet strand's permission model + API surface. Write
`TheseusNavigator/DESIGN-integrated-wallet.md` before B.2+ code.
- The autofill contentScript's origin-binding rule details (eTLD+1 via
public-suffix list — bundle the snapshot; do not fetch at runtime).
- Sia sync UX (user's own credentials vs operator relay). Design in the
A.3 write-up before code.
**Coordination:**
- A parallel session (labeled `parallel:*` in PENDING.md) has been very
active in this repo — mobile Ariadne, VPS infrastructure, docs, and
frequent snapshot commits. Look at `git log --oneline -20` before
touching a file to know if it recently changed. Commit small and often
so your working tree isn't a shared bucket.
- Use session label `YYYY-MM-DD:identity-wallet` in `PENDING.md` for
anything you leave uncommitted.
- Deploys are one-at-a-time. Check `PENDING.md` before starting one.
**Ship discipline (from `HANDOFF.md` and `GOTCHAS.md`):**
- Every new `WebContentsView` or `loadFile` — add the target file to
`TheseusNavigator/package.json` `build.files`. Verify present in
`dist-public/win-unpacked/resources/app.asar` after build (there's a
one-liner probe in `GOTCHAS.md`).
- Build: from `TheseusNavigator/`, `SOURCE_DATE_EPOCH=<epoch>
CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist`. Admin terminal only
the first time on a fresh machine (winCodeSign symlinks).
- Ship sequence: backup live to `/opt/silent-mode/dl/_prev/`, scp new
`.exe`s + manifest, `node Argus/src/lib/sia-upload.js ../site
bns/silentmode`. Rollback commands documented in `PENDING.md` under
"Last shipment".
- On-chain `releases.silentmode.bch` publishes the manifest URL, not
fixed hashes — you don't need a wallet spend to make new hashes
reachable via BCNR unless the URL itself changes.
**Where to start (pick one, in order of user demand):**
1. **A.2 — password autofill.** The user-visible next step. Small enough
to ship in one round. Users are currently copying passwords from
Settings; autofill removes that friction. Design detail is in
`ROADMAP-identity-wallet.md`.
2. **B.1 — wallet design doc.** No code yet. Write
`DESIGN-integrated-wallet.md`, get user alignment on the permission
model, then B.2 (injection scaffold + read-only APIs).
3. **A.3 — Sia backup.** Small feature, unlocks cross-device story.
Blocks on user picking "own creds vs operator relay" default.
**Then tell me what you want to do.** If unspecified, start with A.2
(autofill) — it's the highest-leverage next step in Strand A and the
crypto foundation is already tested + shipped.
**Conventions:**
- Prefix shell commands with `rtk` (user convention, see
`~/.claude/CLAUDE.md`).
- Windows + PowerShell primary; Bash tool available for POSIX.
- Chipnet only unless explicitly directed to touch mainnet.
- Never ship secrets (`Argus/wallets.json`, `sia-s3.json`,
`ca/root-ca.key`). The build enforces this; don't weaken the check.
- Never put the operator's name or email in any file.
- Recovery phrases, elevated steps, captchas, and on-chain wallet
spends are the user's to perform — hand off with exact commands.

View file

@ -0,0 +1,8 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("picker", {
onSuggestions: (cb) => ipcRenderer.on("address-suggest", (_e, d) => cb(d)),
onCursor: (cb) => ipcRenderer.on("address-cursor", (_e, dir) => cb(dir)),
pick: (url) => ipcRenderer.invoke("address-pick", url),
close: () => ipcRenderer.invoke("close-address-picker"),
resize: (h) => ipcRenderer.invoke("address-picker-resize", h),
});

48
address-picker.html Normal file
View file

@ -0,0 +1,48 @@
<!doctype html>
<html><head><meta charset="utf-8">
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
html, body { margin: 0; background: transparent; }
.menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; }
.item { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #ffffff08; }
.item:last-child { border-bottom: 0; }
.item:hover, .item.on { background: #ffffff12; }
.item .ic { flex: none; font-size: 12px; color: #7f8aa0; width: 14px; text-align: center; }
.item .txt { flex: 1; overflow: hidden; }
.item .url { color: #e7eaf1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; }
.item .ttl { color: #7f8aa0; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { padding: 14px; color: #7f8aa0; font-size: 12.5px; text-align: center; }
@media (prefers-color-scheme: light) {
.menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; }
.item:hover, .item.on { background: rgba(0,0,0,.05); }
.item { border-bottom-color: rgba(0,0,0,.06); }
.item .url { color: #1a1f28; }
.item .ttl, .item .ic, .empty { color: #7b8494; }
}
</style></head>
<body>
<div class="menu"><div id="list"></div></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;");
let items = []; let cursor = -1;
function report() { requestAnimationFrame(() => { try { window.picker.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
function paint() {
const list = $("list");
if (!items.length) { list.innerHTML = `<div class="empty">No history yet.</div>`; report(); return; }
list.innerHTML = items.map((h, i) =>
`<div class="item ${i === cursor ? "on" : ""}" data-url="${esc(h.url)}"><span class="ic">🕘</span><span class="txt"><div class="url">${esc(h.url)}</div>${h.title ? `<div class="ttl">${esc(h.title)}</div>` : ""}</span></div>`
).join("");
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.picker.pick(el.dataset.url));
report();
}
window.picker.onSuggestions((data) => { items = data.suggestions || []; cursor = -1; paint(); });
window.picker.onCursor((dir) => {
if (!items.length) return;
if (dir === "next") cursor = (cursor + 1) % items.length;
else if (dir === "prev") cursor = (cursor - 1 + items.length) % items.length;
else if (dir === "enter") { if (cursor >= 0) window.picker.pick(items[cursor].url); return; }
paint();
});
</script>
</body></html>

View file

@ -17,10 +17,16 @@
} }
[hidden] { display: none !important; } [hidden] { display: none !important; }
body { margin: 0; height: 100vh; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; } body { margin: 0; height: 100vh; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; }
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; } .tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
.tab { display: flex; align-items: center; gap: 8px; max-width: 200px; min-width: 90px; padding: 6px 10px; /* Same-size tabs: each tab claims an equal share of the row, capped at
200px so 2 tabs don't stretch across the whole window. min-width lets
many tabs shrink cleanly while still showing a couple letters. */
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 60px; padding: 6px 10px;
background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0; background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0;
font-size: 12.5px; cursor: default; color: var(--mut); } font-size: 12.5px; cursor: grab; color: var(--mut); user-select: none; }
.tab.dragging { opacity: .45; cursor: grabbing; }
.tab.dropbefore { box-shadow: -2px 0 0 var(--acid); }
.tab.dropafter { box-shadow: 2px 0 0 var(--acid); }
.tab.active { background: var(--active); color: var(--ink); } .tab.active { background: var(--active); color: var(--ink); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: #d6ff3d; border-radius: 50%; animation: spin .7s linear infinite; } .tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: #d6ff3d; border-radius: 50%; animation: spin .7s linear infinite; }
@ -94,6 +100,11 @@
.tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); } .tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); }
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; } .star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; } .star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
/* Password-fill chip — only visible when the vault is unlocked AND the
current site has matching credentials. Count sits to the left of the key. */
.pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; }
.pwchip:hover { background: rgba(63,185,80,.14); }
.pwchip svg { width: 14px; height: 14px; }
/* search box: engine icon (click = pick engine) + a wide typing area */ /* search box: engine icon (click = pick engine) + a wide typing area */
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line); .searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 12px 0 3px; } border-radius: 999px; padding: 0 12px 0 3px; }
@ -122,6 +133,18 @@
.bm .bx { opacity: 0; cursor: pointer; font-size: 11px; } .bm:hover .bx { opacity: .55; } .bm .bx { opacity: 0; cursor: pointer; font-size: 11px; } .bm:hover .bx { opacity: .55; }
.bm .bx:hover { opacity: 1; color: #f6768a; } .bm .bx:hover { opacity: 1; color: #f6768a; }
.bm-empty { color: var(--faint); font-size: 11.5px; white-space: nowrap; } .bm-empty { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
/* Right-click menu on the bookmarks bar (same visual as settings ctxmenu). */
.ctxmenu { position: fixed; z-index: 9999; background: #1c222c; border: 1px solid var(--line);
border-radius: 8px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 180px; font-size: 13px; color: var(--ink); }
.ctxmenu .mi { padding: 7px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; }
.ctxmenu .mi:hover { background: #ffffff10; }
.ctxmenu .mi.danger { color: #f6768a; } .ctxmenu .mi.danger:hover { background: rgba(246,118,138,.12); }
.ctxmenu .mi.off { color: var(--faint); cursor: default; } .ctxmenu .mi.off:hover { background: transparent; }
.ctxmenu .sep { height: 1px; background: var(--line); margin: 4px 0; }
@media (prefers-color-scheme: light) {
.ctxmenu { background: #ffffff; border-color: rgba(0,0,0,.15); }
.ctxmenu .mi:hover { background: rgba(0,0,0,.05); }
}
.tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; } .tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; }
.tordisc a { color: #d6ff3d; } .tordisc a { color: #d6ff3d; }
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut); .bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut);
@ -154,6 +177,7 @@
</button> </button>
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false"> <input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
<span class="reg" id="reg" hidden></span> <span class="reg" id="reg" hidden></span>
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
<button class="star" id="star" title="Save this page"></button> <button class="star" id="star" title="Save this page"></button>
</div> </div>
<div class="searchbox"> <div class="searchbox">
@ -196,7 +220,33 @@
$("url").blur(); $("url").blur();
T.navigate(v); T.navigate(v);
} }
$("url").addEventListener("keydown", (e) => { if (e.key === "Enter") goURL(); }); // Address-bar suggestions — debounced call to main on every keystroke.
// Main computes matches from history and shows/hides a floating overlay
// WebContentsView anchored under the address bar's client rect.
let suggestTimer = null;
let pickerOpen = false;
const askSuggest = () => {
const q = $("url").value;
const r = $("url").getBoundingClientRect();
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 4), w: Math.round(r.width + 40) };
T.suggestAddress(q, rect);
// main hides itself when there are zero matches; assume open otherwise.
pickerOpen = true;
};
const debouncedSuggest = () => { clearTimeout(suggestTimer); suggestTimer = setTimeout(askSuggest, 80); };
$("url").addEventListener("input", debouncedSuggest);
$("url").addEventListener("focus", debouncedSuggest);
$("url").addEventListener("blur", () => {
// Delay: gives a click on the picker time to register before we close.
setTimeout(() => { T.closeAddressPicker(); pickerOpen = false; }, 160);
});
$("url").addEventListener("keydown", (e) => {
if (e.key === "Enter") { T.closeAddressPicker(); pickerOpen = false; goURL(); return; }
if (e.key === "Escape") { T.closeAddressPicker(); pickerOpen = false; return; }
if (!pickerOpen) return;
if (e.key === "ArrowDown") { e.preventDefault(); T.addressCursor("next"); }
else if (e.key === "ArrowUp") { e.preventDefault(); T.addressCursor("prev"); }
});
// Enter submits the search; keep the query visible so the user can refine // Enter submits the search; keep the query visible so the user can refine
// it or search again — clearing it on submit lost context and made refining // it or search again — clearing it on submit lost context and made refining
// annoying (esp. when the engine's own results page uses its own search box). // annoying (esp. when the engine's own results page uses its own search box).
@ -253,7 +303,7 @@
const box = $("bookmarks"); const box = $("bookmarks");
box.innerHTML = bookmarks.length box.innerHTML = bookmarks.length
? bookmarks.map((b, i) => `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,"&quot;")}"><span class="bt">${(b.title||b.url).replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}"></span></div>`).join("") ? bookmarks.map((b, i) => `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,"&quot;")}"><span class="bt">${(b.title||b.url).replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}"></span></div>`).join("")
: `<span class="bm-empty">No saved pages yet — click ☆ in the address bar to save one.</span>`; : `<span class="bm-empty">No saved pages yet — click ☆ or right-click here to add one.</span>`;
box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => { box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => {
const i = Number(el.dataset.i); const i = Number(el.dataset.i);
if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url); if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url);
@ -261,6 +311,63 @@
}); });
updateStar(); syncHeight(); updateStar(); syncHeight();
} }
// Right-click context menu on the bookmarks bar. Two modes:
// - On a specific bookmark: Open / Edit title / Remove
// - On empty area OR the placeholder text: Add current page (or Remove
// if the page is already bookmarked)
function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); }
function openBmMenu(x, y, ctx) {
closeBmMenu();
const items = [];
if (ctx.kind === "item") {
const bm = bookmarks[ctx.index];
items.push({ label: "Open", act: () => T.navigate(bm.url) });
items.push({ label: "Edit title…", act: () => {
const nt = prompt("Edit bookmark title", bm.title || bm.url);
if (nt != null && nt.trim() && nt !== bm.title) {
// No dedicated update IPC yet — round-trip: remove + re-add with new title at same URL.
T.removeBookmark(bm.url).then(() => T.addBookmark({ title: nt.trim(), url: bm.url }));
}
}});
items.push({ label: "Remove", act: () => T.removeBookmark(bm.url), danger: true });
items.push({ sep: true });
}
if (current.url) {
const saved = bookmarks.some((b) => b.url === current.url);
items.push(saved
? { label: "Remove current page", act: () => T.removeBookmark(current.url), danger: true }
: { label: "Add current page", act: () => T.addBookmark({ title: current.title || current.url, url: current.url }) });
} else {
items.push({ label: "Add current page", disabled: true });
}
const m = document.createElement("div");
m.id = "bmMenu"; m.className = "ctxmenu";
m.innerHTML = items.map((it, i) => it.sep
? `<div class="sep"></div>`
: `<div class="mi${it.danger ? " danger" : ""}${it.disabled ? " off" : ""}" data-i="${i}">${it.label.replace(/</g, "&lt;")}</div>`
).join("");
document.body.appendChild(m);
const r = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.left = Math.min(x, vw - r.width - 6) + "px";
m.style.top = Math.min(y, vh - r.height - 6) + "px";
m.querySelectorAll(".mi").forEach((el) => {
const it = items[Number(el.dataset.i)];
if (it.disabled) return;
el.onclick = () => { closeBmMenu(); try { it.act(); } catch (e) { console.error(e); } };
});
setTimeout(() => {
const off = (ev) => { if (!m.contains(ev.target)) { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
const esc = (ev) => { if (ev.key === "Escape") { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", esc);
}, 0);
}
$("bookmarks").addEventListener("contextmenu", (e) => {
e.preventDefault();
const bm = e.target.closest(".bm");
openBmMenu(e.clientX, e.clientY, bm ? { kind: "item", index: Number(bm.dataset.i) } : { kind: "bar" });
});
function updateStar() { function updateStar() {
const saved = !!current.url && bookmarks.some((b) => b.url === current.url); const saved = !!current.url && bookmarks.some((b) => b.url === current.url);
const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved); const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved);
@ -274,6 +381,22 @@
T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); }); T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); });
T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); }); T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); });
// ---- password-fill chip ----
// Visible only when the vault is unlocked AND the current site has matching
// credentials. Click opens a floating picker of matches; clicking a match
// injects the fill into the active tab.
T.onPwAvailability && T.onPwAvailability((d) => {
const chip = $("pwchip"); if (!chip) return;
const n = d && d.count ? d.count : 0;
chip.hidden = n === 0;
$("pwchipCount").textContent = n > 1 ? String(n) : "";
chip.title = n === 1 ? "Fill password for this site" : `${n} saved credentials for this site`;
});
$("pwchip") && ($("pwchip").onclick = () => {
const r = $("pwchip").getBoundingClientRect();
T.togglePwFill({ x: Math.round(r.right - 280), y: Math.round(r.bottom + 4) });
});
// ---- security badge + minimal registry indicator ---- // ---- security badge + minimal registry indicator ----
// Shield colour communicates connection state at a glance: // Shield colour communicates connection state at a glance:
// neutral — home / resolving (no site OR pending) // neutral — home / resolving (no site OR pending)
@ -353,12 +476,44 @@
updateStar(); updateStar();
const box = $("tabs"); const box = $("tabs");
box.innerHTML = d.tabs.map((t) => box.innerHTML = d.tabs.map((t) =>
`<div class="tab ${t.active ? "active" : ""}" data-id="${t.id}">${t.loading ? '<span class="spin"></span>' : ''}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span><span class="x" data-close="${t.id}"></span></div>`).join("") + `<div class="tab ${t.active ? "active" : ""}" data-id="${t.id}" draggable="true">${t.loading ? '<span class="spin"></span>' : ''}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span><span class="x" data-close="${t.id}"></span></div>`).join("") +
`<span class="newtab" id="newtab">+</span>`; `<span class="newtab" id="newtab">+</span>`;
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => { box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close)); if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id)); else T.switchTab(Number(el.dataset.id));
}); });
// Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer
// is on the left or right half of the target tab (matches Chrome UX).
let dragId = null;
box.querySelectorAll(".tab").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragId = Number(row.dataset.id);
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {}
row.classList.add("dragging");
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
dragId = null;
});
row.addEventListener("dragover", (e) => {
if (dragId == null || Number(row.dataset.id) === dragId) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
row.classList.toggle("dropbefore", before);
row.classList.toggle("dropafter", !before);
});
row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter"));
row.addEventListener("drop", (e) => {
e.preventDefault();
const targetId = Number(row.dataset.id);
if (dragId == null || dragId === targetId) return;
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
T.moveTab(dragId, targetId, before ? "before" : "after");
});
});
$("newtab").onclick = () => T.newTab(); $("newtab").onclick = () => T.newTab();
}); });

13
home-preload.js Normal file
View file

@ -0,0 +1,13 @@
// Preload for tab webContents that host the built-in home page. All non-
// settings tabs get this preload since we don't know in advance whether a
// tab will land on home.html; the corresponding IPC handlers in main.js
// validate the sender's URL is our own home.html file:// and reject any
// origin-mismatched call, so a third-party page can inspect the API's
// SHAPE but can't invoke it against local user data.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("home", {
getCards: () => ipcRenderer.invoke("home-cards-get"),
setCards: (cards) => ipcRenderer.invoke("home-cards-set", cards),
resetCards: () => ipcRenderer.invoke("home-cards-reset"),
navigate: (url) => ipcRenderer.invoke("navigate", url),
});

251
home.html
View file

@ -2,43 +2,97 @@
<html> <html>
<head><meta charset="utf-8"><title>Theseus</title> <head><meta charset="utf-8"><title>Theseus</title>
<style> <style>
:root { color-scheme: light dark; } :root { color-scheme: light dark;
--bg1: #16202e; --bg2: #0b0e14;
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif; body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif;
background: radial-gradient(1000px 500px at 50% -10%, #16202e, #0b0e14); background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
color: #e7eaf1; display: flex; flex-direction: column; align-items: center; } color: var(--ink); display: flex; flex-direction: column; align-items: center; }
.hero { text-align: center; margin-top: 14vh; padding: 0 1rem; } .hero { text-align: center; margin-top: 8vh; padding: 0 1rem; width: 100%; }
.mark { font-size: 64px; line-height: 1; } .mark { font-size: 64px; line-height: 1; }
h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; } h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; }
h1 .g { color: #d6ff3d; } h1 .g { color: var(--acid); }
.tag { color: #8b98a9; margin: 0 0 1.6rem; } .tag { color: var(--mut); margin: 0 0 1.6rem; }
form { display: flex; gap: 8px; justify-content: center; max-width: 560px; margin: 0 auto; } form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid #ffffff22; form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2);
background: #141a24; color: #e7eaf1; font-size: 15px; outline: none; } background: var(--card); color: var(--ink); font-size: 15px; outline: none; }
input:focus { border-color: #4b7bec; } form input:focus { border-color: var(--blue); }
button { padding: 13px 20px; border-radius: 999px; border: none; background: #4b7bec; color: #fff; font-size: 15px; cursor: pointer; } form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.hint { color: #5e6678; font-size: 12.5px; margin-top: .8rem; } .hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px;
max-width: 720px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; } /* Cards — larger, responsive. Grid autoscales to viewport width:
.card { display: block; text-decoration: none; color: inherit; background: #141a24; border: 1px solid #ffffff12; wide screens fit 4-5 across, narrow screens collapse to 2, mobile to 1. */
border-radius: 12px; padding: 14px 16px; transition: .15s; } .cards-wrap { max-width: 1200px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; }
.card:hover { border-color: #4b7bec66; background: #18202c; transform: translateY(-1px); } .cards-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 0 4px; }
.card .n { font-weight: 600; color: #e7eaf1; } .cards-hdr .title { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); }
.card .d { color: #8b98a9; font-size: 12.5px; margin-top: 3px; } .cards-hdr .tools button { background: transparent; border: 1px solid var(--line2); color: var(--mut);
.badge { display: inline-block; font-size: 10.5px; padding: 1px 7px; border-radius: 999px; margin-top: 8px; } border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; margin-left: 6px; }
.b-chain { background: #d6ff3d22; color: #d6ff3d; } .b-sia { background: #b39ddb22; color: #b39ddb; } .cards-hdr .tools button:hover { background: var(--card-hi); color: var(--ink); }
.b-srv { background: #4fd1a522; color: #4fd1a5; } .cards-hdr .tools button.on { border-color: var(--acid); color: var(--acid); background: rgba(214,255,61,.08); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
@media (min-width: 900px) { .grid { gap: 18px; } }
@media (min-width: 1200px) { .grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .cards-wrap { padding: 0 .8rem; } }
.card { display: block; text-decoration: none; color: inherit; background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 20px 20px 18px; transition: transform .15s, background .15s, border-color .15s;
position: relative; cursor: pointer; }
.card:hover { border-color: rgba(75,123,236,.4); background: var(--card-hi); transform: translateY(-2px); }
.card .n { font-weight: 600; color: var(--ink); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card .d { color: var(--mut); font-size: 13px; margin-top: 6px; line-height: 1.4; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.card .row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 8px; }
.badge { display: inline-block; font-size: 10.5px; padding: 2px 9px; border-radius: 999px; letter-spacing: .02em; }
.b-on-chain { background: rgba(214,255,61,.14); color: var(--acid); }
.b-Sia { background: rgba(179,157,219,.14); color: var(--sia); }
.b-server { background: rgba(79,209,165,.14); color: var(--srv); }
.b-default { background: rgba(139,152,169,.14); color: var(--mut); }
/* Edit affordances — hidden until Edit mode toggled */
.card .edit-actions { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; }
.card .edit-actions button { border: 1px solid var(--line2); background: rgba(0,0,0,.35); color: var(--mut);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 13px; padding: 0; }
.card .edit-actions button:hover { color: var(--ink); background: rgba(0,0,0,.55); }
.card .edit-actions button.rm:hover { color: #f6768a; border-color: rgba(246,118,138,.5); }
body.editing .card .edit-actions { display: flex; }
body.editing .card { cursor: default; }
body.editing .card:hover { transform: none; }
.add-card { display: none; align-items: center; justify-content: center;
background: transparent; border: 2px dashed var(--line2); border-radius: 14px;
color: var(--mut); font-size: 14px; cursor: pointer; padding: 20px; min-height: 100px; }
.add-card:hover { border-color: var(--acid); color: var(--acid); background: rgba(214,255,61,.04); }
body.editing .add-card { display: flex; }
/* Modal for add/edit */
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100;
align-items: center; justify-content: center; padding: 1rem; }
.modal-bg.on { display: flex; }
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; }
.modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); }
.modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; }
.modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2);
background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; }
.modal input:focus, .modal select:focus { border-color: var(--blue); }
.modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
background: transparent; color: var(--mut); }
.modal button:hover { background: var(--card-hi); color: var(--ink); }
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.modal button.primary:hover { background: #5a89f5; }
footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; } footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; }
footer b { color: #6b7688; } footer b { color: #6b7688; }
/* light theme (placed last so it wins over the dark base rules) */
/* Light theme */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
body { background: radial-gradient(1000px 500px at 50% -10%, #dce6f2, #eef0f4); color: #1a1f28; } :root { --bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2; }
h1 .g { color: #4a8a00; } h1 .g { color: #4a8a00; }
.tag { color: #4a5262; } .hint { color: #8a93a2; } .modal { background: #ffffff; }
input { background: #ffffff; border-color: rgba(0,0,0,.14); color: #1a1f28; }
.card { background: #ffffff; border-color: rgba(0,0,0,.10); }
.card:hover { background: #f4f7fb; } .card .n { color: #1a1f28; } .card .d { color: #4a5262; }
footer { color: #8a93a2; } footer b { color: #4a5262; }
} }
</style></head> </style></head>
<body> <body>
@ -46,24 +100,141 @@
<div class="mark">⛓️</div> <div class="mark">⛓️</div>
<h1>Theseus <span class="g">Navigator</span></h1> <h1>Theseus <span class="g">Navigator</span></h1>
<p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p> <p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p>
<form action="https://duckduckgo.com/" method="GET"> <form id="searchForm">
<input name="q" placeholder="Search the web (DuckDuckGo)" autofocus spellcheck="false"> <input id="q" name="q" placeholder="Search the web (DuckDuckGo)" autofocus spellcheck="false">
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>
<p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p> <p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p>
</div> </div>
<div class="grid"> <div class="cards-wrap">
<a class="card" href="https://hello.bch/"><div class="n">hello.bch</div><div class="d">The page hosted on the blockchain itself.</div><span class="badge b-chain">on-chain</span></a> <div class="cards-hdr">
<a class="card" href="https://theseus.bch/"><div class="n">theseus.bch</div><div class="d">This browser's own name, on-chain.</div><span class="badge b-chain">on-chain</span></a> <span class="title">Quick links</span>
<a class="card" href="https://silentmode.bch/"><div class="n">silentmode.bch</div><div class="d">The division building this stack.</div><span class="badge b-chain">on-chain</span></a> <div class="tools">
<a class="card" href="https://argo.bch/"><div class="n">argo.bch</div><div class="d">The host network, crewed by Argonauts.</div><span class="badge b-chain">on-chain</span></a> <button id="editBtn" type="button">Edit</button>
<a class="card" href="https://coinspectrum.deviant.bch/"><div class="n">coinspectrum.deviant.bch</div><div class="d">A full site served from the Sia network.</div><span class="badge b-sia">Sia</span></a> <button id="resetBtn" type="button" title="Restore the default set of cards" style="display:none">Reset to defaults</button>
<a class="card" href="https://coinspectrum.bch/"><div class="n">coinspectrum.bch</div><div class="d">The same site, from a direct server.</div><span class="badge b-srv">server</span></a> </div>
<a class="card" href="https://faucet.deviant.bch/"><div class="n">faucet.deviant.bch</div><div class="d">A testnet faucet hub, on Sia.</div><span class="badge b-sia">Sia</span></a> </div>
<a class="card" href="https://siatest.bch/"><div class="n">siatest.bch</div><div class="d">A page with no server at all.</div><span class="badge b-sia">Sia</span></a> <div class="grid" id="grid"></div>
</div>
<div class="modal-bg" id="modal">
<div class="modal">
<h2 id="modalTitle">Add card</h2>
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
<label>URL</label><input id="mUrl" placeholder="https://example.com/">
<label>Subtitle (optional)</label><input id="mSub" placeholder="one-line description">
<label>Badge</label>
<select id="mBadge">
<option value="">— none —</option>
<option value="on-chain">on-chain</option>
<option value="Sia">Sia</option>
<option value="server">server</option>
<option value="default">(custom)</option>
</select>
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
</div>
</div> </div>
<footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer> <footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;");
let cards = []; let editIdx = -1;
// Search form: submit → navigate the tab via IPC (main.js applies the
// user's default search engine + regional hints).
$("searchForm").addEventListener("submit", (e) => {
e.preventDefault();
const q = $("q").value.trim(); if (!q) return;
if (window.home && window.home.navigate) window.home.navigate(q);
else window.location.href = "https://duckduckgo.com/?q=" + encodeURIComponent(q);
});
function badgeClass(b) { return b ? "b-" + b : "b-default"; }
function render() {
const g = $("grid");
g.innerHTML = cards.map((c, i) => {
const badge = c.badge ? `<span class="badge ${badgeClass(c.badge)}">${esc(c.badge)}</span>` : "";
return `<div class="card" data-i="${i}">
<div class="edit-actions">
<button class="ed" title="Edit"></button>
<button class="rm" title="Remove"></button>
</div>
<div class="n">${esc(c.title)}</div>
<div class="d">${esc(c.sub || c.url)}</div>
<div class="row">${badge}</div>
</div>`;
}).join("") + `<div class="add-card" id="addBtn">+ Add card</div>`;
// Wire clicks: normal-mode = navigate, edit-mode = per-action buttons.
g.querySelectorAll(".card").forEach((el) => {
const i = Number(el.dataset.i);
el.onclick = (e) => {
if (document.body.classList.contains("editing")) {
if (e.target.classList.contains("rm")) return removeCard(i);
if (e.target.classList.contains("ed")) return openModal(i);
return; // click on card body in edit mode = no-op
}
// normal mode: navigate
if (window.home && window.home.navigate) window.home.navigate(cards[i].url);
else window.location.href = cards[i].url;
};
});
$("addBtn").onclick = () => openModal(-1);
}
function removeCard(i) {
if (!confirm(`Remove "${cards[i].title}" from Quick links?`)) return;
cards.splice(i, 1); persist(); render();
}
function openModal(i) {
editIdx = i;
const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add card" : "Edit card";
const c = isNew ? { title: "", url: "", sub: "", badge: "" } : cards[i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
$("mSub").value = c.sub || "";
$("mBadge").value = c.badge || "";
$("modal").classList.add("on");
setTimeout(() => $("mTitle").focus(), 20);
}
function closeModal() { $("modal").classList.remove("on"); }
$("mCancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); });
$("mSave").onclick = () => {
const title = $("mTitle").value.trim();
let url = $("mUrl").value.trim();
if (!title || !url) { alert("Title and URL are required."); return; }
// Normalize URL: allow bare hosts like "example.com" (add https://).
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const card = { title, url, sub: $("mSub").value.trim(), badge: $("mBadge").value };
if (editIdx < 0) cards.push(card); else cards[editIdx] = card;
persist(); render(); closeModal();
};
$("editBtn").onclick = () => {
const editing = document.body.classList.toggle("editing");
$("editBtn").classList.toggle("on", editing);
$("editBtn").textContent = editing ? "Done" : "Edit";
$("resetBtn").style.display = editing ? "" : "none";
};
$("resetBtn").onclick = async () => {
if (!confirm("Restore the default Quick links? Your custom entries will be lost.")) return;
if (window.home) await window.home.resetCards();
cards = window.home ? (await window.home.getCards()) : [];
render();
};
function persist() { if (window.home && window.home.setCards) window.home.setCards(cards); }
// Initial load — via IPC when available, otherwise a hardcoded fallback so
// opening home.html directly (e.g. from disk without Electron) still shows
// something instead of a blank grid.
(async () => {
if (window.home && window.home.getCards) cards = await window.home.getCards();
if (!cards || !cards.length) cards = [
{ title: "hello.bch", url: "https://hello.bch/", sub: "On the blockchain itself.", badge: "on-chain" },
{ title: "silentmode.bch", url: "https://silentmode.bch/", sub: "The division behind this stack.", badge: "on-chain" },
];
render();
})();
</script>
</body> </body>
</html> </html>

279
main.js
View file

@ -229,6 +229,91 @@ function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks =
function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } } function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } }
function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} } function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} }
// ---- home page editable cards (userData/home-cards.json) ------------------
// Rendered by home.html as the "quick links" grid on the new-tab page. User
// can add/edit/remove via the page's edit mode. First-run seed = the classic
// Silent Mode showcase (hello.bch, theseus.bch, silentmode.bch, etc.).
const DEFAULT_HOME_CARDS = [
{ title: "hello.bch", url: "https://hello.bch/", sub: "On the blockchain itself.", badge: "on-chain" },
{ title: "theseus.bch", url: "https://theseus.bch/", sub: "This browser's own name.", badge: "on-chain" },
{ title: "silentmode.bch", url: "https://silentmode.bch/", sub: "The division behind this stack.", badge: "on-chain" },
{ title: "argo.bch", url: "https://argo.bch/", sub: "Host network, crewed by Argonauts.", badge: "on-chain" },
{ title: "coinspectrum.deviant.bch", url: "https://coinspectrum.deviant.bch/", sub: "A full site from Sia.", badge: "Sia" },
{ title: "coinspectrum.bch", url: "https://coinspectrum.bch/", sub: "Same site, direct server.", badge: "server" },
{ title: "faucet.deviant.bch", url: "https://faucet.deviant.bch/", sub: "Testnet faucet hub, on Sia.", badge: "Sia" },
{ title: "siatest.bch", url: "https://siatest.bch/", sub: "A page with no server.", badge: "Sia" },
];
const homeCardsFile = () => path.join(app.getPath("userData"), "home-cards.json");
function loadHomeCards() {
try {
if (fs.existsSync(homeCardsFile())) {
const v = JSON.parse(fs.readFileSync(homeCardsFile(), "utf8"));
if (Array.isArray(v)) return v;
}
} catch (e) { console.error("home cards load failed:", e.message); }
return DEFAULT_HOME_CARDS.slice();
}
function saveHomeCards(cards) {
try { fs.writeFileSync(homeCardsFile(), JSON.stringify(cards, null, 2)); }
catch (e) { console.error("home cards save failed:", e.message); }
}
// Sender validation — only accept IPC from our own home.html file:// URL.
// Rejects third-party pages that see the API shape via the preload.
function isHomePageSender(sender) {
try {
const u = sender.getURL() || "";
return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u);
} catch { return false; }
}
// ---- address-bar history (userData/history.json) --------------------------
// Suggestions dropdown source. Deduped LRU capped at HISTORY_CAP entries.
// Cleared on quit when settings.clearHistoryOnQuit is on (default).
const HISTORY_CAP = 500;
let history = []; // [{ url, title, ts }]
let historySaveTimer = null;
const historyFile = () => path.join(app.getPath("userData"), "history.json");
function loadHistory() { try { if (fs.existsSync(historyFile())) history = JSON.parse(fs.readFileSync(historyFile(), "utf8")); } catch (e) { console.error("history load failed:", e.message); history = []; } }
function saveHistoryDebounced() {
clearTimeout(historySaveTimer);
historySaveTimer = setTimeout(() => {
try { fs.writeFileSync(historyFile(), JSON.stringify(history)); } catch (e) { console.error("history save failed:", e.message); }
}, 800);
}
function historyAdd(url, title) {
if (!url) return;
const clean = String(url).trim();
// Skip internal / non-http(s) URLs — never useful in address suggestions.
if (!/^https?:\/\//i.test(clean) && !/^bns:\/\//i.test(clean)) return;
// LRU: remove any existing entry for this URL, unshift a fresh one to the top.
const i = history.findIndex((h) => h.url === clean);
if (i >= 0) history.splice(i, 1);
history.unshift({ url: clean, title: String(title || "").slice(0, 200), ts: Date.now() });
if (history.length > HISTORY_CAP) history.length = HISTORY_CAP;
saveHistoryDebounced();
}
// Rank matches: prefix-of-host wins, then prefix-of-URL, then contains,
// then recency. Cap results — the dropdown wants at most ~8 entries.
function historySearch(query, cap = 8) {
const q = String(query || "").trim().toLowerCase();
if (!q) return history.slice(0, cap);
const scored = [];
for (const h of history) {
const u = h.url.toLowerCase();
const host = u.replace(/^https?:\/\//, "").split(/[/?#]/)[0];
let score;
if (host.startsWith(q)) score = 100;
else if (u.startsWith(q)) score = 80;
else if (host.includes(q)) score = 60;
else if (u.includes(q)) score = 40;
else if ((h.title || "").toLowerCase().includes(q)) score = 20;
else continue;
scored.push({ h, score });
}
scored.sort((a, b) => (b.score - a.score) || (b.h.ts - a.h.ts));
return scored.slice(0, cap).map((s) => s.h);
}
// ---- BCNR/ICANN collisions (userData/collisions.json) -------------------- // ---- BCNR/ICANN collisions (userData/collisions.json) --------------------
// Per-name / per-TLD "always use X" overrides for soft "Open with…" mode. // Per-name / per-TLD "always use X" overrides for soft "Open with…" mode.
// See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model. // See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model.
@ -392,6 +477,10 @@ async function clearHistoryNow() {
try { t.view.webContents.navigationHistory.clear(); } catch {} try { t.view.webContents.navigationHistory.clear(); } catch {}
} }
try { fs.unlinkSync(sessionFile()); } catch {} try { fs.unlinkSync(sessionFile()); } catch {}
// Address-bar suggestions history — wipe both in-memory + on-disk.
history = [];
clearTimeout(historySaveTimer); historySaveTimer = null;
try { fs.unlinkSync(historyFile()); } catch {}
} }
// Accept-Language header follows the locale setting (session-wide, best effort). // Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage() { function applyAcceptLanguage() {
@ -753,6 +842,14 @@ const EP_W = 250; let epH = 320;
// recently-finished downloads. Anchored under the toolbar's download button. // recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 }; let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
const DL_W = 340; let dlH = 240; const DL_W = 340; let dlH = 240;
// Address-bar suggestions dropdown. Floating overlay under the address bar.
let addressPicker, apVisible = false, apPos = { x: 60, y: 70 };
let apW = 520; let apH = 60;
// Password-fill picker — floating dropdown under a small key chip in the
// toolbar that appears only when the vault is unlocked AND the current
// site has matching credentials.
let pwFillPop, pwfVisible = false, pwfPos = { x: 8, y: 90 };
const PWF_W = 280; let pwfH = 80;
// In-memory download list. Not persisted: closing the browser clears history // In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped). // (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
@ -774,6 +871,7 @@ function pushNav(prov) {
const p = decorate(prov); const p = decorate(prov);
chrome?.webContents.send("nav", p); chrome?.webContents.send("nav", p);
if (popVisible) popover?.webContents.send("site-info", p); if (popVisible) popover?.webContents.send("site-info", p);
emitPwAvailability();
} }
function layout() { function layout() {
@ -785,6 +883,8 @@ function layout() {
positionPopover(); positionPopover();
positionEnginePicker(); positionEnginePicker();
positionDownloads(); positionDownloads();
positionAddressPicker();
positionPwFill();
} }
function positionPopover() { function positionPopover() {
if (!popover) return; if (!popover) return;
@ -835,6 +935,91 @@ function showDownloads(show) {
downloadsPop.webContents.send("downloads", downloadsPublic()); downloadsPop.webContents.send("downloads", downloadsPublic());
} else { downloadsPop.setVisible(false); dlVisible = false; } } else { downloadsPop.setVisible(false); dlVisible = false; }
} }
function positionAddressPicker() {
if (!addressPicker) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(apPos.x, width - apW - 6));
addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH });
}
function positionPwFill() {
if (!pwFillPop) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6));
pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH });
}
function showPwFill(show, matches) {
if (!pwFillPop) return;
if (show) {
positionPwFill();
win.contentView.removeChildView(pwFillPop);
win.contentView.addChildView(pwFillPop);
pwFillPop.setVisible(true); pwfVisible = true;
pwFillPop.webContents.send("pw-matches", { matches: matches || [] });
} else { pwFillPop.setVisible(false); pwfVisible = false; }
}
// Compute credential matches for a host. Exact hostname match in phase-1;
// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot).
function pwMatchesForHost(host) {
if (!vaultState || !host) return [];
const h = String(host).toLowerCase();
return (vaultState.entries || [])
.filter((e) => e.domain === h)
.map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" }));
}
// Emit the current tab's match count to chrome so the toolbar chip can
// show/hide + display the count. Cheap; called on nav + vault unlock/lock.
function emitPwAvailability() {
const t = activeTab();
const host = t?.prov?.host || "";
const count = pwMatchesForHost(host).length;
try { chrome?.webContents.send("pw-availability", { host, count }); } catch {}
}
// Inject a small script into the active tab that fills the first visible
// password field + tries to fill the adjacent/associated username field.
// Kept intentionally small — the whole autofill affordance is opt-in
// (user clicks the chip; nothing runs on page load).
async function pwFillIntoActiveTab(entry) {
const t = activeTab(); if (!t) return false;
const wc = t.view.webContents;
const script = `(() => {
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible);
if (!pwds.length) return { ok: false, why: 'no-password-field' };
const pw = pwds[0];
const form = pw.closest('form');
const scope = form ? form.querySelectorAll('input') : document.querySelectorAll('input');
const users = [...scope].filter((el) => el !== pw && visible(el) && !el.disabled &&
/^(?:text|email|tel|url|search|)$/i.test(el.type || 'text') &&
/^(?:username|user|email|login|account|id)$/i.test((el.name || el.id || el.autocomplete || '').replace(/[-_]/g, '').toLowerCase()));
const user = users[0] || null;
const fill = (el, v) => {
el.focus();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
if (user && ${JSON.stringify(String(entry.username || ""))}) fill(user, ${JSON.stringify(String(entry.username || ""))});
fill(pw, ${JSON.stringify(String(entry.password))});
pw.blur();
return { ok: true, filledUsername: !!user };
})()`;
try {
const res = await wc.executeJavaScript(script, true);
return res;
} catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; }
}
function showAddressPicker(show, suggestions) {
if (!addressPicker) return;
if (show) {
if (!suggestions || !suggestions.length) return showAddressPicker(false);
positionAddressPicker();
win.contentView.removeChildView(addressPicker);
win.contentView.addChildView(addressPicker);
addressPicker.setVisible(true); apVisible = true;
addressPicker.webContents.send("address-suggest", { suggestions });
} else { addressPicker.setVisible(false); apVisible = false; }
}
// Public view of a download — no DownloadItem refs leak to the renderer. // Public view of a download — no DownloadItem refs leak to the renderer.
const downloadsPublic = () => downloads.map((d) => ({ ...d })); const downloadsPublic = () => downloads.map((d) => ({ ...d }));
function emitDownloads() { function emitDownloads() {
@ -923,7 +1108,13 @@ function loadHome(id) {
} }
function createTab(initial, opts = {}) { function createTab(initial, opts = {}) {
const id = ++tabSeq; const id = ++tabSeq;
const view = new WebContentsView(opts.settings ? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } } : {}); // Non-settings tabs get home-preload so the built-in home page can round-
// trip its editable-cards state via IPC. IPC handlers reject any call
// whose sender URL isn't our own home.html, so a third-party page sees
// the API's shape but can't act through it.
const view = new WebContentsView(opts.settings
? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } }
: { webPreferences: { preload: path.join(__dirname, "home-preload.js") } });
const wc = view.webContents; const wc = view.webContents;
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
@ -931,9 +1122,13 @@ function createTab(initial, opts = {}) {
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", prov: null, settings: !!opts.settings }; const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", prov: null, settings: !!opts.settings };
tabs.push(tab); tabs.push(tab);
win.contentView.addChildView(view); win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); }); wc.on("page-title-updated", (_e, title) => {
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); }); tab.title = title; emitTabs();
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); }); // Keep the top-of-history title in sync when a page's title loads late.
if (history[0] && tab.url && history[0].url === tab.url) { history[0].title = title; saveHistoryDebounced(); }
});
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-start-loading", () => setLoading(tab, true)); wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false)); wc.on("did-stop-loading", () => setLoading(tab, false));
wc.on("will-navigate", (e, u) => { wc.on("will-navigate", (e, u) => {
@ -1055,6 +1250,18 @@ function createWindow() {
win.contentView.addChildView(downloadsPop); win.contentView.addChildView(downloadsPop);
downloadsPop.webContents.loadFile("downloads.html"); downloadsPop.webContents.loadFile("downloads.html");
downloadsPop.setVisible(false); downloadsPop.setVisible(false);
// Floating address-bar suggestions dropdown.
addressPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "address-picker-preload.js") } });
try { addressPicker.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(addressPicker);
addressPicker.webContents.loadFile("address-picker.html");
addressPicker.setVisible(false);
// Floating password-fill picker.
pwFillPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "pw-fill-preload.js") } });
try { pwFillPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(pwFillPop);
pwFillPop.webContents.loadFile("pw-fill.html");
pwFillPop.setVisible(false);
chrome.webContents.once("did-finish-load", () => { chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : []; const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
@ -1184,7 +1391,21 @@ ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q)));
ipcMain.handle("new-tab", () => createTab()); ipcMain.handle("new-tab", () => createTab());
ipcMain.handle("close-tab", (_e, id) => closeTab(id)); ipcMain.handle("close-tab", (_e, id) => closeTab(id));
ipcMain.handle("switch-tab", (_e, id) => setActive(id)); ipcMain.handle("switch-tab", (_e, id) => setActive(id));
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
const src = tabs.findIndex((t) => t.id === id);
const dst = tabs.findIndex((t) => t.id === targetId);
if (src < 0 || dst < 0 || src === dst) return;
const [t] = tabs.splice(src, 1);
const insertAt = tabs.findIndex((x) => x.id === targetId);
tabs.splice(place === "after" ? insertAt + 1 : insertAt, 0, t);
emitTabs();
});
ipcMain.handle("go-home", () => loadHome(activeId)); ipcMain.handle("go-home", () => loadHome(activeId));
// Home page editable cards. Origin-gated to home.html — random pages that
// snoop the preload can't act on the local file.
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
ipcMain.handle("home-cards-set", (e, cards) => { if (!isHomePageSender(e.sender)) return false; if (!Array.isArray(cards)) return false; saveHomeCards(cards); return true; });
ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) return false; try { fs.unlinkSync(homeCardsFile()); } catch {} return true; });
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); }); ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
ipcMain.handle("reload", () => activeTab()?.view.webContents.reload()); ipcMain.handle("reload", () => activeTab()?.view.webContents.reload());
@ -1286,6 +1507,7 @@ ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => {
purposeRootHex = v.bytesToHex(root); purposeRootHex = v.bytesToHex(root);
} }
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex); vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex);
emitPwAvailability();
return vaultOk(); return vaultOk();
} catch (e) { return vaultErr(e?.message || e); } } catch (e) { return vaultErr(e?.message || e); }
}); });
@ -1295,11 +1517,12 @@ ipcMain.handle("password-unlock", async (_e, masterPassword) => {
if (!fs.existsSync(vaultFile())) return vaultErr("no vault"); if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
const v = await loadVaultLib(); const v = await loadVaultLib();
vaultState = await v.unlockVault(vaultFile(), masterPassword); vaultState = await v.unlockVault(vaultFile(), masterPassword);
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) }; return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); } } catch (e) { return vaultErr(e?.message || e); }
}); });
ipcMain.handle("password-lock", () => { vaultState = null; return true; }); ipcMain.handle("password-lock", () => { vaultState = null; emitPwAvailability(); return true; });
ipcMain.handle("password-list", async () => { ipcMain.handle("password-list", async () => {
if (!vaultState) return { ok: false, err: "locked" }; if (!vaultState) return { ok: false, err: "locked" };
@ -1466,6 +1689,51 @@ ipcMain.handle("toggle-downloads", (_e, rect) => {
}); });
ipcMain.handle("close-downloads", () => showDownloads(false)); ipcMain.handle("close-downloads", () => showDownloads(false));
ipcMain.handle("downloads-resize", (_e, h) => { dlH = Math.max(80, Math.min(480, Math.round(h) || 240)); if (dlVisible) positionDownloads(); }); ipcMain.handle("downloads-resize", (_e, h) => { dlH = Math.max(80, Math.min(480, Math.round(h) || 240)); if (dlVisible) positionDownloads(); });
// Address-bar suggestions: show/hide, forward arrow-keys to the dropdown.
ipcMain.handle("suggest-address", (_e, query, rect) => {
const suggestions = historySearch(query);
if (!suggestions.length) { showAddressPicker(false); return; }
if (rect) { apPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; apW = Math.max(280, Math.round(rect.w || 520)); }
showAddressPicker(true, suggestions);
});
ipcMain.handle("close-address-picker", () => showAddressPicker(false));
ipcMain.handle("address-picker-resize", (_e, h) => {
apH = Math.max(40, Math.min(400, Math.round(h) || 60));
if (apVisible) positionAddressPicker();
});
ipcMain.handle("address-pick", (_e, url) => { showAddressPicker(false); if (url) navigateTab(activeId, String(url)); });
// Password fill — chip in the toolbar opens a picker of matching credentials
// for the current site. Clicking a match injects the fill script into the
// active tab. Whole flow is user-initiated; no page-load DOM watchers yet.
ipcMain.handle("toggle-pw-fill", async (_e, rect) => {
if (pwfVisible) return showPwFill(false);
const t = activeTab(); const host = t?.prov?.host || "";
const matches = pwMatchesForHost(host);
if (!matches.length) return showPwFill(false);
if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showPwFill(true, matches);
});
ipcMain.handle("close-pw-fill", () => showPwFill(false));
ipcMain.handle("pw-fill-resize", (_e, h) => {
pwfH = Math.max(60, Math.min(300, Math.round(h) || 80));
if (pwfVisible) positionPwFill();
});
ipcMain.handle("pw-fill-pick", async (_e, id) => {
showPwFill(false);
if (!vaultState) return { ok: false, err: "locked" };
try {
const v = await loadVaultLib();
const entry = vaultState.entries.find((x) => x.id === id);
if (!entry) return { ok: false, err: "no such entry" };
const password = await v.resolvePassword(vaultState, id);
return await pwFillIntoActiveTab({ username: entry.username, password });
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
});
// The chrome sends arrow-up/down/enter through so the picker can move its
// selection cursor without stealing focus from the address input.
ipcMain.handle("address-cursor", (_e, dir) => {
if (apVisible && addressPicker) try { addressPicker.webContents.send("address-cursor", dir); } catch {}
});
ipcMain.handle("download-open", (_e, id) => { ipcMain.handle("download-open", (_e, id) => {
const d = downloads.find((x) => x.id === id); const d = downloads.find((x) => x.id === id);
if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {}); if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {});
@ -1544,6 +1812,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
loadSettings(); loadSettings();
applyTheme(); applyTheme();
loadBookmarks(); loadBookmarks();
loadHistory();
loadCollisions(); loadCollisions();
applyPermissions(); applyPermissions();
applyEmbedCookieShim(); applyEmbedCookieShim();

View file

@ -38,6 +38,11 @@
"engine-picker-preload.js", "engine-picker-preload.js",
"downloads.html", "downloads.html",
"downloads-preload.js", "downloads-preload.js",
"address-picker.html",
"address-picker-preload.js",
"pw-fill.html",
"pw-fill-preload.js",
"home-preload.js",
"collision.html", "collision.html",
"collision-preload.js", "collision-preload.js",
"package.json", "package.json",

View file

@ -5,6 +5,12 @@ contextBridge.exposeInMainWorld("theseus", {
newTab: () => ipcRenderer.invoke("new-tab"), newTab: () => ipcRenderer.invoke("new-tab"),
closeTab: (id) => ipcRenderer.invoke("close-tab", id), closeTab: (id) => ipcRenderer.invoke("close-tab", id),
switchTab: (id) => ipcRenderer.invoke("switch-tab", id), switchTab: (id) => ipcRenderer.invoke("switch-tab", id),
moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place),
suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect),
closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"),
addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir),
togglePwFill: (rect) => ipcRenderer.invoke("toggle-pw-fill", rect),
onPwAvailability: (cb) => ipcRenderer.on("pw-availability", (_e, d) => cb(d)),
goHome: () => ipcRenderer.invoke("go-home"), goHome: () => ipcRenderer.invoke("go-home"),
back: () => ipcRenderer.invoke("go-back"), back: () => ipcRenderer.invoke("go-back"),
forward: () => ipcRenderer.invoke("go-forward"), forward: () => ipcRenderer.invoke("go-forward"),

6
pw-fill-preload.js Normal file
View file

@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("pwfill", {
onMatches: (cb) => ipcRenderer.on("pw-matches", (_e, d) => cb(d)),
pick: (id) => ipcRenderer.invoke("pw-fill-pick", id),
resize: (h) => ipcRenderer.invoke("pw-fill-resize", h),
});

41
pw-fill.html Normal file
View file

@ -0,0 +1,41 @@
<!doctype html>
<html><head><meta charset="utf-8">
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
html, body { margin: 0; background: transparent; }
.menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; }
.hdr { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #8b98a9; padding: 10px 14px 6px; }
.item { display: flex; align-items: center; gap: 10px; padding: 9px 14px; cursor: pointer; font-size: 13px; }
.item:hover { background: #ffffff12; }
.item .ic { font-size: 14px; }
.item .txt { flex: 1; overflow: hidden; }
.item .u { color: #e7eaf1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item .d { color: #7f8aa0; font-size: 11.5px; }
.empty { padding: 14px; color: #7f8aa0; font-size: 12.5px; text-align: center; }
@media (prefers-color-scheme: light) {
.menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; }
.item:hover { background: rgba(0,0,0,.05); }
.item .u { color: #1a1f28; } .item .d, .hdr, .empty { color: #7b8494; }
}
</style></head>
<body>
<div class="menu">
<div class="hdr">Fill password for this site</div>
<div id="list"></div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;");
function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
window.pwfill.onMatches((data) => {
const list = $("list");
const matches = data.matches || [];
if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; }
list.innerHTML = matches.map((m) =>
`<div class="item" data-id="${esc(m.id)}"><span class="ic">🔑</span><span class="txt"><div class="u">${esc(m.username || "(no username)")}</div><div class="d">${esc(m.domain)}</div></span></div>`
).join("");
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id));
report();
});
</script>
</body></html>