move shim to new package

This commit is contained in:
Nystik
2026-05-20 20:49:28 +02:00
parent a0b44bde58
commit fe11f30c01
66 changed files with 46 additions and 23 deletions

20
packages/shim/build.js Normal file
View File

@@ -0,0 +1,20 @@
const esbuild = require("esbuild");
const path = require("path");
const { version: ignisVersion } = require("../../package.json");
module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "loader.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "shim-loader.js"),
format: "iife",
platform: "browser",
target: ["chrome90"],
alias: {
path: "path-browserify",
},
define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
},
logLevel: "info",
});

View File

@@ -2,5 +2,17 @@
"name": "@ignis/shim",
"version": "0.0.0-internal",
"private": true,
"main": "src/loader.js"
"main": "src/loader.js",
"scripts": {
"build": "node build.js"
},
"dependencies": {
"@ignis/services": "*",
"@noble/hashes": "^2.2.0",
"pako": "^2.1.0",
"path-browserify": "^1.0.1"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}

View File

@@ -0,0 +1,4 @@
// Obsidian wraps this in try/catch: try{this.btime=window.require("btime")}catch(e){}
// Returning null causes graceful degradation. mtime is used instead.
export const btimeShim = null;

View File

@@ -0,0 +1,88 @@
import { sha256, sha512 } from "@noble/hashes/sha2.js";
import { sha1, md5 } from "@noble/hashes/legacy.js";
const HASHERS = {
SHA1: sha1,
SHA256: sha256,
SHA512: sha512,
MD5: md5,
};
const SUBTLE_ALG = {
SHA1: "SHA-1",
SHA256: "SHA-256",
SHA512: "SHA-512",
};
function normalizeAlgorithm(algorithm) {
return algorithm.toUpperCase().replace(/-/g, "");
}
function encode(bytes, encoding) {
if (!encoding) {
return bytes;
}
if (encoding === "hex") {
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
if (encoding === "base64") {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
throw new Error(`Unsupported digest encoding: ${encoding}`);
}
export function createHash(algorithm) {
const alg = normalizeAlgorithm(algorithm);
const hasher = HASHERS[alg];
if (!hasher) {
throw new Error(`Unsupported hash algorithm: ${algorithm}`);
}
let inputData = new Uint8Array(0);
return {
update(data) {
const bytes =
typeof data === "string" ? new TextEncoder().encode(data) : data;
const merged = new Uint8Array(inputData.length + bytes.length);
merged.set(inputData);
merged.set(bytes, inputData.length);
inputData = merged;
return this;
},
digest(encoding) {
return encode(hasher(inputData), encoding);
},
async digestAsync(encoding) {
const subtleAlg = SUBTLE_ALG[alg];
if (!subtleAlg) {
// SubtleCrypto doesn't cover MD5; fall back to the sync hasher.
return encode(hasher(inputData), encoding);
}
const buf = await crypto.subtle.digest(subtleAlg, inputData);
return encode(new Uint8Array(buf), encoding);
},
};
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { createHash } from "./create-hash.js";
// "abc" / empty SHA digests: NIST FIPS 180-4 worked examples (SHA_All.pdf).
// MD5: RFC 1321 §A.5 test suite.
const VECTORS = {
SHA1: {
empty: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
abc: "a9993e364706816aba3e25717850c26c9cd0d89d",
},
SHA256: {
empty: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
abc: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
},
SHA512: {
empty:
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
abc: "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
},
MD5: {
empty: "d41d8cd98f00b204e9800998ecf8427e",
abc: "900150983cd24fb0d6963f7d28e17f72",
},
};
describe("createHash", () => {
for (const [alg, vec] of Object.entries(VECTORS)) {
it(`${alg} digests "abc" correctly (hex)`, () => {
expect(createHash(alg).update("abc").digest("hex")).toBe(vec.abc);
});
}
it("handles empty input (no update calls)", () => {
expect(createHash("sha256").digest("hex")).toBe(VECTORS.SHA256.empty);
});
it("normalizes algorithm names (sha-256 -> SHA256)", () => {
expect(createHash("sha-256").update("abc").digest("hex")).toBe(
VECTORS.SHA256.abc,
);
});
it("digest() with no encoding returns raw bytes", () => {
const result = createHash("sha256").update("abc").digest();
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(32);
});
it("digest('base64') returns the base64 of the raw bytes", () => {
const result = createHash("sha256").update("abc").digest("base64");
expect(result).toBe("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=");
});
it("supports multiple update() calls", () => {
const result = createHash("sha256")
.update("a")
.update("b")
.update("c")
.digest("hex");
expect(result).toBe(VECTORS.SHA256.abc);
});
it("throws on unsupported algorithm", () => {
expect(() => createHash("whirlpool")).toThrow(/Unsupported hash algorithm/);
});
it("throws on unsupported encoding", () => {
expect(() => createHash("sha256").update("abc").digest("utf8")).toThrow(
/Unsupported digest encoding/,
);
});
});
describe("digestAsync", () => {
it("SHA-256 async matches the sync result", async () => {
const h = createHash("sha256");
h.update("abc");
expect(await h.digestAsync("hex")).toBe(VECTORS.SHA256.abc);
});
it("MD5 async falls back to the sync hasher (SubtleCrypto doesn't support it)", async () => {
const h = createHash("md5");
h.update("abc");
expect(await h.digestAsync("hex")).toBe(VECTORS.MD5.abc);
});
});

View File

@@ -0,0 +1,11 @@
import { randomBytes } from "./random-bytes.js";
import { createHash } from "./create-hash.js";
import { scrypt } from "./scrypt.js";
import { randomUUID } from "./random-uuid.js";
export const cryptoShim = {
randomBytes,
createHash,
scrypt,
randomUUID,
};

View File

@@ -0,0 +1,20 @@
export function randomBytes(size) {
const buf = new Uint8Array(size);
crypto.getRandomValues(buf);
buf.toString = function (encoding) {
if (encoding === "hex") {
return Array.from(this)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
if (encoding === "base64") {
return btoa(String.fromCharCode(...this));
}
return new TextDecoder().decode(this);
};
return buf;
}

View File

@@ -0,0 +1,3 @@
export function randomUUID() {
return crypto.randomUUID();
}

View File

@@ -0,0 +1,26 @@
export function scrypt(password, salt, keylen, options, callback) {
if (typeof options === "function") {
callback = options;
options = {};
}
const N = options?.N || 32768;
const r = options?.r || 8;
const p = options?.p || 1;
if (window.scrypt && window.scrypt.scrypt) {
const pwBytes =
typeof password === "string"
? new TextEncoder().encode(password)
: password;
const saltBytes =
typeof salt === "string" ? new TextEncoder().encode(salt) : salt;
window.scrypt
.scrypt(pwBytes, saltBytes, N, r, p, keylen)
.then((result) => callback(null, new Uint8Array(result)))
.catch((err) => callback(err));
} else {
callback(new Error("scrypt not available"));
}
}

View File

@@ -0,0 +1,9 @@
// Injects a link to the CSS overrides stylesheet served from /assets/overrides.css.
export function installCssOverrides() {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/assets/overrides.css";
link.setAttribute("data-ignis", "css-overrides");
document.head.appendChild(link);
}

View File

@@ -0,0 +1,47 @@
const DEBUG = true;
const _accessLog = new Map(); // "module.property" -> count
export function wrapWithProxy(obj, name) {
if (!DEBUG || !obj || typeof obj !== "object") {
return obj;
}
return new Proxy(obj, {
get(target, prop) {
if (
typeof prop === "string" &&
prop !== "then" &&
prop !== "toJSON" &&
!prop.startsWith("_")
) {
const key = `${name}.${prop}`;
_accessLog.set(key, (_accessLog.get(key) || 0) + 1);
if (!(prop in target)) {
console.warn(`[shim:MISS] ${key} - property not found on shim`);
}
}
return target[prop];
},
});
}
export function installDebugHelpers(rawRegistry) {
window.__shimLog = function () {
const sorted = [..._accessLog.entries()].sort((a, b) => b[1] - a[1]);
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
};
window.__shimMisses = function () {
const sorted = [..._accessLog.entries()]
.filter(([k]) => {
const [mod, prop] = k.split(".");
const shim = rawRegistry[mod];
return shim && !(prop in shim);
})
.sort((a, b) => b[1] - a[1]);
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
};
}

61
packages/shim/src/demo.js Normal file
View File

@@ -0,0 +1,61 @@
// Client-side demo mode hooks.
//
// Detects demo mode via the body data attribute the server stamps in buildIndexHtml.
// Pre-trusts vaults so Obsidian skips its first-run "Trust author" dialog, and bridges no-vault landing to /api/demo/provision.
export function isDemoMode() {
return (
typeof document !== "undefined" &&
document.body &&
document.body.dataset.demoMode === "true"
);
}
// Demo vaults are provisioned from our own template, never from an unknown source.
export function autoTrustDemoVaults(vaultList) {
if (!isDemoMode() || !Array.isArray(vaultList)) {
return;
}
for (const v of vaultList) {
if (v && v.id) {
localStorage.setItem("enable-plugin-" + v.id, "true");
}
}
}
// In demo mode with no vault selected, ask the server to provision one and reload at ?vault=<name>.
// Sync XHR so we block before Obsidian boots. Returns true if navigation is in progress (caller should halt init).
export function maybeProvisionDemoVault() {
if (!isDemoMode()) {
return false;
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("vault")) {
return false;
}
try {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/demo/provision", false);
xhr.send();
if (xhr.status === 200) {
const { vault } = JSON.parse(xhr.responseText);
if (vault) {
// Pre-trust before redirect.
localStorage.setItem("enable-plugin-" + vault, "true");
window.location.replace("/?vault=" + encodeURIComponent(vault));
return true;
}
}
} catch (e) {
console.warn("[ignis] Demo provision failed:", e);
}
return false;
}

View File

@@ -0,0 +1,51 @@
import { ipcRenderer } from "./ipc-renderer.js";
import { webFrame } from "./web-frame.js";
import { remoteShim } from "./remote/index.js";
import { nativeImageShim } from "./remote/native-image.js";
import { clipboardShim } from "./remote/clipboard.js";
export const electronShim = {
ipcRenderer,
webFrame,
remote: remoteShim,
nativeImage: nativeImageShim,
clipboard: clipboardShim,
safeStorage: {
isEncryptionAvailable() {
return false;
},
encryptString(plainText) {
return Buffer.from(plainText);
},
decryptString(encrypted) {
return encrypted.toString();
},
},
webUtils: {
getPathForFile(file) {
return "";
},
},
deprecate: {
function(fn, name) {
return fn;
},
event(emitter, name) {},
removeFunction(fn, name) {
return fn;
},
log(message) {
console.log("[electron:deprecate]", message);
},
warn(oldName, newName) {},
promisify(fn) {
return fn;
},
renameFunction(fn, newName) {
return fn;
},
},
};

View File

@@ -0,0 +1,260 @@
import { showVaultManager } from "../ui-registry.js";
import { vaultService } from "@ignis/services";
const listeners = new Map();
const syncHandlers = {
vault: () => window.__vaultConfig || { id: "default-vault", path: "/" },
version: () => window.__obsidianVersion || "0.0.0",
"is-dev": () => false,
"file-url": () =>
"/vault-files/" + encodeURIComponent(window.__currentVaultId || "") + "/",
"disable-update": () => true,
update: () => "",
"disable-gpu": () => false,
frame: () => null,
"set-icon": () => null,
"get-icon": () => null,
relaunch: () => {
window.location.reload();
return null;
},
starter: () => {
showVaultManager();
return null;
},
help: () => {
window.open("https://help.obsidian.md/", "_blank");
return null;
},
sandbox: () => null,
"copy-asar": () => false,
"check-update": () => null,
"vault-list": () => {
const result = {};
for (const v of window.__vaultList || []) {
result[v.id] = {
path: "/" + v.id,
ts: Date.now(),
open: v.id === vaultService.getCurrentVaultId(),
};
}
return result;
},
"vault-open": (vaultPath, newWindow) => {
const id = (vaultPath || "").replace(/^\/+/, "");
const vault = (window.__vaultList || []).find((v) => v.id === id);
if (!vault && id) {
if (!vaultService.createVaultSync(id)) {
return "Failed to create vault";
}
}
vaultService.openVault(id);
return true;
},
"vault-remove": (vaultPath) => {
const id = (vaultPath || "").replace(/^\/+/, "");
return vaultService.deleteVaultSync(id);
},
"vault-move": (oldPath, newPath) => {
return "Moving vaults is not supported in the web version";
},
"vault-message": () => null,
"get-default-vault-path": () => "/My Vault",
"get-documents-path": () => "/",
"desktop-dir": () => "/desktop",
"documents-dir": () => "/documents",
resources: () => "",
};
function arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
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);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
async function handleRequestUrl(requestId, request) {
try {
let body = request.body;
let binary = false;
if (body instanceof ArrayBuffer) {
body = arrayBufferToBase64(body);
binary = true;
}
const res = await fetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
contentType: request.contentType,
body,
binary,
}),
});
const proxyResult = await res.json();
if (!res.ok) {
ipcRenderer._emit(requestId, {
error: proxyResult.error || "Proxy request failed",
});
return;
}
// Electron's e.reply(requestId, data) sends on the requestId channel
ipcRenderer._emit(requestId, {
status: proxyResult.status,
headers: proxyResult.headers,
body: base64ToArrayBuffer(proxyResult.body),
});
} catch (e) {
ipcRenderer._emit(requestId, {
error: e.message,
});
}
}
export const ipcRenderer = {
send(channel, ...args) {
console.log("[shim:ipcRenderer] send:", channel, args);
if (channel === "context-menu") {
queueMicrotask(() =>
ipcRenderer._emit("context-menu", {
webContentsId: 1,
editFlags: { canCut: true, canCopy: true, canPaste: true },
}),
);
return;
}
if (channel === "request-url") {
const [requestId, request] = args;
handleRequestUrl(requestId, request);
return;
}
if (channel === "print-to-pdf") {
const iframe = window.__popupIframe;
if (iframe) {
setTimeout(() => {
iframe.contentWindow.print();
setTimeout(() => {
iframe.contentWindow.close();
ipcRenderer._emit("print-to-pdf", { success: true });
}, 500);
}, 200);
} else {
window.print();
queueMicrotask(() => {
ipcRenderer._emit("print-to-pdf", { success: true });
});
}
return;
}
},
sendSync(channel, ...args) {
console.log("[shim:ipcRenderer] sendSync:", channel, args);
if (syncHandlers[channel]) {
return syncHandlers[channel](...args);
}
console.warn("[shim:ipcRenderer] Unhandled sendSync channel:", channel);
return null;
},
on(channel, listener) {
if (!listeners.has(channel)) {
listeners.set(channel, []);
}
listeners.get(channel).push(listener);
return ipcRenderer;
},
once(channel, listener) {
const wrapped = (...args) => {
ipcRenderer.removeListener(channel, wrapped);
listener(...args);
};
return ipcRenderer.on(channel, wrapped);
},
removeListener(channel, listener) {
const arr = listeners.get(channel);
if (arr) {
const idx = arr.indexOf(listener);
if (idx >= 0) {
arr.splice(idx, 1);
}
}
return ipcRenderer;
},
removeAllListeners(channel) {
if (channel) {
listeners.delete(channel);
} else {
listeners.clear();
}
return ipcRenderer;
},
_emit(channel, ...args) {
const arr = listeners.get(channel);
if (arr) {
for (const fn of arr) {
fn({}, ...args);
}
}
},
};

View File

@@ -0,0 +1,43 @@
export const appShim = {
getPath(name) {
const paths = {
userData: "/.obsidian",
home: "/",
documents: "/documents",
desktop: "/desktop",
temp: "/tmp",
appData: "/.obsidian",
};
return paths[name] || "/";
},
getVersion() {
return window.__obsidianVersion || "0.0.0";
},
getName() {
return "Obsidian";
},
getLocale() {
return navigator.language || "en-US";
},
isPackaged: true,
quit() {
console.log("[shim:app] quit (stub)");
},
relaunch() {
window.location.reload();
},
whenReady() {
return Promise.resolve();
},
on() {},
once() {},
removeListener() {},
};

