feat(theseus): profile at %APPDATA%\Theseus, extensions under extensions\

The profile folder was Electron's default from the product name
("Theseus Navigator") and add-ons lived in addons\ under it. Now:

  %APPDATA%\Theseus\extensions\          installed extensions
  %APPDATA%\Theseus\extensions-data\     per-extension storage + scratch
  %APPDATA%\Theseus\extensions-backups\  replaced copies
  %APPDATA%\Theseus\extensions-staged\   staged updates

Both moves are one-time migrations on the first start that finds the old
layout: the profile folder is renamed (same volume, instant) or copied
when a rename is refused, with the old folder left in place in that case;
the four sub-folders are renamed before the extension host first reads
them. Nothing is deleted. THESEUS_USER_DATA still overrides everything.

The host now hands each extension its data folder as api.dataDir; the
Screenshot and PDF editor add-ons used to rebuild the old path from their
own folder for scratch files (so they recreated addons-data\ after the
move) and now use the field, with versions bumped so the bundles reseed.
This commit is contained in:
Local Dev 2026-09-21 01:55:25 +02:00
parent 5e4bd22609
commit bd6aab02fe
9 changed files with 60 additions and 19 deletions

View file

@ -4,9 +4,9 @@
// background boot task, Theseus fetches that URL, expects a signed
// updates.json manifest, verifies the signature against a hardcoded set
// of operator pubkeys (addon-update-pubkeys.js), and stages any newer
// signed version under <userData>/addons-updates-staged/<id>-<version>/.
// signed version under <userData>/extensions-staged/<id>-<version>/.
// The NEXT launch's promoteStagedUpdates() moves the staged copy into
// <userData>/addons/<id>/, reusing the same backup dance seedBundledAddons
// <userData>/extensions/<id>/, reusing the same backup dance seedBundledAddons
// already uses so any local edits the user made survive.
//
// Trust model:

View file

