From 4fff803cbdd7813fa62d2efa7a957919ff985242 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 23 May 2026 16:32:59 +0200 Subject: [PATCH] prevent native menus in browser --- apps/ignis-server/server/index.js | 10 +- packages/server-core/src/write-coalescer.js | 4 +- packages/shim/src/electron/remote/menu.js | 12 ++ packages/shim/src/fs/content-cache.test.js | 4 +- packages/shim/src/fs/metadata-cache.test.js | 4 +- packages/shim/src/init.js | 2 + packages/shim/src/native-menu-guard.js | 164 ++++++++++++++++++++ 7 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 packages/shim/src/native-menu-guard.js diff --git a/apps/ignis-server/server/index.js b/apps/ignis-server/server/index.js index 6f874a9..1fa3353 100644 --- a/apps/ignis-server/server/index.js +++ b/apps/ignis-server/server/index.js @@ -4,7 +4,11 @@ const path = require("path"); const compression = require("compression"); const config = require("./config"); const { getVersion } = require("./version"); -const { setupWebSocket, watcher, writeCoalescer } = require("@ignis/server-core"); +const { + setupWebSocket, + watcher, + writeCoalescer, +} = require("@ignis/server-core"); const { updateBridgePluginInAllVaults } = require("./bridge-plugin"); const { initPlugins, shutdownPlugins } = require("./plugin-system/manager"); const pluginRoutes = require("./routes/plugins"); @@ -92,9 +96,7 @@ app.use("/vault-files", (req, res, next) => { express.static(vaultPath)(req, res, next); }); -// Serve our own index.html. Obsidian's scripts are discovered at startup -// and injected dynamically by the client -- no Obsidian files are read or -// transformed in the response. +// Serve our own index.html. Obsidian's scripts are discovered at startup and injected dynamically by the client. let cachedHtml = null; function buildIndexHtml() { diff --git a/packages/server-core/src/write-coalescer.js b/packages/server-core/src/write-coalescer.js index 4d5661a..86341b3 100644 --- a/packages/server-core/src/write-coalescer.js +++ b/packages/server-core/src/write-coalescer.js @@ -148,9 +148,7 @@ async function flushAll() { const timeout = new Promise((resolve) => { setTimeout(() => { - console.warn( - "[write-coalesce] Flush timeout -- some writes may be lost", - ); + console.warn("[write-coalesce] Flush timeout. Some writes may be lost"); resolve(); }, FLUSH_TIMEOUT_MS); }); diff --git a/packages/shim/src/electron/remote/menu.js b/packages/shim/src/electron/remote/menu.js index 45461e0..b0c0e35 100644 --- a/packages/shim/src/electron/remote/menu.js +++ b/packages/shim/src/electron/remote/menu.js @@ -31,6 +31,18 @@ export class menuShim { } closePopup() {} + + // If the appearance guard in native-menu-guard.js ever fails to block the native-menu path, warn instead of throwing. + on(channel, listener) { + console.warn( + `[shim:Menu] Menu.on(${channel}) called; native-menu path escaped the guard.`, + ); + return this; + } + + off(channel, listener) { + return this; + } } export class menuItemShim { diff --git a/packages/shim/src/fs/content-cache.test.js b/packages/shim/src/fs/content-cache.test.js index 004bd17..57a5d57 100644 --- a/packages/shim/src/fs/content-cache.test.js +++ b/packages/shim/src/fs/content-cache.test.js @@ -62,7 +62,7 @@ describe("ContentCache LRU eviction", () => { cache.set("b.md", "bbbb"); // 4, accessedAt=1001 // Touch a.md so b.md becomes the LRU cache.get("a.md"); // a.md accessedAt=1002 - cache.set("c.md", "cccc"); // 4 -- should evict b.md (1001), not a.md (1002) + cache.set("c.md", "cccc"); // 4, should evict b.md (1001), not a.md (1002) expect(cache.has("a.md")).toBe(true); expect(cache.has("b.md")).toBe(false); expect(cache.has("c.md")).toBe(true); @@ -73,7 +73,7 @@ describe("ContentCache LRU eviction", () => { it("entry larger than maxSize still gets stored", () => { const cache = new ContentCache(5); cache.set("small.md", "ab"); // 2 - cache.set("big.md", "abcdefghij"); // 10 -- larger than maxSize + cache.set("big.md", "abcdefghij"); // 10, larger than maxSize expect(cache.has("small.md")).toBe(false); expect(cache.has("big.md")).toBe(true); expect(cache.currentBytes).toBe(10); diff --git a/packages/shim/src/fs/metadata-cache.test.js b/packages/shim/src/fs/metadata-cache.test.js index 37f85d9..926e9f5 100644 --- a/packages/shim/src/fs/metadata-cache.test.js +++ b/packages/shim/src/fs/metadata-cache.test.js @@ -49,7 +49,7 @@ describe("MetadataCache populate and merge", () => { expect(cache.has("added.md")).toBe(true); }); - it("populate then merge -- pre-existing entries survive merge", () => { + it("populate then merge. pre-existing entries survive merge", () => { const cache = new MetadataCache(); cache.populate({ "a.md": { type: "file", size: 1 }, @@ -112,7 +112,7 @@ describe("MetadataCache readdir", () => { "foo/sub/deep.md": { type: "file", size: 3 }, "foobar/other.md": { type: "file", size: 4 }, "root.md": { type: "file", size: 5 }, - "docs": { type: "directory", size: 0 }, + docs: { type: "directory", size: 0 }, }); return cache; } diff --git a/packages/shim/src/init.js b/packages/shim/src/init.js index bc31f5c..0d25dda 100644 --- a/packages/shim/src/init.js +++ b/packages/shim/src/init.js @@ -10,6 +10,7 @@ import { } from "./workspace.js"; import { prefetchVaultContent } from "./fs/indexer-prefetch.js"; import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js"; +import { initNativeMenuGuard } from "./native-menu-guard.js"; function resolveVaultId() { const urlParams = new URLSearchParams(window.location.search); @@ -221,6 +222,7 @@ export function initialize() { resolveVaultId(); resolveWorkspaceName(); loadPresetIfRequested(); + initNativeMenuGuard(window.__currentVaultId); const bootstrap = fetchBootstrap(); diff --git a/packages/shim/src/native-menu-guard.js b/packages/shim/src/native-menu-guard.js new file mode 100644 index 0000000..93b6161 --- /dev/null +++ b/packages/shim/src/native-menu-guard.js @@ -0,0 +1,164 @@ +// Obsidian's native-menu path uses Electron Menu APIs that can't render in a browser. +// Use transforms to keep nativeMenus = false in browser context while preserving user config on disk. +// Also disable the settings toggle and patch setConfig. + +import { + registerReadTransform, + registerWriteTransform, +} from "./fs/transforms.js"; + +const APPEARANCE_PATH = ".obsidian/appearance.json"; + +// undefined = key absent on disk; write transform keeps it absent. +let preservedNativeMenus = undefined; + +function snapshotAppearance(vaultId) { + if (!vaultId) { + return; + } + + try { + const xhr = new XMLHttpRequest(); + const url = + "/api/fs/readFile?vault=" + + encodeURIComponent(vaultId) + + "&path=" + + encodeURIComponent(APPEARANCE_PATH) + + "&encoding=utf-8"; + + xhr.open("GET", url, false); + xhr.send(); + + if (xhr.status !== 200) { + return; + } + + const obj = JSON.parse(xhr.responseText); + + if ("nativeMenus" in obj) { + preservedNativeMenus = obj.nativeMenus; + } + } catch { + // File missing or malformed; preservedNativeMenus stays undefined. + } +} + +function readTransform(data) { + const text = typeof data === "string" ? data : new TextDecoder().decode(data); + + try { + const obj = JSON.parse(text); + + if (obj.nativeMenus) { + obj.nativeMenus = false; + return JSON.stringify(obj); + } + } catch {} + + return data; +} + +function writeTransform(data) { + const text = typeof data === "string" ? data : new TextDecoder().decode(data); + + try { + const obj = JSON.parse(text); + + if (preservedNativeMenus === undefined) { + delete obj.nativeMenus; + } else { + obj.nativeMenus = preservedNativeMenus; + } + + return JSON.stringify(obj); + } catch { + return data; + } +} + +// Prevent setting from being set during runtime. +function patchSetConfig() { + const tryPatch = () => { + const vault = window.app && window.app.vault; + + if (!vault || typeof vault.setConfig !== "function") { + return false; + } + + if (vault.__ignisNativeMenuGuarded) { + return true; + } + + const orig = vault.setConfig.bind(vault); + + vault.setConfig = function (key, value) { + if (key === "nativeMenus") { + return orig("nativeMenus", false); + } + + return orig(key, value); + }; + vault.__ignisNativeMenuGuarded = true; + + return true; + }; + + if (tryPatch()) { + return; + } + + const observer = new MutationObserver(() => { + if (tryPatch()) { + observer.disconnect(); + } + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); +} + +// Disable the "Native menus" toggle in appearance settings. +function disableNativeMenuToggle() { + const apply = () => { + document.querySelectorAll(".setting-item-name").forEach((nameEl) => { + if (!/native.?menu/i.test(nameEl.textContent)) { + return; + } + + const item = nameEl.closest(".setting-item"); + const input = item && item.querySelector('input[type="checkbox"]'); + + if (!input || input.__ignisDisabled) { + return; + } + + input.disabled = true; + input.__ignisDisabled = true; + + const container = input.closest(".checkbox-container"); + + if (container) { + container.title = + "Forced off in Ignis - browser context can't render native menus."; + } + }); + }; + + const observer = new MutationObserver(apply); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); +} + +export function initNativeMenuGuard(vaultId) { + // Snapshot before registering transforms so the write transform has the original disk value to substitute back. + snapshotAppearance(vaultId); + registerReadTransform(APPEARANCE_PATH, readTransform); + registerWriteTransform(APPEARANCE_PATH, writeTransform); + patchSetConfig(); + disableNativeMenuToggle(); +}