From 5ea4515085ec92076d58f9dbe9d94c15133315dc Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 31 Aug 2026 19:30:30 +0200 Subject: [PATCH] Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chrome.html | 101 +++++++++++++++++++++++++++++++++++++++++++- main.js | 35 ++++++++++++++- nsis/installer.nsh | 10 +++++ nsis/make-icons.mjs | 94 +++++++++++++++++++++++++++++++++++++++++ package.json | 7 ++- preload.js | 5 +++ 6 files changed, 247 insertions(+), 5 deletions(-) diff --git a/chrome.html b/chrome.html index 4912165..35224c7 100644 --- a/chrome.html +++ b/chrome.html @@ -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 ? '' : (t.favicon ? `` : ''); - return `
${icon}${(t.title||"New Tab").replace(/✕
`; + const groupDot = t.group ? `` : ""; + const mute = t.muted ? `🔇` : ""; + return `
${groupDot}${icon}${(t.title||"New Tab").replace(/${mute}✕
`; }).join("") + `+`; 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 ? '' : ""), "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('None' + (t.group ? "" : " ✓"), () => T.tabGroup(t.id, null)); + for (const c of TAB_GROUP_COLORS) { + row('' + 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); diff --git a/main.js b/main.js index a9fcb87..b7397ce 100644 --- a/main.js +++ b/main.js @@ -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); diff --git a/nsis/installer.nsh b/nsis/installer.nsh index 74f9190..fb9d2e2 100644 --- a/nsis/installer.nsh +++ b/nsis/installer.nsh @@ -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. diff --git a/nsis/make-icons.mjs b/nsis/make-icons.mjs index 4d29618..05da783 100644 --- a/nsis/make-icons.mjs +++ b/nsis/make-icons.mjs @@ -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 = ` + Theseus + NAVIGATOR + `; + 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); diff --git a/package.json b/package.json index 77482fd..d511e84 100644 --- a/package.json +++ b/package.json @@ -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}" diff --git a/preload.js b/preload.js index 47eeb00..f885e08 100644 --- a/preload.js +++ b/preload.js @@ -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