prevent native menus in browser

This commit is contained in:
Nystik
2026-05-23 16:32:59 +02:00
parent 10c6782652
commit 4fff803cbd
7 changed files with 189 additions and 11 deletions

View File

@@ -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() {

View File

@@ -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);
});

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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();
}