View File

@@ -0,0 +1,64 @@
export const clipboardShim = {
readText() {
return "";
},
writeText(text) {
navigator.clipboard.writeText(text).catch((e) => {
console.warn("[shim:clipboard] writeText failed:", e);
});
},
readHTML() {
return "";
},
writeHTML(html) {
navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([html], { type: "text/plain" }),
}),
])
.catch((e) => {
console.warn("[shim:clipboard] writeHTML failed:", e);
});
},
readImage() {
return { isEmpty: () => true, toPNG: () => new Uint8Array(0) };
},
writeImage(image) {
if (!image || image.isEmpty()) {
return;
}
const pngData = image.toPNG();
if (!pngData || pngData.length === 0) {
return;
}
const blob = new Blob([pngData], { type: "image/png" });
navigator.clipboard
.write([new ClipboardItem({ "image/png": blob })])
.catch((e) => {
console.warn("[shim:clipboard] writeImage failed:", e);
});
},
has(format) {
return false;
},
read(format) {
return "";
},
clear() {
navigator.clipboard.writeText("").catch(() => {});
},
};

View File

@@ -0,0 +1,259 @@
import {
showMessageDialog,
showConfirmDialog,
showPromptDialog,
} from "../../ui-registry.js";
import { inputCacheSet, inputCacheDelete } from "../../fs/input-cache.js";
const IMPORTS_DIR = ".obsidian/imports";
const STAGED_TTL_MS = 120_000; // 2 minutes
let staged = { paths: [], fingerprint: null, timestamp: 0 };
function getCallerFingerprint() {
const stack = new Error().stack || "";
const frames = stack
.split("\n")
.filter((l) => !l.includes("shim-loader") && !l.includes("dialog.js"));
return frames.slice(0, 3).join("|");
}
function clearStagedFiles() {
if (staged.paths.length === 0) return;
console.log("[shim:dialog] Clearing expired staged files");
for (const p of staged.paths) {
inputCacheDelete(p.replace(/^\//, ""));
}
staged = { paths: [], fingerprint: null, timestamp: 0 };
}
function buildAcceptString(filters) {
if (!filters || filters.length === 0) {
return "";
}
const extensions = filters.flatMap((f) => f.extensions || []);
if (extensions.includes("*")) {
return "";
}
return extensions.map((ext) => "." + ext).join(",");
}
function pickFiles(accept, multiple) {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple;
input.style.display = "none";
if (accept) {
input.accept = accept;
}
input.addEventListener("change", () => {
const files = Array.from(input.files || []);
input.remove();
resolve(files);
});
// User closed the picker without selecting
input.addEventListener("cancel", () => {
input.remove();
resolve([]);
});
document.body.appendChild(input);
input.click();
});
}
async function cacheToImports(file) {
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
const targetPath = IMPORTS_DIR + "/" + file.name;
inputCacheSet(targetPath, bytes);
return "/" + targetPath;
}
async function startWorkaroundFlow(options, fingerprint) {
const properties = options?.properties || [];
const multiple = properties.includes("multiSelections");
const accept = buildAcceptString(options?.filters);
const files = await pickFiles(accept, multiple);
if (files.length === 0) {
return;
}
const paths = [];
for (const file of files) {
const vaultPath = await cacheToImports(file);
paths.push(vaultPath);
}
staged = { paths, fingerprint, timestamp: Date.now() };
const names = paths.map((p) => p.split("/").pop()).join(", ");
console.log("[shim:dialog] Files staged for caller:", fingerprint);
await showMessageDialog(
"Files Ready",
`Staged: ${names}\n\nPlease retry the action that brought you here. ` +
"The files will be provided automatically.",
);
}
export const dialogShim = {
async showOpenDialog(browserWindow, options) {
if (typeof browserWindow === "object" && !options) {
options = browserWindow;
}
const properties = options?.properties || [];
const multiple = properties.includes("multiSelections");
const accept = buildAcceptString(options?.filters);
console.log("[shim:dialog] showOpenDialog - opening browser file picker");
const files = await pickFiles(accept, multiple);
if (files.length === 0) {
return { canceled: true, filePaths: [] };
}
const filePaths = [];
for (const file of files) {
const vaultPath = await cacheToImports(file);
filePaths.push(vaultPath);
}
console.log("[shim:dialog] showOpenDialog - cached:", filePaths);
return { canceled: false, filePaths };
},
showOpenDialogSync(browserWindow, options) {
if (typeof browserWindow === "object" && !options) {
options = browserWindow;
}
// If files were staged from a previous workaround, validate and return them
if (staged.paths.length > 0) {
const elapsed = Date.now() - staged.timestamp;
const fingerprint = getCallerFingerprint();
const fingerprintMatch = fingerprint === staged.fingerprint;
const expired = elapsed > STAGED_TTL_MS;
if (expired) {
console.warn("[shim:dialog] Staged files expired after", elapsed, "ms");
clearStagedFiles();
} else if (!fingerprintMatch) {
console.warn(
"[shim:dialog] Staged files caller mismatch - ignoring",
"\n expected:",
staged.fingerprint,
"\n got:",
fingerprint,
);
} else {
const paths = staged.paths;
staged = { paths: [], fingerprint: null, timestamp: 0 };
console.log(
"[shim:dialog] showOpenDialogSync - returning staged files:",
paths,
);
return paths;
}
}
console.warn(
"[shim:dialog] showOpenDialogSync requires workaround in browser context",
);
// Capture fingerprint here where the plugin's call stack is still visible
const callerFingerprint = getCallerFingerprint();
// Fire-and-forget: show warning, then optionally start workaround flow
showConfirmDialog(
"Feature Not Available",
"This action requires a native file picker which is not available in the browser.",
"A workaround is available: select your files first, then retry the action. " +
"They will be provided automatically.\n\n" +
"Note: individual files must be under 200 MB.",
"Select Files",
).then((confirmed) => {
if (confirmed) {
startWorkaroundFlow(options, callerFingerprint);
}
});
return undefined;
},
async showSaveDialog(browserWindow, options) {
if (typeof browserWindow === "object" && !options) {
options = browserWindow;
}
const defaultName =
options?.defaultPath?.split(/[\/\\]/).pop() || "download";
const name = await showPromptDialog(
"Save File",
"Save as:",
"filename",
defaultName,
"Save",
);
if (!name) {
return { canceled: true, filePath: undefined };
}
return { canceled: false, filePath: "/downloads/" + name };
},
async showMessageBox(browserWindow, options) {
if (typeof browserWindow === "object" && !options) {
options = browserWindow;
}
console.log("[shim:dialog] showMessageBox:", options);
const message = options.message || "";
const detail = options.detail || "";
const buttons = options.buttons || ["OK"];
const fullMessage = message + (detail ? "\n\n" + detail : "");
if (buttons.length <= 1) {
await showMessageDialog(options.title || "Message", fullMessage);
return { response: 0, checkboxChecked: false };
}
const result = await showConfirmDialog(
options.title || "Confirm",
message,
detail,
buttons[0],
);
return {
response: result ? 0 : 1,
checkboxChecked: false,
};
},
showErrorBox(title, content) {
console.error("[shim:dialog] Error:", title, content);
showMessageDialog(title, content);
},
};

View File

@@ -0,0 +1,50 @@
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,
shell: shellShim,
dialog: dialogShim,
Menu: menuShim,
MenuItem: menuItemShim,
app: appShim,
BrowserWindow: windowShim,
nativeTheme: themeShim,
session: sessionShim,
systemPreferences: systemPreferencesShim,
screen: screenShim,
nativeImage: nativeImageShim,
Notification: notificationShim,
safeStorage: {
isEncryptionAvailable() {
return false;
},
encryptString(plainText) {
return Buffer.from(plainText);
},
decryptString(encrypted) {
return encrypted.toString();
},
},
getCurrentWindow() {
return windowShim._current();
},
webContents: webContentsShim,
getCurrentWebContents() {
return webContentsShim._current();
},
};

View File

@@ -0,0 +1,53 @@
export class menuShim {
constructor() {
this.items = [];
}
static buildFromTemplate(template) {
const menu = new menuShim();
menu.items = (template || []).map((item) => new menuItemShim(item));
return menu;
}
static setApplicationMenu(menu) {
console.log("[shim:Menu] setApplicationMenu (stub)");
}
static getApplicationMenu() {
return null;
}
popup(options) {
console.log("[shim:Menu] popup (stub)", options);
}
append(menuItem) {
this.items.push(menuItem);
}
insert(pos, menuItem) {
this.items.splice(pos, 0, menuItem);
}
closePopup() {}
}
export class menuItemShim {
constructor(options = {}) {
this.label = options.label || "";
this.type = options.type || "normal";
this.click = options.click || null;
this.role = options.role || null;
this.accelerator = options.accelerator || "";
this.enabled = options.enabled !== false;
this.visible = options.visible !== false;
this.checked = !!options.checked;
this.submenu = options.submenu
? menuShim.buildFromTemplate(
Array.isArray(options.submenu) ? options.submenu : [],
)
: null;
this.id = options.id || "";
}
}

View File

@@ -0,0 +1,83 @@
function createImage(buffer, mimeType) {
return {
_buffer: buffer,
_mimeType: mimeType || "image/png",
isEmpty() {
return !buffer || buffer.length === 0;
},
getSize() {
return { width: 0, height: 0 };
},
toPNG() {
return buffer || new Uint8Array(0);
},
toJPEG(quality) {
return buffer || new Uint8Array(0);
},
toDataURL() {
if (!buffer || buffer.length === 0) {
return "";
}
const bytes =
buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return `data:${this._mimeType};base64,${btoa(binary)}`;
},
toBitmap() {
return buffer || new Uint8Array(0);
},
getBitmap() {
return buffer || new Uint8Array(0);
},
};
}
export const nativeImageShim = {
createFromBuffer(buffer, options) {
return createImage(buffer, options?.mimeType);
},
createFromPath(filePath) {
return createImage(new Uint8Array(0));
},
createEmpty() {
return createImage(new Uint8Array(0));
},
createFromDataURL(dataURL) {
if (!dataURL || !dataURL.startsWith("data:")) {
return createImage(new Uint8Array(0));
}
const parts = dataURL.split(",");
const mimeMatch = parts[0].match(/data:([^;]+)/);
const mimeType = mimeMatch ? mimeMatch[1] : "image/png";
try {
const binary = atob(parts[1]);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return createImage(bytes, mimeType);
} catch {
return createImage(new Uint8Array(0));
}
},
};

View File

@@ -0,0 +1,37 @@
export class notificationShim {
constructor(options = {}) {
this.title = options.title || "";
this.body = options.body || "";
this.silent = options.silent || false;
this._handlers = {};
}
show() {
if ("Notification" in window && Notification.permission === "granted") {
new Notification(this.title, { body: this.body, silent: this.silent });
} else if (
"Notification" in window &&
Notification.permission !== "denied"
) {
Notification.requestPermission().then((perm) => {
if (perm === "granted") {
new Notification(this.title, {
body: this.body,
silent: this.silent,
});
}
});
}
}
close() {}
on(event, handler) {
this._handlers[event] = handler;
return this;
}
static isSupported() {
return "Notification" in window;
}
}

View File

@@ -0,0 +1,40 @@
export const screenShim = {
getPrimaryDisplay() {
return {
workAreaSize: {
width: window.screen.availWidth,
height: window.screen.availHeight,
},
size: { width: window.screen.width, height: window.screen.height },
scaleFactor: window.devicePixelRatio || 1,
bounds: {
x: 0,
y: 0,
width: window.screen.width,
height: window.screen.height,
},
workArea: {
x: 0,
y: 0,
width: window.screen.availWidth,
height: window.screen.availHeight,
},
};
},
getAllDisplays() {
return [screenShim.getPrimaryDisplay()];
},
getDisplayNearestPoint(point) {
return screenShim.getPrimaryDisplay();
},
getCursorScreenPoint() {
return { x: 0, y: 0 };
},
on() {},
once() {},
removeListener() {},
};

View File

@@ -0,0 +1,20 @@
export const sessionShim = {
defaultSession: {
clearCache() {
return Promise.resolve();
},
clearStorageData() {
return Promise.resolve();
},
setSpellCheckerLanguages(langs) {},
getSpellCheckerLanguages() {
return [];
},
on() {},
once() {},
removeListener() {},
},
};

View File

@@ -0,0 +1,15 @@
export const shellShim = {
openExternal(url) {
window.open(url, "_blank");
return Promise.resolve();
},
openPath(filePath) {
console.log("[shim:shell] openPath (stub):", filePath);
return Promise.resolve("");
},
showItemInFolder(filePath) {
console.log("[shim:shell] showItemInFolder (stub):", filePath);
},
};

View File

@@ -0,0 +1,21 @@
export const systemPreferencesShim = {
getAccentColor() {
return "0078d4"; // Default Windows accent blue
},
isAeroGlassEnabled() {
return false;
},
getMediaAccessStatus(mediaType) {
return "granted";
},
askForMediaAccess(mediaType) {
return Promise.resolve(true);
},
on() {},
once() {},
removeListener() {},
};

View File

@@ -0,0 +1,64 @@
const listeners = [];
const darkQuery =
typeof window !== "undefined"
? window.matchMedia("(prefers-color-scheme: dark)")
: null;
if (darkQuery?.addEventListener) {
darkQuery.addEventListener("change", () => {
for (const fn of listeners) {
fn();
}
});
}
export const themeShim = {
get shouldUseDarkColors() {
return darkQuery ? darkQuery.matches : true;
},
get themeSource() {
return "system";
},
set themeSource(val) {
// No-op in browser; theme is controlled by OS
},
on(event, callback) {
if (event === "updated") {
listeners.push(callback);
}
return themeShim;
},
once(event, callback) {
if (event === "updated") {
const wrapped = () => {
const idx = listeners.indexOf(wrapped);
if (idx >= 0) {
listeners.splice(idx, 1);
}
callback();
};
listeners.push(wrapped);
}
return themeShim;
},
removeListener(event, callback) {
const idx = listeners.indexOf(callback);
if (idx >= 0) {
listeners.splice(idx, 1);
}
return themeShim;
},
removeAllListeners() {
listeners.length = 0;
return themeShim;
},
};

View File

