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:
parent
6f2d32b9d5
commit
2be859267e
12 changed files with 1076 additions and 51 deletions
198
ROADMAP-identity-wallet.md
Normal file
198
ROADMAP-identity-wallet.md
Normal 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.
|
||||
105
SESSION-PROMPT-identity-wallet.md
Normal file
105
SESSION-PROMPT-identity-wallet.md
Normal 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.
|
||||
8
address-picker-preload.js
Normal file
8
address-picker-preload.js
Normal 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
48
address-picker.html
Normal 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, "<").replace(/"/g, """);
|
||||
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>
|
||||
167
chrome.html
167
chrome.html
|
|
@ -17,10 +17,16 @@
|
|||
}
|
||||
[hidden] { display: none !important; }
|
||||
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; }
|
||||
.tab { display: flex; align-items: center; gap: 8px; max-width: 200px; min-width: 90px; padding: 6px 10px;
|
||||
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
|
||||
/* 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;
|
||||
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 .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; }
|
||||
|
|
@ -94,6 +100,11 @@
|
|||
.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: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 */
|
||||
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
|
||||
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:hover { opacity: 1; color: #f6768a; }
|
||||
.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 a { color: #d6ff3d; }
|
||||
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut);
|
||||
|
|
@ -154,6 +177,7 @@
|
|||
</button>
|
||||
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
|
||||
<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>
|
||||
</div>
|
||||
<div class="searchbox">
|
||||
|
|
@ -196,7 +220,33 @@
|
|||
$("url").blur();
|
||||
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
|
||||
// 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).
|
||||
|
|
@ -253,7 +303,7 @@
|
|||
const box = $("bookmarks");
|
||||
box.innerHTML = bookmarks.length
|
||||
? bookmarks.map((b, i) => `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,""")}"><span class="bt">${(b.title||b.url).replace(/</g,"<")}</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) => {
|
||||
const i = Number(el.dataset.i);
|
||||
if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url);
|
||||
|
|
@ -261,6 +311,63 @@
|
|||
});
|
||||
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, "<")}</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() {
|
||||
const saved = !!current.url && bookmarks.some((b) => b.url === current.url);
|
||||
const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved);
|
||||
|
|
@ -274,6 +381,22 @@
|
|||
T.getBookmarks().then((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 ----
|
||||
// Shield colour communicates connection state at a glance:
|
||||
// neutral — home / resolving (no site OR pending)
|
||||
|
|
@ -353,12 +476,44 @@
|
|||
updateStar();
|
||||
const box = $("tabs");
|
||||
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,"<")}</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,"<")}</span><span class="x" data-close="${t.id}">✕</span></div>`).join("") +
|
||||
`<span class="newtab" id="newtab">+</span>`;
|
||||
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
|
||||
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
|
|||
13
home-preload.js
Normal file
13
home-preload.js
Normal 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
251
home.html
|
|
@ -2,43 +2,97 @@
|
|||
<html>
|
||||
<head><meta charset="utf-8"><title>Theseus</title>
|
||||
<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; }
|
||||
body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif;
|
||||
background: radial-gradient(1000px 500px at 50% -10%, #16202e, #0b0e14);
|
||||
color: #e7eaf1; display: flex; flex-direction: column; align-items: center; }
|
||||
.hero { text-align: center; margin-top: 14vh; padding: 0 1rem; }
|
||||
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
|
||||
color: var(--ink); display: flex; flex-direction: column; align-items: center; }
|
||||
.hero { text-align: center; margin-top: 8vh; padding: 0 1rem; width: 100%; }
|
||||
.mark { font-size: 64px; line-height: 1; }
|
||||
h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; }
|
||||
h1 .g { color: #d6ff3d; }
|
||||
.tag { color: #8b98a9; margin: 0 0 1.6rem; }
|
||||
form { display: flex; gap: 8px; justify-content: center; max-width: 560px; margin: 0 auto; }
|
||||
input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid #ffffff22;
|
||||
background: #141a24; color: #e7eaf1; font-size: 15px; outline: none; }
|
||||
input:focus { border-color: #4b7bec; }
|
||||
button { padding: 13px 20px; border-radius: 999px; border: none; background: #4b7bec; color: #fff; font-size: 15px; cursor: pointer; }
|
||||
.hint { color: #5e6678; 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; }
|
||||
.card { display: block; text-decoration: none; color: inherit; background: #141a24; border: 1px solid #ffffff12;
|
||||
border-radius: 12px; padding: 14px 16px; transition: .15s; }
|
||||
.card:hover { border-color: #4b7bec66; background: #18202c; transform: translateY(-1px); }
|
||||
.card .n { font-weight: 600; color: #e7eaf1; }
|
||||
.card .d { color: #8b98a9; font-size: 12.5px; margin-top: 3px; }
|
||||
.badge { display: inline-block; font-size: 10.5px; padding: 1px 7px; border-radius: 999px; margin-top: 8px; }
|
||||
.b-chain { background: #d6ff3d22; color: #d6ff3d; } .b-sia { background: #b39ddb22; color: #b39ddb; }
|
||||
.b-srv { background: #4fd1a522; color: #4fd1a5; }
|
||||
h1 .g { color: var(--acid); }
|
||||
.tag { color: var(--mut); margin: 0 0 1.6rem; }
|
||||
form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
|
||||
form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2);
|
||||
background: var(--card); color: var(--ink); font-size: 15px; outline: none; }
|
||||
form input:focus { border-color: var(--blue); }
|
||||
form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
|
||||
.hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
|
||||
|
||||
/* Cards — larger, responsive. Grid autoscales to viewport width:
|
||||
wide screens fit 4-5 across, narrow screens collapse to 2, mobile to 1. */
|
||||
.cards-wrap { max-width: 1200px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; }
|
||||
.cards-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 0 4px; }
|
||||
.cards-hdr .title { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); }
|
||||
.cards-hdr .tools button { background: transparent; border: 1px solid var(--line2); color: var(--mut);
|
||||
border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; margin-left: 6px; }
|
||||
.cards-hdr .tools button:hover { background: var(--card-hi); color: var(--ink); }
|
||||
.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 b { color: #6b7688; }
|
||||
/* light theme (placed last so it wins over the dark base rules) */
|
||||
|
||||
/* Light theme */
|
||||
@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; }
|
||||
.tag { color: #4a5262; } .hint { color: #8a93a2; }
|
||||
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; }
|
||||
.modal { background: #ffffff; }
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
|
@ -46,24 +100,141 @@
|
|||
<div class="mark">⛓️</div>
|
||||
<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>
|
||||
<form action="https://duckduckgo.com/" method="GET">
|
||||
<input name="q" placeholder="Search the web (DuckDuckGo)" autofocus spellcheck="false">
|
||||
<form id="searchForm">
|
||||
<input id="q" name="q" placeholder="Search the web (DuckDuckGo)" autofocus spellcheck="false">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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="cards-wrap">
|
||||
<div class="cards-hdr">
|
||||
<span class="title">Quick links</span>
|
||||
<div class="tools">
|
||||
<button id="editBtn" type="button">Edit</button>
|
||||
<button id="resetBtn" type="button" title="Restore the default set of cards" style="display:none">Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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, "<");
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
279
main.js
279
main.js
|
|
@ -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 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) --------------------
|
||||
// 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.
|
||||
|
|
@ -392,6 +477,10 @@ async function clearHistoryNow() {
|
|||
try { t.view.webContents.navigationHistory.clear(); } 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).
|
||||
function applyAcceptLanguage() {
|
||||
|
|
@ -753,6 +842,14 @@ const EP_W = 250; let epH = 320;
|
|||
// recently-finished downloads. Anchored under the toolbar's download button.
|
||||
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
|
||||
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
|
||||
// (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
|
||||
|
|
@ -774,6 +871,7 @@ function pushNav(prov) {
|
|||
const p = decorate(prov);
|
||||
chrome?.webContents.send("nav", p);
|
||||
if (popVisible) popover?.webContents.send("site-info", p);
|
||||
emitPwAvailability();
|
||||
}
|
||||
|
||||
function layout() {
|
||||
|
|
@ -785,6 +883,8 @@ function layout() {
|
|||
positionPopover();
|
||||
positionEnginePicker();
|
||||
positionDownloads();
|
||||
positionAddressPicker();
|
||||
positionPwFill();
|
||||
}
|
||||
function positionPopover() {
|
||||
if (!popover) return;
|
||||
|
|
@ -835,6 +935,91 @@ function showDownloads(show) {
|
|||
downloadsPop.webContents.send("downloads", downloadsPublic());
|
||||
} 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.
|
||||
const downloadsPublic = () => downloads.map((d) => ({ ...d }));
|
||||
function emitDownloads() {
|
||||
|
|
@ -923,7 +1108,13 @@ function loadHome(id) {
|
|||
}
|
||||
function createTab(initial, opts = {}) {
|
||||
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;
|
||||
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } 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 };
|
||||
tabs.push(tab);
|
||||
win.contentView.addChildView(view);
|
||||
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); });
|
||||
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); });
|
||||
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); });
|
||||
wc.on("page-title-updated", (_e, title) => {
|
||||
tab.title = title; 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-stop-loading", () => setLoading(tab, false));
|
||||
wc.on("will-navigate", (e, u) => {
|
||||
|
|
@ -1055,6 +1250,18 @@ function createWindow() {
|
|||
win.contentView.addChildView(downloadsPop);
|
||||
downloadsPop.webContents.loadFile("downloads.html");
|
||||
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", () => {
|
||||
const saved = settings.restoreSession ? loadSession() : [];
|
||||
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("close-tab", (_e, id) => closeTab(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));
|
||||
// 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-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
|
||||
ipcMain.handle("reload", () => activeTab()?.view.webContents.reload());
|
||||
|
|
@ -1286,6 +1507,7 @@ ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => {
|
|||
purposeRootHex = v.bytesToHex(root);
|
||||
}
|
||||
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex);
|
||||
emitPwAvailability();
|
||||
return vaultOk();
|
||||
} 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");
|
||||
const v = await loadVaultLib();
|
||||
vaultState = await v.unlockVault(vaultFile(), masterPassword);
|
||||
emitPwAvailability();
|
||||
return { ok: true, entries: v.listMetadata(vaultState) };
|
||||
} 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 () => {
|
||||
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("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) => {
|
||||
const d = downloads.find((x) => x.id === id);
|
||||
if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {});
|
||||
|
|
@ -1544,6 +1812,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
|
|||
loadSettings();
|
||||
applyTheme();
|
||||
loadBookmarks();
|
||||
loadHistory();
|
||||
loadCollisions();
|
||||
applyPermissions();
|
||||
applyEmbedCookieShim();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@
|
|||
"engine-picker-preload.js",
|
||||
"downloads.html",
|
||||
"downloads-preload.js",
|
||||
"address-picker.html",
|
||||
"address-picker-preload.js",
|
||||
"pw-fill.html",
|
||||
"pw-fill-preload.js",
|
||||
"home-preload.js",
|
||||
"collision.html",
|
||||
"collision-preload.js",
|
||||
"package.json",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ contextBridge.exposeInMainWorld("theseus", {
|
|||
newTab: () => ipcRenderer.invoke("new-tab"),
|
||||
closeTab: (id) => ipcRenderer.invoke("close-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"),
|
||||
back: () => ipcRenderer.invoke("go-back"),
|
||||
forward: () => ipcRenderer.invoke("go-forward"),
|
||||
|
|
|
|||
6
pw-fill-preload.js
Normal file
6
pw-fill-preload.js
Normal 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
41
pw-fill.html
Normal 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, "<");
|
||||
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>
|
||||
Loading…
Add table
Reference in a new issue