Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)

Setup    09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07

Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.

Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).

Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.

Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.

Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.

Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
This commit is contained in:
Local Dev 2026-08-31 19:30:30 +02:00
parent 19ffde3bfa
commit 5ea4515085
6 changed files with 247 additions and 5 deletions

View file

@ -33,6 +33,24 @@
.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; }
@keyframes spin { to { transform: rotate(360deg); } }
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; }
/* Tab group visual: a colored dot before the title, plus a matching top
accent stripe on the tab itself so a whole group reads as one cluster
even when tabs are next to each other. */
.tab .gdot { width: 8px; height: 8px; flex: none; border-radius: 50%; }
.tab.grp { box-shadow: inset 0 2px 0 var(--gc, transparent); }
.tab.g-red, .gdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; }
.tab.g-orange, .gdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; }
.tab.g-yellow, .gdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; }
.tab.g-green, .gdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; }
.tab.g-cyan, .gdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; }
.tab.g-blue, .gdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; }
.tab.g-purple, .gdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; }
/* Submenu popover for the tab context menu (Group → color). */
.ctxmenu .mi.sub { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ctxmenu .mi.sub::after { content: "▸"; color: var(--dim); font-size: 11px; }
.ctxmenu.sub2 { min-width: 140px; }
.ctxmenu .swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 8px; vertical-align: -1px; border: 1px solid rgba(255,255,255,.10); }
.tab .x:hover { opacity: 1; background: var(--line2); }
.newtab { padding: 4px 10px; cursor: pointer; color: var(--dim); border-radius: 6px; font-size: 16px; }
.newtab:hover { background: var(--hover); color: var(--ink); }
@ -561,13 +579,24 @@
const icon = t.loading
? '<span class="spin"></span>'
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : '');
return `<div class="tab ${t.active ? "active" : ""}" data-id="${t.id}" draggable="true">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span><span class="x" data-close="${t.id}"></span></div>`;
const groupDot = t.group ? `<span class="gdot g-${t.group}" title="Group: ${t.group}"></span>` : "";
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : "";
return `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true">${groupDot}${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span>${mute}<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));
});
// Right-click a tab → floating menu (Reload / Duplicate / Group / Add to
// Bookmarks / Mute / Close). Menu closes on any other click.
box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => {
e.preventDefault();
const id = Number(el.dataset.id);
const t = d.tabs.find((x) => x.id === id);
if (!t) return;
openTabContextMenu(e.clientX, e.clientY, t);
}));
// 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;
@ -603,6 +632,76 @@
$("newtab").onclick = () => T.newTab();
});
// ---- Tab context menu (right-click a tab) ----
const TAB_GROUP_COLORS = [
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
{ id: "purple", label: "Purple" },
];
function closeAllMenus() { document.querySelectorAll(".ctxmenu").forEach((m) => m.remove()); }
function openTabContextMenu(x, y, t) {
closeAllMenus();
const m = document.createElement("div");
m.className = "ctxmenu";
m.style.left = x + "px"; m.style.top = y + "px";
// Build items
const item = (label, cls, fn) => {
const el = document.createElement("div");
el.className = "mi" + (cls ? " " + cls : "");
el.innerHTML = label;
el.onclick = (ev) => { ev.stopPropagation(); if (fn) fn(); closeAllMenus(); };
return el;
};
m.appendChild(item("Reload", "", () => T.tabReload(t.id)));
m.appendChild(item("Duplicate", "", () => T.tabDuplicate(t.id)));
// Group submenu — hover/click opens a second menu next to the first.
const gp = item("Group " + (t.group ? '<span class="swatch g-' + t.group + '"></span>' : ""), "sub", null);
gp.onclick = (ev) => {
ev.stopPropagation();
const rect = gp.getBoundingClientRect();
openGroupSubmenu(rect.right + 2, rect.top, t);
};
m.appendChild(gp);
// Add to Bookmarks (disabled if the tab has no url — e.g. Home).
const bmDisabled = !t.url;
m.appendChild(item("Add to Bookmarks", bmDisabled ? "off" : "", bmDisabled ? null : () => T.tabBookmark(t.id)));
m.appendChild(item(t.muted ? "Unmute" : "Mute", "", () => T.tabMute(t.id)));
const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep);
m.appendChild(item("Close", "danger", () => T.closeTab(t.id)));
document.body.appendChild(m);
// Clamp inside viewport
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px";
if (r.bottom > window.innerHeight) m.style.top = Math.max(4, window.innerHeight - r.height - 4) + "px";
}
function openGroupSubmenu(x, y, t) {
document.querySelectorAll(".ctxmenu.sub2").forEach((m) => m.remove());
const m = document.createElement("div");
m.className = "ctxmenu sub2";
m.style.left = x + "px"; m.style.top = y + "px";
const row = (html, fn) => {
const el = document.createElement("div");
el.className = "mi";
el.innerHTML = html;
el.onclick = (ev) => { ev.stopPropagation(); fn(); closeAllMenus(); };
m.appendChild(el);
};
row('<span class="swatch" style="background:transparent;border:1px dashed var(--line2)"></span>None' + (t.group ? "" : " ✓"), () => T.tabGroup(t.id, null));
for (const c of TAB_GROUP_COLORS) {
row('<span class="swatch g-' + c.id + '"></span>' + c.label + (t.group === c.id ? " ✓" : ""), () => T.tabGroup(t.id, c.id));
}
document.body.appendChild(m);
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = Math.max(4, x - r.width - 6) + "px";
if (r.bottom > window.innerHeight) m.style.top = Math.max(4, window.innerHeight - r.height - 4) + "px";
}
document.addEventListener("click", (e) => {
// Close any open menus on outside click.
if (!e.target.closest(".ctxmenu")) closeAllMenus();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeAllMenus(); });
// ---- provenance ----
T.onNav((d) => {
setBadge(d); setReg(d);

35
main.js
View file

@ -1668,7 +1668,7 @@ function emitTabs() {
const t = activeTab();
const wc = t?.view.webContents;
chrome?.webContents.send("tabs", {
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null })),
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "" })),
url: t?.url || "",
loading: !!t?.loading,
canBack: wc ? wc.navigationHistory.canGoBack() : false,
@ -1759,7 +1759,7 @@ function createTab(initial, opts = {}) {
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
applyFingerprint(wc);
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings };
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null };
tabs.push(tab);
win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => {
@ -2108,6 +2108,37 @@ 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));
// Tab context menu backing IPCs. All scoped to a specific tab id so the
// active tab doesn't have to be the one the user right-clicked.
ipcMain.handle("tab-reload", (_e, id) => { const t = tabById(id); if (t) try { t.view.webContents.reload(); } catch {} });
ipcMain.handle("tab-duplicate", (_e, id) => {
const t = tabById(id); if (!t) return;
const target = t.url || "";
if (target) createTab(target); else createTab();
});
ipcMain.handle("tab-mute", (_e, id, on) => {
const t = tabById(id); if (!t) return false;
const want = typeof on === "boolean" ? on : !t.muted;
try { t.view.webContents.setAudioMuted(want); t.muted = want; emitTabs(); return want; }
catch { return t.muted; }
});
ipcMain.handle("tab-group", (_e, id, color) => {
const t = tabById(id); if (!t) return null;
// color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const allowed = new Set(["red","orange","yellow","green","cyan","blue","purple"]);
t.group = allowed.has(color) ? color : null;
emitTabs();
return t.group;
});
ipcMain.handle("tab-bookmark", (_e, id) => {
const t = tabById(id); if (!t) return false;
const url = t.url; const title = t.title || url;
if (!url) return false;
if (bookmarks.some((b) => b.url === url)) return true; // already saved
bookmarks.unshift({ url, title, addedAt: Date.now() });
saveBookmarks(); emitBookmarks();
return true;
});
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
const src = tabs.findIndex((t) => t.id === id);
const dst = tabs.findIndex((t) => t.id === targetId);

View file

@ -25,6 +25,16 @@ Var InstallAriadneFlag
Page custom AriadnePageCreate AriadnePageLeave
Function AriadnePageCreate
; Silent install (/S) no user, no page. Default to "don't touch Ariadne";
; the whole point of /S is unattended, and existing Ariadne stays as-is.
; nsDialogs::Create in silent mode returns a dialog handle we can't display,
; so we short-circuit BEFORE calling any nsDialogs API.
IfSilent ariadne_page_silent ariadne_page_check
ariadne_page_silent:
StrCpy $InstallAriadneFlag "0"
Return
ariadne_page_check:
; Skip the page entirely if Ariadne's Thread is already on this machine
; nothing to offer. Registry lookup uses HKLM 64-bit view because Ariadne's
; Inno script sets ArchitecturesInstallIn64BitMode=x64compatible.

View file

@ -11,6 +11,46 @@ import { fileURLToPath } from "node:url";
import sharp from "sharp";
import pngToIco from "png-to-ico";
// Minimal 24-bit uncompressed BMP writer. Sharp can produce RGB raw pixels
// but not BMP output; NSIS's MUI wizard branding needs BMP.
function writeBmp24(outPath, width, height, rgb) {
const rowBytes = width * 3;
const rowPad = (4 - (rowBytes % 4)) % 4;
const stride = rowBytes + rowPad;
const pixelBytes = stride * height;
const fileSize = 14 + 40 + pixelBytes;
const buf = Buffer.alloc(fileSize);
// BITMAPFILEHEADER
buf.write("BM", 0);
buf.writeUInt32LE(fileSize, 2);
buf.writeUInt32LE(0, 6);
buf.writeUInt32LE(54, 10);
// BITMAPINFOHEADER
buf.writeUInt32LE(40, 14);
buf.writeInt32LE(width, 18);
buf.writeInt32LE(height, 22);
buf.writeUInt16LE(1, 26);
buf.writeUInt16LE(24, 28);
buf.writeUInt32LE(0, 30);
buf.writeUInt32LE(pixelBytes, 34);
buf.writeInt32LE(2835, 38); // 72 DPI
buf.writeInt32LE(2835, 42);
buf.writeUInt32LE(0, 46);
buf.writeUInt32LE(0, 50);
// Pixel rows bottom-up, BGR order.
for (let y = 0; y < height; y++) {
const srcRow = (height - 1 - y) * width * 3;
const dstRow = 54 + y * stride;
for (let x = 0; x < width; x++) {
buf[dstRow + x * 3] = rgb[srcRow + x * 3 + 2]; // B
buf[dstRow + x * 3 + 1] = rgb[srcRow + x * 3 + 1]; // G
buf[dstRow + x * 3 + 2] = rgb[srcRow + x * 3]; // R
}
// trailing padding bytes are already 0-filled by alloc()
}
fs.writeFileSync(outPath, buf);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// SVG source lives in the theseus.x standalone site so the browser icon
// and the site brand stay in lockstep.
@ -18,6 +58,9 @@ const SVG = path.join(__dirname, "..", "..", "site-theseus-x", "assets", "favico
// Icons drop into build/, which is gitignored (regenerated artifacts).
// electron-builder picks them up from there via package.json.
const OUT_DIR = path.join(__dirname, "..", "build");
const INSTALLER_SIDEBAR = path.join(OUT_DIR, "installerSidebar.bmp");
const UNINSTALLER_SIDEBAR = path.join(OUT_DIR, "uninstallerSidebar.bmp");
const INSTALLER_HEADER = path.join(OUT_DIR, "installerHeader.bmp");
const PNG_512 = path.join(OUT_DIR, "icon.png");
const ICO = path.join(OUT_DIR, "icon.ico");
const TMP_DIR = path.join(OUT_DIR, ".icon-tmp");
@ -46,3 +89,54 @@ console.log("wrote", ICO, `(${ICO_SIZES.length} sizes)`);
// Clean up the temp PNGs.
for (const p of paths) fs.unlinkSync(p);
fs.rmdirSync(TMP_DIR);
// ---- NSIS wizard branding ----------------------------------------------
// MUI2's Welcome/Finish sidebar is 164×314 BMP (no alpha). Compose the
// compass mark centered on the Silent Mode dark background so the wizard
// stops shouting "default Electron installer" at the user.
async function makeSidebar(outFile, W, H, markSize) {
const bg = { r: 11, g: 14, b: 20, alpha: 1 };
const canvas = sharp({
create: { width: W, height: H, channels: 3, background: bg },
});
const markX = Math.floor((W - markSize) / 2);
const markY = Math.floor((H - markSize) / 2) - Math.floor(H * 0.08); // slight nudge up
const markPng = await sharp(SVG).resize(markSize, markSize).png().toBuffer();
// "Theseus Navigator" wordmark rendered as SVG text so we don't need a font file.
const wordmarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W - 20}" height="60">
<text x="50%" y="24" text-anchor="middle" fill="#e7eaf1"
font-family="Segoe UI, Arial, sans-serif" font-weight="600" font-size="18">Theseus</text>
<text x="50%" y="48" text-anchor="middle" fill="#d6ff3d"
font-family="Segoe UI, Arial, sans-serif" font-weight="600" font-size="14" letter-spacing="1">NAVIGATOR</text>
</svg>`;
const wordmarkPng = await sharp(Buffer.from(wordmarkSvg)).png().toBuffer();
await canvas
.composite([
{ input: markPng, top: markY, left: markX },
{ input: wordmarkPng, top: markY + markSize + 14, left: 10 },
])
.toColorspace("srgb")
.raw({ depth: "uchar" })
.toBuffer({ resolveWithObject: true })
.then(({ data, info }) => writeBmp24(outFile, info.width, info.height, data));
console.log("wrote", outFile);
}
// NSIS MUI2 sidebar: 164x314. Uninstaller reuses the same asset.
await makeSidebar(INSTALLER_SIDEBAR, 164, 314, 96);
fs.copyFileSync(INSTALLER_SIDEBAR, UNINSTALLER_SIDEBAR);
console.log("wrote", UNINSTALLER_SIDEBAR);
// NSIS header (top strip, 150x57) — small compass on dark, right-aligned so
// it doesn't compete with the page title on the left.
async function makeHeader(outFile) {
const W = 150, H = 57;
const bg = { r: 11, g: 14, b: 20, alpha: 1 };
const mark = await sharp(SVG).resize(40, 40).png().toBuffer();
await sharp({ create: { width: W, height: H, channels: 3, background: bg } })
.composite([{ input: mark, top: 8, left: W - 48 }])
.toColorspace("srgb")
.raw({ depth: "uchar" })
.toBuffer({ resolveWithObject: true })
.then(({ data, info }) => writeBmp24(outFile, info.width, info.height, data));
console.log("wrote", outFile);
}
await makeHeader(INSTALLER_HEADER);

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.1",
"version": "0.3.2",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
"main": "main.js",
@ -118,7 +118,10 @@
"include": "nsis/installer.nsh",
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"installerHeaderIcon": "build/icon.ico"
"installerHeaderIcon": "build/icon.ico",
"installerSidebar": "build/installerSidebar.bmp",
"uninstallerSidebar": "build/uninstallerSidebar.bmp",
"installerHeader": "build/installerHeader.bmp"
},
"portable": {
"artifactName": "TheseusNavigator-${version}-portable.${ext}"

View file

@ -41,6 +41,11 @@ contextBridge.exposeInMainWorld("theseus", {
onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)),
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
tabReload: (id) => ipcRenderer.invoke("tab-reload", id),
tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id),
tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on),
tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color),
tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id),
onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
// Collision-mode (BCNR ↔ ICANN) live switcher for the active tab