@@ -0,0 +1,419 @@
const currentWindowState = {
title: "Obsidian",
isMaximized: false,
isMinimized: false,
isFullScreen: false,
isAlwaysOnTop: false,
bounds: { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight },
focusTime: Date.now(),
};
const currentWindow = {
isMaximized: () => currentWindowState.isMaximized,
isMinimized: () => currentWindowState.isMinimized,
isFullScreen: () => !!document.fullscreenElement,
isAlwaysOnTop: () => currentWindowState.isAlwaysOnTop,
isFocused: () => document.hasFocus(),
isVisible: () => true,
isDestroyed: () => false,
minimize() {
console.log("[shim:window] minimize (stub)");
},
maximize() {
currentWindowState.isMaximized = true;
},
unmaximize() {
currentWindowState.isMaximized = false;
},
restore() {
currentWindowState.isMinimized = false;
},
close() {
console.log("[shim:window] close (stub)");
},
focus() {
window.focus();
},
show() {},
hide() {},
setTitle(title) {
currentWindowState.title = title;
document.title = title;
},
getTitle() {
return currentWindowState.title;
},
setAlwaysOnTop(flag) {
currentWindowState.isAlwaysOnTop = flag;
},
setFullScreen(flag) {
if (flag) {
document.documentElement.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
},
getBounds() {
return {
x: window.screenX,
y: window.screenY,
width: window.innerWidth,
height: window.innerHeight,
};
},
setBounds(bounds) {
console.log("[shim:window] setBounds (stub):", bounds);
},
setSize(width, height) {},
setPosition(x, y) {},
center() {},
setTrafficLightPosition() {},
setWindowButtonPosition() {},
get webContents() {
return webContentsShim._current();
},
get menuBarVisible() {
return false;
},
set menuBarVisible(v) {},
get loaded() {
return true;
},
set loaded(v) {},
get focusTime() {
return currentWindowState.focusTime;
},
set focusTime(v) {
currentWindowState.focusTime = v;
},
on(event, handler) {
if (event === "focus") {
window.addEventListener("focus", handler);
} else if (event === "blur") {
window.addEventListener("blur", handler);
} else if (event === "resize") {
window.addEventListener("resize", handler);
}
return currentWindow;
},
once(event, handler) {
if (event === "focus") {
window.addEventListener("focus", handler, { once: true });
}
return currentWindow;
},
removeListener() {
return currentWindow;
},
removeAllListeners() {
return currentWindow;
},
};
const currentWebContents = {
id: 1,
_zoomLevel: 0,
get zoomLevel() {
return this._zoomLevel;
},
set zoomLevel(v) {
this._zoomLevel = v;
},
executeJavaScript(code) {
try {
return Promise.resolve(eval(code));
} catch (e) {
return Promise.reject(e);
}
},
getZoomFactor() {
return Math.pow(1.2, this._zoomLevel);
},
getZoomLevel() {
return this._zoomLevel;
},
setZoomLevel(v) {
this._zoomLevel = v;
},
isDevToolsOpened() {
return false;
},
openDevTools() {},
setWindowOpenHandler(handler) {
this._windowOpenHandler = handler;
},
printToPDF(options) {
return new Promise((resolve) => {
window.print();
resolve(Buffer.from([]));
});
},
capturePage(rect) {
// TODO: could use html2canvas
console.log("[shim:webContents] capturePage (stub)");
return Promise.resolve({
toPNG: () => new Uint8Array(0),
toJPEG: () => new Uint8Array(0),
});
},
undo() {},
redo() {},
cut() {
document.execCommand("cut");
},
copy() {
document.execCommand("copy");
},
paste() {
navigator.clipboard
.read()
.then(async (items) => {
const dt = new DataTransfer();
let hasFiles = false;
for (const item of items) {
for (const type of item.types) {
const blob = await item.getType(type);
if (type.startsWith("text/")) {
const text = await blob.text();
dt.items.add(text, type);
} else {
const ext = type.split("/")[1] || "bin";
dt.items.add(
new File([blob], `pasted-image.${ext}`, { type }),
);
hasFiles = true;
}
}
}
const pasteEvent = new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData: dt,
});
const target = document.activeElement || document.body;
target.dispatchEvent(pasteEvent);
})
.catch((e) => {
console.warn("[shim:webContents] paste failed:", e);
});
},
pasteAndMatchStyle() {
navigator.clipboard
.read()
.then(async (items) => {
for (const item of items) {
if (item.types.includes("text/plain")) {
const blob = await item.getType("text/plain");
const text = await blob.text();
document.execCommand("insertText", false, text);
return;
}
}
})
.catch((e) => {
console.warn("[shim:webContents] pasteAndMatchStyle failed:", e);
});
},
replaceMisspelling(word) {},
session: {
availableSpellCheckerLanguages: [],
setSpellCheckerLanguages(langs) {},
addWordToSpellCheckerDictionary(word) {},
},
setSpellCheckerLanguages(langs) {},
on(event, handler) {
return currentWebContents;
},
once(event, handler) {
return currentWebContents;
},
removeListener() {
return currentWebContents;
},
get isSecured() {
return true;
},
set isSecured(v) {},
};
// Popup tracking for PDF export etc.
let _popupWindow = null;
let _popupWebContents = null;
export function registerPopupWindow() {
_popupWebContents = {
id: 2,
_zoomLevel: 0,
getZoomFactor() {
return 1;
},
getZoomLevel() {
return 0;
},
setZoomLevel() {},
printToPDF(options) {
return Promise.resolve(Buffer.from([]));
},
executeJavaScript(code) {
try {
return Promise.resolve(eval(code));
} catch (e) {
return Promise.reject(e);
}
},
on() {
return _popupWebContents;
},
once() {
return _popupWebContents;
},
removeListener() {
return _popupWebContents;
},
isDestroyed() {
return false;
},
isFocused() {
return false;
},
};
_popupWindow = {
id: 2,
webContents: _popupWebContents,
isDestroyed() {
return false;
},
isFocused() {
return false;
},
isVisible() {
return false;
},
close() {
_popupWindow = null;
_popupWebContents = null;
},
destroy() {
_popupWindow = null;
_popupWebContents = null;
},
on() {
return _popupWindow;
},
once() {
return _popupWindow;
},
removeListener() {
return _popupWindow;
},
};
return _popupWindow;
}
export function unregisterPopupWindow() {
_popupWindow = null;
_popupWebContents = null;
}
export const windowShim = {
_current: () => currentWindow,
getFocusedWindow() {
return currentWindow;
},
getAllWindows() {
const wins = [currentWindow];
if (_popupWindow) {
wins.push(_popupWindow);
}
return wins;
},
fromId(id) {
if (id === currentWindow.id) {
return currentWindow;
}
if (_popupWindow && id === _popupWindow.id) {
return _popupWindow;
}
return null;
},
fromWebContents(wc) {
if (wc === currentWebContents) {
return currentWindow;
}
if (_popupWebContents && wc === _popupWebContents) {
return _popupWindow;
}
return null;
},
};
export const webContentsShim = {
_current: () => currentWebContents,
fromId(id) {
if (id === currentWebContents.id) {
return currentWebContents;
}
if (_popupWebContents && id === _popupWebContents.id) {
return _popupWebContents;
}
return null;
},
getAllWebContents() {
const wcs = [currentWebContents];
if (_popupWebContents) {
wcs.push(_popupWebContents);
}
return wcs;
},
};

View File

@@ -0,0 +1,24 @@
let currentZoom = 0;
export const webFrame = {
getZoomLevel() {
return currentZoom;
},
setZoomLevel(level) {
currentZoom = level;
// Approximate Electron's zoom behavior via CSS zoom
// Electron zoom level 0 = 100%, each step is ~20%
const scale = Math.pow(1.2, level);
document.body.style.zoom = scale;
},
getZoomFactor() {
return Math.pow(1.2, currentZoom);
},
setZoomFactor(factor) {
currentZoom = Math.log(factor) / Math.log(1.2);
document.body.style.zoom = factor;
},
};

View File

@@ -0,0 +1,20 @@
// Node.js fs.constants equivalents
export const constants = {
F_OK: 0,
R_OK: 4,
W_OK: 2,
X_OK: 1,
COPYFILE_EXCL: 1,
COPYFILE_FICLONE: 2,
COPYFILE_FICLONE_FORCE: 4,
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_EXCL: 128,
O_TRUNC: 512,
O_APPEND: 1024,
};

View File

@@ -0,0 +1,95 @@
// In-memory content cache with simple LRU eviction
// Stores file content fetched from the server.
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export class ContentCache {
constructor(maxSize = DEFAULT_MAX_SIZE) {
this._cache = new Map(); // path -> { data, size, accessedAt }
this._currentSize = 0;
this._maxSize = maxSize;
}
has(path) {
return this._cache.has(this._normalize(path));
}
get(path) {
const entry = this._cache.get(this._normalize(path));
if (entry) {
entry.accessedAt = Date.now();
return entry.data;
}
return null;
}
set(path, data) {
const norm = this._normalize(path);
const size = data ? data.length || data.byteLength || 0 : 0;
// Remove old entry if replacing
if (this._cache.has(norm)) {
this._currentSize -= this._cache.get(norm).size;
}
// Evict LRU entries if needed
while (this._currentSize + size > this._maxSize && this._cache.size > 0) {
this._evictOne();
}
this._cache.set(norm, { data, size, accessedAt: Date.now() });
this._currentSize += size;
}
delete(path) {
const norm = this._normalize(path);
const entry = this._cache.get(norm);
if (entry) {
this._currentSize -= entry.size;
this._cache.delete(norm);
}
}
// Invalidate a path (remove from cache so next read fetches fresh)
invalidate(path) {
this.delete(path);
}
clear() {
this._cache.clear();
this._currentSize = 0;
}
get size() {
return this._cache.size;
}
get currentBytes() {
return this._currentSize;
}
_evictOne() {
let oldest = null;
let oldestTime = Infinity;
for (const [key, entry] of this._cache) {
if (entry.accessedAt < oldestTime) {
oldest = key;
oldestTime = entry.accessedAt;
}
}
if (oldest) {
this.delete(oldest);
}
}
_normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from "vitest";
import { ContentCache } from "./content-cache.js";
// -- Size accounting ---------------------------------------------------
describe("ContentCache size accounting", () => {
it("set increases currentBytes by data length", () => {
const cache = new ContentCache(1024);
cache.set("a.md", "hello"); // 5 bytes
expect(cache.currentBytes).toBe(5);
});
it("delete returns currentBytes to 0", () => {
const cache = new ContentCache(1024);
cache.set("a.md", "hello");
cache.delete("a.md");
expect(cache.currentBytes).toBe(0);
});
it("replacing an entry reflects the new size", () => {
const cache = new ContentCache(1024);
cache.set("a.md", "short");
cache.set("a.md", "a much longer string");
expect(cache.currentBytes).toBe("a much longer string".length);
});
it("deleting one of several entries leaves the sum of the rest", () => {
const cache = new ContentCache(1024);
cache.set("a.md", "aaa"); // 3
cache.set("b.md", "bbbbb"); // 5
cache.set("c.md", "cc"); // 2
cache.delete("b.md");
expect(cache.currentBytes).toBe(5); // 3 + 2
});
});
// -- LRU eviction ------------------------------------------------------
describe("ContentCache LRU eviction", () => {
it("evicts the least-recently-accessed entry when full", () => {
let now = 1000;
vi.spyOn(Date, "now").mockImplementation(() => now++);
const cache = new ContentCache(10);
cache.set("a.md", "aaaa"); // 4
cache.set("b.md", "bbbb"); // 4
// At 8/10. Adding 4 more would exceed, so LRU (a.md) should be evicted.
cache.set("c.md", "cccc"); // 4
expect(cache.has("a.md")).toBe(false);
expect(cache.has("b.md")).toBe(true);
expect(cache.has("c.md")).toBe(true);
vi.restoreAllMocks();
});
it("accessing an entry refreshes it so it survives eviction", () => {
let now = 1000;
vi.spyOn(Date, "now").mockImplementation(() => now++);
const cache = new ContentCache(10);
cache.set("a.md", "aaaa"); // 4, accessedAt=1000
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)
expect(cache.has("a.md")).toBe(true);
expect(cache.has("b.md")).toBe(false);
expect(cache.has("c.md")).toBe(true);
vi.restoreAllMocks();
});
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
expect(cache.has("small.md")).toBe(false);
expect(cache.has("big.md")).toBe(true);
expect(cache.currentBytes).toBe(10);
});
});
// -- Path normalization ------------------------------------------------
describe("ContentCache path normalization", () => {
it("backslash and slash variants hit the same cache entry", () => {
const cache = new ContentCache(1024);
cache.set("foo\\bar\\baz.md", "content");
expect(cache.has("foo/bar/baz.md")).toBe(true);
expect(cache.get("foo/bar/baz.md")).toBe("content");
});
});

View File

@@ -0,0 +1,31 @@
// Shared echo suppression for file watcher.
// fs operations mark paths as "locally modified" so the watcher client
// can skip events that originated from this client.
const ECHO_SUPPRESS_MS = 1500;
const recentOps = new Map(); // normalized path -> timestamp
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
export function markLocalOp(path) {
recentOps.set(normalize(path), Date.now());
}
export function isRecentLocalOp(path) {
const norm = normalize(path);
const ts = recentOps.get(norm);
if (!ts) return false;
if (Date.now() - ts < ECHO_SUPPRESS_MS) {
return true;
}
recentOps.delete(norm);
return false;
}

173
packages/shim/src/fs/fd.js Normal file
View File

@@ -0,0 +1,173 @@
// File descriptor shim - maps fake integer fds to in-memory file buffers.
// Enables libraries like yauzl that use fs.open/fs.read/fs.close to seek
// around files without loading them via readFileSync upfront.
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { resolvePath } from "./transforms.js";
let nextFd = 100;
const openFiles = new Map();
export function createFdOps(metadataCache, contentCache, transport) {
function ensureData(path) {
// Check input cache first for files picked via browser file dialogs.
if (isInputCachePath(path)) {
const inputData = inputCacheGet(path);
if (inputData !== null) {
if (typeof inputData === "string") {
return new TextEncoder().encode(inputData);
}
return inputData;
}
}
const resolved = resolvePath(path);
const cached = contentCache.get(resolved);
if (cached !== null) {
if (typeof cached === "string") {
return new TextEncoder().encode(cached);
}
return cached;
}
// Synchronous fetch fallback
console.warn("[shim:fs] fd open cache miss, using sync XHR:", resolved);
const data = transport.readFileSync(resolved);
contentCache.set(resolved, data);
return data;
}
function getEntry(fd) {
const entry = openFiles.get(fd);
if (!entry) {
const err = new Error(`EBADF: bad file descriptor, fd ${fd}`);
err.code = "EBADF";
throw err;
}
return entry;
}
// --- Sync ---
function openSync(path, flags, mode) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path);
if (!hasInCache && !metadataCache.has(resolved)) {
const err = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
err.code = "ENOENT";
throw err;
}
const data = ensureData(path);
const fd = nextFd++;
openFiles.set(fd, { path: resolved, data });
return fd;
}
function readSync(fd, buffer, offset, length, position) {
const entry = getEntry(fd);
const available = Math.min(length, entry.data.length - position);
if (available <= 0) {
return 0;
}
const slice = entry.data.subarray(position, position + available);
buffer.set(slice, offset);
return available;
}
function closeSync(fd) {
openFiles.delete(fd);
}
function fstatSync(fd) {
const entry = getEntry(fd);
const stat = metadataCache.toStat(entry.path);
if (stat) {
return stat;
}
// Fallback: construct minimal stat from the buffer
return {
size: entry.data.length,
isFile: () => true,
isDirectory: () => false,
};
}
// --- Async (callback style) ---
function open(path, flags, modeOrCb, cb) {
if (typeof modeOrCb === "function") {
cb = modeOrCb;
}
try {
const fd = openSync(path, flags);
queueMicrotask(() => cb(null, fd));
} catch (e) {
queueMicrotask(() => cb(e));
}
}
function read(fd, buffer, offset, length, position, cb) {
try {
const bytesRead = readSync(fd, buffer, offset, length, position);
queueMicrotask(() => cb(null, bytesRead, buffer));
} catch (e) {
queueMicrotask(() => cb(e));
}
}
function close(fd, cb) {
try {
closeSync(fd);
if (cb) {
queueMicrotask(() => cb(null));
}
} catch (e) {
if (cb) {
queueMicrotask(() => cb(e));
}
}
}
function fstat(fd, optionsOrCb, cb) {
if (typeof optionsOrCb === "function") {
cb = optionsOrCb;
}
try {
const stat = fstatSync(fd);
queueMicrotask(() => cb(null, stat));
} catch (e) {
queueMicrotask(() => cb(e));
}
}
return {
openSync,
readSync,
closeSync,
fstatSync,
open,
read,
close,
fstat,
};
}

View File

