mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
minor refactor, cleanup
This commit is contained in:
43
ARCHITECTURE.md
Normal file
43
ARCHITECTURE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Architecture
|
||||
|
||||
This document covers how the shim layer is structured.
|
||||
|
||||
## Loading
|
||||
|
||||
The index file is patched to run the shim loader first. It replaces the module system and makes a blocking HTTP request to fetch the vault's directory tree into memory. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
|
||||
|
||||
## Shims
|
||||
|
||||
| Module | Implementation |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| `fs` / `original-fs` | HTTP transport + client-side metadata/content caches |
|
||||
| `electron` | ipcRenderer dispatcher, webFrame stubs |
|
||||
| `@electron/remote` | Partial: clipboard (browser API), shell, dialog, Menu, BrowserWindow, nativeTheme |
|
||||
| `path` | path-browserify |
|
||||
| `crypto` | Web Crypto (randomBytes, createHash, scrypt) |
|
||||
| `url` | Browser URL API wrapper |
|
||||
| `process` | Platform/version stubs |
|
||||
|
||||
Unknown modules return an empty proxy and log a warning. The shim exposes two console helpers, one showing everything that has been accessed and one showing what is missing.
|
||||
|
||||
## Filesystem
|
||||
|
||||
On page load the server returns the full directory tree, which gets cached in memory with paths, sizes, and modification times. Sync filesystem calls hit the cache rather than the network. File contents are cached after first read and written through immediately on writes.
|
||||
|
||||
Sync calls use synchronous XHR, to ensure blocking behavior. Async calls use fetch. Everything goes through a transport layer that handles vault ID injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values.
|
||||
|
||||
## IPC
|
||||
|
||||
IPC is faked with a synchronous dispatcher that maps channel names to handlers.
|
||||
|
||||
## Vaults
|
||||
|
||||
Any subdirectory under the vault root is treated as a vault. The active vault is selected via a URL parameter. A custom vault manager modal replaces Obsidian's native startup screen.
|
||||
|
||||
## Plugins
|
||||
|
||||
Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which is our shim. Plugins that use the filesystem, path utilities, or crypto get our implementations without any changes. Plugins that need child processes or native addons won't work.
|
||||
|
||||
## Server
|
||||
|
||||
A simple Express server that handles filesystem operations, vault management, and static file serving.
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,4 +1,3 @@
|
||||
# Stage 1: Build shims and extract/patch Obsidian
|
||||
FROM node:20-slim AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -10,20 +9,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install dependencies first (layer caching)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Copy source
|
||||
COPY build.js ./
|
||||
COPY shims/ ./shims/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY server/ ./server/
|
||||
|
||||
# Build shim-loader bundle
|
||||
RUN npm run build:shims
|
||||
|
||||
# Download and extract Obsidian
|
||||
|
||||
ARG OBSIDIAN_VERSION=1.8.9
|
||||
RUN curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian_${OBSIDIAN_VERSION}_amd64.deb" \
|
||||
-o /tmp/obsidian.deb \
|
||||
@@ -33,7 +29,7 @@ RUN curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download
|
||||
&& tar -xf /tmp/obsidian-deb/data.tar.xz -C /tmp/obsidian-pkg \
|
||||
&& rm -rf /tmp/obsidian.deb /tmp/obsidian-deb
|
||||
|
||||
# Extract asar
|
||||
|
||||
RUN npx --yes @electron/asar extract \
|
||||
/tmp/obsidian-pkg/opt/Obsidian/resources/obsidian.asar \
|
||||
/build/obsidian-app \
|
||||
@@ -42,10 +38,9 @@ RUN npx --yes @electron/asar extract \
|
||||
# Patch index.html
|
||||
RUN node scripts/patch-obsidian.js /build/obsidian-app
|
||||
|
||||
# Copy built shim-loader into the obsidian app directory
|
||||
RUN cp dist/shim-loader.js /build/obsidian-app/shim-loader.js
|
||||
|
||||
# Stage 2: Production image
|
||||
# Production image
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
10
README.md
10
README.md
@@ -1,3 +1,13 @@
|
||||
# Ignis
|
||||
|
||||
An Electron shim and server bridge for running Obsidian in a browser.
|
||||
|
||||
## How it works
|
||||
|
||||
Ignis replaces the electron backend of Obsidian with a browser-compatible 'shim' that intercepts calls to Node.js and Electron APIs and routes them to a server.
|
||||
|
||||
An in-memory metadata cache is built on page load so that sync filesystem calls (`existsSync`, `statSync`, etc.) work without round-tripping to the server every time. Async reads and writes go over HTTP. IPC channels like `ipcRenderer.sendSync("vault")` are faked with a dispatcher that returns what Obsidian expects. Native stuff like clipboard, menus, and dialogs have minimal stubs.
|
||||
|
||||
## Status
|
||||
|
||||
Ignis is in an experimental state. Basic functionality works but no guarantee of stability or feature completeness. See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Patches the extracted Obsidian asar for browser use:
|
||||
// 1. Removes Content-Security-Policy meta tag
|
||||
// 2. Injects shim-loader.js script (non-deferred, before all other scripts)
|
||||
// Patches both index.html and starter.html.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
@@ -7,7 +7,6 @@ const app = express();
|
||||
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
|
||||
// --- Request logging ---
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
const origEnd = res.end;
|
||||
@@ -29,7 +28,6 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// --- Routes ---
|
||||
const fsRoutes = require("./routes/fs");
|
||||
const vaultRoutes = require("./routes/vault");
|
||||
|
||||
@@ -51,15 +49,10 @@ app.use("/vault-files", (req, res, next) => {
|
||||
express.static(vaultPath)(req, res, next);
|
||||
});
|
||||
|
||||
// --- Static serving ---
|
||||
// dist/ has shim-loader.js + patched index.html (dev mode).
|
||||
// In Docker, these live inside the obsidian assets dir instead.
|
||||
app.use(express.static(path.join(__dirname, "..", "dist")));
|
||||
|
||||
// Serve obsidian assets (app.js, app.css, libs, fonts, etc.)
|
||||
app.use(express.static(config.obsidianAssetsPath));
|
||||
|
||||
// --- Start ---
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(
|
||||
`[obsidian-bridge] Server running on http://localhost:${config.port}`,
|
||||
|
||||
@@ -17,8 +17,7 @@ function getVaultRoot(req, res) {
|
||||
}
|
||||
|
||||
// Resolve a client-provided path to an absolute path within a vault.
|
||||
// Strips leading slashes so paths from the client are always treated as
|
||||
// relative to the vault root. Rejects path traversal attempts.
|
||||
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
||||
function resolveVaultPath(vaultRoot, relativePath) {
|
||||
const cleaned = (relativePath || "").replace(/^\/+/, "");
|
||||
const resolved = path.resolve(vaultRoot, cleaned);
|
||||
@@ -46,6 +45,19 @@ function guardPath(req, res) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Same as guardPath but reads path from req.body (POST routes)
|
||||
function guardBodyPath(req, res) {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
if (!vaultRoot) return null;
|
||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
||||
if (!resolved) {
|
||||
res.status(403).json({ error: "Invalid path" });
|
||||
return null;
|
||||
}
|
||||
req._vaultRoot = vaultRoot;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// GET /api/fs/stat?path=...
|
||||
router.get("/stat", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
@@ -70,7 +82,7 @@ router.get("/readdir", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
if (!resolved) return;
|
||||
try {
|
||||
// Check if path is a file - return ENOTDIR instead of crashing
|
||||
// Check if path is a file. return ENOTDIR instead of crashing
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
return res
|
||||
@@ -123,10 +135,8 @@ router.get("/readFile", async (req, res) => {
|
||||
|
||||
// POST /api/fs/writeFile { path, content, encoding?, vault? }
|
||||
router.post("/writeFile", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
if (!vaultRoot) return;
|
||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||
const resolved = guardBodyPath(req, res);
|
||||
if (!resolved) return;
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(resolved);
|
||||
@@ -151,10 +161,8 @@ router.post("/writeFile", async (req, res) => {
|
||||
|
||||
// POST /api/fs/appendFile { path, content, vault? }
|
||||
router.post("/appendFile", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
if (!vaultRoot) return;
|
||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||
const resolved = guardBodyPath(req, res);
|
||||
if (!resolved) return;
|
||||
try {
|
||||
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
|
||||
res.json({ ok: true });
|
||||
@@ -165,10 +173,8 @@ router.post("/appendFile", async (req, res) => {
|
||||
|
||||
// POST /api/fs/mkdir { path, recursive?, vault? }
|
||||
router.post("/mkdir", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
if (!vaultRoot) return;
|
||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||
const resolved = guardBodyPath(req, res);
|
||||
if (!resolved) return;
|
||||
try {
|
||||
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
|
||||
res.json({ ok: true });
|
||||
@@ -277,10 +283,8 @@ router.get("/realpath", async (req, res) => {
|
||||
|
||||
// POST /api/fs/utimes { path, atime, mtime, vault? }
|
||||
router.post("/utimes", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
if (!vaultRoot) return;
|
||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||
const resolved = guardBodyPath(req, res);
|
||||
if (!resolved) return;
|
||||
try {
|
||||
await fs.promises.utimes(
|
||||
resolved,
|
||||
@@ -293,7 +297,7 @@ router.post("/utimes", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/tree?path=...&vault=... - returns full recursive file tree with metadata
|
||||
// GET /api/fs/tree?path=...&vault=... returns full recursive file tree with metadata
|
||||
router.get("/tree", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
if (!vaultRoot) return;
|
||||
|
||||
@@ -5,7 +5,7 @@ const path = require("path");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/vault/list - returns all discovered vaults (re-scans on each call)
|
||||
// GET /api/vault/list - returns all discovered vaults (re-scans on each call)
|
||||
router.get("/list", (req, res) => {
|
||||
config.refreshVaults();
|
||||
const list = Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
||||
@@ -16,7 +16,7 @@ router.get("/list", (req, res) => {
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
// GET /api/vault/info?vault=<id> - returns info for a specific vault
|
||||
// GET /api/vault/info?vault=<id> - returns info for a specific vault
|
||||
router.get("/info", (req, res) => {
|
||||
const vaultId = req.query.vault || config.defaultVaultId;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
@@ -32,7 +32,7 @@ router.get("/info", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/vault/create { name } - create a new vault in VAULT_ROOT
|
||||
// POST /api/vault/create { name } - create a new vault in VAULT_ROOT
|
||||
router.post("/create", async (req, res) => {
|
||||
const name = req.body?.name;
|
||||
if (!name || /[\/\\:*?"<>|]/.test(name)) {
|
||||
@@ -54,7 +54,7 @@ router.post("/create", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/vault/remove?vault=<id> - remove a vault from disk
|
||||
// DELETE /api/vault/remove?vault=<id> - remove a vault from disk
|
||||
router.delete("/remove", async (req, res) => {
|
||||
const vaultId = req.query.vault;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Shim for the btime native module (file birth time)
|
||||
// Obsidian wraps this in try/catch: try{this.btime=window.require("btime")}catch(e){}
|
||||
// Returning null causes graceful degradation - mtime is used instead.
|
||||
// Returning null causes graceful degradation. mtime is used instead.
|
||||
|
||||
export const btimeShim = null;
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
// Shim for crypto.createHash
|
||||
// Obsidian uses createHash('SHA256') for signature verification (main process only)
|
||||
// and possibly for content hashing in the renderer.
|
||||
// Uses SubtleCrypto where possible.
|
||||
|
||||
export function createHash(algorithm) {
|
||||
const alg = algorithm.toUpperCase().replace('-', '');
|
||||
const subtleAlg = alg === 'SHA256' ? 'SHA-256' : alg === 'SHA1' ? 'SHA-1' : alg === 'SHA512' ? 'SHA-512' : alg;
|
||||
const alg = algorithm.toUpperCase().replace("-", "");
|
||||
const subtleAlg =
|
||||
alg === "SHA256"
|
||||
? "SHA-256"
|
||||
: alg === "SHA1"
|
||||
? "SHA-1"
|
||||
: alg === "SHA512"
|
||||
? "SHA-512"
|
||||
: alg;
|
||||
|
||||
let inputData = new Uint8Array(0);
|
||||
|
||||
return {
|
||||
update(data) {
|
||||
if (typeof data === 'string') {
|
||||
if (typeof data === "string") {
|
||||
data = new TextEncoder().encode(data);
|
||||
}
|
||||
// Concatenate
|
||||
const merged = new Uint8Array(inputData.length + data.length);
|
||||
merged.set(inputData);
|
||||
merged.set(data, inputData.length);
|
||||
@@ -22,27 +23,23 @@ export function createHash(algorithm) {
|
||||
return this;
|
||||
},
|
||||
|
||||
// Note: digest is sync in Node but we may need async.
|
||||
// For now provide sync hex/base64 via a simple JS implementation.
|
||||
// TODO: evaluate if any sync call sites exist; if not, make this async.
|
||||
digest(encoding) {
|
||||
// Fallback: simple sync hash (for SHA-256 only)
|
||||
// This is a placeholder - swap in a proper sync implementation if needed
|
||||
console.warn('[shim:crypto] createHash.digest - using placeholder');
|
||||
console.warn("[shim:crypto] createHash.digest - using placeholder");
|
||||
const hash = simpleHash(inputData);
|
||||
if (encoding === 'hex') return hash;
|
||||
if (encoding === 'base64') return btoa(hash);
|
||||
if (encoding === "hex") return hash;
|
||||
if (encoding === "base64") return btoa(hash);
|
||||
return hash;
|
||||
},
|
||||
|
||||
// Async alternative for contexts that can await
|
||||
async digestAsync(encoding) {
|
||||
const hashBuffer = await crypto.subtle.digest(subtleAlg, inputData);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
if (encoding === 'hex') {
|
||||
return Array.from(hashArray).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
if (encoding === "hex") {
|
||||
return Array.from(hashArray)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
if (encoding === 'base64') {
|
||||
if (encoding === "base64") {
|
||||
return btoa(String.fromCharCode(...hashArray));
|
||||
}
|
||||
return hashArray;
|
||||
@@ -50,11 +47,10 @@ export function createHash(algorithm) {
|
||||
};
|
||||
}
|
||||
|
||||
// Very basic placeholder hash - not cryptographic, just for bootstrapping
|
||||
function simpleHash(data) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hash = ((hash << 5) - hash + data[i]) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(8, '0');
|
||||
return Math.abs(hash).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Crypto shim
|
||||
// Obsidian uses: scrypt, randomBytes, createHash
|
||||
|
||||
import { randomBytes } from './random-bytes.js';
|
||||
import { createHash } from './create-hash.js';
|
||||
import { scrypt } from './scrypt.js';
|
||||
import { randomBytes } from "./random-bytes.js";
|
||||
import { createHash } from "./create-hash.js";
|
||||
import { scrypt } from "./scrypt.js";
|
||||
|
||||
export const cryptoShim = {
|
||||
randomBytes,
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// Shim for crypto.randomBytes
|
||||
// Uses Web Crypto API under the hood
|
||||
|
||||
export function randomBytes(size) {
|
||||
const buf = new Uint8Array(size);
|
||||
crypto.getRandomValues(buf);
|
||||
|
||||
// Add Buffer-like convenience methods
|
||||
buf.toString = function(encoding) {
|
||||
if (encoding === 'hex') {
|
||||
return Array.from(this).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
buf.toString = function (encoding) {
|
||||
if (encoding === "hex") {
|
||||
return Array.from(this)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
if (encoding === 'base64') {
|
||||
if (encoding === "base64") {
|
||||
return btoa(String.fromCharCode(...this));
|
||||
}
|
||||
return new TextDecoder().decode(this);
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
// Shim for crypto.scrypt
|
||||
// Delegates to window.scrypt which is already loaded by Obsidian's own scrypt.js
|
||||
|
||||
export function scrypt(password, salt, keylen, options, callback) {
|
||||
// Node signature: scrypt(password, salt, keylen, options, callback)
|
||||
// Obsidian's app.js checks for window.require("crypto") and uses it if available,
|
||||
// otherwise falls back to window.scrypt - so this shim just delegates to the latter.
|
||||
|
||||
if (typeof options === 'function') {
|
||||
if (typeof options === "function") {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
@@ -16,14 +9,18 @@ export function scrypt(password, salt, keylen, options, callback) {
|
||||
const p = options?.p || 1;
|
||||
|
||||
if (window.scrypt && window.scrypt.scrypt) {
|
||||
// Use the browser scrypt library already loaded by Obsidian
|
||||
const pwBytes = typeof password === 'string' ? new TextEncoder().encode(password) : password;
|
||||
const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt;
|
||||
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)
|
||||
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'));
|
||||
callback(new Error("scrypt not available"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Electron module shim
|
||||
// Returned when Obsidian calls: window.require('electron')
|
||||
|
||||
import { ipcRenderer } from "./ipc-renderer.js";
|
||||
import { webFrame } from "./web-frame.js";
|
||||
import { remoteShim } from "./remote/index.js";
|
||||
@@ -10,14 +7,12 @@ export const electronShim = {
|
||||
webFrame,
|
||||
remote: remoteShim,
|
||||
|
||||
// electron.webUtils - used for drag/drop file path extraction (desktop only)
|
||||
webUtils: {
|
||||
getPathForFile(file) {
|
||||
return "";
|
||||
},
|
||||
},
|
||||
|
||||
// electron.deprecate - used by Obsidian to mark deprecated APIs
|
||||
deprecate: {
|
||||
function(fn, name) {
|
||||
return fn;
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
// Shim for electron.ipcRenderer
|
||||
// Obsidian uses: .send(), .sendSync(), .on(), .once()
|
||||
//
|
||||
// sendSync channels discovered in app.js:
|
||||
// vault → {id, path} - critical for startup
|
||||
// version → string - app version
|
||||
// is-dev → boolean - dev mode flag
|
||||
// file-url → string - base URL prefix for vault assets
|
||||
// disable-update → boolean - whether updates are disabled
|
||||
// update → string - update status
|
||||
// disable-gpu → boolean - GPU acceleration toggle
|
||||
// frame → void - window frame style
|
||||
// set-icon → void - custom vault icon
|
||||
// get-icon → null|object - get custom vault icon
|
||||
// relaunch → void - restart app
|
||||
// starter → void - open vault chooser
|
||||
// help → void - open help
|
||||
// sandbox → void - open sandbox vault
|
||||
// copy-asar → boolean - install update
|
||||
|
||||
import { showVaultManager } from "../ui/vault-manager.js";
|
||||
|
||||
const listeners = new Map();
|
||||
|
||||
// Sync channel handlers - must return values synchronously
|
||||
const syncHandlers = {
|
||||
vault: () => window.__vaultConfig || { id: "default-vault", path: "/" },
|
||||
version: () => "1.8.9",
|
||||
@@ -51,7 +30,6 @@ const syncHandlers = {
|
||||
"copy-asar": () => false,
|
||||
"check-update": () => null,
|
||||
"vault-list": () => {
|
||||
// Starter expects an object keyed by ID: {id: {path, ts, name}}
|
||||
const result = {};
|
||||
for (const v of window.__vaultList || []) {
|
||||
result[v.id] = {
|
||||
@@ -66,14 +44,12 @@ const syncHandlers = {
|
||||
const id = (vaultPath || "").replace(/^\/+/, "");
|
||||
const vault = (window.__vaultList || []).find((v) => v.id === id);
|
||||
if (!vault && id) {
|
||||
// New vault created by starter - create it on the server
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/vault/create", false);
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
xhr.send(JSON.stringify({ name: id }));
|
||||
if (xhr.status >= 400) return "Failed to create vault";
|
||||
}
|
||||
// Navigate - use parent if in iframe, otherwise current window
|
||||
const target = window.parent !== window ? window.parent : window;
|
||||
target.location.href = "/?vault=" + encodeURIComponent(id);
|
||||
return true;
|
||||
@@ -90,7 +66,6 @@ const syncHandlers = {
|
||||
return xhr.status < 400;
|
||||
},
|
||||
"vault-move": (oldPath, newPath) => {
|
||||
// Not supported in web context
|
||||
return "Moving vaults is not supported in the web version";
|
||||
},
|
||||
"vault-message": () => null,
|
||||
@@ -105,10 +80,6 @@ export const ipcRenderer = {
|
||||
send(channel, ...args) {
|
||||
console.log("[shim:ipcRenderer] send:", channel, args);
|
||||
|
||||
// context-menu: Obsidian sends this and waits (up to 1s) for a response.
|
||||
// In Electron, the main process returns spell-check info + edit flags.
|
||||
// We reply immediately with a response object so Obsidian proceeds to
|
||||
// build and show its HTML context menu without delay.
|
||||
if (channel === "context-menu") {
|
||||
queueMicrotask(() =>
|
||||
ipcRenderer._emit("context-menu", {
|
||||
@@ -163,7 +134,6 @@ export const ipcRenderer = {
|
||||
return ipcRenderer;
|
||||
},
|
||||
|
||||
// Internal: emit an event to registered listeners (used by ws bridge)
|
||||
_emit(channel, ...args) {
|
||||
const arr = listeners.get(channel);
|
||||
if (arr) {
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
// Shim for remote.app
|
||||
// Obsidian uses: getPath, getVersion, getName, quit, isPackaged, getLocale
|
||||
|
||||
export const appShim = {
|
||||
getPath(name) {
|
||||
// Return web-friendly paths; config lives server-side in the vault's .obsidian/ dir
|
||||
const paths = {
|
||||
userData: '/.obsidian',
|
||||
home: '/',
|
||||
documents: '/documents',
|
||||
desktop: '/desktop',
|
||||
temp: '/tmp',
|
||||
appData: '/.obsidian',
|
||||
userData: "/.obsidian",
|
||||
home: "/",
|
||||
documents: "/documents",
|
||||
desktop: "/desktop",
|
||||
temp: "/tmp",
|
||||
appData: "/.obsidian",
|
||||
};
|
||||
return paths[name] || '/';
|
||||
return paths[name] || "/";
|
||||
},
|
||||
|
||||
getVersion() {
|
||||
return '1.8.9';
|
||||
return "1.8.9";
|
||||
},
|
||||
|
||||
getName() {
|
||||
return 'Obsidian';
|
||||
return "Obsidian";
|
||||
},
|
||||
|
||||
getLocale() {
|
||||
return navigator.language || 'en-US';
|
||||
return navigator.language || "en-US";
|
||||
},
|
||||
|
||||
isPackaged: true,
|
||||
|
||||
quit() {
|
||||
console.log('[shim:app] quit (stub)');
|
||||
console.log("[shim:app] quit (stub)");
|
||||
},
|
||||
|
||||
relaunch() {
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
// Shim for remote.clipboard
|
||||
// Obsidian uses: readText, writeText, readImage, writeImage, readHTML, writeHTML
|
||||
|
||||
export const clipboardShim = {
|
||||
readText() {
|
||||
// navigator.clipboard.readText() is async; return empty for sync calls
|
||||
// TODO: maintain a local mirror updated via async reads
|
||||
return '';
|
||||
return "";
|
||||
},
|
||||
|
||||
writeText(text) {
|
||||
navigator.clipboard.writeText(text).catch((e) => {
|
||||
console.warn('[shim:clipboard] writeText failed:', e);
|
||||
console.warn("[shim:clipboard] writeText failed:", e);
|
||||
});
|
||||
},
|
||||
|
||||
readHTML() {
|
||||
return '';
|
||||
return "";
|
||||
},
|
||||
|
||||
writeHTML(html) {
|
||||
// TODO: use clipboard API with text/html mime type
|
||||
console.log('[shim:clipboard] writeHTML (stub)');
|
||||
console.log("[shim:clipboard] writeHTML (stub)");
|
||||
},
|
||||
|
||||
readImage() {
|
||||
// TODO: implement if needed
|
||||
return { isEmpty: () => true, toPNG: () => new Uint8Array(0) };
|
||||
},
|
||||
|
||||
writeImage(image) {
|
||||
console.log('[shim:clipboard] writeImage (stub)');
|
||||
console.log("[shim:clipboard] writeImage (stub)");
|
||||
},
|
||||
|
||||
has(format) {
|
||||
@@ -37,10 +31,10 @@ export const clipboardShim = {
|
||||
},
|
||||
|
||||
read(format) {
|
||||
return '';
|
||||
return "";
|
||||
},
|
||||
|
||||
clear() {
|
||||
navigator.clipboard.writeText('').catch(() => {});
|
||||
navigator.clipboard.writeText("").catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
// Shim for remote.dialog
|
||||
// Obsidian uses: showOpenDialog, showSaveDialog, showMessageBox, showErrorBox
|
||||
|
||||
export const dialogShim = {
|
||||
async showOpenDialog(browserWindow, options) {
|
||||
// TODO: implement custom modal UI with server-side file listing
|
||||
console.log('[shim:dialog] showOpenDialog (stub):', options);
|
||||
// TODO: implement custom modal with server-side file listing
|
||||
console.log("[shim:dialog] showOpenDialog (stub):", options);
|
||||
return { canceled: true, filePaths: [] };
|
||||
},
|
||||
|
||||
async showSaveDialog(browserWindow, options) {
|
||||
// TODO: implement custom modal UI
|
||||
console.log('[shim:dialog] showSaveDialog (stub):', options);
|
||||
// TODO: implement custom modal
|
||||
console.log("[shim:dialog] showSaveDialog (stub):", options);
|
||||
return { canceled: true, filePath: undefined };
|
||||
},
|
||||
|
||||
async showMessageBox(browserWindow, options) {
|
||||
// TODO: implement custom modal matching Electron's return format
|
||||
// For now, use browser confirm/alert as rough approximation
|
||||
if (typeof browserWindow === 'object' && !options) {
|
||||
if (typeof browserWindow === "object" && !options) {
|
||||
options = browserWindow;
|
||||
}
|
||||
console.log('[shim:dialog] showMessageBox:', options);
|
||||
console.log("[shim:dialog] showMessageBox:", options);
|
||||
|
||||
const message = options.message || '';
|
||||
const detail = options.detail || '';
|
||||
const buttons = options.buttons || ['OK'];
|
||||
const message = options.message || "";
|
||||
const detail = options.detail || "";
|
||||
const buttons = options.buttons || ["OK"];
|
||||
|
||||
// Simple fallback: use confirm for 2-button, alert for 1-button
|
||||
if (buttons.length <= 1) {
|
||||
alert(message + (detail ? '\n\n' + detail : ''));
|
||||
alert(message + (detail ? "\n\n" + detail : ""));
|
||||
return { response: 0, checkboxChecked: false };
|
||||
}
|
||||
|
||||
const result = confirm(message + (detail ? '\n\n' + detail : '') + '\n\n[OK] = "' + buttons[0] + '", [Cancel] = "' + buttons[1] + '"');
|
||||
const result = confirm(
|
||||
message +
|
||||
(detail ? "\n\n" + detail : "") +
|
||||
'\n\n[OK] = "' +
|
||||
buttons[0] +
|
||||
'", [Cancel] = "' +
|
||||
buttons[1] +
|
||||
'"',
|
||||
);
|
||||
return {
|
||||
response: result ? 0 : 1,
|
||||
checkboxChecked: false,
|
||||
@@ -40,7 +42,7 @@ export const dialogShim = {
|
||||
},
|
||||
|
||||
showErrorBox(title, content) {
|
||||
console.error('[shim:dialog] Error:', title, content);
|
||||
alert(title + '\n\n' + content);
|
||||
console.error("[shim:dialog] Error:", title, content);
|
||||
alert(title + "\n\n" + content);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// @electron/remote shim
|
||||
// Returned when Obsidian calls: window.require('@electron/remote')
|
||||
|
||||
import { clipboardShim } from "./clipboard.js";
|
||||
import { shellShim } from "./shell.js";
|
||||
import { dialogShim } from "./dialog.js";
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Shim for remote.Menu and remote.MenuItem
|
||||
// Obsidian uses: Menu.buildFromTemplate, Menu.popup, Menu.setApplicationMenu
|
||||
|
||||
export class menuShim {
|
||||
constructor() {
|
||||
this.items = [];
|
||||
@@ -8,13 +5,12 @@ export class menuShim {
|
||||
|
||||
static buildFromTemplate(template) {
|
||||
const menu = new menuShim();
|
||||
menu.items = (template || []).map(item => new menuItemShim(item));
|
||||
menu.items = (template || []).map((item) => new menuItemShim(item));
|
||||
return menu;
|
||||
}
|
||||
|
||||
static setApplicationMenu(menu) {
|
||||
// No native menu bar in browser - no-op
|
||||
console.log('[shim:Menu] setApplicationMenu (stub)');
|
||||
console.log("[shim:Menu] setApplicationMenu (stub)");
|
||||
}
|
||||
|
||||
static getApplicationMenu() {
|
||||
@@ -22,8 +18,8 @@ export class menuShim {
|
||||
}
|
||||
|
||||
popup(options) {
|
||||
// TODO: implement custom HTML context menu rendered at mouse position
|
||||
console.log('[shim:Menu] popup (stub)', options);
|
||||
// TODO: render custom HTML context menu at mouse position
|
||||
console.log("[shim:Menu] popup (stub)", options);
|
||||
}
|
||||
|
||||
append(menuItem) {
|
||||
@@ -41,19 +37,19 @@ export class menuShim {
|
||||
|
||||
export class menuItemShim {
|
||||
constructor(options = {}) {
|
||||
this.label = options.label || '';
|
||||
this.type = options.type || 'normal';
|
||||
this.label = options.label || "";
|
||||
this.type = options.type || "normal";
|
||||
this.click = options.click || null;
|
||||
this.role = options.role || null;
|
||||
this.accelerator = options.accelerator || '';
|
||||
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 : []
|
||||
Array.isArray(options.submenu) ? options.submenu : [],
|
||||
)
|
||||
: null;
|
||||
this.id = options.id || '';
|
||||
this.id = options.id || "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Shim for remote.nativeImage
|
||||
// Minimal stub - Obsidian's renderer-side usage is limited
|
||||
|
||||
export const nativeImageShim = {
|
||||
createFromBuffer(buffer) {
|
||||
return {
|
||||
@@ -8,7 +5,7 @@ export const nativeImageShim = {
|
||||
getSize: () => ({ width: 0, height: 0 }),
|
||||
toPNG: () => buffer || new Uint8Array(0),
|
||||
toJPEG: (quality) => buffer || new Uint8Array(0),
|
||||
toDataURL: () => '',
|
||||
toDataURL: () => "",
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
// Shim for remote.Notification
|
||||
// Maps to browser Notification API
|
||||
|
||||
export class notificationShim {
|
||||
constructor(options = {}) {
|
||||
this.title = options.title || '';
|
||||
this.body = options.body || '';
|
||||
this.title = options.title || "";
|
||||
this.body = options.body || "";
|
||||
this.silent = options.silent || false;
|
||||
this._handlers = {};
|
||||
}
|
||||
|
||||
show() {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
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') {
|
||||
} 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 });
|
||||
if (perm === "granted") {
|
||||
new Notification(this.title, {
|
||||
body: this.body,
|
||||
silent: this.silent,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -29,6 +32,6 @@ export class notificationShim {
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return 'Notification' in window;
|
||||
return "Notification" in window;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
// Shim for remote.screen
|
||||
// Obsidian uses screen for display/monitor info
|
||||
|
||||
export const screenShim = {
|
||||
getPrimaryDisplay() {
|
||||
return {
|
||||
workAreaSize: { width: window.screen.availWidth, height: window.screen.availHeight },
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Shim for remote.session
|
||||
// Mostly no-op; Obsidian's use is minimal
|
||||
|
||||
export const sessionShim = {
|
||||
defaultSession: {
|
||||
clearCache() {
|
||||
@@ -12,7 +9,9 @@ export const sessionShim = {
|
||||
},
|
||||
|
||||
setSpellCheckerLanguages(langs) {},
|
||||
getSpellCheckerLanguages() { return []; },
|
||||
getSpellCheckerLanguages() {
|
||||
return [];
|
||||
},
|
||||
|
||||
on() {},
|
||||
once() {},
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
// Shim for remote.shell
|
||||
// Obsidian uses: openExternal, openPath, showItemInFolder
|
||||
|
||||
export const shellShim = {
|
||||
openExternal(url) {
|
||||
window.open(url, '_blank');
|
||||
window.open(url, "_blank");
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
openPath(filePath) {
|
||||
// TODO: could trigger a server-side download or preview
|
||||
console.log('[shim:shell] openPath (stub):', filePath);
|
||||
return Promise.resolve('');
|
||||
console.log("[shim:shell] openPath (stub):", filePath);
|
||||
return Promise.resolve("");
|
||||
},
|
||||
|
||||
showItemInFolder(filePath) {
|
||||
// No OS file manager in browser context
|
||||
console.log('[shim:shell] showItemInFolder (stub):', filePath);
|
||||
console.log("[shim:shell] showItemInFolder (stub):", filePath);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Shim for remote.systemPreferences
|
||||
// No-op with safe defaults
|
||||
|
||||
export const systemPreferencesShim = {
|
||||
getAccentColor() {
|
||||
return '0078d4'; // Default Windows accent blue
|
||||
return "0078d4"; // Default Windows accent blue
|
||||
},
|
||||
|
||||
isAeroGlassEnabled() {
|
||||
@@ -11,7 +8,7 @@ export const systemPreferencesShim = {
|
||||
},
|
||||
|
||||
getMediaAccessStatus(mediaType) {
|
||||
return 'granted';
|
||||
return "granted";
|
||||
},
|
||||
|
||||
askForMediaAccess(mediaType) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// Shim for remote.nativeTheme
|
||||
// Obsidian uses: shouldUseDarkColors, on('updated', cb)
|
||||
|
||||
const listeners = [];
|
||||
|
||||
const darkQuery = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)')
|
||||
: null;
|
||||
const darkQuery =
|
||||
typeof window !== "undefined"
|
||||
? window.matchMedia("(prefers-color-scheme: dark)")
|
||||
: null;
|
||||
|
||||
if (darkQuery?.addEventListener) {
|
||||
darkQuery.addEventListener('change', () => {
|
||||
darkQuery.addEventListener("change", () => {
|
||||
for (const fn of listeners) {
|
||||
fn();
|
||||
}
|
||||
@@ -21,7 +19,7 @@ export const themeShim = {
|
||||
},
|
||||
|
||||
get themeSource() {
|
||||
return 'system';
|
||||
return "system";
|
||||
},
|
||||
|
||||
set themeSource(val) {
|
||||
@@ -29,14 +27,14 @@ export const themeShim = {
|
||||
},
|
||||
|
||||
on(event, callback) {
|
||||
if (event === 'updated') {
|
||||
if (event === "updated") {
|
||||
listeners.push(callback);
|
||||
}
|
||||
return themeShim;
|
||||
},
|
||||
|
||||
once(event, callback) {
|
||||
if (event === 'updated') {
|
||||
if (event === "updated") {
|
||||
const wrapped = () => {
|
||||
const idx = listeners.indexOf(wrapped);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// Shim for remote.getCurrentWindow() / remote.BrowserWindow
|
||||
// Obsidian uses: isMaximized, isMinimized, isFullScreen, minimize, maximize,
|
||||
// unmaximize, close, setTitle, setAlwaysOnTop, isAlwaysOnTop,
|
||||
// getBounds, setBounds, show, focus, setFullScreen, etc.
|
||||
|
||||
const currentWindowState = {
|
||||
title: "Obsidian",
|
||||
isMaximized: false,
|
||||
@@ -80,7 +75,6 @@ const currentWindow = {
|
||||
},
|
||||
|
||||
setBounds(bounds) {
|
||||
// Cannot resize browser window from JS
|
||||
console.log("[shim:window] setBounds (stub):", bounds);
|
||||
},
|
||||
|
||||
@@ -113,7 +107,6 @@ const currentWindow = {
|
||||
},
|
||||
|
||||
on(event, handler) {
|
||||
// Map some Electron window events to browser equivalents
|
||||
if (event === "focus") window.addEventListener("focus", handler);
|
||||
else if (event === "blur") window.addEventListener("blur", handler);
|
||||
else if (event === "resize") window.addEventListener("resize", handler);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Shim for electron.webFrame
|
||||
// Obsidian uses: getZoomLevel(), setZoomLevel()
|
||||
|
||||
let currentZoom = 0;
|
||||
|
||||
export const webFrame = {
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
// Filesystem shim - the core piece
|
||||
// Returned for both require('original-fs') and require('fs')
|
||||
//
|
||||
// Strategy: metadata cache + on-demand content fetch + write-through
|
||||
// Server sync mechanism (REST vs WebSocket) is TBD - abstracted behind
|
||||
// the transport layer in ./transport.js
|
||||
|
||||
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 { constants } from './constants.js';
|
||||
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 { constants } from "./constants.js";
|
||||
|
||||
const metadataCache = new MetadataCache();
|
||||
const contentCache = new ContentCache();
|
||||
@@ -21,10 +14,8 @@ const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||
const fsWatch = createFsWatch(transport);
|
||||
|
||||
export const fsShim = {
|
||||
// Async promise-based API (this.fsPromises = this.fs.promises)
|
||||
promises: fsPromises,
|
||||
|
||||
// Sync methods
|
||||
existsSync: fsSync.existsSync,
|
||||
readFileSync: fsSync.readFileSync,
|
||||
writeFileSync: fsSync.writeFileSync,
|
||||
@@ -33,17 +24,12 @@ export const fsShim = {
|
||||
statSync: fsSync.statSync,
|
||||
readdirSync: fsSync.readdirSync,
|
||||
|
||||
// Watch
|
||||
watch: fsWatch.watch,
|
||||
|
||||
// Constants
|
||||
constants,
|
||||
|
||||
// Internal: for initialization
|
||||
_metadataCache: metadataCache,
|
||||
_contentCache: contentCache,
|
||||
|
||||
// Initialize the caches by fetching the full tree from server
|
||||
async _init(basePath) {
|
||||
const tree = await transport.fetchTree(basePath);
|
||||
metadataCache.populate(tree);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Async fs.promises implementation
|
||||
// Maps to transport layer (REST/WebSocket/hybrid - TBD)
|
||||
|
||||
export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
return {
|
||||
async stat(path) {
|
||||
// Try cache first, fall back to server
|
||||
const cached = metadataCache.toStat(path);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -14,17 +10,15 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
},
|
||||
|
||||
async lstat(path) {
|
||||
// No symlinks in our context - same as stat
|
||||
// No symlinks in our context
|
||||
return this.stat(path);
|
||||
},
|
||||
|
||||
async readdir(path) {
|
||||
// If metadata cache knows this is a file, return empty (ENOTDIR)
|
||||
const meta = metadataCache.get(path);
|
||||
if (meta && meta.type === "file") {
|
||||
return [];
|
||||
}
|
||||
// If path not in cache at all (and not root), it doesn't exist
|
||||
if (!meta && path && path !== "/" && path !== ".") {
|
||||
const e = new Error(
|
||||
`ENOENT: no such file or directory, scandir '${path}'`,
|
||||
@@ -32,7 +26,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
}
|
||||
// Serve from metadata cache
|
||||
const entries = metadataCache.readdir(path);
|
||||
return entries.map((e) => e.name);
|
||||
},
|
||||
@@ -41,14 +34,12 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||
|
||||
// Short-circuit: reading a directory is an error
|
||||
const meta = metadataCache.get(path);
|
||||
if (meta && meta.type === "directory") {
|
||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||
e.code = "EISDIR";
|
||||
throw e;
|
||||
}
|
||||
// Short-circuit: file not in metadata cache → doesn't exist
|
||||
if (!meta && path) {
|
||||
const e = new Error(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
@@ -57,7 +48,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Check content cache
|
||||
const cached = contentCache.get(path);
|
||||
if (cached !== null) {
|
||||
if (wantText) {
|
||||
@@ -65,14 +55,13 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
? cached
|
||||
: new TextDecoder().decode(cached);
|
||||
}
|
||||
// Binary mode: ensure we return a proper Uint8Array with .buffer
|
||||
// binary. ensure we return a proper Uint8Array with .buffer
|
||||
if (typeof cached === "string") {
|
||||
return new TextEncoder().encode(cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from server
|
||||
const data = await transport.readFile(path, encoding);
|
||||
contentCache.set(path, data);
|
||||
return data;
|
||||
@@ -81,7 +70,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
async writeFile(path, data, encoding) {
|
||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||
|
||||
// Update caches optimistically
|
||||
contentCache.set(path, data);
|
||||
const size =
|
||||
typeof data === "string" ? data.length : data.byteLength || 0;
|
||||
@@ -92,9 +80,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
||||
});
|
||||
|
||||
// Send to server
|
||||
const result = await transport.writeFile(path, data, encoding);
|
||||
// Update metadata with server-confirmed values
|
||||
if (result.mtime) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
@@ -108,7 +94,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
async appendFile(path, data, encoding) {
|
||||
contentCache.invalidate(path);
|
||||
await transport.appendFile(path, data);
|
||||
// Refresh metadata
|
||||
|
||||
const meta = await transport.stat(path);
|
||||
metadataCache.set(path, meta);
|
||||
},
|
||||
@@ -120,13 +106,11 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
},
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
// Move content cache entry
|
||||
const content = contentCache.get(oldPath);
|
||||
if (content !== null) {
|
||||
contentCache.set(newPath, content);
|
||||
contentCache.delete(oldPath);
|
||||
}
|
||||
// Move metadata
|
||||
metadataCache.rename(oldPath, newPath);
|
||||
|
||||
await transport.rename(oldPath, newPath);
|
||||
@@ -154,7 +138,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
|
||||
async copyFile(src, dest) {
|
||||
await transport.copyFile(src, dest);
|
||||
// Refresh metadata for dest
|
||||
const meta = await transport.stat(dest);
|
||||
metadataCache.set(dest, meta);
|
||||
},
|
||||
@@ -169,7 +152,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
},
|
||||
|
||||
async realpath(path) {
|
||||
// Empty path = vault root, return the vault base path
|
||||
if (!path || path === "/" || path === ".") return "/";
|
||||
return transport.realpath(path);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Synchronous fs method implementations
|
||||
// Served from caches where possible, sync XHR fallback for uncached content.
|
||||
|
||||
export function createFsSync(metadataCache, contentCache, transport) {
|
||||
return {
|
||||
existsSync(path) {
|
||||
@@ -32,7 +29,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
readFileSync(path, encoding) {
|
||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||
|
||||
// Short-circuit: reading a directory is an error
|
||||
const meta = metadataCache.get(path);
|
||||
if (meta && meta.type === "directory") {
|
||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||
@@ -40,7 +36,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Try content cache first
|
||||
const cached = contentCache.get(path);
|
||||
if (cached !== null) {
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
@@ -51,7 +46,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fallback: synchronous XHR
|
||||
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
|
||||
const data = transport.readFileSync(path, encoding);
|
||||
contentCache.set(path, data);
|
||||
@@ -61,7 +55,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
writeFileSync(path, data, encoding) {
|
||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||
|
||||
// Write to cache immediately (sync return)
|
||||
contentCache.set(path, data);
|
||||
const size =
|
||||
typeof data === "string" ? data.length : data.byteLength || 0;
|
||||
@@ -86,7 +79,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
contentCache.delete(path);
|
||||
metadataCache.delete(path);
|
||||
|
||||
// Fire-and-forget - suppress ENOENT (file already gone, e.g. .OBSIDIANTEST race)
|
||||
// Fire-and-forget - suppress ENOENT (file already gone)
|
||||
transport.unlink(path).catch((e) => {
|
||||
if (e.code !== "ENOENT") {
|
||||
console.error(
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
// Transport abstraction layer
|
||||
// Decouples the fs shim from the sync mechanism (REST, WebSocket, or hybrid).
|
||||
// Currently implements a REST-based transport. This can be swapped or extended
|
||||
// once the sync strategy is finalized.
|
||||
|
||||
const API_BASE = "/api/fs";
|
||||
|
||||
// Strip leading slashes from paths before sending to server
|
||||
function normPath(p) {
|
||||
return (p || "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
// Convert a Uint8Array to base64 without blowing the stack
|
||||
function uint8ToBase64(bytes) {
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
@@ -56,12 +49,10 @@ async function requestJson(method, endpoint, params = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Synchronous XHR - used only as fallback for sync fs calls on uncached content.
|
||||
// Blocking but functional. Should be rare after pre-warming.
|
||||
function requestSync(method, endpoint, params = {}) {
|
||||
const url = new URL(API_BASE + endpoint, window.location.origin);
|
||||
|
||||
if (method === "GET") {
|
||||
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);
|
||||
@@ -71,7 +62,7 @@ function requestSync(method, endpoint, params = {}) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url.toString(), false); // synchronous
|
||||
|
||||
if (method !== "GET") {
|
||||
if (method !== "GET" && method !== "DELETE") {
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
xhr.send(JSON.stringify({ vault: vaultId(), ...params }));
|
||||
} else {
|
||||
@@ -95,8 +86,6 @@ function requestSync(method, endpoint, params = {}) {
|
||||
}
|
||||
|
||||
export const transport = {
|
||||
// --- Async methods (used by fs.promises) ---
|
||||
|
||||
async fetchTree(basePath) {
|
||||
return requestJson("GET", "/tree", basePath ? { path: basePath } : {});
|
||||
},
|
||||
@@ -190,8 +179,6 @@ export const transport = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- Sync methods (fallback) ---
|
||||
|
||||
readFileSync(path, encoding) {
|
||||
const xhr = requestSync("GET", "/readFile", {
|
||||
path: normPath(path),
|
||||
@@ -200,7 +187,6 @@ export const transport = {
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
return xhr.responseText;
|
||||
}
|
||||
// Binary: return as Uint8Array
|
||||
const binary = xhr.responseText;
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// File watching shim
|
||||
// Translates fs.watch() calls into WebSocket subscriptions.
|
||||
// The server pushes file-change events; this module dispatches them
|
||||
// to registered watch listeners.
|
||||
|
||||
export function createFsWatch(transport) {
|
||||
const watchers = new Map(); // path -> Set<listener>
|
||||
|
||||
return {
|
||||
watch(path, options, listener) {
|
||||
if (typeof options === 'function') {
|
||||
if (typeof options === "function") {
|
||||
listener = options;
|
||||
options = {};
|
||||
}
|
||||
@@ -32,22 +27,28 @@ export function createFsWatch(transport) {
|
||||
}
|
||||
}
|
||||
},
|
||||
on() { return this; },
|
||||
once() { return this; },
|
||||
removeListener() { return this; },
|
||||
on() {
|
||||
return this;
|
||||
},
|
||||
once() {
|
||||
return this;
|
||||
},
|
||||
removeListener() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// Internal: called when transport receives a file-change event
|
||||
_dispatch(eventType, filePath) {
|
||||
for (const [watchPath, listeners] of watchers) {
|
||||
if (filePath === watchPath || filePath.startsWith(watchPath + '/')) {
|
||||
if (filePath === watchPath || filePath.startsWith(watchPath + "/")) {
|
||||
const relativeName = filePath.slice(watchPath.length + 1) || filePath;
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(eventType, relativeName);
|
||||
} catch (e) {
|
||||
console.error('[shim:fs:watch] Listener error:', e);
|
||||
console.error("[shim:fs:watch] Listener error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
// shim-loader.js
|
||||
// Loaded before app.js. Defines window.require() and window.process
|
||||
// to intercept all Electron/Node API calls from Obsidian's renderer code.
|
||||
|
||||
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 { btimeShim } from "./btime.js";
|
||||
import { processShim } from "./process.js";
|
||||
|
||||
// Debug mode: wrap shims in Proxy to log all property accesses
|
||||
const DEBUG = true;
|
||||
const _accessLog = new Map(); // "module.property" -> count
|
||||
|
||||
@@ -28,7 +22,7 @@ function wrapWithProxy(obj, name) {
|
||||
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`);
|
||||
console.warn(`[shim:MISS] ${key} - property not found on shim`);
|
||||
}
|
||||
}
|
||||
return target[prop];
|
||||
@@ -36,7 +30,6 @@ function wrapWithProxy(obj, name) {
|
||||
});
|
||||
}
|
||||
|
||||
// Expose access log for debugging in console: window.__shimLog()
|
||||
window.__shimLog = function () {
|
||||
const sorted = [..._accessLog.entries()].sort((a, b) => b[1] - a[1]);
|
||||
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
|
||||
@@ -60,7 +53,6 @@ const rawRegistry = {
|
||||
path: pathShim,
|
||||
url: urlShim,
|
||||
crypto: cryptoShim,
|
||||
btime: btimeShim,
|
||||
};
|
||||
|
||||
const shimRegistry = {};
|
||||
@@ -68,7 +60,6 @@ for (const [name, shim] of Object.entries(rawRegistry)) {
|
||||
shimRegistry[name] = wrapWithProxy(shim, name);
|
||||
}
|
||||
|
||||
// Modules that should throw on require (native modules that don't exist in browser)
|
||||
const throwOnRequire = new Set(["btime", "get-fonts", "vibrancy-win"]);
|
||||
|
||||
window.require = function (moduleName) {
|
||||
@@ -84,9 +75,7 @@ window.require = function (moduleName) {
|
||||
|
||||
window.process = processShim;
|
||||
|
||||
// Provide a global Buffer if needed
|
||||
if (typeof window.Buffer === "undefined") {
|
||||
// TODO: evaluate if a full Buffer polyfill is needed or if Uint8Array suffices
|
||||
window.Buffer = {
|
||||
from: function (data, encoding) {
|
||||
if (typeof data === "string") {
|
||||
@@ -113,22 +102,10 @@ if (typeof window.Buffer === "undefined") {
|
||||
};
|
||||
}
|
||||
|
||||
// Prevent app.js from closing the window (browser blocks this anyway, but suppress the error)
|
||||
// In an iframe (starter modal), close the modal overlay instead.
|
||||
const _origClose = window.close;
|
||||
window.close = function () {
|
||||
if (window.parent !== window) {
|
||||
const modal = window.parent.document.getElementById("ignis-starter-modal");
|
||||
if (modal) modal.remove();
|
||||
return;
|
||||
}
|
||||
console.log("[obsidian-bridge] window.close() blocked");
|
||||
};
|
||||
|
||||
// Suppress the browser's native context menu without breaking Obsidian's.
|
||||
// Problem: preventDefault() blocks the browser menu but also sets
|
||||
// event.defaultPrevented=true, which Obsidian checks to bail out.
|
||||
// Solution: call preventDefault() then shadow defaultPrevented to return false.
|
||||
window.addEventListener(
|
||||
"contextmenu",
|
||||
(e) => {
|
||||
@@ -138,11 +115,9 @@ window.addEventListener(
|
||||
true,
|
||||
);
|
||||
|
||||
// Read vault ID from URL query param (?vault=my-notes)
|
||||
const _urlParams = new URLSearchParams(window.location.search);
|
||||
window.__currentVaultId = _urlParams.get("vault") || "";
|
||||
|
||||
// Fetch vault config from server synchronously (before metadata cache)
|
||||
(function initVaultConfig() {
|
||||
try {
|
||||
const vaultParam = window.__currentVaultId
|
||||
@@ -165,7 +140,6 @@ window.__currentVaultId = _urlParams.get("vault") || "";
|
||||
}
|
||||
})();
|
||||
|
||||
// Fetch vault list for IPC handlers
|
||||
(function initVaultList() {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -179,8 +153,6 @@ window.__currentVaultId = _urlParams.get("vault") || "";
|
||||
}
|
||||
})();
|
||||
|
||||
// Pre-populate fs metadata cache synchronously before app.js runs.
|
||||
// This ensures existsSync() works for the vault path during startup.
|
||||
(function initMetadataCache() {
|
||||
try {
|
||||
const vaultParam = window.__currentVaultId
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Path shim - delegates to path-browserify (bundled via esbuild alias)
|
||||
// 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";
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Shim for window.process
|
||||
// Obsidian checks process.platform, process.versions.electron, etc.
|
||||
|
||||
export const processShim = {
|
||||
platform: 'linux',
|
||||
platform: "linux",
|
||||
versions: {
|
||||
electron: '28.0.0',
|
||||
node: '18.18.0',
|
||||
chrome: '120.0.0.0',
|
||||
electron: "28.0.0",
|
||||
node: "18.18.0",
|
||||
chrome: "120.0.0.0",
|
||||
},
|
||||
env: {},
|
||||
cwd: () => '/',
|
||||
cwd: () => "/",
|
||||
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
|
||||
argv: [],
|
||||
type: 'renderer',
|
||||
resourcesPath: '/',
|
||||
type: "renderer",
|
||||
resourcesPath: "/",
|
||||
stdout: { write: (s) => console.log(s) },
|
||||
stderr: { write: (s) => console.error(s) },
|
||||
on: () => {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Custom vault manager modal - vanilla JS (will migrate to Svelte later)
|
||||
// Custom vault manager modal. will migrate to Svelte later
|
||||
// Shows list of vaults, create new, delete, switch.
|
||||
|
||||
export function showVaultManager() {
|
||||
@@ -74,11 +74,7 @@ export function showVaultManager() {
|
||||
"color:var(--text-muted);border-radius:4px;padding:2px 8px;font-size:12px;cursor:pointer;";
|
||||
del.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
!confirm(
|
||||
'Delete vault "' + v.name + '"? This removes all files.',
|
||||
)
|
||||
)
|
||||
if (!confirm('Delete vault "' + v.name + '"? This removes all files.'))
|
||||
return;
|
||||
const xhr2 = new XMLHttpRequest();
|
||||
xhr2.open(
|
||||
|
||||
13
shims/url.js
13
shims/url.js
@@ -1,22 +1,19 @@
|
||||
// URL shim
|
||||
// Obsidian uses: pathToFileURL, fileURLToPath, URL, URLSearchParams
|
||||
|
||||
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(/^\/+/, '');
|
||||
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:///')) {
|
||||
let str = typeof url === "string" ? url : url.href || url.toString();
|
||||
if (str.startsWith("file:///")) {
|
||||
str = str.slice(8);
|
||||
} else if (str.startsWith('file://')) {
|
||||
} else if (str.startsWith("file://")) {
|
||||
str = str.slice(7);
|
||||
}
|
||||
return decodeURI(str);
|
||||
|
||||
Reference in New Issue
Block a user