diff --git a/main.js b/main.js index 370b0beb..84d24565 100644 --- a/main.js +++ b/main.js @@ -12164,6 +12164,22 @@ function openInMainTab(url) { // A second launch: `Theseus.exe --app=` (an installed app's shortcut) // opens that app's window here; a plain launch fronts the browser window, // recreating it when only app windows are left. +// A bare URL on the command line, or a local file path, becomes a new tab — +// which is what Windows passes when the user picks Theseus from an "Open +// with" menu, or clicks a link in another app once Theseus is the default +// browser. argv[0] is the exe; the first entry that is a recognised scheme +// or an existing file on disk wins. Returns a URL string or null. +function urlFromArgv(argv) { + for (let i = 1; i < (argv || []).length; i++) { + const a = argv[i]; + if (!a || a.startsWith("-")) continue; + if (/^(https?|file|bns):/i.test(a)) return a; + try { + if (fs.existsSync(a) && fs.statSync(a).isFile()) return url.pathToFileURL(a).href; + } catch {} + } + return null; +} function handleLaunch(argv) { const appUrl = webapps.appUrlFromArgv(argv); if (appUrl) { if (webapps.launch(appUrl)) return; openInMainTab(appUrl); return; } @@ -12172,8 +12188,10 @@ function handleLaunch(argv) { // when the user is actively in another window) but it's also a reasonable // CLI entry point for `theseus --messages`. const openMsg = Array.isArray(argv) && argv.some((a) => a === "--messages"); + const openUrl = urlFromArgv(argv); if (win && !win.isDestroyed()) { try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} } else createWindow(); + if (openUrl) setImmediate(() => { try { openInMainTab(openUrl); } catch {} }); if (openMsg) { // openHermesInSidebarOrWindow is defined further down; defer with setImmediate // so it runs after createWindow's queue has settled on first launch. Falls @@ -12181,6 +12199,99 @@ function handleLaunch(argv) { setImmediate(() => { try { openHermesInSidebarOrWindow(); } catch {} }); } } +// ---- default browser + file-association registration (0.3.121) ----------- +// Windows writes everything under HKCU (no admin). On other platforms the +// Settings UI hides these rows; the IPC calls return supported:false. +const DEFAULT_BROWSER_PROTOS = ["http", "https"]; +const FILE_ASSOC_EXTS = [".html", ".htm", ".pdf", ".svg", ".xhtml"]; +const WIN_PROGID = "TheseusNavigator.html"; +function isDefaultProtocolClient() { + try { return DEFAULT_BROWSER_PROTOS.every((p) => app.isDefaultProtocolClient(p)); } + catch { return false; } +} +function setAsDefaultBrowser(on) { + try { + for (const p of DEFAULT_BROWSER_PROTOS) { + if (on) app.setAsDefaultProtocolClient(p); + else app.removeAsDefaultProtocolClient(p); + } + return true; + } catch (e) { console.warn("setAsDefaultBrowser:", e?.message); return false; } +} +function reg(args) { + return new Promise((res) => { + try { + const p = require("child_process").spawn("reg.exe", args, { windowsHide: true, stdio: ["ignore", "ignore", "ignore"] }); + p.on("close", (code) => res(code === 0)); + p.on("error", () => res(false)); + } catch { res(false); } + }); +} +const regAdd = (key, name, value, type = "REG_SZ") => { + // reg.exe uses /ve for the key's default (unnamed) value, /v NAME otherwise. + const sel = (name == null || name === "") ? ["/ve"] : ["/v", name]; + return reg(["add", key, ...sel, "/t", type, "/d", value, "/f"]); +}; +const regDel = (key, name) => reg(["delete", key, "/v", name, "/f"]); +const regDelKey = (key) => reg(["delete", key, "/f"]); +// Writes a progid Theseus owns and lists it under each extension's +// OpenWithProgids. Windows then shows Theseus in the "Open with" dropdown +// for those files, and `theseus ""` is what Windows calls to launch. +async function setFileAssociations(on) { + if (process.platform !== "win32") return false; + const exe = process.execPath; + const openCmd = `"${exe}" "%1"`; + const classes = `HKCU\\Software\\Classes`; + const caps = `HKCU\\Software\\Theseus Navigator\\Capabilities`; + if (on) { + await regAdd(`${classes}\\${WIN_PROGID}`, "", "Theseus HTML Document"); + await regAdd(`${classes}\\${WIN_PROGID}\\DefaultIcon`, "", `${exe},0`); + await regAdd(`${classes}\\${WIN_PROGID}\\shell\\open\\command`, "", openCmd); + for (const ext of FILE_ASSOC_EXTS) { + await regAdd(`${classes}\\${ext}\\OpenWithProgids`, WIN_PROGID, ""); + } + // RegisteredApplications is what makes Theseus show up by name in + // Windows Settings → Default Apps for each listed extension. + await regAdd(caps, "ApplicationName", "Theseus Navigator"); + await regAdd(caps, "ApplicationIcon", `${exe},0`); + await regAdd(caps, "ApplicationDescription", "A browser that follows the thread."); + for (const ext of FILE_ASSOC_EXTS) await regAdd(`${caps}\\FileAssociations`, ext, WIN_PROGID); + for (const p of DEFAULT_BROWSER_PROTOS) await regAdd(`${caps}\\URLAssociations`, p, WIN_PROGID); + await regAdd(`HKCU\\Software\\RegisteredApplications`, "Theseus Navigator", "Software\\Theseus Navigator\\Capabilities"); + return true; + } + for (const ext of FILE_ASSOC_EXTS) await regDel(`${classes}\\${ext}\\OpenWithProgids`, WIN_PROGID); + await regDelKey(`${classes}\\${WIN_PROGID}`); + await regDelKey(`HKCU\\Software\\Theseus Navigator`); + await regDel(`HKCU\\Software\\RegisteredApplications`, "Theseus Navigator"); + return true; +} +function hasFileAssociations() { + if (process.platform !== "win32") return false; + try { + const out = require("child_process").execSync( + `reg query "HKCU\\Software\\Classes\\${WIN_PROGID}\\shell\\open\\command" /ve`, + { stdio: ["ignore", "pipe", "ignore"], windowsHide: true } + ).toString(); + return /\.exe/i.test(out); + } catch { return false; } +} +async function openSystemDefaultApps() { + try { + if (process.platform === "win32") { await shell.openExternal("ms-settings:defaultapps"); return true; } + if (process.platform === "darwin") { await shell.openExternal("x-apple.systempreferences:com.apple.preference.general"); return true; } + return false; + } catch { return false; } +} +ipcMain.handle("default-browser-state", () => ({ + supported: process.platform === "win32", + isDefault: isDefaultProtocolClient(), + hasAssocs: hasFileAssociations(), + exe: process.execPath, +})); +ipcMain.handle("default-browser-set", (_e, on) => setAsDefaultBrowser(!!on)); +ipcMain.handle("file-assocs-set", (_e, on) => setFileAssociations(!!on)); +ipcMain.handle("open-system-defaults", () => openSystemDefaultApps()); if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { // Another Theseus owns this profile; it got our argv via second-instance. app.quit(); diff --git a/package.json b/package.json index ab5e9490..abdf5df2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.120", + "version": "0.3.121", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", diff --git a/settings-preload.js b/settings-preload.js index 85c3c4d9..4aaead47 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -121,4 +121,11 @@ contextBridge.exposeInMainWorld("cfg", { resolversReset: () => ipcRenderer.invoke("extra-resolvers-reset"), // Settings › Sidebar › Reset panel widths (moved from the strip's gear in 0.3.100). sidebarResetWidths: () => ipcRenderer.invoke("sidebar-reset-widths"), + // Settings › General › Default handling. On Windows, writes HKCU entries + // only — no admin — then opens the Windows Default Apps page so the user + // confirms the switch there. + defaultBrowserState: () => ipcRenderer.invoke("default-browser-state"), + setDefaultBrowser: (on) => ipcRenderer.invoke("default-browser-set", !!on), + setFileAssocs: (on) => ipcRenderer.invoke("file-assocs-set", !!on), + openSystemDefaults: () => ipcRenderer.invoke("open-system-defaults"), }); diff --git a/settings.html b/settings.html index 2a9a3731..debee010 100644 --- a/settings.html +++ b/settings.html @@ -400,6 +400,22 @@
+ + +

About

Theseus Navigator is free software
@@ -1251,6 +1267,40 @@ console.error("Updates panel init failed:", e); } })(); + // General › Default handling. Hidden on platforms without a backing path + // (today: everything other than Windows). + (async () => { + const row = document.getElementById("defaultBrowserRow"); + const assocRow = document.getElementById("fileAssocsRow"); + const head = document.getElementById("defHandlingHead"); + const defBrowser = document.getElementById("defaultBrowser"); + const assoc = document.getElementById("fileAssocs"); + const openSys = document.getElementById("openSysDefaults"); + async function refresh() { + try { + const d = await cfg.defaultBrowserState(); + if (!d.supported) return; + head.hidden = false; row.hidden = false; assocRow.hidden = false; + defBrowser.checked = !!d.isDefault; + assoc.checked = !!d.hasAssocs; + } catch {} + } + defBrowser.addEventListener("change", async () => { + await cfg.setDefaultBrowser(defBrowser.checked); + // Windows does not let an app assert itself as default; we land the + // user on the Settings page where that choice lives. + if (defBrowser.checked) await cfg.openSystemDefaults(); + setTimeout(refresh, 1000); + }); + assoc.addEventListener("change", async () => { + assoc.disabled = true; + try { await cfg.setFileAssocs(assoc.checked); } + finally { assoc.disabled = false; } + setTimeout(refresh, 600); + }); + openSys.addEventListener("click", () => cfg.openSystemDefaults()); + refresh(); + })(); // sidebar navigation // "naming" is intentionally absent: the Registries section was folded // into General in 0.3.21. Leaving the id in this list threw a