@@ -0,0 +1,275 @@
import { describe, it, expect } from "vitest";
import { createFdOps } from "./fd.js";
function makeStubs(files = {}) {
const meta = {
has(p) {
return p in files;
},
toStat(p) {
if (!(p in files)) {
return null;
}
return {
size: files[p].length,
isFile: () => true,
isDirectory: () => false,
};
},
};
const content = {
_store: {},
get(p) {
return this._store[p] ?? null;
},
set(p, data) {
this._store[p] = data;
},
};
const transport = {
readFileSync(p) {
return files[p] ?? null;
},
};
// Pre-populate content cache so ensureData doesn't hit transport
for (const [p, data] of Object.entries(files)) {
content.set(p, data);
}
return { meta, content, transport };
}
function makeOps(files = {}) {
const { meta, content, transport } = makeStubs(files);
return createFdOps(meta, content, transport);
}
// -- openSync / closeSync lifecycle ------------------------------------
describe("fd openSync / closeSync lifecycle", () => {
it("open returns an integer fd", () => {
const ops = makeOps({ "a.md": new Uint8Array([1, 2, 3]) });
const fd = ops.openSync("a.md", "r");
expect(typeof fd).toBe("number");
expect(Number.isInteger(fd)).toBe(true);
});
it("multiple opens return distinct fds", () => {
const ops = makeOps({ "a.md": new Uint8Array([1]) });
const fd1 = ops.openSync("a.md", "r");
const fd2 = ops.openSync("a.md", "r");
expect(fd1).not.toBe(fd2);
});
it("close removes the fd", () => {
const ops = makeOps({ "a.md": new Uint8Array([1]) });
const fd = ops.openSync("a.md", "r");
ops.closeSync(fd);
expect(() => ops.readSync(fd, new Uint8Array(1), 0, 1, 0)).toThrow(
"EBADF",
);
});
it("open throws ENOENT for missing path", () => {
const ops = makeOps({});
expect(() => ops.openSync("nope.md", "r")).toThrow("ENOENT");
});
it("accessing a closed fd throws EBADF", () => {
const ops = makeOps({ "a.md": new Uint8Array([1]) });
const fd = ops.openSync("a.md", "r");
ops.closeSync(fd);
expect(() => ops.fstatSync(fd)).toThrow("EBADF");
});
});
// -- readSync ----------------------------------------------------------
describe("fd readSync", () => {
const data = new Uint8Array([10, 20, 30, 40, 50]);
function openData() {
const ops = makeOps({ "f.bin": data });
const fd = ops.openSync("f.bin", "r");
return { ops, fd };
}
it("read from position 0, full length", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(5);
const n = ops.readSync(fd, buf, 0, 5, 0);
expect(n).toBe(5);
expect(buf).toEqual(data);
});
it("read from mid-file position", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(3);
const n = ops.readSync(fd, buf, 0, 3, 2);
expect(n).toBe(3);
expect(buf).toEqual(new Uint8Array([30, 40, 50]));
});
it("read with length exceeding remaining data", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(10);
const n = ops.readSync(fd, buf, 0, 10, 3);
expect(n).toBe(2);
expect(buf[0]).toBe(40);
expect(buf[1]).toBe(50);
});
it("read at position === data.length returns 0 bytes", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(5);
const n = ops.readSync(fd, buf, 0, 5, 5);
expect(n).toBe(0);
});
it("read at position > data.length returns 0 bytes", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(5);
const n = ops.readSync(fd, buf, 0, 5, 100);
expect(n).toBe(0);
});
it("read with offset > 0 places data at correct position", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(6);
buf.fill(0);
const n = ops.readSync(fd, buf, 3, 2, 0);
expect(n).toBe(2);
expect(buf).toEqual(new Uint8Array([0, 0, 0, 10, 20, 0]));
});
it("target buffer is actually modified", () => {
const { ops, fd } = openData();
const buf = new Uint8Array(3);
buf.fill(255);
ops.readSync(fd, buf, 0, 2, 0);
expect(buf[0]).toBe(10);
expect(buf[1]).toBe(20);
expect(buf[2]).toBe(255); // untouched
});
});
// -- fstatSync ---------------------------------------------------------
describe("fd fstatSync", () => {
it("returns metadata from cache when available", () => {
const ops = makeOps({ "a.md": new Uint8Array([1, 2, 3]) });
const fd = ops.openSync("a.md", "r");
const stat = ops.fstatSync(fd);
expect(stat.size).toBe(3);
expect(stat.isFile()).toBe(true);
expect(stat.isDirectory()).toBe(false);
});
it("falls back to buffer length when metadata is missing", () => {
const { meta, content, transport } = makeStubs({
"a.md": new Uint8Array([1, 2, 3, 4]),
});
// Override toStat to return null, simulating missing metadata
meta.toStat = () => null;
const ops = createFdOps(meta, content, transport);
const fd = ops.openSync("a.md", "r");
const stat = ops.fstatSync(fd);
expect(stat.size).toBe(4);
expect(stat.isFile()).toBe(true);
});
});
// -- Async wrappers ----------------------------------------------------
describe("fd async wrappers", () => {
it("open() calls callback asynchronously", async () => {
const ops = makeOps({ "a.md": new Uint8Array([1]) });
let called = false;
const promise = new Promise((resolve, reject) => {
ops.open("a.md", "r", (err, fd) => {
called = true;
if (err) {
reject(err);
} else {
resolve(fd);
}
});
});
// Callback should not have fired synchronously
expect(called).toBe(false);
const fd = await promise;
expect(typeof fd).toBe("number");
});
it("open() error path calls cb(err)", async () => {
const ops = makeOps({});
const err = await new Promise((resolve) => {
ops.open("nope.md", "r", (e) => resolve(e));
});
expect(err).toBeTruthy();
expect(err.code).toBe("ENOENT");
});
it("read() delivers results via callback", async () => {
const ops = makeOps({ "a.md": new Uint8Array([10, 20]) });
const fd = await new Promise((resolve, reject) => {
ops.open("a.md", "r", (err, fd) => (err ? reject(err) : resolve(fd)));
});
const buf = new Uint8Array(2);
const bytesRead = await new Promise((resolve, reject) => {
ops.read(fd, buf, 0, 2, 0, (err, n) =>
err ? reject(err) : resolve(n),
);
});
expect(bytesRead).toBe(2);
expect(buf).toEqual(new Uint8Array([10, 20]));
});
it("close() calls callback asynchronously", async () => {
const ops = makeOps({ "a.md": new Uint8Array([1]) });
const fd = ops.openSync("a.md", "r");
const result = await new Promise((resolve) => {
ops.close(fd, (err) => resolve(err));
});
expect(result).toBe(null);
});
it("fstat() calls callback asynchronously", async () => {
const ops = makeOps({ "a.md": new Uint8Array([1, 2, 3]) });
const fd = ops.openSync("a.md", "r");
const stat = await new Promise((resolve, reject) => {
ops.fstat(fd, (err, s) => (err ? reject(err) : resolve(s)));
});
expect(stat.size).toBe(3);
});
it("fstat() error path calls cb(err) for bad fd", async () => {
const ops = makeOps({});
const err = await new Promise((resolve) => {
ops.fstat(99999, (e) => resolve(e));
});
expect(err).toBeTruthy();
expect(err.code).toBe("EBADF");
});
});

View File

@@ -0,0 +1,75 @@
import { MetadataCache } from "./metadata-cache.js";
import { ContentCache } from "./content-cache.js";
import { transport } from "./transport.js";
import { createFsPromises } from "./promises.js";
import { createFsSync } from "./sync.js";
import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js";
import { constants } from "./constants.js";
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
const metadataCache = new MetadataCache();
const contentCache = new ContentCache();
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport);
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch);
const fdOps = createFdOps(metadataCache, contentCache, transport);
export const fsShim = {
promises: fsPromises,
existsSync: fsSync.existsSync,
readFileSync: fsSync.readFileSync,
writeFileSync: fsSync.writeFileSync,
unlinkSync: fsSync.unlinkSync,
accessSync: fsSync.accessSync,
statSync: fsSync.statSync,
readdirSync: fsSync.readdirSync,
open: fdOps.open,
openSync: fdOps.openSync,
read: fdOps.read,
readSync: fdOps.readSync,
close: fdOps.close,
closeSync: fdOps.closeSync,
fstat: fdOps.fstat,
fstatSync: fdOps.fstatSync,
watch: fsWatch.watch,
constants,
invalidate(path) {
contentCache.invalidate(resolvePath(path));
},
_metadataCache: metadataCache,
_contentCache: contentCache,
_watcherClient: watcherClient,
_registerReadTransform: registerReadTransform,
_removeReadTransform: removeReadTransform,
async _init(basePath) {
const tree = await transport.fetchTree(basePath);
metadataCache.populate(tree);
console.log(`[shim:fs] Initialized with ${metadataCache.size} entries`);
},
async _refreshSubtree(subPath) {
const tree = await transport.fetchTree(subPath);
const prefix = subPath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
// Tree keys are relative to subPath, so prefix them to make vault-relative
const prefixed = {};
prefixed[prefix] = { type: "directory" };
for (const [key, meta] of Object.entries(tree)) {
prefixed[prefix + "/" + key] = meta;
}
metadataCache.merge(prefixed);
},
};

View File

@@ -0,0 +1,113 @@
// Eager batch pre-fetch of vault content into ContentCache.
//
// Fired once after the metadata cache is populated. Iterates the tree in
// directory-traversal order and pulls text file contents in batches via
// /api/fs/batch-read. Caps at MAX_BYTES so it doesn't thrash the LRU.
// Drops content directly into ContentCache; the indexer hits the cache
// instead of fetching each file individually.
const TEXT_EXTENSIONS = new Set([
".md", ".markdown", ".txt", ".json", ".csv",
".css", ".js", ".ts", ".tsx", ".mjs", ".cjs",
".html", ".xml", ".yaml", ".yml", ".toml",
".svg",
]);
const MAX_BYTES = 30 * 1024 * 1024; // 30 MB
const MAX_FILE_BYTES = 512 * 1024; // skip files larger than 512 KB
const BATCH_SIZE = 50;
function isTextPath(path) {
const dot = path.lastIndexOf(".");
if (dot < 0) {
return false;
}
return TEXT_EXTENSIONS.has(path.slice(dot).toLowerCase());
}
function selectPrefetchTargets(tree) {
const paths = [];
let bytes = 0;
// Iterate in tree key order, which already matches directory traversal
// because the server's walk emits parent-before-children.
for (const [path, entry] of Object.entries(tree)) {
if (entry.type !== "file") {
continue;
}
if (!isTextPath(path)) {
continue;
}
const size = entry.size || 0;
if (size === 0 || size > MAX_FILE_BYTES) {
continue;
}
if (bytes + size > MAX_BYTES) {
break;
}
paths.push(path);
bytes += size;
}
return { paths, bytes };
}
async function fetchBatch(vaultId, paths) {
const res = await fetch("/api/fs/batch-read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vault: vaultId, paths }),
});
if (!res.ok) {
throw new Error("batch-read failed: " + res.status);
}
return res.json();
}
export async function prefetchVaultContent(vaultId, tree, contentCache) {
if (!vaultId || !tree) {
return;
}
const { paths, bytes } = selectPrefetchTargets(tree);
if (paths.length === 0) {
return;
}
const t0 = Date.now();
let cached = 0;
for (let i = 0; i < paths.length; i += BATCH_SIZE) {
const batch = paths.slice(i, i + BATCH_SIZE);
try {
const result = await fetchBatch(vaultId, batch);
for (const [path, content] of Object.entries(result.files || {})) {
if (typeof content === "string") {
contentCache.set(path, content);
cached++;
}
}
} catch (e) {
console.warn("[ignis] Prefetch batch failed:", e.message);
return;
}
}
const ms = Date.now() - t0;
console.log(
`[ignis] Prefetched ${cached}/${paths.length} files (${(bytes / 1024).toFixed(0)} KB) in ${ms}ms`,
);
}

View File

@@ -0,0 +1,123 @@
// Dedicated cache for files picked via browser file dialogs.
// Avoids server round trips for input-only files (e.g., importer plugin).
//
// - 200MB size limit (higher than content cache; import batches can be large)
// - 5-minute TTL per entry
// - Entries kept until TTL expires (plugins may read the same file multiple times)
const MAX_SIZE = 200 * 1024 * 1024;
const TTL_MS = 5 * 60 * 1000;
const cache = new Map(); // path -> { data, size, createdAt }
let currentSize = 0;
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
function evictExpired() {
const now = Date.now();
for (const [key, entry] of cache) {
if (now - entry.createdAt > TTL_MS) {
currentSize -= entry.size;
cache.delete(key);
}
}
}
function evictOldest() {
let oldest = null;
let oldestTime = Infinity;
for (const [key, entry] of cache) {
if (entry.createdAt < oldestTime) {
oldest = key;
oldestTime = entry.createdAt;
}
}
if (oldest) {
currentSize -= cache.get(oldest).size;
cache.delete(oldest);
}
}
export function inputCacheHas(path) {
const norm = normalize(path);
const entry = cache.get(norm);
if (!entry) {
return false;
}
if (Date.now() - entry.createdAt > TTL_MS) {
currentSize -= entry.size;
cache.delete(norm);
return false;
}
return true;
}
export function inputCacheGet(path) {
const norm = normalize(path);
const entry = cache.get(norm);
if (!entry) {
return null;
}
if (Date.now() - entry.createdAt > TTL_MS) {
currentSize -= entry.size;
cache.delete(norm);
return null;
}
return entry.data;
}
export function inputCacheSet(path, data) {
const norm = normalize(path);
const size = data ? data.length || data.byteLength || 0 : 0;
// Remove existing entry if replacing
if (cache.has(norm)) {
currentSize -= cache.get(norm).size;
cache.delete(norm);
}
// Evict expired entries first
evictExpired();
// Evict oldest entries if still over limit
while (currentSize + size > MAX_SIZE && cache.size > 0) {
evictOldest();
}
cache.set(norm, { data, size, createdAt: Date.now() });
currentSize += size;
}
export function inputCacheDelete(path) {
const norm = normalize(path);
const entry = cache.get(norm);
if (entry) {
currentSize -= entry.size;
cache.delete(norm);
}
}
export function inputCacheClear() {
cache.clear();
currentSize = 0;
}
export function isInputCachePath(path) {
const norm = normalize(path);
return norm.startsWith(".obsidian/imports/");
}

View File

@@ -0,0 +1,130 @@
// In-memory metadata cache
// Populated from /api/fs/tree on startup, kept in sync via transport events.
// All stat/exists/readdir calls are served from this cache.
export class MetadataCache {
constructor() {
// Map<string, { type: 'file'|'directory', size: number, mtime: number, ctime: number }>
this._entries = new Map();
}
// Populate from a server-provided tree object
// tree shape: { "relative/path": { type, size, mtime, ctime }, ... }
populate(tree) {
this._entries.clear();
for (const [path, meta] of Object.entries(tree)) {
this._entries.set(this._normalize(path), meta);
}
}
has(path) {
return this._entries.has(this._normalize(path));
}
get(path) {
return this._entries.get(this._normalize(path)) || null;
}
set(path, meta) {
this._entries.set(this._normalize(path), meta);
}
delete(path) {
this._entries.delete(this._normalize(path));
}
// Rename: move metadata from old path to new path (and children if directory)
rename(oldPath, newPath) {
const oldNorm = this._normalize(oldPath);
const newNorm = this._normalize(newPath);
const meta = this._entries.get(oldNorm);
if (meta) {
this._entries.delete(oldNorm);
this._entries.set(newNorm, meta);
}
// Move children
const prefix = oldNorm + "/";
for (const [key, val] of this._entries) {
if (key.startsWith(prefix)) {
const newKey = newNorm + "/" + key.slice(prefix.length);
this._entries.delete(key);
this._entries.set(newKey, val);
}
}
}
// List direct children of a directory path
readdir(dirPath) {
const norm = this._normalize(dirPath);
const prefix = norm === "" ? "" : norm + "/";
const results = [];
const seen = new Set();
for (const [key, meta] of this._entries) {
if (prefix === "" || key.startsWith(prefix)) {
const rest = key.slice(prefix.length);
const slashIdx = rest.indexOf("/");
const childName = slashIdx >= 0 ? rest.slice(0, slashIdx) : rest;
if (childName && !seen.has(childName)) {
seen.add(childName);
const childMeta = this._entries.get(prefix + childName);
results.push({
name: childName,
type: childMeta?.type || (slashIdx >= 0 ? "directory" : "file"),
});
}
}
}
return results;
}
// Merge entries from a subtree without clearing existing data
merge(tree) {
for (const [path, meta] of Object.entries(tree)) {
this._entries.set(this._normalize(path), meta);
}
}
get size() {
return this._entries.size;
}
toStat(path) {
const meta = this.get(path);
if (!meta) {
return null;
}
return {
size: meta.size || 0,
mtimeMs: meta.mtime || 0,
ctimeMs: meta.ctime || 0,
atimeMs: meta.mtime || 0,
birthtimeMs: meta.ctime || 0,
mtime: new Date(meta.mtime || 0),
ctime: new Date(meta.ctime || 0),
atime: new Date(meta.mtime || 0),
birthtime: new Date(meta.ctime || 0),
isFile: () => meta.type === "file",
isDirectory: () => meta.type === "directory",
isSymbolicLink: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
};
}
_normalize(p) {
// Normalize slashes, remove leading and trailing slashes
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
}

View File

@@ -0,0 +1,213 @@
import { describe, it, expect } from "vitest";
import { MetadataCache } from "./metadata-cache.js";
// -- Path normalization ----------------------------------------------
describe("MetadataCache path normalization", () => {
it("converts backslashes to forward slashes", () => {
const cache = new MetadataCache();
cache.set("foo\\bar\\baz.md", { type: "file", size: 10 });
expect(cache.has("foo/bar/baz.md")).toBe(true);
});
it("strips leading and trailing slashes", () => {
const cache = new MetadataCache();
cache.set("/foo/bar/", { type: "file", size: 10 });
expect(cache.has("foo/bar")).toBe(true);
});
it("handles null and undefined as empty string", () => {
const cache = new MetadataCache();
cache.set(null, { type: "directory", size: 0 });
expect(cache.has("")).toBe(true);
expect(cache.has(undefined)).toBe(true);
});
it("normalizes //foo\\\\bar// to foo/bar", () => {
const cache = new MetadataCache();
cache.set("//foo\\bar//", { type: "file", size: 5 });
expect(cache.has("foo/bar")).toBe(true);
});
});
// -- Operations ------------------------------------------------------
describe("MetadataCache populate and merge", () => {
it("populate() clears existing entries", () => {
const cache = new MetadataCache();
cache.set("old.md", { type: "file", size: 1 });
cache.populate({ "new.md": { type: "file", size: 2 } });
expect(cache.has("old.md")).toBe(false);
expect(cache.has("new.md")).toBe(true);
});
it("merge() preserves existing entries", () => {
const cache = new MetadataCache();
cache.set("existing.md", { type: "file", size: 1 });
cache.merge({ "added.md": { type: "file", size: 2 } });
expect(cache.has("existing.md")).toBe(true);
expect(cache.has("added.md")).toBe(true);
});
it("populate then merge -- pre-existing entries survive merge", () => {
const cache = new MetadataCache();
cache.populate({
"a.md": { type: "file", size: 1 },
"b.md": { type: "file", size: 2 },
});
cache.merge({ "c.md": { type: "file", size: 3 } });
expect(cache.has("a.md")).toBe(true);
expect(cache.has("b.md")).toBe(true);
expect(cache.has("c.md")).toBe(true);
});
});
describe("MetadataCache toStat", () => {
it("returns correct shape with all expected fields and methods", () => {
const cache = new MetadataCache();
cache.set("file.md", { type: "file", size: 42, mtime: 1000, ctime: 2000 });
const stat = cache.toStat("file.md");
expect(stat.size).toBe(42);
expect(stat.mtimeMs).toBe(1000);
expect(stat.ctimeMs).toBe(2000);
expect(stat.atimeMs).toBe(1000);
expect(stat.birthtimeMs).toBe(2000);
expect(stat.mtime).toEqual(new Date(1000));
expect(stat.ctime).toEqual(new Date(2000));
expect(stat.atime).toEqual(new Date(1000));
expect(stat.birthtime).toEqual(new Date(2000));
expect(stat.isFile()).toBe(true);
expect(stat.isDirectory()).toBe(false);
expect(stat.isSymbolicLink()).toBe(false);
expect(stat.isBlockDevice()).toBe(false);
expect(stat.isCharacterDevice()).toBe(false);
expect(stat.isFIFO()).toBe(false);
expect(stat.isSocket()).toBe(false);
});
it("returns null for missing paths", () => {
const cache = new MetadataCache();
expect(cache.toStat("nonexistent.md")).toBe(null);
});
it("constructs dates from zero when mtime/ctime are missing", () => {
const cache = new MetadataCache();
cache.set("bare.md", { type: "file", size: 1 });
const stat = cache.toStat("bare.md");
expect(stat.mtimeMs).toBe(0);
expect(stat.ctimeMs).toBe(0);
expect(stat.mtime).toEqual(new Date(0));
expect(stat.ctime).toEqual(new Date(0));
});
});
describe("MetadataCache readdir", () => {
function populated() {
const cache = new MetadataCache();
cache.populate({
"foo/bar.md": { type: "file", size: 1 },
"foo/baz.md": { type: "file", size: 2 },
"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 },
});
return cache;
}
it("root readdir returns top-level entries", () => {
const cache = populated();
const entries = cache.readdir("");
const names = entries.map((e) => e.name).sort();
expect(names).toEqual(["docs", "foo", "foobar", "root.md"]);
});
it("nested dir returns only direct children, not grandchildren", () => {
const cache = populated();
const entries = cache.readdir("foo");
const names = entries.map((e) => e.name).sort();
expect(names).toEqual(["bar.md", "baz.md", "sub"]);
expect(names).not.toContain("deep.md");
});
it("readdir of foo does not include foobar entries (prefix false-match)", () => {
const cache = populated();
const entries = cache.readdir("foo");
const names = entries.map((e) => e.name);
expect(names).not.toContain("foobar");
expect(names).not.toContain("other.md");
});
it("infers directory type for paths with no direct map entry", () => {
const cache = populated();
const entries = cache.readdir("foo");
const sub = entries.find((e) => e.name === "sub");
expect(sub).toBeDefined();
expect(sub.type).toBe("directory");
});
it("returns empty array for path with no children", () => {
const cache = populated();
const entries = cache.readdir("docs");
expect(entries).toEqual([]);
});
it("returns empty array for nonexistent path", () => {
const cache = populated();
const entries = cache.readdir("nope/not/here");
expect(entries).toEqual([]);
});
});
describe("MetadataCache rename", () => {
it("rename file: old path gone, new path present with same metadata", () => {
const cache = new MetadataCache();
const meta = { type: "file", size: 10, mtime: 100 };
cache.set("a.md", meta);
cache.rename("a.md", "b.md");
expect(cache.has("a.md")).toBe(false);
expect(cache.has("b.md")).toBe(true);
expect(cache.get("b.md")).toBe(meta);
});
it("rename directory moves all children", () => {
const cache = new MetadataCache();
const dirMeta = { type: "directory", size: 0 };
const fileMeta = { type: "file", size: 5 };
const deepMeta = { type: "file", size: 8 };
cache.set("a", dirMeta);
cache.set("a/file.md", fileMeta);
cache.set("a/sub/deep.md", deepMeta);
cache.rename("a", "b");
expect(cache.has("a")).toBe(false);
expect(cache.has("a/file.md")).toBe(false);
expect(cache.has("a/sub/deep.md")).toBe(false);
expect(cache.get("b")).toBe(dirMeta);
expect(cache.get("b/file.md")).toBe(fileMeta);
expect(cache.get("b/sub/deep.md")).toBe(deepMeta);
});
it("rename where old and new share a common prefix", () => {
const cache = new MetadataCache();
const meta = { type: "file", size: 1 };
cache.set("a/b", meta);
cache.rename("a/b", "a/c");
expect(cache.has("a/b")).toBe(false);
expect(cache.get("a/c")).toBe(meta);
});
it("rename to a deeper nesting level", () => {
const cache = new MetadataCache();
const meta = { type: "file", size: 1 };
cache.set("x", meta);
cache.rename("x", "y/z");
expect(cache.has("x")).toBe(false);
expect(cache.get("y/z")).toBe(meta);
});
});

View File

@@ -0,0 +1,303 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js";
export function createFsPromises(metadataCache, contentCache, transport) {
return {
async stat(path) {
const resolved = resolvePath(path);
const cached = metadataCache.toStat(resolved);
if (cached) {
return cached;
}
const meta = await transport.stat(resolved);
metadataCache.set(resolved, meta);
return metadataCache.toStat(resolved);
},
async lstat(path) {
// No symlinks in our context
return this.stat(path);
},
async readdir(path) {
const meta = metadataCache.get(path);
if (meta && meta.type === "file") {
return [];
}
if (!meta && path && path !== "/" && path !== ".") {
const e = new Error(
`ENOENT: no such file or directory, scandir '${path}'`,
);
e.code = "ENOENT";
throw e;
}
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
async readFile(path, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path);
let result = null;
// Check input cache for files picked via browser file dialogs.
if (isInputCachePath(path)) {
result = inputCacheGet(path);
}
if (result === null) {
const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
throw e;
}
if (!meta && resolved && resolved === path) {
// Throw ENOENT only when not redirected; redirected paths fall through to the transport's fallback.
const e = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
e.code = "ENOENT";
throw e;
}
result = contentCache.get(resolved);
}
if (result === null) {
try {
result = await transport.readFile(resolved, encoding);
} catch (e) {
if (resolved !== path && e.code === "ENOENT") {
result = await transport.readFile(path, encoding);
} else {
throw e;
}
}
contentCache.set(resolved, result);
}
// Apply registered read transforms (e.g., patching synced config files).
result = applyReadTransform(resolved, result);
if (wantText) {
return typeof result === "string"
? result
: new TextDecoder().decode(result);
}
if (typeof result === "string") {
return new TextEncoder().encode(result);
}
return result;
},
async writeFile(path, data, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const resolved = resolvePath(path);
const transformed = applyWriteTransform(resolved, data);
markLocalOp(resolved);
contentCache.set(resolved, transformed);
const size =
typeof transformed === "string"
? transformed.length
: transformed.byteLength || 0;
metadataCache.set(resolved, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
});
const result = await transport.writeFile(resolved, transformed, encoding);
if (result.mtime) {
metadataCache.set(resolved, {
type: "file",
size: result.size || size,
mtime: result.mtime,
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
});
}
},
async appendFile(path, data, encoding) {
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.invalidate(resolved);
await transport.appendFile(resolved, data);
const meta = await transport.stat(resolved);
metadataCache.set(resolved, meta);
},
async unlink(path) {
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.delete(resolved);
metadataCache.delete(resolved);
await transport.unlink(resolved);
},
async rename(oldPath, newPath) {
const resolvedOld = resolvePath(oldPath);
const resolvedNew = resolvePath(newPath);
markLocalOp(resolvedOld);
markLocalOp(resolvedNew);
const content = contentCache.get(resolvedOld);
if (content !== null) {
contentCache.set(resolvedNew, content);
contentCache.delete(resolvedOld);
}
metadataCache.rename(resolvedOld, resolvedNew);
await transport.rename(resolvedOld, resolvedNew);
},
async mkdir(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
markLocalOp(path);
metadataCache.set(path, { type: "directory" });
await transport.mkdir(path, recursive);
},
async rmdir(path) {
markLocalOp(path);
metadataCache.delete(path);
await transport.rmdir(path);
},
async rm(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
const resolved = resolvePath(path);
markLocalOp(resolved);
metadataCache.delete(resolved);
contentCache.delete(resolved);
await transport.rm(resolved, recursive);
},
async copyFile(src, dest) {
const resolvedDest = resolvePath(dest);
markLocalOp(resolvedDest);
await transport.copyFile(src, resolvedDest);
const meta = await transport.stat(resolvedDest);
metadataCache.set(resolvedDest, meta);
},
async access(path) {
const resolved = resolvePath(path);
if (metadataCache.has(resolved)) {
return;
}
const e = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
e.code = "ENOENT";
throw e;
},
async realpath(path) {
if (!path || path === "/" || path === ".") {
return "/";
}
return transport.realpath(path);
},
async utimes(path, atime, mtime) {
const resolved = resolvePath(path);
await transport.utimes(resolved, atime, mtime);
const meta = metadataCache.get(resolved);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(resolved, meta);
}
},
async open(path, flags) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path);
if (!hasInCache && !metadataCache.has(resolved)) {
const err = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
err.code = "ENOENT";
throw err;
}
const data = await this.readFile(path);
const fileData =
typeof data === "string" ? new TextEncoder().encode(data) : data;
const fileStat = metadataCache.toStat(resolved) || {
size: fileData.length,
isFile: () => true,
isDirectory: () => false,
};
return {
async stat() {
return fileStat;
},
async read(buffer, offset, length, position) {
const available = Math.min(length, fileData.length - position);
if (available <= 0) {
return { bytesRead: 0, buffer };
}
const slice = fileData.subarray(position, position + available);
buffer.set(slice, offset);
return { bytesRead: available, buffer };
},
async close() {
// Nothing to clean up - data is in memory
},
};
},
};
}

View File

@@ -0,0 +1,184 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import {
applyReadTransform,
applyWriteTransform,
resolvePath,
} from "./transforms.js";
export function createFsSync(metadataCache, contentCache, transport) {
return {
existsSync(path) {
if (isInputCachePath(path) && inputCacheGet(path) !== null) {
return true;
}
const resolved = resolvePath(path);
return metadataCache.has(resolved);
},
statSync(path) {
if (isInputCachePath(path) && inputCacheGet(path) !== null) {
const data = inputCacheGet(path);
const size = data ? data.length || data.byteLength || 0 : 0;
return {
size,
mtime: new Date(),
ctime: new Date(),
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
};
}
const resolved = resolvePath(path);
const stat = metadataCache.toStat(resolved);
if (!stat) {
const err = new Error(
`ENOENT: no such file or directory, stat '${path}'`,
);
err.code = "ENOENT";
throw err;
}
return stat;
},
accessSync(path, mode) {
if (isInputCachePath(path) && inputCacheGet(path) !== null) {
return;
}
const resolved = resolvePath(path);
if (!metadataCache.has(resolved)) {
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;
}
const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path);
const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
throw e;
}
let result = null;
// Check input cache for files picked via browser file dialogs.
if (isInputCachePath(path)) {
const inputData = inputCacheGet(path);
if (inputData !== null) {
result = inputData;
}
}
if (result === null) {
result = contentCache.get(resolved);
}
if (result === null) {
// ENOENT fallback: if the resolved path doesn't exist, try the original.
// Covers per-name workspace files that haven't been saved yet.
try {
result = transport.readFileSync(resolved, encoding);
} catch (e) {
if (resolved !== path && e.code === "ENOENT") {
console.warn(
"[shim:fs] readFileSync cache miss, using sync XHR:",
path,
);
result = transport.readFileSync(path, encoding);
} else {
throw e;
}
}
contentCache.set(resolved, result);
}
// Apply registered read transforms (e.g., patching synced config files).
result = applyReadTransform(resolved, result);
if (wantText) {
return typeof result === "string"
? result
: new TextDecoder().decode(result);
}
return result;
},
writeFileSync(path, data, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const resolved = resolvePath(path);
const transformed = applyWriteTransform(resolved, data);
markLocalOp(resolved);
contentCache.set(resolved, transformed);
const size =
typeof transformed === "string"
? transformed.length
: transformed.byteLength || 0;
metadataCache.set(resolved, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
});
// Fire-and-forget async send to server
transport.writeFile(resolved, transformed, encoding).catch((e) => {
console.error(
"[shim:fs] writeFileSync background save failed:",
resolved,
e,
);
});
},
unlinkSync(path) {
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.delete(resolved);
metadataCache.delete(resolved);
// Fire-and-forget. suppress ENOENT (file already gone)
transport.unlink(resolved).catch((e) => {
if (e.code !== "ENOENT") {
console.error(
"[shim:fs] unlinkSync background delete failed:",
resolved,
e,
);
}
});
},
readdirSync(path) {
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
};
}

View File

@@ -0,0 +1,96 @@
// FS shim translation registry.
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
function normalize(p) {
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
}
// --- Path resolvers ---
const pathResolvers = [];
export function registerPathResolver(matcher, resolver) {
pathResolvers.push({ matcher, resolver });
}
export function resolvePath(path) {
const norm = normalize(path);
for (const { matcher, resolver } of pathResolvers) {
try {
if (matcher(norm)) {
const resolved = resolver(norm);
if (typeof resolved === "string" && resolved.length > 0) {
return resolved;
}
}
} catch {}
}
return norm;
}
// --- Read transforms ---
const readTransforms = new Map();
export function registerReadTransform(path, fn) {
readTransforms.set(normalize(path), fn);
}
export function removeReadTransform(path) {
readTransforms.delete(normalize(path));
}
export function applyReadTransform(path, data) {
const fn = readTransforms.get(normalize(path));
if (!fn) {
return data;
}
try {
return fn(data);
} catch {
return data;
}
}
export function hasReadTransform(path) {
return readTransforms.has(normalize(path));
}
// --- Write transforms ---
const writeTransforms = new Map();
export function registerWriteTransform(path, fn) {
writeTransforms.set(normalize(path), fn);
}
export function removeWriteTransform(path) {
writeTransforms.delete(normalize(path));
}
export function applyWriteTransform(path, data) {
const fn = writeTransforms.get(normalize(path));
if (!fn) {
return data;
}
try {
return fn(data);
} catch {
return data;
}
}
// Test-only: clear all registered hooks.
export function _reset() {
pathResolvers.length = 0;
readTransforms.clear();
writeTransforms.clear();
}

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
registerPathResolver,
resolvePath,
registerReadTransform,
applyReadTransform,
registerWriteTransform,
applyWriteTransform,
_reset,
} from "./transforms.js";
beforeEach(() => {
_reset();
});
describe("resolvePath", () => {
it("returns the resolved path when a matcher hits", () => {
registerPathResolver(
(p) => p === ".obsidian/workspace.json",
() => ".obsidian/workspace.default.json",
);
expect(resolvePath(".obsidian/workspace.json")).toBe(
".obsidian/workspace.default.json",
);
});
it("returns the input path when no matcher hits", () => {
registerPathResolver(
(p) => p === ".obsidian/workspace.json",
() => ".obsidian/workspace.default.json",
);
expect(resolvePath("notes/foo.md")).toBe("notes/foo.md");
});
});
describe("applyReadTransform", () => {
it("applies a registered transform when the path matches", () => {
registerReadTransform(".obsidian/core-plugins.json", (data) =>
data.replace('"sync":true', '"sync":false'),
);
const input = '{"sync":true,"foo":1}';
expect(applyReadTransform(".obsidian/core-plugins.json", input)).toBe(
'{"sync":false,"foo":1}',
);
});
it("returns data unchanged when no transform is registered for the path", () => {
registerReadTransform(".obsidian/core-plugins.json", (data) =>
data.replace('"sync":true', '"sync":false'),
);
const input = '{"sync":true}';
expect(applyReadTransform("notes/foo.md", input)).toBe(input);
});
});
describe("applyWriteTransform", () => {
it("applies a registered transform when the path matches", () => {
registerWriteTransform(".obsidian/workspaces.json", (data) =>
data.replace('"active":"w2"', '"active":"default"'),
);
const input = '{"active":"w2"}';
expect(applyWriteTransform(".obsidian/workspaces.json", input)).toBe(
'{"active":"default"}',
);
});
});
describe("path normalization", () => {
it("matches a path registered with one separator form against lookups in another", () => {
registerReadTransform(".obsidian/foo.json", (data) => `seen:${data}`);
expect(applyReadTransform(".obsidian\\foo.json", "x")).toBe("seen:x");
expect(applyReadTransform("/.obsidian/foo.json", "x")).toBe("seen:x");
});
});

View File

@@ -0,0 +1,224 @@
const API_BASE = "/api/fs";
function normPath(p) {
return (p || "").replace(/^\/+/, "");
}
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);
}
function vaultId() {
return window.__currentVaultId || "";
}
async function request(method, endpoint, params = {}) {
const url = new URL(API_BASE + endpoint, window.location.origin);
const options = { method };
if (method === "GET" || method === "DELETE") {
if (vaultId()) {
url.searchParams.set("vault", vaultId());
}
for (const [key, val] of Object.entries(params)) {
url.searchParams.set(key, val);
}
} else {
options.headers = { "Content-Type": "application/json" };
options.body = JSON.stringify({ vault: vaultId(), ...params });
}
const res = await fetch(url.toString(), options);
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ error: res.statusText, code: "UNKNOWN" }));
const e = new Error(err.error || res.statusText);
e.code = err.code || "UNKNOWN";
throw e;
}
return res;
}
async function requestJson(method, endpoint, params = {}) {
const res = await request(method, endpoint, params);
return res.json();
}
function requestSync(method, endpoint, params = {}) {
const url = new URL(API_BASE + endpoint, window.location.origin);
if (method === "GET" || method === "DELETE") {
if (vaultId()) {
url.searchParams.set("vault", vaultId());
}
for (const [key, val] of Object.entries(params)) {
url.searchParams.set(key, val);
}
}
const xhr = new XMLHttpRequest();
xhr.open(method, url.toString(), false); // synchronous
if (method !== "GET" && method !== "DELETE") {
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ vault: vaultId(), ...params }));
} else {
xhr.send();
}
if (xhr.status >= 400) {
let err;
try {
const body = JSON.parse(xhr.responseText);
err = new Error(body.error || "Request failed");
err.code = body.code || "UNKNOWN";
} catch {
err = new Error("Request failed: " + xhr.status);
err.code = "UNKNOWN";
}
throw err;
}
return xhr;
}
export const transport = {
async fetchTree(basePath) {
return requestJson("GET", "/tree", basePath ? { path: basePath } : {});
},
async stat(path) {
return requestJson("GET", "/stat", { path: normPath(path) });
},
async readdir(path) {
return requestJson("GET", "/readdir", { path: normPath(path) });
},
async readFile(path, encoding) {
const res = await request("GET", "/readFile", {
path: normPath(path),
encoding: encoding || "",
});
if (encoding === "utf8" || encoding === "utf-8") {
return res.text();
}
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
},
async writeFile(path, content, encoding) {
const isText = typeof content === "string";
return requestJson("POST", "/writeFile", {
path: normPath(path),
content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText,
});
},
async appendFile(path, content) {
return requestJson("POST", "/appendFile", {
path: normPath(path),
content,
});
},
async mkdir(path, recursive) {
return requestJson("POST", "/mkdir", { path: normPath(path), recursive });
},
async rename(oldPath, newPath) {
return requestJson("POST", "/rename", {
oldPath: normPath(oldPath),
newPath: normPath(newPath),
});
},
async copyFile(src, dest) {
return requestJson("POST", "/copyFile", {
src: normPath(src),
dest: normPath(dest),
});
},
async unlink(path) {
return requestJson("DELETE", "/unlink", { path: normPath(path) });
},
async rmdir(path) {
return requestJson("DELETE", "/rmdir", { path: normPath(path) });
},
async rm(path, recursive) {
return requestJson("DELETE", "/rm", {
path: normPath(path),
recursive: recursive ? "true" : "false",
});
},
async access(path) {
return requestJson("GET", "/access", { path: normPath(path) });
},
async realpath(path) {
const result = await requestJson("GET", "/realpath", {
path: normPath(path),
});
return result.path;
},
async utimes(path, atime, mtime) {
return requestJson("POST", "/utimes", {
path: normPath(path),
atime,
mtime,
});
},
readFileSync(path, encoding) {
const xhr = requestSync("GET", "/readFile", {
path: normPath(path),
encoding: encoding || "",
});
if (encoding === "utf8" || encoding === "utf-8") {
return xhr.responseText;
}
const binary = xhr.responseText;
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
},
writeFileSync(path, content, encoding) {
const isText = typeof content === "string";
requestSync("POST", "/writeFile", {
path: normPath(path),
content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText,
});
},
};

View File

@@ -0,0 +1,109 @@
export function createFsWatch(transport) {
const watchers = new Map(); // path -> Set<listener>
return {
watch(path, options, listener) {
if (typeof options === "function") {
listener = options;
options = {};
}
if (!watchers.has(path)) {
watchers.set(path, new Set());
}
// Wrapper that holds both direct listener and .on() listeners
const entry = {
direct: typeof listener === "function" ? listener : null,
eventListeners: new Map(), // event name -> Set<fn>
call(eventType, filename) {
if (this.direct) {
this.direct(eventType, filename);
}
const fns = this.eventListeners.get("change");
if (fns) {
for (const fn of fns) {
try {
fn(eventType, filename);
} catch (e) {
console.error("[shim:fs:watch] Listener error:", e);
}
}
}
},
};
watchers.get(path).add(entry);
// Return a watcher-like object
return {
close() {
const set = watchers.get(path);
if (set) {
set.delete(entry);
if (set.size === 0) {
watchers.delete(path);
}
}
},
on(event, fn) {
if (!entry.eventListeners.has(event)) {
entry.eventListeners.set(event, new Set());
}
entry.eventListeners.get(event).add(fn);
return this;
},
once(event, fn) {
const wrapped = (...args) => {
this.removeListener(event, wrapped);
fn(...args);
};
return this.on(event, wrapped);
},
removeListener(event, fn) {
const fns = entry.eventListeners.get(event);
if (fns) {
fns.delete(fn);
}
return this;
},
};
},
// Internal: called when transport receives a file-change event
_dispatch(eventType, filePath) {
const normFile = (filePath || "").replace(/^\/+/, "");
let matched = false;
for (const [watchPath, listeners] of watchers) {
const normWatch = (watchPath || "").replace(/^\/+/, "");
// Empty normWatch means root watcher - matches everything
const isMatch =
normWatch === "" ||
normFile === normWatch ||
normFile.startsWith(normWatch + "/");
if (isMatch) {
matched = true;
const relativeName =
normWatch === ""
? normFile
: normFile.slice(normWatch.length + 1) || normFile;
for (const entry of listeners) {
try {
entry.call(eventType, relativeName);
} catch (e) {
console.error("[shim:fs:watch] Listener error:", e);
}
}
}
}
},
};
}

View File

@@ -0,0 +1,147 @@
// Client-side WebSocket file watcher.
// Connects to the server's /ws endpoint, receives file change events,
// updates the metadata/content caches, and dispatches to fs.watch listeners
// so Obsidian's vault picks them up automatically.
import { isRecentLocalOp } from "./echo-guard.js";
const RECONNECT_DELAY = 2000;
export function createWatcherClient(metadataCache, contentCache, fsWatch) {
let ws = null;
let vaultId = null;
let reconnectTimer = null;
function connect(vault) {
vaultId = vault;
if (!vaultId) {
console.warn("[watcher] No vault ID, skipping WebSocket connection");
return;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`;
try {
ws = new WebSocket(url);
window.__ignisWs = ws;
} catch (e) {
console.error("[watcher] Failed to create WebSocket:", e);
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log("[watcher] Connected to file watcher");
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleEvent(msg);
} catch (e) {
console.error("[watcher] Failed to parse message:", e);
}
};
ws.onclose = () => {
console.log("[watcher] Disconnected");
ws = null;
scheduleReconnect();
};
ws.onerror = (e) => {
console.error("[watcher] WebSocket error:", e);
};
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (vaultId) {
console.log("[watcher] Reconnecting...");
connect(vaultId);
}
}, RECONNECT_DELAY);
}
function handleEvent(msg) {
// Skip channel-based plugin messages, those are for other listeners
if (msg.channel) {
return;
}
const { type, path, stat } = msg;
if (!type || !path) return;
// Suppress echo from our own operations
if (isRecentLocalOp(path)) {
return;
}
switch (type) {
case "created":
if (stat) {
metadataCache.set(path, {
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("created", path);
break;
case "folder-created":
metadataCache.set(path, { type: "directory" });
fsWatch._dispatch("folder-created", path);
break;
case "modified":
if (stat) {
metadataCache.set(path, {
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("modified", path);
break;
case "deleted":
metadataCache.delete(path);
contentCache.invalidate(path);
fsWatch._dispatch("deleted", path);
break;
default:
console.warn("[watcher] Unknown event type:", type);
}
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
}
return {
connect,
disconnect,
};
}

View File

@@ -0,0 +1,292 @@
import { processShim } from "./process.js";
import {
registerPopupWindow,
unregisterPopupWindow,
} from "./electron/remote/window.js";
import { showVaultManager } from "./ui-registry.js";
function installProcess() {
window.process = processShim;
}
function installBuffer() {
if (typeof window.Buffer !== "undefined") return;
window.Buffer = {
from: function (data, encoding) {
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
return new Uint8Array(data);
},
alloc: function (size, fill, encoding) {
const buf = new Uint8Array(size);
if (fill !== undefined) {
buf.fill(typeof fill === "string" ? fill.charCodeAt(0) : fill);
}
return buf;
},
allocUnsafe: function (size) {
return new Uint8Array(size);
},
concat: function (arrays) {
const total = arrays.reduce((sum, a) => sum + a.length, 0);
const result = new Uint8Array(total);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
},
isBuffer: function (obj) {
return obj instanceof Uint8Array;
},
byteLength: function (str, encoding) {
return new TextEncoder().encode(str).length;
},
isEncoding: function (encoding) {
return [
"utf8",
"utf-8",
"ascii",
"binary",
"base64",
"hex",
"latin1",
].includes((encoding || "").toLowerCase());
},
};
}
function installWindowClose() {
window.close = function () {
console.log("[ignis] window.close() blocked");
if (!window.__vaultConfig) {
showVaultManager();
}
};
}
function installWindowOpen() {
window.__popupIframe = null;
const _originalOpen = window.open;
window.open = function (url, target, features) {
if (url === "about:blank" || (features && features.includes("popup"))) {
console.log("[ignis] intercepted popup:", url, features);
registerPopupWindow();
const iframe = document.createElement("iframe");
iframe.style.cssText =
"position:fixed;left:-9999px;width:0;height:0;border:none;";
document.body.appendChild(iframe);
window.__popupIframe = iframe;
const iframeWin = iframe.contentWindow;
iframeWin.require = window.require;
iframeWin.module = window.module;
iframeWin.Buffer = window.Buffer;
iframeWin.process = window.process;
iframeWin.global = iframeWin;
iframeWin.globalEnhance = window.globalEnhance;
iframeWin.close = function () {
unregisterPopupWindow();
iframe.remove();
window.__popupIframe = null;
};
return iframeWin;
}
return _originalOpen.call(window, url, target, features);
};
}
function arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
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);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function isSameOrigin(url) {
if (
!url ||
url.startsWith("/") ||
url.startsWith("./") ||
url.startsWith("../")
) {
return true;
}
if (url.startsWith("data:") || url.startsWith("blob:")) {
return true;
}
try {
const parsed = new URL(url, window.location.origin);
return parsed.origin === window.location.origin;
} catch {
return true;
}
}
function installFetchShim() {
const originalFetch = window.fetch.bind(window);
window.__originalFetch = originalFetch;
window.fetch = async function (input, init) {
let url;
if (typeof input === "string") {
url = input;
} else if (input instanceof URL) {
url = input.href;
} else if (input instanceof Request) {
url = input.url;
} else {
url = String(input);
}
if (isSameOrigin(url)) {
return originalFetch(input, init);
}
// Cross-origin - route through server proxy
const method = (
init?.method || (input instanceof Request ? input.method : "GET")
).toUpperCase();
const headers = {};
if (init?.headers) {
const h =
init.headers instanceof Headers
? init.headers
: new Headers(init.headers);
h.forEach((val, key) => {
headers[key] = val;
});
} else if (input instanceof Request) {
input.headers.forEach((val, key) => {
headers[key] = val;
});
}
// Mimic the real Obsidian desktop app headers for cross-origin requests
if (!headers["user-agent"] && !headers["User-Agent"]) {
headers["user-agent"] = navigator.userAgent;
}
if (!headers["origin"] && !headers["Origin"]) {
headers["origin"] = "app://obsidian.md";
}
let body = null;
let binary = false;
if (init?.body && method !== "GET" && method !== "HEAD") {
if (typeof init.body === "string") {
body = init.body;
} else if (init.body instanceof ArrayBuffer) {
body = arrayBufferToBase64(init.body);
binary = true;
} else if (init.body instanceof Uint8Array) {
body = arrayBufferToBase64(init.body.buffer);
binary = true;
} else if (typeof init.body === "object") {
body = JSON.stringify(init.body);
} else {
body = String(init.body);
}
}
console.log("[shim:fetch] Proxying cross-origin:", method, url);
const proxyRes = await originalFetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, method, headers, body, binary }),
});
if (!proxyRes.ok) {
const err = await proxyRes
.json()
.catch(() => ({ error: "Proxy request failed" }));
throw new TypeError(err.error || "Failed to fetch");
}
const result = await proxyRes.json();
const respBody = base64ToArrayBuffer(result.body);
return new Response(respBody, {
status: result.status,
headers: result.headers,
});
};
}
function installVibrateShim() {
if (typeof navigator.vibrate === "function") {
return;
}
// Some Firefox configurations leave navigator.vibrate undefined (gated by dom.vibrator.enabled).
// Obsidian assumes it's always callable, so provide a no-op where it's missing.
try {
Object.defineProperty(navigator, "vibrate", {
configurable: true,
writable: true,
value: () => true,
});
} catch {}
}
function installContextMenuFix() {
// hacky fix to prevent browser from showing context menu while allowing obsidian context menu
window.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
Object.defineProperty(e, "defaultPrevented", { get: () => false });
},
true,
);
}
export function installGlobals() {
installProcess();
installBuffer();
installFetchShim();
installWindowClose();
installWindowOpen();
installVibrateShim();
installContextMenuFix();
}

251
packages/shim/src/init.js Normal file
View File

@@ -0,0 +1,251 @@
import { fsShim } from "./fs/index.js";
import { installRequestUrlShim } from "./request-url.js";
import { vaultService } from "@ignis/services";
import { showPluginInstallDialog } from "./ui-registry.js";
import { registerReadTransform } from "./fs/transforms.js";
import {
resolveWorkspaceName,
loadPresetIfRequested,
initWorkspacePatch,
} from "./workspace.js";
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
function resolveVaultId() {
const urlParams = new URLSearchParams(window.location.search);
window.__currentVaultId =
urlParams.get("vault") || localStorage.getItem("last-vault") || "";
window.__workspaceName = urlParams.get("workspace") || "";
}
// Single round-trip bootstrap: vault info + vault list + metadata tree + plugins.
// Returns the parsed response, or null if the call failed (no vault, network error, etc.)
function fetchBootstrap() {
if (!window.__currentVaultId) {
return null;
}
try {
const xhr = new XMLHttpRequest();
xhr.open(
"GET",
"/api/bootstrap?vault=" + encodeURIComponent(window.__currentVaultId),
false,
);
xhr.send();
if (xhr.status === 200) {
return JSON.parse(xhr.responseText);
}
} catch (e) {
console.warn("[ignis] Bootstrap fetch failed:", e);
}
return null;
}
function applyVaultInfo(info) {
window.__currentVaultId = info.id;
localStorage.setItem("last-vault", info.id);
window.__obsidianVersion = info.version || "0.0.0";
window.__vaultConfig = {
id: info.id,
path: "/",
};
window.__ignisPlugin = info.ignisPlugin || null;
console.log("[ignis] Vault:", window.__vaultConfig);
console.log("[ignis] Obsidian version:", window.__obsidianVersion);
}
function applyTree(tree) {
fsShim._metadataCache.populate(tree);
fsShim._metadataCache.set("", { type: "directory" });
fsShim._metadataCache.set("/", { type: "directory" });
console.log(
"[ignis] Metadata cache populated:",
fsShim._metadataCache.size,
"entries",
);
}
function initVaultConfigFallback() {
try {
const vaultParam = window.__currentVaultId
? "?vault=" + encodeURIComponent(window.__currentVaultId)
: "";
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/vault/info" + vaultParam, false);
xhr.send();
if (xhr.status === 200) {
applyVaultInfo(JSON.parse(xhr.responseText));
} else {
console.warn("[ignis] No vault found, will show manager");
}
} catch (e) {
console.error("[ignis] Failed to fetch vault config:", e);
}
}
function initVaultListFallback() {
try {
vaultService.listVaultsSync();
} catch (e) {
window.__vaultList = [];
}
}
function initMetadataCacheFallback() {
try {
const vaultParam = window.__currentVaultId
? "?vault=" + encodeURIComponent(window.__currentVaultId)
: "";
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/fs/tree" + vaultParam, false);
xhr.send();
if (xhr.status === 200) {
applyTree(JSON.parse(xhr.responseText));
} else {
console.error("[ignis] Failed to fetch metadata tree:", xhr.status);
}
} catch (e) {
console.error("[ignis] Failed to init metadata cache:", e);
}
}
function initPluginPrompt() {
if (
!window.__ignisPlugin ||
window.__ignisPlugin.installed ||
window.__ignisPlugin.prompted
) {
return;
}
const vaultId = window.__currentVaultId;
const observer = new MutationObserver(() => {
if (document.querySelector(".workspace")) {
observer.disconnect();
showPluginInstallDialog(vaultId);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian.
// this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list".
// i.e ensure Ignis always has sync: false if headless sync is active.
// This may be somewhat overengineered. Could revisit later.
function applyCoreSyncGuard(plugins) {
const vaultId = window.__currentVaultId;
if (!vaultId || !plugins) {
return;
}
const headlessSync = plugins.find(
(p) => p.id === "headless-sync" && p.bundledPluginId,
);
if (!headlessSync || !headlessSync.enabledVaults.includes(vaultId)) {
return;
}
console.log(
"[ignis] Headless sync active for this vault, patching core-plugins.json reads",
);
window.__ignisHeadlessSyncActive = true;
registerReadTransform(".obsidian/core-plugins.json", (data) => {
if (!window.__ignisHeadlessSyncActive) {
return data;
}
let text =
typeof data === "string" ? data : new TextDecoder().decode(data);
try {
const config = JSON.parse(text);
if (config.sync === true) {
config.sync = false;
return JSON.stringify(config);
}
} catch {}
return data;
});
}
function initCoreSyncGuardFallback() {
const vaultId = window.__currentVaultId;
if (!vaultId) {
return;
}
try {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/plugins", false);
xhr.send();
if (xhr.status === 200) {
applyCoreSyncGuard(JSON.parse(xhr.responseText));
}
} catch (e) {
console.warn("[ignis] Failed to init core sync guard:", e);
}
}
export function initialize() {
if (maybeProvisionDemoVault()) {
return;
}
resolveVaultId();
resolveWorkspaceName();
loadPresetIfRequested();
const bootstrap = fetchBootstrap();
if (bootstrap) {
applyVaultInfo(bootstrap.vault);
window.__vaultList = bootstrap.vaultList;
autoTrustDemoVaults(bootstrap.vaultList);
applyTree(bootstrap.tree);
applyCoreSyncGuard(bootstrap.plugins);
// Race the indexer: batch-fetch text content into ContentCache so
// Obsidian's startup indexing reads hit the cache instead of the network.
prefetchVaultContent(
window.__currentVaultId,
bootstrap.tree,
fsShim._contentCache,
);
} else {
initVaultConfigFallback();
initVaultListFallback();
initMetadataCacheFallback();
initCoreSyncGuardFallback();
}
installRequestUrlShim();
initWorkspacePatch();
initPluginPrompt();
}

View File

@@ -0,0 +1,30 @@
import { installRequire } from "./require.js";
import { installGlobals } from "./globals.js";
import { installCssOverrides } from "./css-overrides.js";
import { initialize } from "./init.js";
import { fsShim } from "./fs/index.js";
import { registerUI } from "./ui-registry.js";
// __IGNIS_VERSION__ is replaced at build time from package.json.
window.__ignis = { version: __IGNIS_VERSION__ };
window.__ignis_registerUI = registerUI;
installGlobals(); // process, Buffer, window overrides (before require so Buffer is available)
installRequire(); // shim registry, window.require
installCssOverrides(); // browser-specific CSS fixes
// Set EmulateMobile flag for small viewports so Obsidian activates its mobile UI
if (window.innerWidth < 600) {
localStorage.setItem("EmulateMobile", "true");
} else {
localStorage.removeItem("EmulateMobile");
}
initialize(); // vault config, metadata cache, plugin prompt
// Connect file watcher WebSocket after everything is initialized
if (window.__currentVaultId) {
fsShim._watcherClient.connect(window.__currentVaultId);
}
console.log("[ignis] Shim loader initialized");

View File

@@ -0,0 +1,15 @@
function notAvailable(name) {
return function () {
throw new Error(
`child_process.${name}() is not available in the web version.`,
);
};
}
export const exec = notAvailable("exec");
export const execSync = notAvailable("execSync");
export const spawn = notAvailable("spawn");
export const fork = notAvailable("fork");
export const execFile = notAvailable("execFile");
export const execFileSync = notAvailable("execFileSync");
export const spawnSync = notAvailable("spawnSync");

View File

@@ -0,0 +1,106 @@
export class EventEmitter {
constructor() {
this._events = {};
}
on(event, listener) {
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].push(listener);
return this;
}
once(event, listener) {
const wrapped = (...args) => {
this.removeListener(event, wrapped);
listener.apply(this, args);
};
wrapped._original = listener;
return this.on(event, wrapped);
}
emit(event, ...args) {
const listeners = this._events[event];
if (!listeners || listeners.length === 0) {
return false;
}
for (const fn of [...listeners]) {
fn.apply(this, args);
}
return true;
}
removeListener(event, listener) {
const arr = this._events[event];
if (!arr) {
return this;
}
const idx = arr.findIndex(
(fn) => fn === listener || fn._original === listener,
);
if (idx >= 0) {
arr.splice(idx, 1);
}
return this;
}
off(event, listener) {
return this.removeListener(event, listener);
}
removeAllListeners(event) {
if (event) {
delete this._events[event];
} else {
this._events = {};
}
return this;
}
listeners(event) {
return (this._events[event] || []).slice();
}
listenerCount(event) {
return (this._events[event] || []).length;
}
addListener(event, listener) {
return this.on(event, listener);
}
prependListener(event, listener) {
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].unshift(listener);
return this;
}
eventNames() {
return Object.keys(this._events);
}
setMaxListeners() {
return this;
}
getMaxListeners() {
return 10;
}
}
export default EventEmitter;

View File

@@ -0,0 +1,54 @@
// Minimal http/https stub. Plugins needing full http.request won't work,
// but this prevents crashes for plugins that just import the module.
import { EventEmitter } from "./events.js";
export class IncomingMessage extends EventEmitter {
constructor() {
super();
this.headers = {};
this.statusCode = 0;
}
}
export class ClientRequest extends EventEmitter {
constructor() {
super();
}
end() {}
write() {}
abort() {}
destroy() {}
}
export function request(options, callback) {
const req = new ClientRequest();
if (callback) {
req.once("response", callback);
}
// Immediately error. real HTTP requests need fetch or the proxy
setTimeout(() => {
req.emit(
"error",
new Error(
"http.request is not available in the web version. Use requestUrl() instead.",
),
);
}, 0);
return req;
}
export function get(options, callback) {
const req = request(options, callback);
req.end();
return req;
}
export function createServer() {
throw new Error("http.createServer is not available in the web version.");
}
export const Agent = class {};
export const globalAgent = new Agent();

View File

@@ -0,0 +1,19 @@
function notAvailable(name) {
return function () {
throw new Error(`net.${name}() is not available in the web version.`);
};
}
export const createServer = notAvailable("createServer");
export const createConnection = notAvailable("createConnection");
export const connect = notAvailable("connect");
export class Socket {
constructor() {
throw new Error("net.Socket is not available in the web version.");
}
}
export class Server {
constructor() {
throw new Error("net.Server is not available in the web version.");
}
}

View File

@@ -0,0 +1,53 @@
export function platform() {
return "linux";
}
export function arch() {
return "x64";
}
export function homedir() {
return "/";
}
export function tmpdir() {
return "/tmp";
}
export function hostname() {
return "localhost";
}
export function type() {
return "Linux";
}
export function release() {
return "0.0.0";
}
export function cpus() {
return [{ model: "browser", speed: 0 }];
}
export function totalmem() {
return 0;
}
export function freemem() {
return 0;
}
export function networkInterfaces() {
return {};
}
export function endianness() {
return "LE";
}
export function version() {
return "v20.0.0";
}
export const EOL = "\n";

View File

@@ -0,0 +1,163 @@
// Shim for Node's `util` module.
// Implements the most commonly used functions; stubs the rest.
function promisify(fn) {
if (typeof fn !== "function") {
throw new TypeError('The "original" argument must be of type Function');
}
// If the function already has a custom promisified version, use it.
if (fn[promisify.custom]) {
return fn[promisify.custom];
}
function promisified(...args) {
return new Promise((resolve, reject) => {
fn.call(this, ...args, (err, ...results) => {
if (err) {
reject(err);
} else if (results.length <= 1) {
resolve(results[0]);
} else {
resolve(results);
}
});
});
}
return promisified;
}
promisify.custom = Symbol.for("nodejs.util.promisify.custom");
function callbackify(fn) {
if (typeof fn !== "function") {
throw new TypeError('The "original" argument must be of type Function');
}
function callbackified(...args) {
const callback = args.pop();
fn.apply(this, args).then(
(result) => callback(null, result),
(err) => callback(err),
);
}
return callbackified;
}
function inherits(ctor, superCtor) {
ctor.super_ = superCtor;
Object.setPrototypeOf(ctor.prototype, superCtor.prototype);
}
function deprecate(fn, msg) {
let warned = false;
function deprecated(...args) {
if (!warned) {
console.warn("[ignis:util] DeprecationWarning:", msg);
warned = true;
}
return fn.apply(this, args);
}
return deprecated;
}
function inspect(obj, opts) {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
function format(fmt, ...args) {
if (typeof fmt !== "string") {
return [fmt, ...args].map(String).join(" ");
}
let i = 0;
const result = fmt.replace(/%[sdjifoO%]/g, (match) => {
if (match === "%%") {
return "%";
}
if (i >= args.length) {
return match;
}
const arg = args[i++];
switch (match) {
case "%s":
return String(arg);
case "%d":
case "%i":
return parseInt(arg, 10).toString();
case "%f":
return parseFloat(arg).toString();
case "%j":
try {
return JSON.stringify(arg);
} catch {
return "[Circular]";
}
case "%o":
case "%O":
return inspect(arg);
default:
return match;
}
});
// Append remaining args.
const remaining = args.slice(i);
if (remaining.length > 0) {
return result + " " + remaining.map(String).join(" ");
}
return result;
}
function debuglog(section) {
return function () {};
}
function isDeepStrictEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
const types = {
isArray: Array.isArray,
isDate: (v) => v instanceof Date,
isRegExp: (v) => v instanceof RegExp,
isAsyncFunction: (v) => typeof v === "function" && v.constructor.name === "AsyncFunction",
isPromise: (v) => v instanceof Promise,
isGeneratorFunction: (v) => typeof v === "function" && v.constructor.name === "GeneratorFunction",
isArrayBuffer: (v) => v instanceof ArrayBuffer,
isTypedArray: (v) => ArrayBuffer.isView(v) && !(v instanceof DataView),
isMap: (v) => v instanceof Map,
isSet: (v) => v instanceof Set,
isWeakMap: (v) => v instanceof WeakMap,
isWeakSet: (v) => v instanceof WeakSet,
};
module.exports = {
promisify,
callbackify,
inherits,
deprecate,
inspect,
format,
debuglog,
isDeepStrictEqual,
types,
TextEncoder: globalThis.TextEncoder,
TextDecoder: globalThis.TextDecoder,
};

View File

@@ -0,0 +1,138 @@
// Zlib shim using pako for browser-side deflate/inflate/gzip/gunzip.
// Implements Node's zlib convenience functions (async callback + sync variants).
// Streaming classes (createDeflate, createGzip, etc.) are NOT implemented yet.
import pako from "pako";
// --- Constants ---
export const constants = {
Z_NO_FLUSH: 0,
Z_PARTIAL_FLUSH: 1,
Z_SYNC_FLUSH: 2,
Z_FULL_FLUSH: 3,
Z_FINISH: 4,
Z_BLOCK: 5,
Z_TREES: 6,
Z_OK: 0,
Z_STREAM_END: 1,
Z_NEED_DICT: 2,
Z_ERRNO: -1,
Z_STREAM_ERROR: -2,
Z_DATA_ERROR: -3,
Z_MEM_ERROR: -4,
Z_BUF_ERROR: -5,
Z_VERSION_ERROR: -6,
Z_NO_COMPRESSION: 0,
Z_BEST_SPEED: 1,
Z_BEST_COMPRESSION: 9,
Z_DEFAULT_COMPRESSION: -1,
Z_FILTERED: 1,
Z_HUFFMAN_ONLY: 2,
Z_RLE: 3,
Z_FIXED: 4,
Z_DEFAULT_STRATEGY: 0,
Z_DEFAULT_WINDOWBITS: 15,
Z_DEFAULT_MEMLEVEL: 8,
};
// --- Helpers ---
function toUint8Array(buf) {
if (buf instanceof Uint8Array) {
return buf;
}
if (typeof buf === "string") {
return new TextEncoder().encode(buf);
}
if (buf instanceof ArrayBuffer) {
return new Uint8Array(buf);
}
if (ArrayBuffer.isView(buf)) {
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
return new Uint8Array(buf);
}
function wrapAsync(syncFn) {
return function (buf, optionsOrCb, cb) {
if (typeof optionsOrCb === "function") {
cb = optionsOrCb;
optionsOrCb = {};
}
try {
const result = syncFn(buf, optionsOrCb || {});
if (cb) {
queueMicrotask(() => cb(null, result));
}
} catch (e) {
if (cb) {
queueMicrotask(() => cb(e));
}
}
};
}
// --- Sync functions ---
export function deflateSync(buf, options) {
return pako.deflate(toUint8Array(buf), options);
}
export function inflateSync(buf, options) {
return pako.inflate(toUint8Array(buf), options);
}
export function deflateRawSync(buf, options) {
return pako.deflateRaw(toUint8Array(buf), options);
}
export function inflateRawSync(buf, options) {
return pako.inflateRaw(toUint8Array(buf), options);
}
export function gzipSync(buf, options) {
return pako.gzip(toUint8Array(buf), options);
}
export function gunzipSync(buf, options) {
return pako.ungzip(toUint8Array(buf), options);
}
export function unzipSync(buf, options) {
return pako.ungzip(toUint8Array(buf), options);
}
// --- Async functions (callback style) ---
export const deflate = wrapAsync(deflateSync);
export const inflate = wrapAsync(inflateSync);
export const deflateRaw = wrapAsync(deflateRawSync);
export const inflateRaw = wrapAsync(inflateRawSync);
export const gzip = wrapAsync(gzipSync);
export const gunzip = wrapAsync(gunzipSync);
export const unzip = wrapAsync(unzipSync);
// --- Streaming stubs (not yet implemented) ---
function notImplemented(name) {
return function () {
throw new Error(
`zlib.${name}() streaming is not yet implemented. Use the sync/callback variants instead.`,
);
};
}
export const createDeflate = notImplemented("createDeflate");
export const createInflate = notImplemented("createInflate");
export const createDeflateRaw = notImplemented("createDeflateRaw");
export const createInflateRaw = notImplemented("createInflateRaw");
export const createGzip = notImplemented("createGzip");
export const createGunzip = notImplemented("createGunzip");
export const createUnzip = notImplemented("createUnzip");

18
packages/shim/src/path.js Normal file
View File

@@ -0,0 +1,18 @@
// Path shim. delegates to path-browserify (bundled via esbuild alias)
// Configured for posix mode since vault paths are normalized to forward slashes.
import pathBrowserify from "path";
const _origBasename = pathBrowserify.basename;
export const pathShim = {
...pathBrowserify,
basename(p, ext) {
// Vault root "/" should return the vault name for display purposes
if (p === "/" && window.__currentVaultId) {
return window.__currentVaultId;
}
return _origBasename(p, ext);
},
};

View File

@@ -0,0 +1,19 @@
export const processShim = {
platform: "linux",
versions: {
electron: "28.2.3",
node: "18.18.0",
chrome: "120.0.0.0",
},
env: {},
cwd: () => "/",
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
argv: [],
type: "renderer",
resourcesPath: "/",
stdout: { write: (s) => console.log(s) },
stderr: { write: (s) => console.error(s) },
on: () => {},
once: () => {},
removeListener: () => {},
};

View File

@@ -0,0 +1,120 @@
// Override window.requestUrl to proxy external requests through our server, bypassing CORS.
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
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 proxyRequestUrl(request) {
if (typeof request === "string") {
request = { url: request };
}
const isSameOrigin =
request.url.startsWith(window.location.origin) ||
request.url.startsWith("/");
// Same-origin requests don't need the proxy
if (isSameOrigin) {
const res = await fetch(request.url, {
method: request.method || "GET",
headers: request.headers || {},
body: request.body,
});
const arrayBuf = await res.arrayBuffer();
return makeResponse(
request,
res.status,
Object.fromEntries(res.headers),
arrayBuf,
);
}
// Cross-origin: route through server proxy
let body = request.body;
let binary = false;
if (body instanceof ArrayBuffer) {
body = arrayBufferToBase64(body);
binary = true;
}
const res = await fetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
body,
binary,
}),
});
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ error: "Proxy request failed" }));
throw new Error(err.error);
}
const proxyResult = await res.json();
const arrayBuf = base64ToArrayBuffer(proxyResult.body);
return makeResponse(
request,
proxyResult.status,
proxyResult.headers,
arrayBuf,
);
}
function makeResponse(request, status, headers, arrayBuf) {
const text = new TextDecoder().decode(arrayBuf);
let json;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { status, headers, arrayBuffer: arrayBuf, text, json };
}
export function installRequestUrlShim() {
// Obsidian sets window.requestUrl in app.js. We override it once the page loads.
// Use a getter so it intercepts even if app.js sets it later.
let _original = null;
Object.defineProperty(window, "requestUrl", {
get() {
return proxyRequestUrl;
},
set(val) {
_original = val;
},
configurable: true,
});
}

View File

@@ -0,0 +1,69 @@
import { electronShim } from "./electron/index.js";
import { remoteShim } from "./electron/remote/index.js";
import { fsShim } from "./fs/index.js";
import { pathShim } from "./path.js";
import { urlShim } from "./url.js";
import { cryptoShim } from "./crypto/index.js";
import * as childProcessShim from "./node/child_process.js";
import * as eventsShim from "./node/events.js";
import * as osShim from "./node/os.js";
import * as netShim from "./node/net.js";
import * as httpShim from "./node/http.js";
import * as zlibShim from "./node/zlib.js";
import * as utilShim from "./node/util.js";
import { wrapWithProxy, installDebugHelpers } from "./debug.js";
const rawRegistry = {
electron: electronShim,
"@electron/remote": remoteShim,
"original-fs": fsShim,
fs: fsShim,
path: pathShim,
url: urlShim,
crypto: cryptoShim,
child_process: childProcessShim,
events: eventsShim,
os: osShim,
net: netShim,
http: httpShim,
https: httpShim,
zlib: zlibShim,
util: utilShim,
};
const shimRegistry = {};
const throwOnRequire = new Set(["btime", "get-fonts", "vibrancy-win"]);
export function installRequire() {
for (const [name, shim] of Object.entries(rawRegistry)) {
shimRegistry[name] = wrapWithProxy(shim, name);
}
// Add buffer shim (protobufjs inquire() checks for this)
if (typeof window.Buffer !== "undefined") {
shimRegistry.buffer = window.Buffer;
}
// Add empty long shim (optional protobufjs dependency, gracefully handled)
shimRegistry.long = undefined;
window.require = function (moduleName) {
// Strip node: prefix if present
const normalizedName = moduleName.startsWith("node:")
? moduleName.slice(5)
: moduleName;
if (throwOnRequire.has(normalizedName)) {
throw new Error(`Cannot find module '${moduleName}'`);
}
if (shimRegistry[normalizedName]) {
return shimRegistry[normalizedName];
}
console.warn("[ignis] Unshimmed require:", moduleName);
return wrapWithProxy({}, `UNKNOWN(${moduleName})`);
};
installDebugHelpers(rawRegistry);
}

View File

@@ -0,0 +1,26 @@
// Use a runtime registry to avoid bloating bundles with imported component code.
let handlers = {};
export function registerUI(impls) {
handlers = { ...handlers, ...impls };
}
function proxy(name) {
return (...args) => {
const fn = handlers[name];
if (typeof fn !== "function") {
console.warn(`[ignis] UI handler '${name}' not registered`);
return undefined;
}
return fn(...args);
};
}
export const showVaultManager = proxy("showVaultManager");
export const showMessageDialog = proxy("showMessageDialog");
export const showConfirmDialog = proxy("showConfirmDialog");
export const showPluginInstallDialog = proxy("showPluginInstallDialog");
export const showPromptDialog = proxy("showPromptDialog");

24
packages/shim/src/url.js Normal file
View File

@@ -0,0 +1,24 @@
export const urlShim = {
URL: globalThis.URL,
URLSearchParams: globalThis.URLSearchParams,
pathToFileURL(p) {
// Return an object with .href matching Node's url.pathToFileURL behavior
const encoded = encodeURI(p.replace(/\\/g, "/"));
const href = "file:///" + encoded.replace(/^\/+/, "");
return { href, toString: () => href };
},
fileURLToPath(url) {
let str = typeof url === "string" ? url : url.href || url.toString();
if (str.startsWith("file:///")) {
str = str.slice(8);
} else if (str.startsWith("file://")) {
str = str.slice(7);
}
return decodeURI(str);
},
};

View File

@@ -0,0 +1,250 @@
import { fsShim } from "./fs/index.js";
import {
registerPathResolver,
registerReadTransform,
registerWriteTransform,
} from "./fs/transforms.js";
const WORKSPACE_PATH = ".obsidian/workspace.json";
const WORKSPACES_PATH = ".obsidian/workspaces.json";
// Redirect workspace.json to a per-name file when a workspace is active in this tab.
registerPathResolver(
(path) => path === WORKSPACE_PATH && !!window.__workspaceName,
() => `.obsidian/workspace.${window.__workspaceName}.json`,
);
// Keep workspaces.json's active field at the canonical value on disk so other tabs see a stable state.
registerWriteTransform(WORKSPACES_PATH, (content) => {
const original = window.__originalActiveWorkspace;
if (!original || !window.__workspaceName) {
return content;
}
if (typeof content !== "string") {
return content;
}
try {
const parsed = JSON.parse(content);
if (parsed.active !== original) {
parsed.active = original;
return JSON.stringify(parsed);
}
} catch {}
return content;
});
function setWorkspaceParam(name) {
const url = new URL(window.location.href);
if (name) {
url.searchParams.set("workspace", name);
} else {
url.searchParams.delete("workspace");
}
history.replaceState(null, "", url.toString());
}
// When ?load=preset is set, copy the named preset from workspaces.json into this tab's per-workspace state file.
// This overwrites any stale state from a prior session.
// Then strip the param so a page reload doesn't keep resetting.
export function loadPresetIfRequested() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("load") !== "preset" || !window.__workspaceName) {
return;
}
try {
const presetsText = fsShim.readFileSync(WORKSPACES_PATH, "utf-8");
const presets = JSON.parse(presetsText);
const preset =
presets.workspaces && presets.workspaces[window.__workspaceName];
if (!preset) {
console.warn(
"[ignis] load=preset requested but no preset found for:",
window.__workspaceName,
);
return;
}
// Path resolver routes this write to workspace.<name>.json.
fsShim.writeFileSync(WORKSPACE_PATH, JSON.stringify(preset), "utf-8");
console.log("[ignis] Loaded preset for workspace:", window.__workspaceName);
} catch (e) {
console.warn("[ignis] Failed to load preset:", e);
} finally {
const url = new URL(window.location.href);
url.searchParams.delete("load");
history.replaceState(null, "", url.toString());
}
}
export function resolveWorkspaceName() {
try {
const vaultParam = window.__currentVaultId
? "?vault=" + encodeURIComponent(window.__currentVaultId)
: "";
const sep = vaultParam ? "&" : "?";
// If no param provided, check if workspaces plugin is enabled before resolving.
if (!window.__workspaceName) {
const coreXhr = new XMLHttpRequest();
coreXhr.open(
"GET",
"/api/fs/readFile" +
vaultParam +
sep +
"path=.obsidian/core-plugins.json&encoding=utf-8",
false,
);
coreXhr.send();
if (coreXhr.status !== 200) {
return;
}
const corePlugins = JSON.parse(coreXhr.responseText);
if (!corePlugins.workspaces) {
return;
}
}
// Read workspaces.json to get the active field.
const xhr = new XMLHttpRequest();
xhr.open(
"GET",
"/api/fs/readFile" +
vaultParam +
sep +
"path=.obsidian/workspaces.json&encoding=utf-8",
false,
);
xhr.send();
if (xhr.status !== 200) {
return;
}
const workspaces = JSON.parse(xhr.responseText);
// Always store the original active value for the write transform.
if (workspaces.active) {
window.__originalActiveWorkspace = workspaces.active;
}
// If no param was provided, seed from the active workspace.
if (!window.__workspaceName && workspaces.active) {
window.__workspaceName = workspaces.active;
setWorkspaceParam(workspaces.active);
console.log("[ignis] Workspace resolved from active:", workspaces.active);
}
} catch (e) {
console.warn("[ignis] Failed to resolve workspace name:", e);
}
}
export function initWorkspacePatch() {
const observer = new MutationObserver(() => {
if (!document.querySelector(".workspace")) {
return;
}
const plugin =
window.app &&
window.app.internalPlugins &&
window.app.internalPlugins.plugins &&
window.app.internalPlugins.plugins.workspaces;
if (!plugin || !plugin.enabled || !plugin.instance) {
return;
}
observer.disconnect();
const instance = plugin.instance;
const origLoad = instance.loadWorkspace.bind(instance);
const origSave = instance.saveWorkspace.bind(instance);
instance.loadWorkspace = function (name) {
window.__workspaceName = name;
setWorkspaceParam(name);
fsShim.invalidate(WORKSPACE_PATH);
return origLoad(name);
};
instance.saveWorkspace = function (name) {
// Grab the current layout before changing __workspaceName.
let currentLayout = null;
try {
currentLayout = fsShim.readFileSync(WORKSPACE_PATH, "utf-8");
} catch {}
window.__workspaceName = name;
setWorkspaceParam(name);
fsShim.invalidate(WORKSPACE_PATH);
const result = origSave(name);
// Write the layout to the new workspace file so it exists on disk immediately.
if (currentLayout) {
fsShim.writeFileSync(WORKSPACE_PATH, currentLayout, "utf-8");
}
return result;
};
// Override the active field on reads so the menu matches this tab's workspace.
registerReadTransform(WORKSPACES_PATH, (data) => {
if (!window.__workspaceName) {
return data;
}
let text =
typeof data === "string" ? data : new TextDecoder().decode(data);
try {
const parsed = JSON.parse(text);
if (parsed.active !== window.__workspaceName) {
parsed.active = window.__workspaceName;
return JSON.stringify(parsed);
}
} catch {}
return data;
});
// Relay watcher events for workspaces.json to the plugin's config change handler,
// so creating/deleting workspaces in one tab updates the menu in other tabs.
fsShim.watch(".obsidian", (eventType, filename) => {
if (filename === "workspaces.json") {
plugin.loadData().then((data) => {
if (data) {
instance.workspaces = data.workspaces || {};
}
});
}
});
console.log(
"[ignis] Workspaces plugin patched, workspace:",
window.__workspaceName || "(none)",
);
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}