@ -1,6 +1,6 @@
// Theseus add-on framework — loader + API surface.
//
// Add-ons live in <userData>/addons/<id>/ as ordinary folders on disk. Each
// Add-ons live in <userData>/extensions/<id>/ as ordinary folders on disk. Each
// carries an `addon.json` manifest and (per the manifest's `main` field) a
// CommonJS entry that exports `activate(api)` and optionally `deactivate()`.
// Nothing about an add-on ships in the Theseus repo or installer — drop a
@ -13,7 +13,7 @@
//
// Persistence:
// settings.disabledAddons — ids the user has toggled off
// <userData>/addons-data/<id>.json — per-add-on kv store (api.storage)
// <userData>/extensions-data/<id>.json — per-add-on kv store (api.storage)
const fs = require("node:fs");
const path = require("node:path");
@ -399,6 +399,10 @@ class AddonHost {
// Metadata the add-on may want to reflect on
id: manifest.id,
folder,
// Per-extension data folder (<userData>/extensions-data): where the kv
// store lives and where an add-on should keep scratch files rather
// than guessing the path from its own folder.
dataDir: this.dataDir,
log: (...a) => this.log(`[${manifest.id}]`, ...a),
// Persistent per-add-on storage. Small kv JSON on disk.
storage: {

View file

@ -1,7 +1,7 @@
{
"id": "pdf-editor",
"name": "PDF Editor",
"version": "0.1.0",
"version": "0.1.1",
"description": "Open a PDF in a full tab: read it, highlight and mark it up, reorder or rotate or drop pages, fill in its form fields, then save a new copy. Annotations are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "📄",

View file

@ -31,11 +31,13 @@ const MAX_BYTES = 200 * 1024 * 1024; // refuse anything absurd before we buffe
module.exports = {
activate(api) {
// Per-add-on scratch dir under <userData>/addons-data/, a sibling of the
// Per-add-on scratch dir under <userData>/extensions-data/, a sibling of the
// kv JSON. We never write inside the add-on folder — a user reading the
// shipped source should see only what shipped.
const dataParent = path.dirname(path.join(api.folder, ".."));
const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR);
// api.dataDir is the host-provided per-extension data folder (<userData>/extensions-data);
// the fallback rebuilds it for hosts that predate that field.
const scratchDir = path.join(api.dataDir || path.join(dataParent, "extensions-data"), SCRATCH_DIR);
try { fs.mkdirSync(scratchDir, { recursive: true }); }
catch (e) { api.log("scratch mkdir failed:", e?.message); }

View file

@ -1,7 +1,7 @@
{
"id": "screenshot",
"name": "Screenshot",
"version": "0.6.5",
"version": "0.6.6",
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, line, rect, ellipse, pen, resizable text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.",
"author": "Silent Mode",
"icon": "📸",

View file

@ -30,12 +30,14 @@ module.exports = {
page: "panel.html",
});
// Per-add-on scratch dir under <userData>/addons-data/. We don't touch
// Per-add-on scratch dir under <userData>/extensions-data/. We don't touch
// the add-on folder itself — editing it there would confuse users who
// are inspecting the shipped source. api.folder = <userData>/addons/
// are inspecting the shipped source. api.folder = <userData>/extensions/
// screenshot/, so dirname twice lands on <userData>.
const dataParent = path.dirname(path.join(api.folder, ".."));
const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR);
// api.dataDir is the host-provided per-extension data folder (<userData>/extensions-data);
// the fallback rebuilds it for hosts that predate that field.
const scratchDir = path.join(api.dataDir || path.join(dataParent, "extensions-data"), SCRATCH_DIR);
try { fs.mkdirSync(scratchDir, { recursive: true }); }
catch (e) { api.log("scratch mkdir failed:", e?.message); }

View file

@ -4,10 +4,10 @@ An add-on can be updated at runtime, without waiting for the next Theseus
release, if its `addon.json` declares an `updateURL`. The client polls
that URL, verifies an Ed25519 signature against a hardcoded set of
operator pubkeys, stages the new copy under
`<userData>/addons-updates-staged/<id>-<version>/`, and promotes it on the
`<userData>/extensions-staged/<id>-<version>/`, and promotes it on the
next launch — reusing the same backup-then-swap logic as
`seedBundledAddons`, so any local edits the user made survive as
`<userData>/addons-backups/<id>-<oldver>-<timestamp>/`.
`<userData>/extensions-backups/<id>-<oldver>-<timestamp>/`.
## Client boot flow
@ -131,7 +131,7 @@ to have the right shape.
- **Manifest downgrade** — a malicious mirror serves an older signed
entry. The client refuses versions ≤ what's installed, so a rollback
attack cannot land an older signed payload as if it were an update.
A user who wipes `<userData>/addons/` and boots against a hostile
A user who wipes `<userData>/extensions/` and boots against a hostile
mirror could receive a stale-but-signed copy; the bundled fallback
from the Theseus release will beat it via `seedBundledAddons` unless
the bundle is older still.

41
main.js
View file

@ -21,6 +21,24 @@ const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
// touches (or races) the real install's settings, vault and add-ons.
if (process.env.THESEUS_USER_DATA) {
try { app.setPath("userData", path.resolve(process.env.THESEUS_USER_DATA)); } catch (e) { console.warn("userData override failed:", e?.message); }
} else {
// The profile lives at <appData>\Theseus. Electron's default was the
// product name ("Theseus Navigator"); a profile from before this change is
// moved once, on the first start that finds it: a rename when possible
// (same volume — instant, nothing copied), a copy when the rename is
// refused (another process still holds the folder, or a junction to
// another volume), in which case the old folder is left as it was.
try { app.setPath("userData", relocateProfile(app.getPath("appData"))); }
catch (e) { console.warn("profile relocation failed:", e?.message); }
}
function relocateProfile(appData) {
const oldDir = path.join(appData, "Theseus Navigator");
const newDir = path.join(appData, "Theseus");
if (!fs.existsSync(newDir) && fs.existsSync(oldDir)) {
try { fs.renameSync(oldDir, newDir); }
catch { fs.cpSync(oldDir, newDir, { recursive: true }); }
}
return newDir;
}
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
@ -1674,10 +1692,24 @@ let proxyLoginHandler = null;
const { AddonHost } = require("./addons-host.js");
const addonUpdater = require("./addon-updater.js");
const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js");
function addonsUserDir() { return path.join(app.getPath("userData"), "addons"); }
function addonsDataDir() { return path.join(app.getPath("userData"), "addons-data"); }
function addonsBackupDir() { return path.join(app.getPath("userData"), "addons-backups"); }
function addonsStagedDir() { return path.join(app.getPath("userData"), "addons-updates-staged"); }
// Extensions live under <userData>\extensions (installed copies), with their
// per-extension storage, backups of replaced copies and staged updates in
// sibling folders. These were addons / addons-data / addons-backups /
// addons-updates-staged; migrateExtensionDirs() renames a profile's old
// folders once, before the host first reads them.
function addonsUserDir() { return path.join(app.getPath("userData"), "extensions"); }
function addonsDataDir() { return path.join(app.getPath("userData"), "extensions-data"); }
function addonsBackupDir() { return path.join(app.getPath("userData"), "extensions-backups"); }
function addonsStagedDir() { return path.join(app.getPath("userData"), "extensions-staged"); }
function migrateExtensionDirs() {
const ud = app.getPath("userData");
for (const [from, to] of [["addons", "extensions"], ["addons-data", "extensions-data"], ["addons-backups", "extensions-backups"], ["addons-updates-staged", "extensions-staged"]]) {
const src = path.join(ud, from), dst = path.join(ud, to);
if (!fs.existsSync(src) || fs.existsSync(dst)) continue;
try { fs.renameSync(src, dst); console.log(`[addons] moved ${from} -> ${to}`); }
catch (e) { console.warn(`[addons] could not move ${from} -> ${to}:`, e?.message); }
}
}
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
// Copy bundled reference add-ons (shipped inside resources/) into the user's
// addons directory. Users can then edit, disable, or delete them — the
@ -5616,6 +5648,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
} catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); }
protocol.handle("bns", serveBns);
installDownloadTracker();
migrateExtensionDirs();
initAddons();
// Kick off signed add-on update polling 30 s after boot so it never
// slows launch. Any staged update lands in <userData>/addons-updates-

View file

@ -594,7 +594,7 @@
<section id="addons" hidden>
<h1>Extensions</h1>
<p class="lede">Small modules that add capabilities to Theseus. Extensions live as folders under
<code style="background:transparent;border:none;padding:0" id="addonsPathHint">%APPDATA%\Theseus Navigator\addons\</code>. Drop a folder in, restart, it's live.
<code style="background:transparent;border:none;padding:0" id="addonsPathHint">%APPDATA%\Theseus\extensions\</code>. Drop a folder in, restart, it's live.
Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.</p>
<div class="row" style="justify-content:flex-end;gap:8px">