diff --git a/server/index.js b/server/index.js index a6012e0..28d18df 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,9 @@ const vaultRoutes = require("./routes/vault"); app.use("/api/fs", fsRoutes); app.use("/api/vault", vaultRoutes); +// Serve vault files for resource URLs (images, attachments, etc.) +app.use("/vault-files", express.static(config.vaultPath)); + // --- Static serving --- // Serve the built shim-loader.js app.use( diff --git a/shims/electron/index.js b/shims/electron/index.js index feda1d8..08ac557 100644 --- a/shims/electron/index.js +++ b/shims/electron/index.js @@ -10,6 +10,13 @@ export const electronShim = { webFrame, 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 deprecate: { function(fn, name) { diff --git a/shims/electron/ipc-renderer.js b/shims/electron/ipc-renderer.js index ee6e095..949f609 100644 --- a/shims/electron/ipc-renderer.js +++ b/shims/electron/ipc-renderer.js @@ -25,7 +25,7 @@ const syncHandlers = { vault: () => window.__vaultConfig || { id: "default-vault", path: "/" }, version: () => "1.8.9", "is-dev": () => false, - "file-url": () => "", + "file-url": () => "/vault-files/", "disable-update": () => true, update: () => "", "disable-gpu": () => false, @@ -49,7 +49,20 @@ const syncHandlers = { export const 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) { diff --git a/shims/electron/remote/index.js b/shims/electron/remote/index.js index 0e8ad03..c2f7c2f 100644 --- a/shims/electron/remote/index.js +++ b/shims/electron/remote/index.js @@ -1,18 +1,18 @@ // @electron/remote shim // Returned when Obsidian calls: window.require('@electron/remote') -import { clipboardShim } from './clipboard.js'; -import { shellShim } from './shell.js'; -import { dialogShim } from './dialog.js'; -import { menuShim, menuItemShim } from './menu.js'; -import { appShim } from './app.js'; -import { windowShim, webContentsShim } from './window.js'; -import { themeShim } from './theme.js'; -import { sessionShim } from './session.js'; -import { systemPreferencesShim } from './system-preferences.js'; -import { screenShim } from './screen.js'; -import { nativeImageShim } from './native-image.js'; -import { notificationShim } from './notification.js'; +import { clipboardShim } from "./clipboard.js"; +import { shellShim } from "./shell.js"; +import { dialogShim } from "./dialog.js"; +import { menuShim, menuItemShim } from "./menu.js"; +import { appShim } from "./app.js"; +import { windowShim, webContentsShim } from "./window.js"; +import { themeShim } from "./theme.js"; +import { sessionShim } from "./session.js"; +import { systemPreferencesShim } from "./system-preferences.js"; +import { screenShim } from "./screen.js"; +import { nativeImageShim } from "./native-image.js"; +import { notificationShim } from "./notification.js"; export const remoteShim = { clipboard: clipboardShim, @@ -33,6 +33,8 @@ export const remoteShim = { return windowShim._current(); }, + webContents: webContentsShim, + getCurrentWebContents() { return webContentsShim._current(); }, diff --git a/shims/electron/remote/window.js b/shims/electron/remote/window.js index 818b129..3bcd41d 100644 --- a/shims/electron/remote/window.js +++ b/shims/electron/remote/window.js @@ -135,6 +135,7 @@ const currentWindow = { }; const currentWebContents = { + id: 1, _zoomLevel: 0, get zoomLevel() { @@ -182,7 +183,25 @@ const currentWebContents = { undo() {}, 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) {}, @@ -212,4 +231,7 @@ export const windowShim = { export const webContentsShim = { _current: () => currentWebContents, + fromId(id) { + return id === currentWebContents.id ? currentWebContents : null; + }, }; diff --git a/shims/fs/promises.js b/shims/fs/promises.js index a9be4ff..c825bd1 100644 --- a/shims/fs/promises.js +++ b/shims/fs/promises.js @@ -24,20 +24,39 @@ export function createFsPromises(metadataCache, contentCache, transport) { if (meta && meta.type === "file") { 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 const entries = metadataCache.readdir(path); - if (entries.length > 0) { - return entries.map((e) => e.name); - } - // Fallback to server - const serverEntries = await transport.readdir(path); - return serverEntries.map((e) => e.name); + return entries.map((e) => e.name); }, async readFile(path, encoding) { if (typeof encoding === "object") encoding = encoding?.encoding; 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 const cached = contentCache.get(path); if (cached !== null) { @@ -142,7 +161,11 @@ export function createFsPromises(metadataCache, contentCache, transport) { async access(path) { 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) { diff --git a/shims/fs/sync.js b/shims/fs/sync.js index ceb6dc3..f98165a 100644 --- a/shims/fs/sync.js +++ b/shims/fs/sync.js @@ -10,8 +10,10 @@ export function createFsSync(metadataCache, contentCache, transport) { statSync(path) { const stat = metadataCache.toStat(path); if (!stat) { - const err = new Error(`ENOENT: no such file or directory, stat '${path}'`); - err.code = 'ENOENT'; + const err = new Error( + `ENOENT: no such file or directory, stat '${path}'`, + ); + err.code = "ENOENT"; throw err; } return stat; @@ -19,39 +21,52 @@ export function createFsSync(metadataCache, contentCache, transport) { accessSync(path, mode) { if (!metadataCache.has(path)) { - const err = new Error(`ENOENT: no such file or directory, access '${path}'`); - err.code = 'ENOENT'; + const err = new Error( + `ENOENT: no such file or directory, access '${path}'`, + ); + err.code = "ENOENT"; throw err; } }, 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 const cached = contentCache.get(path); if (cached !== null) { - if (encoding === 'utf8' || encoding === 'utf-8') { - return typeof cached === 'string' ? cached : new TextDecoder().decode(cached); + if (encoding === "utf8" || encoding === "utf-8") { + return typeof cached === "string" + ? cached + : new TextDecoder().decode(cached); } return cached; } // 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); contentCache.set(path, data); return data; }, writeFileSync(path, data, encoding) { - if (typeof encoding === 'object') encoding = encoding?.encoding; + if (typeof encoding === "object") encoding = encoding?.encoding; // Write to cache immediately (sync return) 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, { - type: 'file', + type: "file", size, mtime: 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 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); metadataCache.delete(path); - // Fire-and-forget + // Fire-and-forget - suppress ENOENT (file already gone, e.g. .OBSIDIANTEST race) 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) { const entries = metadataCache.readdir(path); - return entries.map(e => e.name); + return entries.map((e) => e.name); }, }; } diff --git a/shims/fs/transport.js b/shims/fs/transport.js index a425260..9d26181 100644 --- a/shims/fs/transport.js +++ b/shims/fs/transport.js @@ -10,6 +10,16 @@ function normPath(p) { 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 = {}) { const url = new URL(API_BASE + endpoint, window.location.origin); @@ -109,7 +119,7 @@ export const transport = { const isText = typeof content === "string"; return requestJson("POST", "/writeFile", { path: normPath(path), - content: isText ? content : btoa(String.fromCharCode(...content)), + content: isText ? content : uint8ToBase64(content), encoding: encoding || (isText ? "utf-8" : "binary"), base64: !isText, }); @@ -197,7 +207,7 @@ export const transport = { const isText = typeof content === "string"; requestSync("POST", "/writeFile", { path: normPath(path), - content: isText ? content : btoa(String.fromCharCode(...content)), + content: isText ? content : uint8ToBase64(content), encoding: encoding || (isText ? "utf-8" : "binary"), base64: !isText, }); diff --git a/shims/loader.js b/shims/loader.js index 879bced..960deb9 100644 --- a/shims/loader.js +++ b/shims/loader.js @@ -119,6 +119,19 @@ window.close = function () { 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. // This ensures existsSync() works for the vault path during startup. (function initMetadataCache() {