fix image urls, fix context menu

This commit is contained in:
Nystik 2026-03-10 20:49:10 +01:00
parent b48ef720b8
commit d8d12054b7
9 changed files with 157 additions and 39 deletions

View file

@ -36,6 +36,9 @@ const vaultRoutes = require("./routes/vault");
app.use("/api/fs", fsRoutes); app.use("/api/fs", fsRoutes);
app.use("/api/vault", vaultRoutes); app.use("/api/vault", vaultRoutes);
// Serve vault files for resource URLs (images, attachments, etc.)
app.use("/vault-files", express.static(config.vaultPath));
// --- Static serving --- // --- Static serving ---
// Serve the built shim-loader.js // Serve the built shim-loader.js
app.use( app.use(

View file

@ -10,6 +10,13 @@ export const electronShim = {
webFrame, webFrame,
remote: remoteShim, remote: remoteShim,
// electron.webUtils - used for drag/drop file path extraction (desktop only)
webUtils: {
getPathForFile(file) {
return "";
},
},
// electron.deprecate - used by Obsidian to mark deprecated APIs // electron.deprecate - used by Obsidian to mark deprecated APIs
deprecate: { deprecate: {
function(fn, name) { function(fn, name) {

View file

@ -25,7 +25,7 @@ const syncHandlers = {
vault: () => window.__vaultConfig || { id: "default-vault", path: "/" }, vault: () => window.__vaultConfig || { id: "default-vault", path: "/" },
version: () => "1.8.9", version: () => "1.8.9",
"is-dev": () => false, "is-dev": () => false,
"file-url": () => "", "file-url": () => "/vault-files/",
"disable-update": () => true, "disable-update": () => true,
update: () => "", update: () => "",
"disable-gpu": () => false, "disable-gpu": () => false,
@ -49,7 +49,20 @@ const syncHandlers = {
export const ipcRenderer = { export const ipcRenderer = {
send(channel, ...args) { send(channel, ...args) {
console.log("[shim:ipcRenderer] send:", channel, args); console.log("[shim:ipcRenderer] send:", channel, args);
// TODO: route to server via chosen sync mechanism if needed
// context-menu: Obsidian sends this and waits (up to 1s) for a response.
// In Electron, the main process returns spell-check info + edit flags.
// We reply immediately with a response object so Obsidian proceeds to
// build and show its HTML context menu without delay.
if (channel === "context-menu") {
queueMicrotask(() =>
ipcRenderer._emit("context-menu", {
webContentsId: 1,
editFlags: { canCut: true, canCopy: true, canPaste: true },
}),
);
return;
}
}, },
sendSync(channel, ...args) { sendSync(channel, ...args) {

View file

@ -1,18 +1,18 @@
// @electron/remote shim // @electron/remote shim
// Returned when Obsidian calls: window.require('@electron/remote') // Returned when Obsidian calls: window.require('@electron/remote')
import { clipboardShim } from './clipboard.js'; import { clipboardShim } from "./clipboard.js";
import { shellShim } from './shell.js'; import { shellShim } from "./shell.js";
import { dialogShim } from './dialog.js'; import { dialogShim } from "./dialog.js";
import { menuShim, menuItemShim } from './menu.js'; import { menuShim, menuItemShim } from "./menu.js";
import { appShim } from './app.js'; import { appShim } from "./app.js";
import { windowShim, webContentsShim } from './window.js'; import { windowShim, webContentsShim } from "./window.js";
import { themeShim } from './theme.js'; import { themeShim } from "./theme.js";
import { sessionShim } from './session.js'; import { sessionShim } from "./session.js";
import { systemPreferencesShim } from './system-preferences.js'; import { systemPreferencesShim } from "./system-preferences.js";
import { screenShim } from './screen.js'; import { screenShim } from "./screen.js";
import { nativeImageShim } from './native-image.js'; import { nativeImageShim } from "./native-image.js";
import { notificationShim } from './notification.js'; import { notificationShim } from "./notification.js";
export const remoteShim = { export const remoteShim = {
clipboard: clipboardShim, clipboard: clipboardShim,
@ -33,6 +33,8 @@ export const remoteShim = {
return windowShim._current(); return windowShim._current();
}, },
webContents: webContentsShim,
getCurrentWebContents() { getCurrentWebContents() {
return webContentsShim._current(); return webContentsShim._current();
}, },

View file

@ -135,6 +135,7 @@ const currentWindow = {
}; };
const currentWebContents = { const currentWebContents = {
id: 1,
_zoomLevel: 0, _zoomLevel: 0,
get zoomLevel() { get zoomLevel() {
@ -182,7 +183,25 @@ const currentWebContents = {
undo() {}, undo() {},
redo() {}, redo() {},
pasteAndMatchStyle() {}, cut() {
document.execCommand("cut");
},
copy() {
document.execCommand("copy");
},
paste() {
document.execCommand("paste");
},
pasteAndMatchStyle() {
document.execCommand("paste");
},
replaceMisspelling(word) {},
session: {
availableSpellCheckerLanguages: [],
setSpellCheckerLanguages(langs) {},
addWordToSpellCheckerDictionary(word) {},
},
setSpellCheckerLanguages(langs) {}, setSpellCheckerLanguages(langs) {},
@ -212,4 +231,7 @@ export const windowShim = {
export const webContentsShim = { export const webContentsShim = {
_current: () => currentWebContents, _current: () => currentWebContents,
fromId(id) {
return id === currentWebContents.id ? currentWebContents : null;
},
}; };

View file

@ -24,20 +24,39 @@ export function createFsPromises(metadataCache, contentCache, transport) {
if (meta && meta.type === "file") { if (meta && meta.type === "file") {
return []; return [];
} }
// If path not in cache at all (and not root), it doesn't exist
if (!meta && path && path !== "/" && path !== ".") {
const e = new Error(
`ENOENT: no such file or directory, scandir '${path}'`,
);
e.code = "ENOENT";
throw e;
}
// Serve from metadata cache // Serve from metadata cache
const entries = metadataCache.readdir(path); const entries = metadataCache.readdir(path);
if (entries.length > 0) { return entries.map((e) => e.name);
return entries.map((e) => e.name);
}
// Fallback to server
const serverEntries = await transport.readdir(path);
return serverEntries.map((e) => e.name);
}, },
async readFile(path, encoding) { async readFile(path, encoding) {
if (typeof encoding === "object") encoding = encoding?.encoding; if (typeof encoding === "object") encoding = encoding?.encoding;
const wantText = encoding === "utf8" || encoding === "utf-8"; const wantText = encoding === "utf8" || encoding === "utf-8";
// Short-circuit: reading a directory is an error
const meta = metadataCache.get(path);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
throw e;
}
// Short-circuit: file not in metadata cache → doesn't exist
if (!meta && path) {
const e = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
e.code = "ENOENT";
throw e;
}
// Check content cache // Check content cache
const cached = contentCache.get(path); const cached = contentCache.get(path);
if (cached !== null) { if (cached !== null) {
@ -142,7 +161,11 @@ export function createFsPromises(metadataCache, contentCache, transport) {
async access(path) { async access(path) {
if (metadataCache.has(path)) return; if (metadataCache.has(path)) return;
await transport.access(path); const e = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
e.code = "ENOENT";
throw e;
}, },
async realpath(path) { async realpath(path) {

View file

@ -10,8 +10,10 @@ export function createFsSync(metadataCache, contentCache, transport) {
statSync(path) { statSync(path) {
const stat = metadataCache.toStat(path); const stat = metadataCache.toStat(path);
if (!stat) { if (!stat) {
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`); const err = new Error(
err.code = 'ENOENT'; `ENOENT: no such file or directory, stat '${path}'`,
);
err.code = "ENOENT";
throw err; throw err;
} }
return stat; return stat;
@ -19,39 +21,52 @@ export function createFsSync(metadataCache, contentCache, transport) {
accessSync(path, mode) { accessSync(path, mode) {
if (!metadataCache.has(path)) { if (!metadataCache.has(path)) {
const err = new Error(`ENOENT: no such file or directory, access '${path}'`); const err = new Error(
err.code = 'ENOENT'; `ENOENT: no such file or directory, access '${path}'`,
);
err.code = "ENOENT";
throw err; throw err;
} }
}, },
readFileSync(path, encoding) { readFileSync(path, encoding) {
if (typeof encoding === 'object') encoding = encoding?.encoding; if (typeof encoding === "object") encoding = encoding?.encoding;
// Short-circuit: reading a directory is an error
const meta = metadataCache.get(path);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
throw e;
}
// Try content cache first // Try content cache first
const cached = contentCache.get(path); const cached = contentCache.get(path);
if (cached !== null) { if (cached !== null) {
if (encoding === 'utf8' || encoding === 'utf-8') { if (encoding === "utf8" || encoding === "utf-8") {
return typeof cached === 'string' ? cached : new TextDecoder().decode(cached); return typeof cached === "string"
? cached
: new TextDecoder().decode(cached);
} }
return cached; return cached;
} }
// Fallback: synchronous XHR // Fallback: synchronous XHR
console.warn('[shim:fs] readFileSync cache miss, using sync XHR:', path); console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
const data = transport.readFileSync(path, encoding); const data = transport.readFileSync(path, encoding);
contentCache.set(path, data); contentCache.set(path, data);
return data; return data;
}, },
writeFileSync(path, data, encoding) { writeFileSync(path, data, encoding) {
if (typeof encoding === 'object') encoding = encoding?.encoding; if (typeof encoding === "object") encoding = encoding?.encoding;
// Write to cache immediately (sync return) // Write to cache immediately (sync return)
contentCache.set(path, data); contentCache.set(path, data);
const size = typeof data === 'string' ? data.length : (data.byteLength || 0); const size =
typeof data === "string" ? data.length : data.byteLength || 0;
metadataCache.set(path, { metadataCache.set(path, {
type: 'file', type: "file",
size, size,
mtime: Date.now(), mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(), ctime: metadataCache.get(path)?.ctime || Date.now(),
@ -59,7 +74,11 @@ export function createFsSync(metadataCache, contentCache, transport) {
// Fire-and-forget async send to server // Fire-and-forget async send to server
transport.writeFile(path, data, encoding).catch((e) => { transport.writeFile(path, data, encoding).catch((e) => {
console.error('[shim:fs] writeFileSync background save failed:', path, e); console.error(
"[shim:fs] writeFileSync background save failed:",
path,
e,
);
}); });
}, },
@ -67,15 +86,21 @@ export function createFsSync(metadataCache, contentCache, transport) {
contentCache.delete(path); contentCache.delete(path);
metadataCache.delete(path); metadataCache.delete(path);
// Fire-and-forget // Fire-and-forget - suppress ENOENT (file already gone, e.g. .OBSIDIANTEST race)
transport.unlink(path).catch((e) => { transport.unlink(path).catch((e) => {
console.error('[shim:fs] unlinkSync background delete failed:', path, e); if (e.code !== "ENOENT") {
console.error(
"[shim:fs] unlinkSync background delete failed:",
path,
e,
);
}
}); });
}, },
readdirSync(path) { readdirSync(path) {
const entries = metadataCache.readdir(path); const entries = metadataCache.readdir(path);
return entries.map(e => e.name); return entries.map((e) => e.name);
}, },
}; };
} }

View file

@ -10,6 +10,16 @@ function normPath(p) {
return (p || "").replace(/^\/+/, ""); return (p || "").replace(/^\/+/, "");
} }
// Convert a Uint8Array to base64 without blowing the stack
function uint8ToBase64(bytes) {
let binary = "";
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
async function request(method, endpoint, params = {}) { async function request(method, endpoint, params = {}) {
const url = new URL(API_BASE + endpoint, window.location.origin); const url = new URL(API_BASE + endpoint, window.location.origin);
@ -109,7 +119,7 @@ export const transport = {
const isText = typeof content === "string"; const isText = typeof content === "string";
return requestJson("POST", "/writeFile", { return requestJson("POST", "/writeFile", {
path: normPath(path), path: normPath(path),
content: isText ? content : btoa(String.fromCharCode(...content)), content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"), encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText, base64: !isText,
}); });
@ -197,7 +207,7 @@ export const transport = {
const isText = typeof content === "string"; const isText = typeof content === "string";
requestSync("POST", "/writeFile", { requestSync("POST", "/writeFile", {
path: normPath(path), path: normPath(path),
content: isText ? content : btoa(String.fromCharCode(...content)), content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"), encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText, base64: !isText,
}); });

View file

@ -119,6 +119,19 @@ window.close = function () {
console.log("[obsidian-bridge] window.close() blocked"); console.log("[obsidian-bridge] window.close() blocked");
}; };
// Suppress the browser's native context menu without breaking Obsidian's.
// Problem: preventDefault() blocks the browser menu but also sets
// event.defaultPrevented=true, which Obsidian checks to bail out.
// Solution: call preventDefault() then shadow defaultPrevented to return false.
window.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
Object.defineProperty(e, "defaultPrevented", { get: () => false });
},
true,
);
// Pre-populate fs metadata cache synchronously before app.js runs. // Pre-populate fs metadata cache synchronously before app.js runs.
// This ensures existsSync() works for the vault path during startup. // This ensures existsSync() works for the vault path during startup.
(function initMetadataCache() { (function initMetadataCache() {