mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
move shim to new package
This commit is contained in:
20
packages/shim/build.js
Normal file
20
packages/shim/build.js
Normal 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",
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/shim/src/btime.js
Normal file
4
packages/shim/src/btime.js
Normal 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;
|
||||
88
packages/shim/src/crypto/create-hash.js
Normal file
88
packages/shim/src/crypto/create-hash.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
86
packages/shim/src/crypto/create-hash.test.js
Normal file
86
packages/shim/src/crypto/create-hash.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
11
packages/shim/src/crypto/index.js
Normal file
11
packages/shim/src/crypto/index.js
Normal 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,
|
||||
};
|
||||
20
packages/shim/src/crypto/random-bytes.js
Normal file
20
packages/shim/src/crypto/random-bytes.js
Normal 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;
|
||||
}
|
||||
3
packages/shim/src/crypto/random-uuid.js
Normal file
3
packages/shim/src/crypto/random-uuid.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function randomUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
26
packages/shim/src/crypto/scrypt.js
Normal file
26
packages/shim/src/crypto/scrypt.js
Normal 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"));
|
||||
}
|
||||
}
|
||||
9
packages/shim/src/css-overrides.js
Normal file
9
packages/shim/src/css-overrides.js
Normal 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);
|
||||
}
|
||||
47
packages/shim/src/debug.js
Normal file
47
packages/shim/src/debug.js
Normal 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
61
packages/shim/src/demo.js
Normal 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;
|
||||
}
|
||||
51
packages/shim/src/electron/index.js
Normal file
51
packages/shim/src/electron/index.js
Normal 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;
|
||||
},
|
||||
},
|
||||
};
|
||||
260
packages/shim/src/electron/ipc-renderer.js
Normal file
260
packages/shim/src/electron/ipc-renderer.js
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
43
packages/shim/src/electron/remote/app.js
Normal file
43
packages/shim/src/electron/remote/app.js
Normal 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() {},
|
||||
};
|
||||
64
packages/shim/src/electron/remote/clipboard.js
Normal file
64
packages/shim/src/electron/remote/clipboard.js
Normal 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(() => {});
|
||||
},
|
||||
};
|
||||
259
packages/shim/src/electron/remote/dialog.js
Normal file
259
packages/shim/src/electron/remote/dialog.js
Normal 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);
|
||||
},
|
||||
};
|
||||
50
packages/shim/src/electron/remote/index.js
Normal file
50
packages/shim/src/electron/remote/index.js
Normal 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();
|
||||
},
|
||||
};
|
||||
53
packages/shim/src/electron/remote/menu.js
Normal file
53
packages/shim/src/electron/remote/menu.js
Normal 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 || "";
|
||||
}
|
||||
}
|
||||
83
packages/shim/src/electron/remote/native-image.js
Normal file
83
packages/shim/src/electron/remote/native-image.js
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
37
packages/shim/src/electron/remote/notification.js
Normal file
37
packages/shim/src/electron/remote/notification.js
Normal 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;
|
||||
}
|
||||
}
|
||||
40
packages/shim/src/electron/remote/screen.js
Normal file
40
packages/shim/src/electron/remote/screen.js
Normal 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() {},
|
||||
};
|
||||
20
packages/shim/src/electron/remote/session.js
Normal file
20
packages/shim/src/electron/remote/session.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export const sessionShim = {
|
||||
defaultSession: {
|
||||
clearCache() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
clearStorageData() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
setSpellCheckerLanguages(langs) {},
|
||||
getSpellCheckerLanguages() {
|
||||
return [];
|
||||
},
|
||||
|
||||
on() {},
|
||||
once() {},
|
||||
removeListener() {},
|
||||
},
|
||||
};
|
||||
15
packages/shim/src/electron/remote/shell.js
Normal file
15
packages/shim/src/electron/remote/shell.js
Normal 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);
|
||||
},
|
||||
};
|
||||
21
packages/shim/src/electron/remote/system-preferences.js
Normal file
21
packages/shim/src/electron/remote/system-preferences.js
Normal 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() {},
|
||||
};
|
||||
64
packages/shim/src/electron/remote/theme.js
Normal file
64
packages/shim/src/electron/remote/theme.js
Normal 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;
|
||||
},
|
||||
};
|
||||
419
packages/shim/src/electron/remote/window.js
Normal file
419
packages/shim/src/electron/remote/window.js
Normal 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;
|
||||
},
|
||||
};
|
||||
24
packages/shim/src/electron/web-frame.js
Normal file
24
packages/shim/src/electron/web-frame.js
Normal 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;
|
||||
},
|
||||
};
|
||||
20
packages/shim/src/fs/constants.js
Normal file
20
packages/shim/src/fs/constants.js
Normal 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,
|
||||
};
|
||||
95
packages/shim/src/fs/content-cache.js
Normal file
95
packages/shim/src/fs/content-cache.js
Normal 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(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
92
packages/shim/src/fs/content-cache.test.js
Normal file
92
packages/shim/src/fs/content-cache.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
31
packages/shim/src/fs/echo-guard.js
Normal file
31
packages/shim/src/fs/echo-guard.js
Normal 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
173
packages/shim/src/fs/fd.js
Normal 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,
|
||||
};
|
||||
}
|
||||
275
packages/shim/src/fs/fd.test.js
Normal file
275
packages/shim/src/fs/fd.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
75
packages/shim/src/fs/index.js
Normal file
75
packages/shim/src/fs/index.js
Normal 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);
|
||||
},
|
||||
};
|
||||
113
packages/shim/src/fs/indexer-prefetch.js
Normal file
113
packages/shim/src/fs/indexer-prefetch.js
Normal 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`,
|
||||
);
|
||||
}
|
||||
123
packages/shim/src/fs/input-cache.js
Normal file
123
packages/shim/src/fs/input-cache.js
Normal 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/");
|
||||
}
|
||||
130
packages/shim/src/fs/metadata-cache.js
Normal file
130
packages/shim/src/fs/metadata-cache.js
Normal 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(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
213
packages/shim/src/fs/metadata-cache.test.js
Normal file
213
packages/shim/src/fs/metadata-cache.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
303
packages/shim/src/fs/promises.js
Normal file
303
packages/shim/src/fs/promises.js
Normal 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
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
184
packages/shim/src/fs/sync.js
Normal file
184
packages/shim/src/fs/sync.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
96
packages/shim/src/fs/transforms.js
Normal file
96
packages/shim/src/fs/transforms.js
Normal 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();
|
||||
}
|
||||
80
packages/shim/src/fs/transforms.test.js
Normal file
80
packages/shim/src/fs/transforms.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
224
packages/shim/src/fs/transport.js
Normal file
224
packages/shim/src/fs/transport.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
109
packages/shim/src/fs/watch.js
Normal file
109
packages/shim/src/fs/watch.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
147
packages/shim/src/fs/watcher-client.js
Normal file
147
packages/shim/src/fs/watcher-client.js
Normal 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,
|
||||
};
|
||||
}
|
||||
292
packages/shim/src/globals.js
Normal file
292
packages/shim/src/globals.js
Normal 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
251
packages/shim/src/init.js
Normal 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();
|
||||
}
|
||||
30
packages/shim/src/loader.js
Normal file
30
packages/shim/src/loader.js
Normal 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");
|
||||
15
packages/shim/src/node/child_process.js
Normal file
15
packages/shim/src/node/child_process.js
Normal 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");
|
||||
106
packages/shim/src/node/events.js
Normal file
106
packages/shim/src/node/events.js
Normal 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;
|
||||
54
packages/shim/src/node/http.js
Normal file
54
packages/shim/src/node/http.js
Normal 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();
|
||||
19
packages/shim/src/node/net.js
Normal file
19
packages/shim/src/node/net.js
Normal 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.");
|
||||
}
|
||||
}
|
||||
53
packages/shim/src/node/os.js
Normal file
53
packages/shim/src/node/os.js
Normal 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";
|
||||
163
packages/shim/src/node/util.js
Normal file
163
packages/shim/src/node/util.js
Normal 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,
|
||||
};
|
||||
138
packages/shim/src/node/zlib.js
Normal file
138
packages/shim/src/node/zlib.js
Normal 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
18
packages/shim/src/path.js
Normal 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);
|
||||
},
|
||||
};
|
||||
19
packages/shim/src/process.js
Normal file
19
packages/shim/src/process.js
Normal 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: () => {},
|
||||
};
|
||||
120
packages/shim/src/request-url.js
Normal file
120
packages/shim/src/request-url.js
Normal 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,
|
||||
});
|
||||
}
|
||||
69
packages/shim/src/require.js
Normal file
69
packages/shim/src/require.js
Normal 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);
|
||||
}
|
||||
26
packages/shim/src/ui-registry.js
Normal file
26
packages/shim/src/ui-registry.js
Normal 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
24
packages/shim/src/url.js
Normal 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);
|
||||
},
|
||||
};
|
||||
250
packages/shim/src/workspace.js
Normal file
250
packages/shim/src/workspace.js
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user