Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint: - Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic, AndroidManifest + build.ps1 - Theseus password manager — settings.html/chrome.html/settings-preload.js UI + main.js wiring + package.json resource; Argus password-vault.js, record-picker.js (+ tests) and resolver-web.d.ts - Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored) - Argus public-gateway.mjs updates - Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant), site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup, Decentralized Storage map, coordination notes - .gitignore — exclude /.keys/ and Hephaestus/.env
This commit is contained in:
parent
56dba6d953
commit
0888048ace
6 changed files with 858 additions and 47 deletions
209
DESIGN-password-manager.md
Normal file
209
DESIGN-password-manager.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# Theseus password manager — design
|
||||||
|
|
||||||
|
Built-in password manager local to Theseus, derived from a Bitcoin Cash-style
|
||||||
|
seed. The seed is the single root of trust; passwords are one purpose among
|
||||||
|
many (BCH wallet, messenger, future identity uses) all held under distinct
|
||||||
|
hardened derivation subtrees so a leak in one purpose can't compromise
|
||||||
|
another.
|
||||||
|
|
||||||
|
**Status**: design + Phase 1 (local vault, settings UI, no autofill, no sync).
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Local process compromise reads plaintext passwords ONLY while the vault is
|
||||||
|
unlocked. Locked-vault-on-disk is opaque.
|
||||||
|
- Disk exfiltration (stolen laptop, forensic image) yields only the
|
||||||
|
encrypted vault. No plaintext, no seed material, no metadata about which
|
||||||
|
sites the user has passwords for.
|
||||||
|
- A malicious web page CANNOT ask the password manager for anything.
|
||||||
|
Autofill (phase 2) will happen via a Theseus-controlled contentScript
|
||||||
|
bound to the origin; there is no `window.passwords` API.
|
||||||
|
|
||||||
|
**Out of scope (phase 1):**
|
||||||
|
- Malware running with keyboard-input capability. (No password manager
|
||||||
|
survives a keylogger.)
|
||||||
|
- Physical shoulder-surfing when the vault is unlocked and shown.
|
||||||
|
- Backup / cloud sync — phase 2 (opt-in, Sia).
|
||||||
|
|
||||||
|
**Explicitly rejected:**
|
||||||
|
- Reusing Chromium's `chrome.storage` or Electron's `safeStorage` as the
|
||||||
|
sole cryptographic layer. Both are DPAPI-backed on Windows (encrypted
|
||||||
|
by the OS user's DPAPI key). Fine as a *belt* alongside our AES-GCM
|
||||||
|
*suspenders*, not as a standalone.
|
||||||
|
|
||||||
|
## Crypto
|
||||||
|
|
||||||
|
### Root
|
||||||
|
|
||||||
|
The user provides ONE of:
|
||||||
|
- Their BCH wallet seed (BIP39 mnemonic) — unified identity, one backup
|
||||||
|
- A fresh seed generated in Phase 1 setup — isolated from any BCH funds
|
||||||
|
|
||||||
|
Seed is never persisted by the password vault. What is persisted is a
|
||||||
|
per-purpose derived root, encrypted under the master password.
|
||||||
|
|
||||||
|
### Hardened derivation discipline
|
||||||
|
|
||||||
|
Every purpose gets its own subtree, using a purpose byte that is
|
||||||
|
distinct from BIP44's coin-type space:
|
||||||
|
|
||||||
|
```
|
||||||
|
BIP32-style: m / purpose' / subpurpose'
|
||||||
|
Passwords: m / 1381' / 0' (1381 = 0x555 = arbitrary picked; documented)
|
||||||
|
Messenger: m / 1414' / 0' (reserved for future)
|
||||||
|
BCH wallet: m / 44' / 145' (SLIP-44 coin 145 — untouched)
|
||||||
|
```
|
||||||
|
|
||||||
|
Hardened (`'`) means the parent public key alone cannot derive child keys
|
||||||
|
— you need the parent private key. So even if a password-purpose child
|
||||||
|
key leaks, an attacker cannot walk backward to the BCH wallet subtree.
|
||||||
|
|
||||||
|
### Vault key
|
||||||
|
|
||||||
|
```
|
||||||
|
master_key_material = PBKDF2(
|
||||||
|
masterPassword,
|
||||||
|
salt = 16 random bytes stored in vault header,
|
||||||
|
iterations = 200_000,
|
||||||
|
hash = SHA-256,
|
||||||
|
keylen = 32
|
||||||
|
)
|
||||||
|
vault_key = AES-256-GCM key(master_key_material)
|
||||||
|
```
|
||||||
|
|
||||||
|
200k PBKDF2 iterations balances phone-CPU login latency (~200ms) against
|
||||||
|
brute-force cost. Bumped to 600k on desktop-detected CPUs in Phase 2.
|
||||||
|
|
||||||
|
### Vault encryption
|
||||||
|
|
||||||
|
```
|
||||||
|
vault_on_disk = {
|
||||||
|
version: 1,
|
||||||
|
kdf: { name: "PBKDF2", iters: 200_000, salt: <hex> },
|
||||||
|
iv: <12-byte hex>,
|
||||||
|
ciphertext: <hex>, // AES-GCM(vault_key, iv, JSON.stringify(plaintext))
|
||||||
|
tag: <16-byte hex, appended>,
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext = {
|
||||||
|
purposeRoot: <32-byte hex>, // the m/1381'/0' node — derived once at setup
|
||||||
|
entries: [
|
||||||
|
{ id, domain, username, addedAt,
|
||||||
|
// one of:
|
||||||
|
literal: <ciphertext>, // legacy pasted password (encrypted with vault_key)
|
||||||
|
generated: { version, rules } // deterministic — re-derived from purposeRoot on demand
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every entry carries a stable `id` (UUIDv4) so autofill (phase 2) can bind
|
||||||
|
by id, not by domain+username (which can change).
|
||||||
|
|
||||||
|
### Deterministic derivation recipe (the "Generate" button)
|
||||||
|
|
||||||
|
For a `generated` entry, the password is not stored — it's computed:
|
||||||
|
|
||||||
|
```
|
||||||
|
info = "silentmode-passwords-v1|" + domain + "|" + username + "|v" + version
|
||||||
|
bits = HKDF(hash=SHA-256, key=purposeRoot, salt=<zero>, info) → 32 bytes
|
||||||
|
password = mapBytesToRules(bits, rules)
|
||||||
|
```
|
||||||
|
|
||||||
|
`rules` default:
|
||||||
|
```
|
||||||
|
{ length: 20, upper: true, lower: true, digits: true, symbols: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`mapBytesToRules` is a template scheme: take the first 4 bytes to seed a
|
||||||
|
DRBG, produce N chars from the requested character classes with guaranteed
|
||||||
|
inclusion of at least one from each enabled class. Same input → same
|
||||||
|
password on every device holding the seed.
|
||||||
|
|
||||||
|
Version-bumping (`v2` etc.) is how a user "rotates" a deterministic
|
||||||
|
password without ever losing the old one — old services rejecting a
|
||||||
|
rotation can still be logged into by looking up v1.
|
||||||
|
|
||||||
|
## Vault file
|
||||||
|
|
||||||
|
Location: `<userData>/passwords.vault` (single file).
|
||||||
|
|
||||||
|
Never written unencrypted. On save: build the new plaintext, encrypt with
|
||||||
|
a fresh IV, write atomically (`.tmp` + rename).
|
||||||
|
|
||||||
|
The file's presence is not itself sensitive — it just says "this user has
|
||||||
|
opted into the password manager". Contents are opaque.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
- **Unlock state** lives in the main process only. Never sent to renderers
|
||||||
|
in plaintext except in response to explicit `password-get(id)` calls.
|
||||||
|
- Vault stays unlocked for the current session. Auto-locks on:
|
||||||
|
- Explicit lock button
|
||||||
|
- App quit (before the storage-clear ran)
|
||||||
|
- N minutes of settings-page inactivity — phase 2 knob
|
||||||
|
- No BROWSER autofill in phase 1. Users copy from Settings → Passwords.
|
||||||
|
|
||||||
|
## UI (phase 1)
|
||||||
|
|
||||||
|
New sidebar entry in Settings between Search and Naming: **Passwords**.
|
||||||
|
|
||||||
|
Two states:
|
||||||
|
|
||||||
|
**Locked / not-yet-set-up:**
|
||||||
|
- "Set up password vault" — one-time form:
|
||||||
|
- Master password (with confirm)
|
||||||
|
- Source of derivation seed: "Use my Ariadne wallet seed" (default) OR "Generate a new seed for passwords"
|
||||||
|
- "Create vault" — writes the encrypted vault file
|
||||||
|
|
||||||
|
- "Unlock vault" (when the file exists) — master password only
|
||||||
|
|
||||||
|
**Unlocked:**
|
||||||
|
- List of entries: favicon + domain + username + reveal / copy / delete
|
||||||
|
- "Add new entry": domain, username, password (paste) OR "Generate" button
|
||||||
|
- "Lock now" at the top-right
|
||||||
|
|
||||||
|
## IPC surface (through settings-preload)
|
||||||
|
|
||||||
|
```
|
||||||
|
password-status() → { setup: bool, unlocked: bool }
|
||||||
|
password-setup(masterPw, seedSource) → { ok: true } | { err }
|
||||||
|
password-unlock(masterPw) → { ok: true, entries: [...] } | { err: "bad password" }
|
||||||
|
password-lock() → true
|
||||||
|
password-list() → array of entries (metadata only)
|
||||||
|
password-get(id) → { password: <plaintext> } (only while unlocked)
|
||||||
|
password-add(entry) → id
|
||||||
|
password-update(id, patch) → true
|
||||||
|
password-remove(id) → true
|
||||||
|
password-generate({ domain, username, version, rules }) → <plaintext string>
|
||||||
|
```
|
||||||
|
|
||||||
|
Renderers NEVER see the seed / purposeRoot / vault key / master password
|
||||||
|
past the unlock call.
|
||||||
|
|
||||||
|
## Phase 2 — autofill + Sia backup
|
||||||
|
|
||||||
|
- **Autofill**: contentScript watches `input[type=password]` on load,
|
||||||
|
binds by (public-suffix-list-derived) eTLD+1 origin so `evil-google.com`
|
||||||
|
can't fill `google.com` entries. Toolbar key icon + right-click "Fill
|
||||||
|
password" menu.
|
||||||
|
- **Sia backup**: user provides a Sia S3 endpoint + credentials (or reuses
|
||||||
|
the operator relay). Vault encrypted-blob is uploaded on save; restored
|
||||||
|
on new device by pointing at the same endpoint with the master password.
|
||||||
|
|
||||||
|
## Phase 3 — unified identity
|
||||||
|
|
||||||
|
- Same seed → Nostr messaging keys under `m/1414'/0'`. Compatible with all
|
||||||
|
Nostr clients (secp256k1 keys, npub/nsec encoding).
|
||||||
|
- `window.bcnr` provider spec (Web3-style) exposing signed BCNR name
|
||||||
|
operations to Silent Mode pages — decision to be made per SECURITY.md.
|
||||||
|
|
||||||
|
## Explicitly not doing
|
||||||
|
|
||||||
|
- Cloud sync via anything but Sia. No opinionated third-party.
|
||||||
|
- Chromium's password autofill UI. Its ergonomics are Google-Sync-shaped
|
||||||
|
and don't fit our threat model.
|
||||||
|
- Silent-Mode-only browser extension. The whole thing is built-in — no
|
||||||
|
install / no separate origin / no extension permissions to grant.
|
||||||
|
|
@ -197,7 +197,10 @@
|
||||||
T.navigate(v);
|
T.navigate(v);
|
||||||
}
|
}
|
||||||
$("url").addEventListener("keydown", (e) => { if (e.key === "Enter") goURL(); });
|
$("url").addEventListener("keydown", (e) => { if (e.key === "Enter") goURL(); });
|
||||||
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) { T.search(q); $("search").value = ""; } } });
|
// 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).
|
||||||
|
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } });
|
||||||
$("back").onclick = () => T.back();
|
$("back").onclick = () => T.back();
|
||||||
$("fwd").onclick = () => T.forward();
|
$("fwd").onclick = () => T.forward();
|
||||||
$("reload").onclick = () => T.reload();
|
$("reload").onclick = () => T.reload();
|
||||||
|
|
|
||||||
230
main.js
230
main.js
|
|
@ -21,6 +21,15 @@ const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
|
||||||
const RESOLVER = app.isPackaged
|
const RESOLVER = app.isPackaged
|
||||||
? path.join(RES_DIR, "resolver-web.mjs")
|
? path.join(RES_DIR, "resolver-web.mjs")
|
||||||
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
|
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
|
||||||
|
// Password vault — same .mjs-in-resources pattern as the resolver.
|
||||||
|
const VAULT_MOD = app.isPackaged
|
||||||
|
? path.join(RES_DIR, "password-vault.mjs")
|
||||||
|
: path.join(__dirname, "..", "Argus", "src", "lib", "password-vault.js");
|
||||||
|
let vaultLib;
|
||||||
|
async function loadVaultLib() {
|
||||||
|
if (!vaultLib) vaultLib = await import(`file://${VAULT_MOD.replace(/\\/g, "/")}`);
|
||||||
|
return vaultLib;
|
||||||
|
}
|
||||||
// Built-in engines. Users can also add their own (settings.customEngines,
|
// Built-in engines. Users can also add their own (settings.customEngines,
|
||||||
// each { id, name, url } where the url contains "%s" for the query).
|
// each { id, name, url } where the url contains "%s" for the query).
|
||||||
// Catalog of built-in engines (users pick which to enable + can add their own).
|
// Catalog of built-in engines (users pick which to enable + can add their own).
|
||||||
|
|
@ -32,7 +41,7 @@ const RESOLVER = app.isPackaged
|
||||||
// URL routing is identical for all — kind and tier are display-only grouping.
|
// URL routing is identical for all — kind and tier are display-only grouping.
|
||||||
const SEARCH_ENGINES = {
|
const SEARCH_ENGINES = {
|
||||||
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
|
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
|
||||||
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
|
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q, h = {}) => `https://www.google.com/search?q=${encodeURIComponent(q)}&hl=${h.hl || "en"}&gl=${h.gl || "us"}&pws=0` },
|
||||||
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
|
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
|
||||||
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
|
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
|
||||||
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
|
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
|
||||||
|
|
@ -94,10 +103,26 @@ function allEngines() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function enabledEnginesList() { return allEngines().filter((e) => e.enabled); }
|
function enabledEnginesList() { return allEngines().filter((e) => e.enabled); }
|
||||||
|
// Region → 2-letter country code, for engines that accept a `gl`-style hint
|
||||||
|
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
|
||||||
|
// a consent redirect or the region-detect start page instead of results).
|
||||||
|
const REGION_TO_COUNTRY = {
|
||||||
|
europe: "de", asia: "jp", north_america: "us", south_america: "br",
|
||||||
|
africa: "ke", middle_east: "ae", australia: "au",
|
||||||
|
};
|
||||||
|
function searchHints() {
|
||||||
|
const loc = effLocale(); // e.g. "en-US" (or null → show real)
|
||||||
|
const region = settings.locationMode === "spoof" ? settings.locationRegion : null;
|
||||||
|
return {
|
||||||
|
hl: (loc || app.getLocale() || "en").split("-")[0],
|
||||||
|
gl: REGION_TO_COUNTRY[region] || (loc && loc.split("-")[1]?.toLowerCase()) || "us",
|
||||||
|
};
|
||||||
|
}
|
||||||
function engineUrl(id, q) {
|
function engineUrl(id, q) {
|
||||||
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q);
|
const h = searchHints();
|
||||||
|
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q, h);
|
||||||
const c = (settings.customEngines || []).find((e) => e.id === id);
|
const c = (settings.customEngines || []).find((e) => e.id === id);
|
||||||
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q);
|
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q, h);
|
||||||
}
|
}
|
||||||
const SEARCH = (q) => engineUrl(settings.searchEngine, q);
|
const SEARCH = (q) => engineUrl(settings.searchEngine, q);
|
||||||
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
|
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
|
||||||
|
|
@ -158,6 +183,12 @@ const SETTINGS_DEFAULTS = {
|
||||||
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
|
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
|
||||||
restoreSession: true, // reopen last session's tabs on launch
|
restoreSession: true, // reopen last session's tabs on launch
|
||||||
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
|
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
|
||||||
|
// Storage retention — nothing persists by default. Auto-clear on quit
|
||||||
|
// means a session leaves no trace on disk unless the user opts in per-type.
|
||||||
|
clearCookiesOnQuit: true, // drop cookies + logins + saved-form data
|
||||||
|
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
|
||||||
|
clearHistoryOnQuit: true, // drop navigation history + saved tabs
|
||||||
|
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
|
||||||
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
|
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
|
||||||
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
|
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
|
||||||
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
|
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
|
||||||
|
|
@ -337,6 +368,30 @@ async function applyFingerprint(wc) {
|
||||||
} catch { /* debugger busy (e.g. devtools) — best effort */ }
|
} catch { /* debugger busy (e.g. devtools) — best effort */ }
|
||||||
}
|
}
|
||||||
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
|
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
|
||||||
|
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
|
||||||
|
// cache, localStorage, IndexedDB, service workers, cache API by default.
|
||||||
|
// This wipes whichever the caller asked for. The `storages` list mirrors
|
||||||
|
// Chromium's clearStorageData taxonomy — we group them into a small user-
|
||||||
|
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
|
||||||
|
async function clearBrowsingData({ cookies = false, cache = false, storage = false } = {}) {
|
||||||
|
const ses = session.defaultSession;
|
||||||
|
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
|
||||||
|
const storages = [];
|
||||||
|
if (cookies) storages.push("cookies");
|
||||||
|
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
|
||||||
|
if (storages.length) {
|
||||||
|
try { await ses.clearStorageData({ storages }); }
|
||||||
|
catch (e) { console.warn("clearStorageData:", e.message); }
|
||||||
|
}
|
||||||
|
// navigation history lives in each webContents; drop it too when history-clear was asked.
|
||||||
|
// (called separately by the before-quit hook, since history-clear also deletes session.json)
|
||||||
|
}
|
||||||
|
async function clearHistoryNow() {
|
||||||
|
for (const t of tabs) {
|
||||||
|
try { t.view.webContents.navigationHistory.clear(); } catch {}
|
||||||
|
}
|
||||||
|
try { fs.unlinkSync(sessionFile()); } 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() {
|
||||||
const loc = effLocale() || app.getLocale() || "en-US";
|
const loc = effLocale() || app.getLocale() || "en-US";
|
||||||
|
|
@ -579,7 +634,19 @@ async function serveBns(request) {
|
||||||
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
|
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
|
||||||
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
|
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
|
||||||
const r = rec.entry.records;
|
const r = rec.entry.records;
|
||||||
|
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
|
||||||
|
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
|
||||||
|
// whole namespace via Host routing; `s3` semantics are exact-key per name.
|
||||||
|
// For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the
|
||||||
|
// apex (host === entry.name), current priority stands. See public-gateway.mjs
|
||||||
|
// for the full argument; keep this in step with that file.
|
||||||
|
const isSubdomain = host !== rec.entry.name;
|
||||||
|
const serveIp = async () => {
|
||||||
|
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
|
||||||
|
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
|
if (isSubdomain && r.ip) return await serveIp();
|
||||||
if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); }
|
if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); }
|
||||||
if (r.s3) {
|
if (r.s3) {
|
||||||
// Secret-free: fetch Sia content from the public gateway (it holds the
|
// Secret-free: fetch Sia content from the public gateway (it holds the
|
||||||
|
|
@ -596,10 +663,7 @@ async function serveBns(request) {
|
||||||
}
|
}
|
||||||
return new Response(body, { status: up.status, headers: { "content-type": ct } });
|
return new Response(body, { status: up.status, headers: { "content-type": ct } });
|
||||||
}
|
}
|
||||||
if (r.ip) {
|
if (r.ip) return await serveIp();
|
||||||
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
|
|
||||||
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
|
|
||||||
}
|
|
||||||
if (r.u) return Response.redirect(r.u, 302);
|
if (r.u) return Response.redirect(r.u, 302);
|
||||||
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
|
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
|
||||||
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
|
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
|
||||||
|
|
@ -1032,7 +1096,15 @@ async function loadBns(t, id, host, rest, tld) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await t.view.webContents.loadURL(`bns://${host}${rest}`);
|
await t.view.webContents.loadURL(`bns://${host}${rest}`);
|
||||||
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record";
|
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
|
||||||
|
// routes via the parent's server, not via Sia. See serveBns for the rule.
|
||||||
|
const _isSub = host !== entry.name;
|
||||||
|
const src = (_isSub && entry.records.ip) ? "direct server"
|
||||||
|
: entry.records.h ? "on-chain (chain)"
|
||||||
|
: entry.records.s3 ? "Sia network"
|
||||||
|
: entry.records.ip ? "direct server"
|
||||||
|
: entry.records.u ? "redirect"
|
||||||
|
: "record";
|
||||||
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
|
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
|
||||||
if (id === activeId) pushNav(t.prov);
|
if (id === activeId) pushNav(t.prov);
|
||||||
emitTabs();
|
emitTabs();
|
||||||
|
|
@ -1087,7 +1159,14 @@ ipcMain.handle("collision-switch", async (_e, arg) => {
|
||||||
await t.view.webContents.loadURL(`bns://${host}/`);
|
await t.view.webContents.loadURL(`bns://${host}/`);
|
||||||
const rec = entries.get(host);
|
const rec = entries.get(host);
|
||||||
const r = rec?.entry?.records || {};
|
const r = rec?.entry?.records || {};
|
||||||
const src = r.h ? "on-chain (chain)" : r.s3 ? "Sia network" : r.ip ? "direct server" : r.u ? "redirect" : "record";
|
// Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
|
||||||
|
const _isSub = rec?.entry?.name && host !== rec.entry.name;
|
||||||
|
const src = (_isSub && r.ip) ? "direct server"
|
||||||
|
: r.h ? "on-chain (chain)"
|
||||||
|
: r.s3 ? "Sia network"
|
||||||
|
: r.ip ? "direct server"
|
||||||
|
: r.u ? "redirect"
|
||||||
|
: "record";
|
||||||
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry };
|
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry };
|
||||||
}
|
}
|
||||||
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
|
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
|
||||||
|
|
@ -1106,6 +1185,120 @@ ipcMain.handle("collision-set-policy", (_e, p) => {
|
||||||
return settings.collisionPolicy;
|
return settings.collisionPolicy;
|
||||||
});
|
});
|
||||||
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
|
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
|
||||||
|
// Storage: clear right now (any subset). "history" also drops the saved-session file.
|
||||||
|
// ---- Password vault -------------------------------------------------------
|
||||||
|
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
|
||||||
|
// held in this main-process closure only — never sent to a renderer except
|
||||||
|
// in the explicit response to password-get(id). Cleared on quit alongside
|
||||||
|
// the other storage clears (see before-quit hook).
|
||||||
|
const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault");
|
||||||
|
let vaultState = null; // { key, purposeRoot, entries, _salt, _iters }
|
||||||
|
const vaultOk = () => ({ ok: true });
|
||||||
|
const vaultErr = (m) => ({ ok: false, err: String(m) });
|
||||||
|
|
||||||
|
ipcMain.handle("password-status", () => ({
|
||||||
|
setup: fs.existsSync(vaultFile()),
|
||||||
|
unlocked: !!vaultState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => {
|
||||||
|
try {
|
||||||
|
if (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short");
|
||||||
|
if (fs.existsSync(vaultFile())) return vaultErr("vault already exists");
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
let purposeRootHex;
|
||||||
|
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
|
||||||
|
const seed = await v.bip39ToSeed(String(seedSource.mnemonic));
|
||||||
|
const root = await v.seedToPurposeRoot(seed, "passwords/0");
|
||||||
|
purposeRootHex = v.bytesToHex(root);
|
||||||
|
} else {
|
||||||
|
// Independent random seed — 32 bytes of purposeRoot directly.
|
||||||
|
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
|
||||||
|
purposeRootHex = v.bytesToHex(root);
|
||||||
|
}
|
||||||
|
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex);
|
||||||
|
return vaultOk();
|
||||||
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("password-unlock", async (_e, masterPassword) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
vaultState = await v.unlockVault(vaultFile(), masterPassword);
|
||||||
|
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-list", async () => {
|
||||||
|
if (!vaultState) return { ok: false, err: "locked" };
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
return { ok: true, entries: v.listMetadata(vaultState) };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("password-get", async (_e, id) => {
|
||||||
|
if (!vaultState) return vaultErr("locked");
|
||||||
|
try {
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
const password = await v.resolvePassword(vaultState, id);
|
||||||
|
return { ok: true, password };
|
||||||
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("password-add", async (_e, spec) => {
|
||||||
|
if (!vaultState) return vaultErr("locked");
|
||||||
|
try {
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
const entry = v.newEntry(spec || {});
|
||||||
|
vaultState.entries.push(entry);
|
||||||
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
|
||||||
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("password-update", async (_e, id, patch) => {
|
||||||
|
if (!vaultState) return vaultErr("locked");
|
||||||
|
try {
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
const e = vaultState.entries.find((x) => x.id === id);
|
||||||
|
if (!e) return vaultErr("no such entry");
|
||||||
|
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
|
||||||
|
for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k];
|
||||||
|
// Switching between literal and generated: drop the other field.
|
||||||
|
if (patch && "literal" in patch) delete e.generated;
|
||||||
|
if (patch && "generated" in patch) delete e.literal;
|
||||||
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
return { ok: true, entries: v.listMetadata(vaultState) };
|
||||||
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("password-remove", async (_e, id) => {
|
||||||
|
if (!vaultState) return vaultErr("locked");
|
||||||
|
try {
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
|
||||||
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
return { ok: true, entries: v.listMetadata(vaultState) };
|
||||||
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("password-generate", async (_e, { domain, username = "", version = 1, rules } = {}) => {
|
||||||
|
if (!vaultState) return vaultErr("locked");
|
||||||
|
try {
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
const password = await v.derivePassword(vaultState.purposeRoot, { domain, username, version, rules });
|
||||||
|
return { ok: true, password };
|
||||||
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("clear-browsing-data", async (_e, opts) => {
|
||||||
|
const o = opts || {};
|
||||||
|
await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage });
|
||||||
|
if (o.history) await clearHistoryNow();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
|
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
|
||||||
ipcMain.handle("set-search-engine", (_e, id) => {
|
ipcMain.handle("set-search-engine", (_e, id) => {
|
||||||
if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
|
if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
|
||||||
|
|
@ -1292,7 +1485,24 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
|
||||||
ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast
|
ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast
|
||||||
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||||||
});
|
});
|
||||||
app.on("before-quit", () => { saveSession(); stopTor(); });
|
app.on("before-quit", async (e) => {
|
||||||
|
// Auto-clear per user settings. saveSession() runs first so restoreSession
|
||||||
|
// still works UNLESS the user asked to drop history — in which case we
|
||||||
|
// wipe the session file too so the next launch is genuinely blank.
|
||||||
|
saveSession();
|
||||||
|
stopTor();
|
||||||
|
vaultState = null; // drop the in-memory vault key + purposeRoot
|
||||||
|
try {
|
||||||
|
await clearBrowsingData({
|
||||||
|
cookies: settings.clearCookiesOnQuit,
|
||||||
|
cache: settings.clearCacheOnQuit,
|
||||||
|
storage: settings.clearStorageOnQuit,
|
||||||
|
});
|
||||||
|
if (settings.clearHistoryOnQuit) {
|
||||||
|
try { fs.unlinkSync(sessionFile()); } catch {}
|
||||||
|
}
|
||||||
|
} catch (err) { console.error("before-quit clear failed:", err?.message); }
|
||||||
|
});
|
||||||
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
|
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,8 @@
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{ "from": "tor", "to": "tor" },
|
{ "from": "tor", "to": "tor" },
|
||||||
{ "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" }
|
{ "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" },
|
||||||
|
{ "from": "../Argus/src/lib/password-vault.js", "to": "password-vault.mjs" }
|
||||||
],
|
],
|
||||||
"win": { "target": ["nsis", "portable"] },
|
"win": { "target": ["nsis", "portable"] },
|
||||||
"nsis": {
|
"nsis": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,22 @@ contextBridge.exposeInMainWorld("cfg", {
|
||||||
setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on),
|
setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on),
|
||||||
setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids),
|
setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids),
|
||||||
removeFromList: (id) => ipcRenderer.invoke("remove-from-list", id),
|
removeFromList: (id) => ipcRenderer.invoke("remove-from-list", id),
|
||||||
|
// Storage: wipe browsing data on demand. Pass any subset of
|
||||||
|
// { cookies, cache, storage, history }.
|
||||||
|
clearBrowsingData: (opts) => ipcRenderer.invoke("clear-browsing-data", opts),
|
||||||
|
// Password vault. All calls return { ok, ... } | { ok: false, err }.
|
||||||
|
// Renderers never see the seed / vault key / master password past setup/
|
||||||
|
// unlock; get() returns plaintext only in explicit response to a user click.
|
||||||
|
pwStatus: () => ipcRenderer.invoke("password-status"),
|
||||||
|
pwSetup: (masterPassword, seedSource) => ipcRenderer.invoke("password-setup", { masterPassword, seedSource }),
|
||||||
|
pwUnlock: (masterPassword) => ipcRenderer.invoke("password-unlock", masterPassword),
|
||||||
|
pwLock: () => ipcRenderer.invoke("password-lock"),
|
||||||
|
pwList: () => ipcRenderer.invoke("password-list"),
|
||||||
|
pwGet: (id) => ipcRenderer.invoke("password-get", id),
|
||||||
|
pwAdd: (entry) => ipcRenderer.invoke("password-add", entry),
|
||||||
|
pwUpdate: (id, patch) => ipcRenderer.invoke("password-update", id, patch),
|
||||||
|
pwRemove: (id) => ipcRenderer.invoke("password-remove", id),
|
||||||
|
pwGenerate: (spec) => ipcRenderer.invoke("password-generate", spec),
|
||||||
// Main asks settings to jump to a specific sidebar section (e.g. from the
|
// Main asks settings to jump to a specific sidebar section (e.g. from the
|
||||||
// engine picker's "Search settings…" click). Emits the section id string.
|
// engine picker's "Search settings…" click). Emits the section id string.
|
||||||
onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)),
|
onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)),
|
||||||
|
|
|
||||||
442
settings.html
442
settings.html
|
|
@ -144,6 +144,7 @@
|
||||||
<div class="brand">⛓ Theseus</div>
|
<div class="brand">⛓ Theseus</div>
|
||||||
<a data-sec="general" class="active">General</a>
|
<a data-sec="general" class="active">General</a>
|
||||||
<a data-sec="search">Search</a>
|
<a data-sec="search">Search</a>
|
||||||
|
<a data-sec="passwords">Passwords</a>
|
||||||
<a data-sec="naming">Registries</a>
|
<a data-sec="naming">Registries</a>
|
||||||
<a data-sec="performance">Performance</a>
|
<a data-sec="performance">Performance</a>
|
||||||
<a data-sec="privacy">Privacy</a>
|
<a data-sec="privacy">Privacy</a>
|
||||||
|
|
@ -215,6 +216,60 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- PASSWORDS -->
|
||||||
|
<section id="passwords" hidden>
|
||||||
|
<h1>Passwords</h1>
|
||||||
|
<p class="lede">Local password vault. Set once, unlocked with a master password. Encrypted at rest; nothing leaves your machine.</p>
|
||||||
|
<!-- State A: no vault yet — set up -->
|
||||||
|
<div id="pwSetup" hidden>
|
||||||
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||||
|
<div class="txt"><div class="t">Master password</div>
|
||||||
|
<div class="d">Used to unlock the vault every session. This is separate from your Ariadne wallet passphrase — memorize it, we can't recover it.</div></div>
|
||||||
|
<div class="addeng"><input id="pwSetupPw1" type="password" placeholder="Master password"><input id="pwSetupPw2" type="password" placeholder="Confirm"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||||
|
<div class="txt"><div class="t">Seed for deterministic passwords</div>
|
||||||
|
<div class="d">The "Generate" button in an entry derives a password from this seed. Same seed on another device → same passwords for the same site + username.</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
<label class="polrow"><input type="radio" name="pwSeedSource" value="mnemonic" checked><span><b>Use my Ariadne wallet mnemonic</b> <span class="pmuted">— unified identity, one seed to back up</span></span></label>
|
||||||
|
<label class="polrow"><input type="radio" name="pwSeedSource" value="generate"><span><b>Generate a new independent seed</b> <span class="pmuted">— isolated from any BCH funds</span></span></label>
|
||||||
|
</div>
|
||||||
|
<textarea id="pwSetupMnemonic" placeholder="12 or 24 BIP39 words separated by spaces" rows="3" style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;font-family:ui-monospace,monospace;outline:none;resize:vertical"></textarea>
|
||||||
|
<div class="pmuted" style="font-size:12px">The mnemonic is used only to derive the password-purpose subtree (m/1381'/0'). It is not stored — only the derived subtree key is persisted, encrypted with your master password.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="justify-content:flex-end">
|
||||||
|
<button id="pwSetupBtn" class="btn" type="button">Create vault</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- State B: vault exists but locked -->
|
||||||
|
<div id="pwLocked" hidden>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Unlock vault</div><div class="d">Enter your master password to view or add entries.</div></div>
|
||||||
|
<div class="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="pwUnlockErr" class="pmuted" style="color:#f6768a;font-size:12.5px;margin-top:4px" hidden></div>
|
||||||
|
</div>
|
||||||
|
<!-- State C: vault unlocked -->
|
||||||
|
<div id="pwUnlocked" hidden>
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="txt"><div class="t">Your passwords</div><div class="d">Reveal, copy, or edit any entry. The vault re-locks when Theseus quits.</div></div>
|
||||||
|
<button id="pwLockBtn" class="btn" type="button">Lock now</button>
|
||||||
|
</div>
|
||||||
|
<div id="pwList"></div>
|
||||||
|
<h2 class="sub">Add an entry</h2>
|
||||||
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||||
|
<div class="addeng"><input id="pwAddDomain" placeholder="Site (e.g. github.com)"><input id="pwAddUser" placeholder="Username or email"></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
<label class="polrow"><input type="radio" name="pwAddKind" value="generated" checked><span><b>Generate deterministically</b> <span class="pmuted">— derived from your seed; same across devices</span></span></label>
|
||||||
|
<label class="polrow"><input type="radio" name="pwAddKind" value="literal"><span><b>Paste an existing password</b> <span class="pmuted">— for legacy accounts you already set elsewhere</span></span></label>
|
||||||
|
</div>
|
||||||
|
<input id="pwAddLiteral" type="password" placeholder="Paste password" hidden>
|
||||||
|
<div class="addeng"><button id="pwAddPreview" class="btn" type="button" style="flex:none">Preview</button><input id="pwAddPreviewOut" readonly placeholder="preview appears here" style="font-family:ui-monospace,monospace"></div>
|
||||||
|
<div style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">There is no autofill yet (phase 2). Copy the password from an entry and paste it into the site.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<!-- NAMING -->
|
<!-- NAMING -->
|
||||||
<section id="naming" hidden>
|
<section id="naming" hidden>
|
||||||
<h1>Registries</h1>
|
<h1>Registries</h1>
|
||||||
|
|
@ -277,12 +332,39 @@
|
||||||
<div class="txt"><div class="t">Timezone</div><div class="d">What sites read via JavaScript (Intl / Date).</div></div>
|
<div class="txt"><div class="t">Timezone</div><div class="d">What sites read via JavaScript (Intl / Date).</div></div>
|
||||||
<div class="ctl">
|
<div class="ctl">
|
||||||
<select id="timezoneMode"><option value="show">Show real</option><option value="hide">Hide (UTC)</option><option value="spoof">Spoof (auto)</option><option value="manual">Manual…</option></select>
|
<select id="timezoneMode"><option value="show">Show real</option><option value="hide">Hide (UTC)</option><option value="spoof">Spoof (auto)</option><option value="manual">Manual…</option></select>
|
||||||
<input id="timezoneValue" list="tzList" placeholder="pick or type e.g. Europe/Berlin" hidden>
|
<!-- Native <select> instead of an <input list=""> datalist — the
|
||||||
<datalist id="tzList">
|
datalist popup was flaky in Electron and never rendered on some
|
||||||
<option value="UTC"><option value="Europe/London"><option value="Europe/Berlin"><option value="Europe/Paris"><option value="Europe/Moscow">
|
displays; a real select is unambiguous. -->
|
||||||
<option value="America/New_York"><option value="America/Chicago"><option value="America/Los_Angeles"><option value="America/Sao_Paulo">
|
<select id="timezoneValue" hidden>
|
||||||
<option value="Asia/Tokyo"><option value="Asia/Shanghai"><option value="Asia/Kolkata"><option value="Asia/Dubai"><option value="Australia/Sydney"><option value="Africa/Nairobi">
|
<option value="UTC">UTC</option>
|
||||||
</datalist>
|
<option value="Europe/London">Europe/London</option>
|
||||||
|
<option value="Europe/Berlin">Europe/Berlin</option>
|
||||||
|
<option value="Europe/Paris">Europe/Paris</option>
|
||||||
|
<option value="Europe/Madrid">Europe/Madrid</option>
|
||||||
|
<option value="Europe/Rome">Europe/Rome</option>
|
||||||
|
<option value="Europe/Moscow">Europe/Moscow</option>
|
||||||
|
<option value="America/New_York">America/New_York</option>
|
||||||
|
<option value="America/Chicago">America/Chicago</option>
|
||||||
|
<option value="America/Denver">America/Denver</option>
|
||||||
|
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||||
|
<option value="America/Sao_Paulo">America/Sao_Paulo</option>
|
||||||
|
<option value="America/Mexico_City">America/Mexico_City</option>
|
||||||
|
<option value="America/Toronto">America/Toronto</option>
|
||||||
|
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||||
|
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||||
|
<option value="Asia/Seoul">Asia/Seoul</option>
|
||||||
|
<option value="Asia/Kolkata">Asia/Kolkata</option>
|
||||||
|
<option value="Asia/Dubai">Asia/Dubai</option>
|
||||||
|
<option value="Asia/Singapore">Asia/Singapore</option>
|
||||||
|
<option value="Asia/Bangkok">Asia/Bangkok</option>
|
||||||
|
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||||
|
<option value="Australia/Perth">Australia/Perth</option>
|
||||||
|
<option value="Africa/Nairobi">Africa/Nairobi</option>
|
||||||
|
<option value="Africa/Cairo">Africa/Cairo</option>
|
||||||
|
<option value="Africa/Johannesburg">Africa/Johannesburg</option>
|
||||||
|
<option value="__other__">Other…</option>
|
||||||
|
</select>
|
||||||
|
<input id="timezoneValueOther" type="text" placeholder="IANA zone, e.g. America/Anchorage" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -301,11 +383,38 @@
|
||||||
<option value="de-DE">German (Deutsch)</option>
|
<option value="de-DE">German (Deutsch)</option>
|
||||||
<option value="ja-JP">Japanese (日本語)</option>
|
<option value="ja-JP">Japanese (日本語)</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="languageValue" list="langList" placeholder="pick or type e.g. it-IT" hidden>
|
<!-- Native <select> (same reason as timezone above). -->
|
||||||
<datalist id="langList">
|
<select id="languageValue" hidden>
|
||||||
<option value="en-US"><option value="en-GB"><option value="zh-CN"><option value="es-ES"><option value="hi-IN"><option value="ar">
|
<option value="en-US">English (US) — en-US</option>
|
||||||
<option value="pt-BR"><option value="ru-RU"><option value="fr-FR"><option value="de-DE"><option value="ja-JP"><option value="it-IT"><option value="ko-KR"><option value="nl-NL"><option value="tr-TR">
|
<option value="en-GB">English (UK) — en-GB</option>
|
||||||
</datalist>
|
<option value="zh-CN">Chinese (Simplified) — zh-CN</option>
|
||||||
|
<option value="zh-TW">Chinese (Traditional) — zh-TW</option>
|
||||||
|
<option value="es-ES">Spanish (Spain) — es-ES</option>
|
||||||
|
<option value="es-MX">Spanish (Mexico) — es-MX</option>
|
||||||
|
<option value="pt-BR">Portuguese (Brazil) — pt-BR</option>
|
||||||
|
<option value="pt-PT">Portuguese (Portugal) — pt-PT</option>
|
||||||
|
<option value="fr-FR">French — fr-FR</option>
|
||||||
|
<option value="de-DE">German — de-DE</option>
|
||||||
|
<option value="it-IT">Italian — it-IT</option>
|
||||||
|
<option value="nl-NL">Dutch — nl-NL</option>
|
||||||
|
<option value="ru-RU">Russian — ru-RU</option>
|
||||||
|
<option value="ja-JP">Japanese — ja-JP</option>
|
||||||
|
<option value="ko-KR">Korean — ko-KR</option>
|
||||||
|
<option value="hi-IN">Hindi — hi-IN</option>
|
||||||
|
<option value="ar">Arabic — ar</option>
|
||||||
|
<option value="tr-TR">Turkish — tr-TR</option>
|
||||||
|
<option value="pl-PL">Polish — pl-PL</option>
|
||||||
|
<option value="uk-UA">Ukrainian — uk-UA</option>
|
||||||
|
<option value="sv-SE">Swedish — sv-SE</option>
|
||||||
|
<option value="fi-FI">Finnish — fi-FI</option>
|
||||||
|
<option value="el-GR">Greek — el-GR</option>
|
||||||
|
<option value="he-IL">Hebrew — he-IL</option>
|
||||||
|
<option value="vi-VN">Vietnamese — vi-VN</option>
|
||||||
|
<option value="th-TH">Thai — th-TH</option>
|
||||||
|
<option value="id-ID">Indonesian — id-ID</option>
|
||||||
|
<option value="__other__">Other…</option>
|
||||||
|
</select>
|
||||||
|
<input id="languageValueOther" type="text" placeholder="BCP-47 tag, e.g. cs-CZ" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -321,24 +430,78 @@
|
||||||
<option value="middle_east">Middle East</option>
|
<option value="middle_east">Middle East</option>
|
||||||
<option value="australia">Australia</option>
|
<option value="australia">Australia</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="coords" id="locationCoords" hidden>
|
<!-- Manual location = pick a city from a native select; its lat/lon
|
||||||
<input id="locationCity" list="cityList" placeholder="pick a city…" style="width:112px">
|
are looked up client-side (see CITIES below) and written to
|
||||||
<input id="locationLat" placeholder="lat"><input id="locationLon" placeholder="lon">
|
locationLat / locationLon settings, which drive the
|
||||||
|
navigator.geolocation override in main.js. The raw lat/lon
|
||||||
|
inputs used to sit here but nobody types coordinates by hand;
|
||||||
|
the city picker gives the same override with a single click. -->
|
||||||
|
<select id="locationCity" hidden>
|
||||||
|
<option value="">Pick a city…</option>
|
||||||
|
<option value="london">London</option>
|
||||||
|
<option value="berlin">Berlin</option>
|
||||||
|
<option value="paris">Paris</option>
|
||||||
|
<option value="madrid">Madrid</option>
|
||||||
|
<option value="rome">Rome</option>
|
||||||
|
<option value="moscow">Moscow</option>
|
||||||
|
<option value="istanbul">Istanbul</option>
|
||||||
|
<option value="dubai">Dubai</option>
|
||||||
|
<option value="mumbai">Mumbai</option>
|
||||||
|
<option value="singapore">Singapore</option>
|
||||||
|
<option value="bangkok">Bangkok</option>
|
||||||
|
<option value="shanghai">Shanghai</option>
|
||||||
|
<option value="tokyo">Tokyo</option>
|
||||||
|
<option value="seoul">Seoul</option>
|
||||||
|
<option value="sydney">Sydney</option>
|
||||||
|
<option value="new_york">New York</option>
|
||||||
|
<option value="los_angeles">Los Angeles</option>
|
||||||
|
<option value="chicago">Chicago</option>
|
||||||
|
<option value="toronto">Toronto</option>
|
||||||
|
<option value="mexico_city">Mexico City</option>
|
||||||
|
<option value="sao_paulo">São Paulo</option>
|
||||||
|
<option value="buenos_aires">Buenos Aires</option>
|
||||||
|
<option value="cairo">Cairo</option>
|
||||||
|
<option value="nairobi">Nairobi</option>
|
||||||
|
<option value="johannesburg">Johannesburg</option>
|
||||||
|
<option value="__other__">Other…</option>
|
||||||
|
</select>
|
||||||
|
<div id="locationOther" class="coords" hidden>
|
||||||
|
<input id="locationLatOther" type="number" step="0.0001" placeholder="lat" style="width:100px">
|
||||||
|
<input id="locationLonOther" type="number" step="0.0001" placeholder="lon" style="width:100px">
|
||||||
</div>
|
</div>
|
||||||
<datalist id="cityList">
|
|
||||||
<option value="London"><option value="Berlin"><option value="Paris"><option value="Moscow"><option value="New York"><option value="Los Angeles">
|
|
||||||
<option value="São Paulo"><option value="Tokyo"><option value="Shanghai"><option value="Mumbai"><option value="Dubai"><option value="Sydney"><option value="Nairobi"><option value="Singapore">
|
|
||||||
</datalist>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="note">These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div>
|
<div class="note">These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div>
|
||||||
|
|
||||||
|
<h2 class="sub">Storage</h2>
|
||||||
|
<p class="subd">By default Theseus keeps <b>nothing</b> across sessions — everything toggled on here is wiped when you quit. Untoggle a bucket to keep it (e.g. cookies to stay signed in on trusted sites).</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Clear cookies on quit</div><div class="d">Drops session + persistent cookies. You'll sign in again next launch.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Clear HTTP cache on quit</div><div class="d">Drops cached images / scripts / stylesheets. Sites re-download; small disk win.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="clearCacheOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Clear site storage on quit</div><div class="d">Drops localStorage, IndexedDB, service workers, and the cache API. Web-app state resets.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="clearStorageOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Clear history on quit</div><div class="d">Drops navigation history + the saved-tabs session file (overrides "Reopen previous tabs").</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="clearHistoryOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="justify-content:flex-end">
|
||||||
|
<button id="clearNow" class="btn" type="button">Clear all now</button>
|
||||||
|
</div>
|
||||||
|
<div class="note">There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const C = window.cfg;
|
const C = window.cfg;
|
||||||
// sidebar navigation
|
// sidebar navigation
|
||||||
const sections = ["general", "search", "naming", "performance", "privacy"];
|
const sections = ["general", "search", "passwords", "naming", "performance", "privacy"];
|
||||||
function showSection(sec) {
|
function showSection(sec) {
|
||||||
if (!sections.includes(sec)) return;
|
if (!sections.includes(sec)) return;
|
||||||
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
|
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
|
||||||
|
|
@ -349,7 +512,8 @@
|
||||||
// click routes to the Search section instead of the General default).
|
// click routes to the Search section instead of the General default).
|
||||||
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
|
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
|
||||||
|
|
||||||
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices"];
|
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices",
|
||||||
|
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];
|
||||||
C.get().then((s) => {
|
C.get().then((s) => {
|
||||||
for (const k of TOGGLES) {
|
for (const k of TOGGLES) {
|
||||||
const el = document.getElementById(k); if (!el) continue;
|
const el = document.getElementById(k); if (!el) continue;
|
||||||
|
|
@ -538,37 +702,126 @@
|
||||||
apply(m.value === "manual");
|
apply(m.value === "manual");
|
||||||
m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); });
|
m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); });
|
||||||
};
|
};
|
||||||
const val = (id) => { const v = document.getElementById(id); v.value = s[id] ?? ""; v.addEventListener("change", () => C.set(id, v.value.trim())); return v; };
|
// Value fields: <select> for tz + lang manual mode, <select> city for
|
||||||
const tzV = val("timezoneValue"), lgV = val("languageValue");
|
// location manual mode. Each has an "Other…" sentinel at the end that
|
||||||
val("locationLat"); val("locationLon");
|
// reveals a text input so the user can enter a value not in the built-in
|
||||||
bind("timezoneMode", null, (manual) => tzV.hidden = !manual);
|
// list (any IANA zone, any BCP-47 locale). selectWithOther handles the
|
||||||
// language: top-10 dropdown when spoofing, free-text locale when manual
|
// round-trip: if a saved value isn't in the predefined options, "Other"
|
||||||
|
// is auto-selected on load and the input pre-fills with that value.
|
||||||
|
function selectWithOther(selectId, otherInputId, settingsKey = selectId) {
|
||||||
|
const sel = document.getElementById(selectId);
|
||||||
|
const inp = document.getElementById(otherInputId);
|
||||||
|
const saved = s[settingsKey] ?? "";
|
||||||
|
const known = new Set([...sel.options].map((o) => o.value).filter((v) => v && v !== "__other__"));
|
||||||
|
const isCustom = saved && !known.has(saved);
|
||||||
|
sel.value = isCustom ? "__other__" : saved;
|
||||||
|
if (isCustom) inp.value = saved;
|
||||||
|
const applyVis = () => { inp.hidden = sel.value !== "__other__"; };
|
||||||
|
applyVis();
|
||||||
|
sel.addEventListener("change", () => {
|
||||||
|
if (sel.value === "__other__") { applyVis(); setTimeout(() => inp.focus(), 0); return; }
|
||||||
|
C.set(settingsKey, sel.value); applyVis();
|
||||||
|
});
|
||||||
|
inp.addEventListener("change", () => { const v = String(inp.value).trim(); if (v) C.set(settingsKey, v); });
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
const tzV = selectWithOther("timezoneValue", "timezoneValueOther");
|
||||||
|
const lgV = selectWithOther("languageValue", "languageValueOther");
|
||||||
|
const tzOther = document.getElementById("timezoneValueOther");
|
||||||
|
const lgOther = document.getElementById("languageValueOther");
|
||||||
|
// "Manual" mode reveals both the select AND the Other input (if Other was picked).
|
||||||
|
const applyTzVis = (manual) => { tzV.hidden = !manual; tzOther.hidden = !manual || tzV.value !== "__other__"; };
|
||||||
|
bind("timezoneMode", null, applyTzVis);
|
||||||
|
// language: fixed dropdown when spoofing (top 10), broader dropdown when manual
|
||||||
const lngMode = document.getElementById("languageMode");
|
const lngMode = document.getElementById("languageMode");
|
||||||
const lngSpoof = document.getElementById("languageSpoof");
|
const lngSpoof = document.getElementById("languageSpoof");
|
||||||
lngSpoof.value = s.languageSpoof || "en-US";
|
lngSpoof.value = s.languageSpoof || "en-US";
|
||||||
lngSpoof.addEventListener("change", () => C.set("languageSpoof", lngSpoof.value));
|
lngSpoof.addEventListener("change", () => C.set("languageSpoof", lngSpoof.value));
|
||||||
const applyLng = () => { lngSpoof.hidden = lngMode.value !== "spoof"; lgV.hidden = lngMode.value !== "manual"; };
|
const applyLng = () => {
|
||||||
|
lngSpoof.hidden = lngMode.value !== "spoof";
|
||||||
|
lgV.hidden = lngMode.value !== "manual";
|
||||||
|
lgOther.hidden = lngMode.value !== "manual" || lgV.value !== "__other__";
|
||||||
|
};
|
||||||
lngMode.value = s.languageMode || "show";
|
lngMode.value = s.languageMode || "show";
|
||||||
applyLng();
|
applyLng();
|
||||||
lngMode.addEventListener("change", () => { C.set("languageMode", lngMode.value); applyLng(); });
|
lngMode.addEventListener("change", () => { C.set("languageMode", lngMode.value); applyLng(); });
|
||||||
// location: region dropdown when spoofing, exact coords when manual
|
// location: region dropdown when spoofing, city dropdown when manual.
|
||||||
|
// Manual writes lat/lon into settings via the CITIES map below; the raw
|
||||||
|
// coord inputs were dropped from the UI (nobody types them by hand).
|
||||||
const locMode = document.getElementById("locationMode");
|
const locMode = document.getElementById("locationMode");
|
||||||
const locRegion = document.getElementById("locationRegion");
|
const locRegion = document.getElementById("locationRegion");
|
||||||
const locCoords = document.getElementById("locationCoords");
|
const locCity = document.getElementById("locationCity");
|
||||||
locRegion.value = s.locationRegion || "europe";
|
locRegion.value = s.locationRegion || "europe";
|
||||||
locRegion.addEventListener("change", () => C.set("locationRegion", locRegion.value));
|
locRegion.addEventListener("change", () => C.set("locationRegion", locRegion.value));
|
||||||
const applyLoc = () => { locRegion.hidden = locMode.value !== "spoof"; locCoords.hidden = locMode.value !== "manual"; };
|
// Location manual reveals the city select; picking "Other…" reveals a
|
||||||
|
// lat/lon pair, whose values are pushed to locationLat/Lon settings.
|
||||||
|
const locOther = document.getElementById("locationOther");
|
||||||
|
const locLatOther = document.getElementById("locationLatOther");
|
||||||
|
const locLonOther = document.getElementById("locationLonOther");
|
||||||
|
const applyLoc = () => {
|
||||||
|
locRegion.hidden = locMode.value !== "spoof";
|
||||||
|
locCity.hidden = locMode.value !== "manual";
|
||||||
|
locOther.hidden = locMode.value !== "manual" || locCity.value !== "__other__";
|
||||||
|
};
|
||||||
locMode.value = s.locationMode || "show";
|
locMode.value = s.locationMode || "show";
|
||||||
applyLoc();
|
applyLoc();
|
||||||
locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); });
|
locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); });
|
||||||
// picking a city fills the exact lat/lon fields
|
locCity.addEventListener("change", applyLoc);
|
||||||
const CITY = { "London":[51.5074,-0.1278],"Berlin":[52.52,13.405],"Paris":[48.8566,2.3522],"Moscow":[55.7558,37.6173],"New York":[40.7128,-74.006],"Los Angeles":[34.0522,-118.2437],"São Paulo":[-23.5505,-46.6333],"Tokyo":[35.6762,139.6503],"Shanghai":[31.2304,121.4737],"Mumbai":[19.076,72.8777],"Dubai":[25.2048,55.2708],"Sydney":[-33.8688,151.2093],"Nairobi":[-1.2921,36.8219],"Singapore":[1.3521,103.8198] };
|
// Prefill Other lat/lon inputs from saved settings.
|
||||||
document.getElementById("locationCity").addEventListener("change", (e) => {
|
locLatOther.value = s.locationLat ?? "";
|
||||||
const c = CITY[e.target.value.trim()]; if (!c) return;
|
locLonOther.value = s.locationLon ?? "";
|
||||||
const la = document.getElementById("locationLat"), lo = document.getElementById("locationLon");
|
const saveOtherCoords = () => {
|
||||||
la.value = c[0]; lo.value = c[1]; C.set("locationLat", String(c[0])); C.set("locationLon", String(c[1]));
|
const la = String(locLatOther.value).trim();
|
||||||
|
const lo = String(locLonOther.value).trim();
|
||||||
|
if (la !== "") C.set("locationLat", la);
|
||||||
|
if (lo !== "") C.set("locationLon", lo);
|
||||||
|
C.set("locationCity", "__other__");
|
||||||
|
};
|
||||||
|
locLatOther.addEventListener("change", saveOtherCoords);
|
||||||
|
locLonOther.addEventListener("change", saveOtherCoords);
|
||||||
|
// If the previously-saved city was "__other__", set the select to it so the
|
||||||
|
// Other inputs stay visible on reload.
|
||||||
|
if (s.locationCity === "__other__") locCity.value = "__other__";
|
||||||
|
// Picking a city writes its lat/lon to settings — that's what
|
||||||
|
// navigator.geolocation returns to pages once "manual" mode is active.
|
||||||
|
const CITIES = {
|
||||||
|
london:[51.5074,-0.1278], berlin:[52.52,13.405], paris:[48.8566,2.3522],
|
||||||
|
madrid:[40.4168,-3.7038], rome:[41.9028,12.4964], moscow:[55.7558,37.6173],
|
||||||
|
istanbul:[41.0082,28.9784], dubai:[25.2048,55.2708], mumbai:[19.076,72.8777],
|
||||||
|
singapore:[1.3521,103.8198], bangkok:[13.7563,100.5018], shanghai:[31.2304,121.4737],
|
||||||
|
tokyo:[35.6762,139.6503], seoul:[37.5665,126.978], sydney:[-33.8688,151.2093],
|
||||||
|
new_york:[40.7128,-74.006], los_angeles:[34.0522,-118.2437], chicago:[41.8781,-87.6298],
|
||||||
|
toronto:[43.6532,-79.3832], mexico_city:[19.4326,-99.1332], sao_paulo:[-23.5505,-46.6333],
|
||||||
|
buenos_aires:[-34.6037,-58.3816], cairo:[30.0444,31.2357], nairobi:[-1.2921,36.8219],
|
||||||
|
johannesburg:[-26.2041,28.0473],
|
||||||
|
};
|
||||||
|
// Restore prior city selection when possible by matching stored lat/lon.
|
||||||
|
const restoreCity = () => {
|
||||||
|
const la = Number(s.locationLat), lo = Number(s.locationLon);
|
||||||
|
for (const [key, [x, y]] of Object.entries(CITIES))
|
||||||
|
if (Math.abs(x - la) < 0.01 && Math.abs(y - lo) < 0.01) { locCity.value = key; return; }
|
||||||
|
};
|
||||||
|
restoreCity();
|
||||||
|
locCity.addEventListener("change", () => {
|
||||||
|
const c = CITIES[locCity.value]; if (!c) return;
|
||||||
|
C.set("locationLat", String(c[0]));
|
||||||
|
C.set("locationLon", String(c[1]));
|
||||||
|
C.set("locationCity", locCity.value); // save the pick so we can restore it later
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Storage: "Clear all now" wipes everything the toggles cover, without
|
||||||
|
// waiting for quit. Confirm first — this signs the user out of everything.
|
||||||
|
const clrBtn = document.getElementById("clearNow");
|
||||||
|
if (clrBtn) clrBtn.onclick = async () => {
|
||||||
|
if (!confirm("Clear cookies, cache, site storage, and history now?\n\nYou'll be signed out of everything and open tabs won't be restored.")) return;
|
||||||
|
clrBtn.disabled = true; clrBtn.textContent = "Clearing…";
|
||||||
|
try {
|
||||||
|
await C.clearBrowsingData({ cookies: true, cache: true, storage: true, history: true });
|
||||||
|
clrBtn.textContent = "Cleared ✓";
|
||||||
|
} catch (e) { clrBtn.textContent = "Clear failed"; console.error(e); }
|
||||||
|
setTimeout(() => { clrBtn.textContent = "Clear all now"; clrBtn.disabled = false; }, 1600);
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Naming section: BCNR/ICANN collision policy + remembered choices ----
|
// ---- Naming section: BCNR/ICANN collision policy + remembered choices ----
|
||||||
function refreshCollisions() {
|
function refreshCollisions() {
|
||||||
C.collisionState().then((cs) => {
|
C.collisionState().then((cs) => {
|
||||||
|
|
@ -588,6 +841,125 @@
|
||||||
document.getElementById("resetCollisions").onclick = () => {
|
document.getElementById("resetCollisions").onclick = () => {
|
||||||
C.resetCollisions().then(refreshCollisions);
|
C.resetCollisions().then(refreshCollisions);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Passwords section: three states (setup / locked / unlocked) ---------
|
||||||
|
// The vault lives in main.js — this UI just calls IPC. No plaintext ever
|
||||||
|
// sits in this DOM except the value produced by a specific Show/Copy click.
|
||||||
|
const pwSetupEl = document.getElementById("pwSetup");
|
||||||
|
const pwLockedEl = document.getElementById("pwLocked");
|
||||||
|
const pwUnlockedEl = document.getElementById("pwUnlocked");
|
||||||
|
const pwListEl = document.getElementById("pwList");
|
||||||
|
function pwShow(which) {
|
||||||
|
pwSetupEl.hidden = which !== "setup";
|
||||||
|
pwLockedEl.hidden = which !== "locked";
|
||||||
|
pwUnlockedEl.hidden = which !== "unlocked";
|
||||||
|
}
|
||||||
|
async function pwRefresh() {
|
||||||
|
const st = await C.pwStatus();
|
||||||
|
if (!st.setup) return pwShow("setup");
|
||||||
|
if (!st.unlocked) return pwShow("locked");
|
||||||
|
pwShow("unlocked");
|
||||||
|
const res = await C.pwList();
|
||||||
|
renderPwList(res.ok ? res.entries : []);
|
||||||
|
}
|
||||||
|
function renderPwList(entries) {
|
||||||
|
if (!entries.length) {
|
||||||
|
pwListEl.innerHTML = `<div class="cempty" style="padding:12px 0">No entries yet — add one below.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
|
||||||
|
`<span class="eic"><img class="ei" src="https://icons.duckduckgo.com/ip3/${esc(e.domain)}.ico" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'🔑'}))"></span>` +
|
||||||
|
`<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` +
|
||||||
|
`<button class="cx pwShow" title="Show + copy">👁</button>` +
|
||||||
|
`<button class="cx pwDel" title="Remove">✕</button>` +
|
||||||
|
`</div>`).join("");
|
||||||
|
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
|
||||||
|
const id = ev.target.closest(".eng").dataset.id;
|
||||||
|
const res = await C.pwGet(id);
|
||||||
|
if (!res.ok) return alert("Couldn't read: " + res.err);
|
||||||
|
try { await navigator.clipboard.writeText(res.password); }
|
||||||
|
catch { /* browser may block clipboard in dev — fall through to a prompt */ prompt("Password (copy manually):", res.password); return; }
|
||||||
|
b.textContent = "copied ✓"; setTimeout(() => (b.textContent = "👁"), 1600);
|
||||||
|
});
|
||||||
|
pwListEl.querySelectorAll(".pwDel").forEach((b) => b.onclick = async (ev) => {
|
||||||
|
const id = ev.target.closest(".eng").dataset.id;
|
||||||
|
if (!confirm("Remove this entry?")) return;
|
||||||
|
await C.pwRemove(id); pwRefresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Setup — create vault
|
||||||
|
document.querySelectorAll('input[name="pwSeedSource"]').forEach((r) => r.addEventListener("change", () => {
|
||||||
|
document.getElementById("pwSetupMnemonic").hidden = document.querySelector('input[name="pwSeedSource"]:checked').value !== "mnemonic";
|
||||||
|
}));
|
||||||
|
document.getElementById("pwSetupBtn").onclick = async () => {
|
||||||
|
const p1 = document.getElementById("pwSetupPw1").value;
|
||||||
|
const p2 = document.getElementById("pwSetupPw2").value;
|
||||||
|
if (!p1 || p1.length < 8) return alert("Master password must be at least 8 characters.");
|
||||||
|
if (p1 !== p2) return alert("Passwords don't match.");
|
||||||
|
const kind = document.querySelector('input[name="pwSeedSource"]:checked').value;
|
||||||
|
const seedSource = kind === "mnemonic"
|
||||||
|
? { kind: "mnemonic", mnemonic: document.getElementById("pwSetupMnemonic").value }
|
||||||
|
: { kind: "generate" };
|
||||||
|
if (kind === "mnemonic" && !seedSource.mnemonic.trim()) return alert("Paste your mnemonic or switch to 'Generate a new independent seed'.");
|
||||||
|
const res = await C.pwSetup(p1, seedSource);
|
||||||
|
if (!res.ok) return alert("Setup failed: " + res.err);
|
||||||
|
// Vault created AND unlocked by main. Clear the setup fields.
|
||||||
|
document.getElementById("pwSetupPw1").value = "";
|
||||||
|
document.getElementById("pwSetupPw2").value = "";
|
||||||
|
document.getElementById("pwSetupMnemonic").value = "";
|
||||||
|
pwRefresh();
|
||||||
|
};
|
||||||
|
// Unlock
|
||||||
|
document.getElementById("pwUnlockBtn").onclick = async () => {
|
||||||
|
const err = document.getElementById("pwUnlockErr");
|
||||||
|
err.hidden = true;
|
||||||
|
const pw = document.getElementById("pwUnlockPw").value;
|
||||||
|
const res = await C.pwUnlock(pw);
|
||||||
|
if (!res.ok) { err.textContent = res.err; err.hidden = false; return; }
|
||||||
|
document.getElementById("pwUnlockPw").value = "";
|
||||||
|
pwRefresh();
|
||||||
|
};
|
||||||
|
document.getElementById("pwUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("pwUnlockBtn").click(); });
|
||||||
|
// Lock
|
||||||
|
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
|
||||||
|
// Add-entry form: toggle literal input; wire preview + save
|
||||||
|
document.querySelectorAll('input[name="pwAddKind"]').forEach((r) => r.addEventListener("change", () => {
|
||||||
|
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
|
||||||
|
document.getElementById("pwAddLiteral").hidden = kind !== "literal";
|
||||||
|
document.getElementById("pwAddPreview").hidden = kind !== "generated";
|
||||||
|
document.getElementById("pwAddPreviewOut").hidden = kind !== "generated";
|
||||||
|
}));
|
||||||
|
document.getElementById("pwAddPreview").onclick = async () => {
|
||||||
|
const domain = document.getElementById("pwAddDomain").value.trim();
|
||||||
|
const username = document.getElementById("pwAddUser").value.trim();
|
||||||
|
if (!domain) return alert("Enter a site.");
|
||||||
|
const res = await C.pwGenerate({ domain, username });
|
||||||
|
if (!res.ok) return alert("Preview failed: " + res.err);
|
||||||
|
document.getElementById("pwAddPreviewOut").value = res.password;
|
||||||
|
};
|
||||||
|
document.getElementById("pwAddBtn").onclick = async () => {
|
||||||
|
const domain = document.getElementById("pwAddDomain").value.trim();
|
||||||
|
const username = document.getElementById("pwAddUser").value.trim();
|
||||||
|
if (!domain) return alert("Enter a site.");
|
||||||
|
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
|
||||||
|
const spec = { domain, username };
|
||||||
|
if (kind === "literal") {
|
||||||
|
const lit = document.getElementById("pwAddLiteral").value;
|
||||||
|
if (!lit) return alert("Paste the password to save.");
|
||||||
|
spec.literal = lit;
|
||||||
|
}
|
||||||
|
const res = await C.pwAdd(spec);
|
||||||
|
if (!res.ok) return alert("Add failed: " + res.err);
|
||||||
|
document.getElementById("pwAddDomain").value = "";
|
||||||
|
document.getElementById("pwAddUser").value = "";
|
||||||
|
document.getElementById("pwAddLiteral").value = "";
|
||||||
|
document.getElementById("pwAddPreviewOut").value = "";
|
||||||
|
pwRefresh();
|
||||||
|
};
|
||||||
|
// Initial state — decide which panel to show now, and every time the user
|
||||||
|
// switches to Passwords in the sidebar (so a lock elsewhere is reflected).
|
||||||
|
pwRefresh();
|
||||||
|
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue