minor refactor, cleanup
This commit is contained in:
parent
2b9ebf1fbd
commit
9789be6d70
38 changed files with 259 additions and 379 deletions
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
|
FROM node:20-slim AS build
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
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
|
WORKDIR /build
|
||||||
|
|
||||||
# Install dependencies first (layer caching)
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY build.js ./
|
COPY build.js ./
|
||||||
COPY shims/ ./shims/
|
COPY shims/ ./shims/
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
|
|
||||||
# Build shim-loader bundle
|
|
||||||
RUN npm run build:shims
|
RUN npm run build:shims
|
||||||
|
|
||||||
# Download and extract Obsidian
|
|
||||||
ARG OBSIDIAN_VERSION=1.8.9
|
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" \
|
RUN curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian_${OBSIDIAN_VERSION}_amd64.deb" \
|
||||||
-o /tmp/obsidian.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 \
|
&& tar -xf /tmp/obsidian-deb/data.tar.xz -C /tmp/obsidian-pkg \
|
||||||
&& rm -rf /tmp/obsidian.deb /tmp/obsidian-deb
|
&& rm -rf /tmp/obsidian.deb /tmp/obsidian-deb
|
||||||
|
|
||||||
# Extract asar
|
|
||||||
RUN npx --yes @electron/asar extract \
|
RUN npx --yes @electron/asar extract \
|
||||||
/tmp/obsidian-pkg/opt/Obsidian/resources/obsidian.asar \
|
/tmp/obsidian-pkg/opt/Obsidian/resources/obsidian.asar \
|
||||||
/build/obsidian-app \
|
/build/obsidian-app \
|
||||||
|
|
@ -42,10 +38,9 @@ RUN npx --yes @electron/asar extract \
|
||||||
# Patch index.html
|
# Patch index.html
|
||||||
RUN node scripts/patch-obsidian.js /build/obsidian-app
|
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
|
RUN cp dist/shim-loader.js /build/obsidian-app/shim-loader.js
|
||||||
|
|
||||||
# Stage 2: Production image
|
# Production image
|
||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -1,3 +1,13 @@
|
||||||
# Ignis
|
# Ignis
|
||||||
|
|
||||||
An Electron shim and server bridge for running Obsidian in a browser.
|
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:
|
// Patches the extracted Obsidian asar for browser use:
|
||||||
// 1. Removes Content-Security-Policy meta tag
|
// 1. Removes Content-Security-Policy meta tag
|
||||||
// 2. Injects shim-loader.js script (non-deferred, before all other scripts)
|
// 2. Injects shim-loader.js script (non-deferred, before all other scripts)
|
||||||
// Patches both index.html and starter.html.
|
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ const app = express();
|
||||||
|
|
||||||
app.use(express.json({ limit: "50mb" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
|
|
||||||
// --- Request logging ---
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const origEnd = res.end;
|
const origEnd = res.end;
|
||||||
|
|
@ -29,7 +28,6 @@ app.use((req, res, next) => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Routes ---
|
|
||||||
const fsRoutes = require("./routes/fs");
|
const fsRoutes = require("./routes/fs");
|
||||||
const vaultRoutes = require("./routes/vault");
|
const vaultRoutes = require("./routes/vault");
|
||||||
|
|
||||||
|
|
@ -51,15 +49,10 @@ app.use("/vault-files", (req, res, next) => {
|
||||||
express.static(vaultPath)(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")));
|
app.use(express.static(path.join(__dirname, "..", "dist")));
|
||||||
|
|
||||||
// Serve obsidian assets (app.js, app.css, libs, fonts, etc.)
|
|
||||||
app.use(express.static(config.obsidianAssetsPath));
|
app.use(express.static(config.obsidianAssetsPath));
|
||||||
|
|
||||||
// --- Start ---
|
|
||||||
const server = app.listen(config.port, () => {
|
const server = app.listen(config.port, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`[obsidian-bridge] Server running on http://localhost:${config.port}`,
|
`[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.
|
// Resolve a client-provided path to an absolute path within a vault.
|
||||||
// Strips leading slashes so paths from the client are always treated as
|
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
||||||
// relative to the vault root. Rejects path traversal attempts.
|
|
||||||
function resolveVaultPath(vaultRoot, relativePath) {
|
function resolveVaultPath(vaultRoot, relativePath) {
|
||||||
const cleaned = (relativePath || "").replace(/^\/+/, "");
|
const cleaned = (relativePath || "").replace(/^\/+/, "");
|
||||||
const resolved = path.resolve(vaultRoot, cleaned);
|
const resolved = path.resolve(vaultRoot, cleaned);
|
||||||
|
|
@ -46,6 +45,19 @@ function guardPath(req, res) {
|
||||||
return resolved;
|
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=...
|
// GET /api/fs/stat?path=...
|
||||||
router.get("/stat", async (req, res) => {
|
router.get("/stat", async (req, res) => {
|
||||||
const resolved = guardPath(req, res);
|
const resolved = guardPath(req, res);
|
||||||
|
|
@ -70,7 +82,7 @@ router.get("/readdir", async (req, res) => {
|
||||||
const resolved = guardPath(req, res);
|
const resolved = guardPath(req, res);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
try {
|
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);
|
const stat = await fs.promises.stat(resolved);
|
||||||
if (!stat.isDirectory()) {
|
if (!stat.isDirectory()) {
|
||||||
return res
|
return res
|
||||||
|
|
@ -123,10 +135,8 @@ router.get("/readFile", async (req, res) => {
|
||||||
|
|
||||||
// POST /api/fs/writeFile { path, content, encoding?, vault? }
|
// POST /api/fs/writeFile { path, content, encoding?, vault? }
|
||||||
router.post("/writeFile", async (req, res) => {
|
router.post("/writeFile", async (req, res) => {
|
||||||
const vaultRoot = getVaultRoot(req, res);
|
const resolved = guardBodyPath(req, res);
|
||||||
if (!vaultRoot) return;
|
if (!resolved) return;
|
||||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
|
||||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
|
||||||
try {
|
try {
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
const dir = path.dirname(resolved);
|
const dir = path.dirname(resolved);
|
||||||
|
|
@ -151,10 +161,8 @@ router.post("/writeFile", async (req, res) => {
|
||||||
|
|
||||||
// POST /api/fs/appendFile { path, content, vault? }
|
// POST /api/fs/appendFile { path, content, vault? }
|
||||||
router.post("/appendFile", async (req, res) => {
|
router.post("/appendFile", async (req, res) => {
|
||||||
const vaultRoot = getVaultRoot(req, res);
|
const resolved = guardBodyPath(req, res);
|
||||||
if (!vaultRoot) return;
|
if (!resolved) return;
|
||||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
|
||||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
|
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|
@ -165,10 +173,8 @@ router.post("/appendFile", async (req, res) => {
|
||||||
|
|
||||||
// POST /api/fs/mkdir { path, recursive?, vault? }
|
// POST /api/fs/mkdir { path, recursive?, vault? }
|
||||||
router.post("/mkdir", async (req, res) => {
|
router.post("/mkdir", async (req, res) => {
|
||||||
const vaultRoot = getVaultRoot(req, res);
|
const resolved = guardBodyPath(req, res);
|
||||||
if (!vaultRoot) return;
|
if (!resolved) return;
|
||||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
|
||||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
|
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|
@ -277,10 +283,8 @@ router.get("/realpath", async (req, res) => {
|
||||||
|
|
||||||
// POST /api/fs/utimes { path, atime, mtime, vault? }
|
// POST /api/fs/utimes { path, atime, mtime, vault? }
|
||||||
router.post("/utimes", async (req, res) => {
|
router.post("/utimes", async (req, res) => {
|
||||||
const vaultRoot = getVaultRoot(req, res);
|
const resolved = guardBodyPath(req, res);
|
||||||
if (!vaultRoot) return;
|
if (!resolved) return;
|
||||||
const resolved = resolveVaultPath(vaultRoot, req.body?.path);
|
|
||||||
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.utimes(
|
await fs.promises.utimes(
|
||||||
resolved,
|
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) => {
|
router.get("/tree", async (req, res) => {
|
||||||
const vaultRoot = getVaultRoot(req, res);
|
const vaultRoot = getVaultRoot(req, res);
|
||||||
if (!vaultRoot) return;
|
if (!vaultRoot) return;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const path = require("path");
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get("/list", (req, res) => {
|
||||||
config.refreshVaults();
|
config.refreshVaults();
|
||||||
const list = Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
const list = Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
||||||
|
|
@ -16,7 +16,7 @@ router.get("/list", (req, res) => {
|
||||||
res.json(list);
|
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) => {
|
router.get("/info", (req, res) => {
|
||||||
const vaultId = req.query.vault || config.defaultVaultId;
|
const vaultId = req.query.vault || config.defaultVaultId;
|
||||||
const vaultPath = config.getVaultPath(vaultId);
|
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) => {
|
router.post("/create", async (req, res) => {
|
||||||
const name = req.body?.name;
|
const name = req.body?.name;
|
||||||
if (!name || /[\/\\:*?"<>|]/.test(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) => {
|
router.delete("/remove", async (req, res) => {
|
||||||
const vaultId = req.query.vault;
|
const vaultId = req.query.vault;
|
||||||
const vaultPath = config.getVaultPath(vaultId);
|
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){}
|
// 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;
|
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) {
|
export function createHash(algorithm) {
|
||||||
const alg = algorithm.toUpperCase().replace('-', '');
|
const alg = algorithm.toUpperCase().replace("-", "");
|
||||||
const subtleAlg = alg === 'SHA256' ? 'SHA-256' : alg === 'SHA1' ? 'SHA-1' : alg === 'SHA512' ? 'SHA-512' : alg;
|
const subtleAlg =
|
||||||
|
alg === "SHA256"
|
||||||
|
? "SHA-256"
|
||||||
|
: alg === "SHA1"
|
||||||
|
? "SHA-1"
|
||||||
|
: alg === "SHA512"
|
||||||
|
? "SHA-512"
|
||||||
|
: alg;
|
||||||
|
|
||||||
let inputData = new Uint8Array(0);
|
let inputData = new Uint8Array(0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(data) {
|
update(data) {
|
||||||
if (typeof data === 'string') {
|
if (typeof data === "string") {
|
||||||
data = new TextEncoder().encode(data);
|
data = new TextEncoder().encode(data);
|
||||||
}
|
}
|
||||||
// Concatenate
|
|
||||||
const merged = new Uint8Array(inputData.length + data.length);
|
const merged = new Uint8Array(inputData.length + data.length);
|
||||||
merged.set(inputData);
|
merged.set(inputData);
|
||||||
merged.set(data, inputData.length);
|
merged.set(data, inputData.length);
|
||||||
|
|
@ -22,27 +23,23 @@ export function createHash(algorithm) {
|
||||||
return this;
|
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) {
|
digest(encoding) {
|
||||||
// Fallback: simple sync hash (for SHA-256 only)
|
console.warn("[shim:crypto] createHash.digest - using placeholder");
|
||||||
// This is a placeholder - swap in a proper sync implementation if needed
|
|
||||||
console.warn('[shim:crypto] createHash.digest - using placeholder');
|
|
||||||
const hash = simpleHash(inputData);
|
const hash = simpleHash(inputData);
|
||||||
if (encoding === 'hex') return hash;
|
if (encoding === "hex") return hash;
|
||||||
if (encoding === 'base64') return btoa(hash);
|
if (encoding === "base64") return btoa(hash);
|
||||||
return hash;
|
return hash;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Async alternative for contexts that can await
|
|
||||||
async digestAsync(encoding) {
|
async digestAsync(encoding) {
|
||||||
const hashBuffer = await crypto.subtle.digest(subtleAlg, inputData);
|
const hashBuffer = await crypto.subtle.digest(subtleAlg, inputData);
|
||||||
const hashArray = new Uint8Array(hashBuffer);
|
const hashArray = new Uint8Array(hashBuffer);
|
||||||
if (encoding === 'hex') {
|
if (encoding === "hex") {
|
||||||
return Array.from(hashArray).map(b => b.toString(16).padStart(2, '0')).join('');
|
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 btoa(String.fromCharCode(...hashArray));
|
||||||
}
|
}
|
||||||
return hashArray;
|
return hashArray;
|
||||||
|
|
@ -50,11 +47,10 @@ export function createHash(algorithm) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Very basic placeholder hash - not cryptographic, just for bootstrapping
|
|
||||||
function simpleHash(data) {
|
function simpleHash(data) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
hash = ((hash << 5) - hash + data[i]) | 0;
|
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
|
import { randomBytes } from "./random-bytes.js";
|
||||||
// Obsidian uses: scrypt, randomBytes, createHash
|
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 = {
|
export const cryptoShim = {
|
||||||
randomBytes,
|
randomBytes,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
// Shim for crypto.randomBytes
|
|
||||||
// Uses Web Crypto API under the hood
|
|
||||||
|
|
||||||
export function randomBytes(size) {
|
export function randomBytes(size) {
|
||||||
const buf = new Uint8Array(size);
|
const buf = new Uint8Array(size);
|
||||||
crypto.getRandomValues(buf);
|
crypto.getRandomValues(buf);
|
||||||
|
|
||||||
// Add Buffer-like convenience methods
|
buf.toString = function (encoding) {
|
||||||
buf.toString = function(encoding) {
|
if (encoding === "hex") {
|
||||||
if (encoding === 'hex') {
|
return Array.from(this)
|
||||||
return Array.from(this).map(b => b.toString(16).padStart(2, '0')).join('');
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
if (encoding === 'base64') {
|
if (encoding === "base64") {
|
||||||
return btoa(String.fromCharCode(...this));
|
return btoa(String.fromCharCode(...this));
|
||||||
}
|
}
|
||||||
return new TextDecoder().decode(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) {
|
export function scrypt(password, salt, keylen, options, callback) {
|
||||||
// Node signature: scrypt(password, salt, keylen, options, callback)
|
if (typeof options === "function") {
|
||||||
// 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') {
|
|
||||||
callback = options;
|
callback = options;
|
||||||
options = {};
|
options = {};
|
||||||
}
|
}
|
||||||
|
|
@ -16,14 +9,18 @@ export function scrypt(password, salt, keylen, options, callback) {
|
||||||
const p = options?.p || 1;
|
const p = options?.p || 1;
|
||||||
|
|
||||||
if (window.scrypt && window.scrypt.scrypt) {
|
if (window.scrypt && window.scrypt.scrypt) {
|
||||||
// Use the browser scrypt library already loaded by Obsidian
|
const pwBytes =
|
||||||
const pwBytes = typeof password === 'string' ? new TextEncoder().encode(password) : password;
|
typeof password === "string"
|
||||||
const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt;
|
? 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)))
|
.then((result) => callback(null, new Uint8Array(result)))
|
||||||
.catch((err) => callback(err));
|
.catch((err) => callback(err));
|
||||||
} else {
|
} 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 { ipcRenderer } from "./ipc-renderer.js";
|
||||||
import { webFrame } from "./web-frame.js";
|
import { webFrame } from "./web-frame.js";
|
||||||
import { remoteShim } from "./remote/index.js";
|
import { remoteShim } from "./remote/index.js";
|
||||||
|
|
@ -10,14 +7,12 @@ export const electronShim = {
|
||||||
webFrame,
|
webFrame,
|
||||||
remote: remoteShim,
|
remote: remoteShim,
|
||||||
|
|
||||||
// electron.webUtils - used for drag/drop file path extraction (desktop only)
|
|
||||||
webUtils: {
|
webUtils: {
|
||||||
getPathForFile(file) {
|
getPathForFile(file) {
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// electron.deprecate - used by Obsidian to mark deprecated APIs
|
|
||||||
deprecate: {
|
deprecate: {
|
||||||
function(fn, name) {
|
function(fn, name) {
|
||||||
return fn;
|
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";
|
import { showVaultManager } from "../ui/vault-manager.js";
|
||||||
|
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
|
|
||||||
// Sync channel handlers - must return values synchronously
|
|
||||||
const syncHandlers = {
|
const syncHandlers = {
|
||||||
vault: () => window.__vaultConfig || { id: "default-vault", path: "/" },
|
vault: () => window.__vaultConfig || { id: "default-vault", path: "/" },
|
||||||
version: () => "1.8.9",
|
version: () => "1.8.9",
|
||||||
|
|
@ -51,7 +30,6 @@ const syncHandlers = {
|
||||||
"copy-asar": () => false,
|
"copy-asar": () => false,
|
||||||
"check-update": () => null,
|
"check-update": () => null,
|
||||||
"vault-list": () => {
|
"vault-list": () => {
|
||||||
// Starter expects an object keyed by ID: {id: {path, ts, name}}
|
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const v of window.__vaultList || []) {
|
for (const v of window.__vaultList || []) {
|
||||||
result[v.id] = {
|
result[v.id] = {
|
||||||
|
|
@ -66,14 +44,12 @@ const syncHandlers = {
|
||||||
const id = (vaultPath || "").replace(/^\/+/, "");
|
const id = (vaultPath || "").replace(/^\/+/, "");
|
||||||
const vault = (window.__vaultList || []).find((v) => v.id === id);
|
const vault = (window.__vaultList || []).find((v) => v.id === id);
|
||||||
if (!vault && id) {
|
if (!vault && id) {
|
||||||
// New vault created by starter - create it on the server
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", "/api/vault/create", false);
|
xhr.open("POST", "/api/vault/create", false);
|
||||||
xhr.setRequestHeader("Content-Type", "application/json");
|
xhr.setRequestHeader("Content-Type", "application/json");
|
||||||
xhr.send(JSON.stringify({ name: id }));
|
xhr.send(JSON.stringify({ name: id }));
|
||||||
if (xhr.status >= 400) return "Failed to create vault";
|
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;
|
const target = window.parent !== window ? window.parent : window;
|
||||||
target.location.href = "/?vault=" + encodeURIComponent(id);
|
target.location.href = "/?vault=" + encodeURIComponent(id);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -90,7 +66,6 @@ const syncHandlers = {
|
||||||
return xhr.status < 400;
|
return xhr.status < 400;
|
||||||
},
|
},
|
||||||
"vault-move": (oldPath, newPath) => {
|
"vault-move": (oldPath, newPath) => {
|
||||||
// Not supported in web context
|
|
||||||
return "Moving vaults is not supported in the web version";
|
return "Moving vaults is not supported in the web version";
|
||||||
},
|
},
|
||||||
"vault-message": () => null,
|
"vault-message": () => null,
|
||||||
|
|
@ -105,10 +80,6 @@ export const ipcRenderer = {
|
||||||
send(channel, ...args) {
|
send(channel, ...args) {
|
||||||
console.log("[shim: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") {
|
if (channel === "context-menu") {
|
||||||
queueMicrotask(() =>
|
queueMicrotask(() =>
|
||||||
ipcRenderer._emit("context-menu", {
|
ipcRenderer._emit("context-menu", {
|
||||||
|
|
@ -163,7 +134,6 @@ export const ipcRenderer = {
|
||||||
return ipcRenderer;
|
return ipcRenderer;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Internal: emit an event to registered listeners (used by ws bridge)
|
|
||||||
_emit(channel, ...args) {
|
_emit(channel, ...args) {
|
||||||
const arr = listeners.get(channel);
|
const arr = listeners.get(channel);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,32 @@
|
||||||
// Shim for remote.app
|
|
||||||
// Obsidian uses: getPath, getVersion, getName, quit, isPackaged, getLocale
|
|
||||||
|
|
||||||
export const appShim = {
|
export const appShim = {
|
||||||
getPath(name) {
|
getPath(name) {
|
||||||
// Return web-friendly paths; config lives server-side in the vault's .obsidian/ dir
|
|
||||||
const paths = {
|
const paths = {
|
||||||
userData: '/.obsidian',
|
userData: "/.obsidian",
|
||||||
home: '/',
|
home: "/",
|
||||||
documents: '/documents',
|
documents: "/documents",
|
||||||
desktop: '/desktop',
|
desktop: "/desktop",
|
||||||
temp: '/tmp',
|
temp: "/tmp",
|
||||||
appData: '/.obsidian',
|
appData: "/.obsidian",
|
||||||
};
|
};
|
||||||
return paths[name] || '/';
|
return paths[name] || "/";
|
||||||
},
|
},
|
||||||
|
|
||||||
getVersion() {
|
getVersion() {
|
||||||
return '1.8.9';
|
return "1.8.9";
|
||||||
},
|
},
|
||||||
|
|
||||||
getName() {
|
getName() {
|
||||||
return 'Obsidian';
|
return "Obsidian";
|
||||||
},
|
},
|
||||||
|
|
||||||
getLocale() {
|
getLocale() {
|
||||||
return navigator.language || 'en-US';
|
return navigator.language || "en-US";
|
||||||
},
|
},
|
||||||
|
|
||||||
isPackaged: true,
|
isPackaged: true,
|
||||||
|
|
||||||
quit() {
|
quit() {
|
||||||
console.log('[shim:app] quit (stub)');
|
console.log("[shim:app] quit (stub)");
|
||||||
},
|
},
|
||||||
|
|
||||||
relaunch() {
|
relaunch() {
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,29 @@
|
||||||
// Shim for remote.clipboard
|
|
||||||
// Obsidian uses: readText, writeText, readImage, writeImage, readHTML, writeHTML
|
|
||||||
|
|
||||||
export const clipboardShim = {
|
export const clipboardShim = {
|
||||||
readText() {
|
readText() {
|
||||||
// navigator.clipboard.readText() is async; return empty for sync calls
|
|
||||||
// TODO: maintain a local mirror updated via async reads
|
// TODO: maintain a local mirror updated via async reads
|
||||||
return '';
|
return "";
|
||||||
},
|
},
|
||||||
|
|
||||||
writeText(text) {
|
writeText(text) {
|
||||||
navigator.clipboard.writeText(text).catch((e) => {
|
navigator.clipboard.writeText(text).catch((e) => {
|
||||||
console.warn('[shim:clipboard] writeText failed:', e);
|
console.warn("[shim:clipboard] writeText failed:", e);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
readHTML() {
|
readHTML() {
|
||||||
return '';
|
return "";
|
||||||
},
|
},
|
||||||
|
|
||||||
writeHTML(html) {
|
writeHTML(html) {
|
||||||
// TODO: use clipboard API with text/html mime type
|
console.log("[shim:clipboard] writeHTML (stub)");
|
||||||
console.log('[shim:clipboard] writeHTML (stub)');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
readImage() {
|
readImage() {
|
||||||
// TODO: implement if needed
|
|
||||||
return { isEmpty: () => true, toPNG: () => new Uint8Array(0) };
|
return { isEmpty: () => true, toPNG: () => new Uint8Array(0) };
|
||||||
},
|
},
|
||||||
|
|
||||||
writeImage(image) {
|
writeImage(image) {
|
||||||
console.log('[shim:clipboard] writeImage (stub)');
|
console.log("[shim:clipboard] writeImage (stub)");
|
||||||
},
|
},
|
||||||
|
|
||||||
has(format) {
|
has(format) {
|
||||||
|
|
@ -37,10 +31,10 @@ export const clipboardShim = {
|
||||||
},
|
},
|
||||||
|
|
||||||
read(format) {
|
read(format) {
|
||||||
return '';
|
return "";
|
||||||
},
|
},
|
||||||
|
|
||||||
clear() {
|
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 = {
|
export const dialogShim = {
|
||||||
async showOpenDialog(browserWindow, options) {
|
async showOpenDialog(browserWindow, options) {
|
||||||
// TODO: implement custom modal UI with server-side file listing
|
// TODO: implement custom modal with server-side file listing
|
||||||
console.log('[shim:dialog] showOpenDialog (stub):', options);
|
console.log("[shim:dialog] showOpenDialog (stub):", options);
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
},
|
},
|
||||||
|
|
||||||
async showSaveDialog(browserWindow, options) {
|
async showSaveDialog(browserWindow, options) {
|
||||||
// TODO: implement custom modal UI
|
// TODO: implement custom modal
|
||||||
console.log('[shim:dialog] showSaveDialog (stub):', options);
|
console.log("[shim:dialog] showSaveDialog (stub):", options);
|
||||||
return { canceled: true, filePath: undefined };
|
return { canceled: true, filePath: undefined };
|
||||||
},
|
},
|
||||||
|
|
||||||
async showMessageBox(browserWindow, options) {
|
async showMessageBox(browserWindow, options) {
|
||||||
// TODO: implement custom modal matching Electron's return format
|
if (typeof browserWindow === "object" && !options) {
|
||||||
// For now, use browser confirm/alert as rough approximation
|
|
||||||
if (typeof browserWindow === 'object' && !options) {
|
|
||||||
options = browserWindow;
|
options = browserWindow;
|
||||||
}
|
}
|
||||||
console.log('[shim:dialog] showMessageBox:', options);
|
console.log("[shim:dialog] showMessageBox:", options);
|
||||||
|
|
||||||
const message = options.message || '';
|
const message = options.message || "";
|
||||||
const detail = options.detail || '';
|
const detail = options.detail || "";
|
||||||
const buttons = options.buttons || ['OK'];
|
const buttons = options.buttons || ["OK"];
|
||||||
|
|
||||||
// Simple fallback: use confirm for 2-button, alert for 1-button
|
|
||||||
if (buttons.length <= 1) {
|
if (buttons.length <= 1) {
|
||||||
alert(message + (detail ? '\n\n' + detail : ''));
|
alert(message + (detail ? "\n\n" + detail : ""));
|
||||||
return { response: 0, checkboxChecked: false };
|
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 {
|
return {
|
||||||
response: result ? 0 : 1,
|
response: result ? 0 : 1,
|
||||||
checkboxChecked: false,
|
checkboxChecked: false,
|
||||||
|
|
@ -40,7 +42,7 @@ export const dialogShim = {
|
||||||
},
|
},
|
||||||
|
|
||||||
showErrorBox(title, content) {
|
showErrorBox(title, content) {
|
||||||
console.error('[shim:dialog] Error:', title, content);
|
console.error("[shim:dialog] Error:", title, content);
|
||||||
alert(title + '\n\n' + 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 { clipboardShim } from "./clipboard.js";
|
||||||
import { shellShim } from "./shell.js";
|
import { shellShim } from "./shell.js";
|
||||||
import { dialogShim } from "./dialog.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 {
|
export class menuShim {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.items = [];
|
this.items = [];
|
||||||
|
|
@ -8,13 +5,12 @@ export class menuShim {
|
||||||
|
|
||||||
static buildFromTemplate(template) {
|
static buildFromTemplate(template) {
|
||||||
const menu = new menuShim();
|
const menu = new menuShim();
|
||||||
menu.items = (template || []).map(item => new menuItemShim(item));
|
menu.items = (template || []).map((item) => new menuItemShim(item));
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
static setApplicationMenu(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() {
|
static getApplicationMenu() {
|
||||||
|
|
@ -22,8 +18,8 @@ export class menuShim {
|
||||||
}
|
}
|
||||||
|
|
||||||
popup(options) {
|
popup(options) {
|
||||||
// TODO: implement custom HTML context menu rendered at mouse position
|
// TODO: render custom HTML context menu at mouse position
|
||||||
console.log('[shim:Menu] popup (stub)', options);
|
console.log("[shim:Menu] popup (stub)", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
append(menuItem) {
|
append(menuItem) {
|
||||||
|
|
@ -41,19 +37,19 @@ export class menuShim {
|
||||||
|
|
||||||
export class menuItemShim {
|
export class menuItemShim {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.label = options.label || '';
|
this.label = options.label || "";
|
||||||
this.type = options.type || 'normal';
|
this.type = options.type || "normal";
|
||||||
this.click = options.click || null;
|
this.click = options.click || null;
|
||||||
this.role = options.role || null;
|
this.role = options.role || null;
|
||||||
this.accelerator = options.accelerator || '';
|
this.accelerator = options.accelerator || "";
|
||||||
this.enabled = options.enabled !== false;
|
this.enabled = options.enabled !== false;
|
||||||
this.visible = options.visible !== false;
|
this.visible = options.visible !== false;
|
||||||
this.checked = !!options.checked;
|
this.checked = !!options.checked;
|
||||||
this.submenu = options.submenu
|
this.submenu = options.submenu
|
||||||
? menuShim.buildFromTemplate(
|
? menuShim.buildFromTemplate(
|
||||||
Array.isArray(options.submenu) ? options.submenu : []
|
Array.isArray(options.submenu) ? options.submenu : [],
|
||||||
)
|
)
|
||||||
: null;
|
: 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 = {
|
export const nativeImageShim = {
|
||||||
createFromBuffer(buffer) {
|
createFromBuffer(buffer) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -8,7 +5,7 @@ export const nativeImageShim = {
|
||||||
getSize: () => ({ width: 0, height: 0 }),
|
getSize: () => ({ width: 0, height: 0 }),
|
||||||
toPNG: () => buffer || new Uint8Array(0),
|
toPNG: () => buffer || new Uint8Array(0),
|
||||||
toJPEG: (quality) => 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 {
|
export class notificationShim {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.title = options.title || '';
|
this.title = options.title || "";
|
||||||
this.body = options.body || '';
|
this.body = options.body || "";
|
||||||
this.silent = options.silent || false;
|
this.silent = options.silent || false;
|
||||||
this._handlers = {};
|
this._handlers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
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 });
|
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) => {
|
Notification.requestPermission().then((perm) => {
|
||||||
if (perm === 'granted') {
|
if (perm === "granted") {
|
||||||
new Notification(this.title, { body: this.body, silent: this.silent });
|
new Notification(this.title, {
|
||||||
|
body: this.body,
|
||||||
|
silent: this.silent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +32,6 @@ export class notificationShim {
|
||||||
}
|
}
|
||||||
|
|
||||||
static isSupported() {
|
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 = {
|
export const screenShim = {
|
||||||
getPrimaryDisplay() {
|
getPrimaryDisplay() {
|
||||||
return {
|
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 },
|
size: { width: window.screen.width, height: window.screen.height },
|
||||||
scaleFactor: window.devicePixelRatio || 1,
|
scaleFactor: window.devicePixelRatio || 1,
|
||||||
bounds: { x: 0, y: 0, width: window.screen.width, height: window.screen.height },
|
bounds: {
|
||||||
workArea: { x: 0, y: 0, width: window.screen.availWidth, height: window.screen.availHeight },
|
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 = {
|
export const sessionShim = {
|
||||||
defaultSession: {
|
defaultSession: {
|
||||||
clearCache() {
|
clearCache() {
|
||||||
|
|
@ -12,7 +9,9 @@ export const sessionShim = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setSpellCheckerLanguages(langs) {},
|
setSpellCheckerLanguages(langs) {},
|
||||||
getSpellCheckerLanguages() { return []; },
|
getSpellCheckerLanguages() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
on() {},
|
on() {},
|
||||||
once() {},
|
once() {},
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
// Shim for remote.shell
|
|
||||||
// Obsidian uses: openExternal, openPath, showItemInFolder
|
|
||||||
|
|
||||||
export const shellShim = {
|
export const shellShim = {
|
||||||
openExternal(url) {
|
openExternal(url) {
|
||||||
window.open(url, '_blank');
|
window.open(url, "_blank");
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
openPath(filePath) {
|
openPath(filePath) {
|
||||||
// TODO: could trigger a server-side download or preview
|
console.log("[shim:shell] openPath (stub):", filePath);
|
||||||
console.log('[shim:shell] openPath (stub):', filePath);
|
return Promise.resolve("");
|
||||||
return Promise.resolve('');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showItemInFolder(filePath) {
|
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 = {
|
export const systemPreferencesShim = {
|
||||||
getAccentColor() {
|
getAccentColor() {
|
||||||
return '0078d4'; // Default Windows accent blue
|
return "0078d4"; // Default Windows accent blue
|
||||||
},
|
},
|
||||||
|
|
||||||
isAeroGlassEnabled() {
|
isAeroGlassEnabled() {
|
||||||
|
|
@ -11,7 +8,7 @@ export const systemPreferencesShim = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getMediaAccessStatus(mediaType) {
|
getMediaAccessStatus(mediaType) {
|
||||||
return 'granted';
|
return "granted";
|
||||||
},
|
},
|
||||||
|
|
||||||
askForMediaAccess(mediaType) {
|
askForMediaAccess(mediaType) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
// Shim for remote.nativeTheme
|
|
||||||
// Obsidian uses: shouldUseDarkColors, on('updated', cb)
|
|
||||||
|
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
|
|
||||||
const darkQuery = typeof window !== 'undefined'
|
const darkQuery =
|
||||||
? window.matchMedia('(prefers-color-scheme: dark)')
|
typeof window !== "undefined"
|
||||||
: null;
|
? window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
: null;
|
||||||
|
|
||||||
if (darkQuery?.addEventListener) {
|
if (darkQuery?.addEventListener) {
|
||||||
darkQuery.addEventListener('change', () => {
|
darkQuery.addEventListener("change", () => {
|
||||||
for (const fn of listeners) {
|
for (const fn of listeners) {
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +19,7 @@ export const themeShim = {
|
||||||
},
|
},
|
||||||
|
|
||||||
get themeSource() {
|
get themeSource() {
|
||||||
return 'system';
|
return "system";
|
||||||
},
|
},
|
||||||
|
|
||||||
set themeSource(val) {
|
set themeSource(val) {
|
||||||
|
|
@ -29,14 +27,14 @@ export const themeShim = {
|
||||||
},
|
},
|
||||||
|
|
||||||
on(event, callback) {
|
on(event, callback) {
|
||||||
if (event === 'updated') {
|
if (event === "updated") {
|
||||||
listeners.push(callback);
|
listeners.push(callback);
|
||||||
}
|
}
|
||||||
return themeShim;
|
return themeShim;
|
||||||
},
|
},
|
||||||
|
|
||||||
once(event, callback) {
|
once(event, callback) {
|
||||||
if (event === 'updated') {
|
if (event === "updated") {
|
||||||
const wrapped = () => {
|
const wrapped = () => {
|
||||||
const idx = listeners.indexOf(wrapped);
|
const idx = listeners.indexOf(wrapped);
|
||||||
if (idx >= 0) listeners.splice(idx, 1);
|
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 = {
|
const currentWindowState = {
|
||||||
title: "Obsidian",
|
title: "Obsidian",
|
||||||
isMaximized: false,
|
isMaximized: false,
|
||||||
|
|
@ -80,7 +75,6 @@ const currentWindow = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setBounds(bounds) {
|
setBounds(bounds) {
|
||||||
// Cannot resize browser window from JS
|
|
||||||
console.log("[shim:window] setBounds (stub):", bounds);
|
console.log("[shim:window] setBounds (stub):", bounds);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -113,7 +107,6 @@ const currentWindow = {
|
||||||
},
|
},
|
||||||
|
|
||||||
on(event, handler) {
|
on(event, handler) {
|
||||||
// Map some Electron window events to browser equivalents
|
|
||||||
if (event === "focus") window.addEventListener("focus", handler);
|
if (event === "focus") window.addEventListener("focus", handler);
|
||||||
else if (event === "blur") window.addEventListener("blur", handler);
|
else if (event === "blur") window.addEventListener("blur", handler);
|
||||||
else if (event === "resize") window.addEventListener("resize", handler);
|
else if (event === "resize") window.addEventListener("resize", handler);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// Shim for electron.webFrame
|
|
||||||
// Obsidian uses: getZoomLevel(), setZoomLevel()
|
|
||||||
|
|
||||||
let currentZoom = 0;
|
let currentZoom = 0;
|
||||||
|
|
||||||
export const webFrame = {
|
export const webFrame = {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
// Filesystem shim - the core piece
|
import { MetadataCache } from "./metadata-cache.js";
|
||||||
// Returned for both require('original-fs') and require('fs')
|
import { ContentCache } from "./content-cache.js";
|
||||||
//
|
import { transport } from "./transport.js";
|
||||||
// Strategy: metadata cache + on-demand content fetch + write-through
|
import { createFsPromises } from "./promises.js";
|
||||||
// Server sync mechanism (REST vs WebSocket) is TBD - abstracted behind
|
import { createFsSync } from "./sync.js";
|
||||||
// the transport layer in ./transport.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 metadataCache = new MetadataCache();
|
||||||
const contentCache = new ContentCache();
|
const contentCache = new ContentCache();
|
||||||
|
|
@ -21,10 +14,8 @@ const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||||
const fsWatch = createFsWatch(transport);
|
const fsWatch = createFsWatch(transport);
|
||||||
|
|
||||||
export const fsShim = {
|
export const fsShim = {
|
||||||
// Async promise-based API (this.fsPromises = this.fs.promises)
|
|
||||||
promises: fsPromises,
|
promises: fsPromises,
|
||||||
|
|
||||||
// Sync methods
|
|
||||||
existsSync: fsSync.existsSync,
|
existsSync: fsSync.existsSync,
|
||||||
readFileSync: fsSync.readFileSync,
|
readFileSync: fsSync.readFileSync,
|
||||||
writeFileSync: fsSync.writeFileSync,
|
writeFileSync: fsSync.writeFileSync,
|
||||||
|
|
@ -33,17 +24,12 @@ export const fsShim = {
|
||||||
statSync: fsSync.statSync,
|
statSync: fsSync.statSync,
|
||||||
readdirSync: fsSync.readdirSync,
|
readdirSync: fsSync.readdirSync,
|
||||||
|
|
||||||
// Watch
|
|
||||||
watch: fsWatch.watch,
|
watch: fsWatch.watch,
|
||||||
|
|
||||||
// Constants
|
|
||||||
constants,
|
constants,
|
||||||
|
|
||||||
// Internal: for initialization
|
|
||||||
_metadataCache: metadataCache,
|
_metadataCache: metadataCache,
|
||||||
_contentCache: contentCache,
|
_contentCache: contentCache,
|
||||||
|
|
||||||
// Initialize the caches by fetching the full tree from server
|
|
||||||
async _init(basePath) {
|
async _init(basePath) {
|
||||||
const tree = await transport.fetchTree(basePath);
|
const tree = await transport.fetchTree(basePath);
|
||||||
metadataCache.populate(tree);
|
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) {
|
export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
async stat(path) {
|
async stat(path) {
|
||||||
// Try cache first, fall back to server
|
|
||||||
const cached = metadataCache.toStat(path);
|
const cached = metadataCache.toStat(path);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
|
@ -14,17 +10,15 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async lstat(path) {
|
async lstat(path) {
|
||||||
// No symlinks in our context - same as stat
|
// No symlinks in our context
|
||||||
return this.stat(path);
|
return this.stat(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async readdir(path) {
|
async readdir(path) {
|
||||||
// If metadata cache knows this is a file, return empty (ENOTDIR)
|
|
||||||
const meta = metadataCache.get(path);
|
const meta = metadataCache.get(path);
|
||||||
if (meta && meta.type === "file") {
|
if (meta && meta.type === "file") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// If path not in cache at all (and not root), it doesn't exist
|
|
||||||
if (!meta && path && path !== "/" && path !== ".") {
|
if (!meta && path && path !== "/" && path !== ".") {
|
||||||
const e = new Error(
|
const e = new Error(
|
||||||
`ENOENT: no such file or directory, scandir '${path}'`,
|
`ENOENT: no such file or directory, scandir '${path}'`,
|
||||||
|
|
@ -32,7 +26,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
e.code = "ENOENT";
|
e.code = "ENOENT";
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
// Serve from metadata cache
|
|
||||||
const entries = metadataCache.readdir(path);
|
const entries = metadataCache.readdir(path);
|
||||||
return entries.map((e) => e.name);
|
return entries.map((e) => e.name);
|
||||||
},
|
},
|
||||||
|
|
@ -41,14 +34,12 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||||
|
|
||||||
// Short-circuit: reading a directory is an error
|
|
||||||
const meta = metadataCache.get(path);
|
const meta = metadataCache.get(path);
|
||||||
if (meta && meta.type === "directory") {
|
if (meta && meta.type === "directory") {
|
||||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||||
e.code = "EISDIR";
|
e.code = "EISDIR";
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
// Short-circuit: file not in metadata cache → doesn't exist
|
|
||||||
if (!meta && path) {
|
if (!meta && path) {
|
||||||
const e = new Error(
|
const e = new Error(
|
||||||
`ENOENT: no such file or directory, open '${path}'`,
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
|
|
@ -57,7 +48,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check content cache
|
|
||||||
const cached = contentCache.get(path);
|
const cached = contentCache.get(path);
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
if (wantText) {
|
if (wantText) {
|
||||||
|
|
@ -65,14 +55,13 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
? cached
|
? cached
|
||||||
: new TextDecoder().decode(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") {
|
if (typeof cached === "string") {
|
||||||
return new TextEncoder().encode(cached);
|
return new TextEncoder().encode(cached);
|
||||||
}
|
}
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from server
|
|
||||||
const data = await transport.readFile(path, encoding);
|
const data = await transport.readFile(path, encoding);
|
||||||
contentCache.set(path, data);
|
contentCache.set(path, data);
|
||||||
return data;
|
return data;
|
||||||
|
|
@ -81,7 +70,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
async writeFile(path, data, encoding) {
|
async writeFile(path, data, encoding) {
|
||||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||||
|
|
||||||
// Update caches optimistically
|
|
||||||
contentCache.set(path, data);
|
contentCache.set(path, data);
|
||||||
const size =
|
const size =
|
||||||
typeof data === "string" ? data.length : data.byteLength || 0;
|
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(),
|
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send to server
|
|
||||||
const result = await transport.writeFile(path, data, encoding);
|
const result = await transport.writeFile(path, data, encoding);
|
||||||
// Update metadata with server-confirmed values
|
|
||||||
if (result.mtime) {
|
if (result.mtime) {
|
||||||
metadataCache.set(path, {
|
metadataCache.set(path, {
|
||||||
type: "file",
|
type: "file",
|
||||||
|
|
@ -108,7 +94,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
async appendFile(path, data, encoding) {
|
async appendFile(path, data, encoding) {
|
||||||
contentCache.invalidate(path);
|
contentCache.invalidate(path);
|
||||||
await transport.appendFile(path, data);
|
await transport.appendFile(path, data);
|
||||||
// Refresh metadata
|
|
||||||
const meta = await transport.stat(path);
|
const meta = await transport.stat(path);
|
||||||
metadataCache.set(path, meta);
|
metadataCache.set(path, meta);
|
||||||
},
|
},
|
||||||
|
|
@ -120,13 +106,11 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async rename(oldPath, newPath) {
|
async rename(oldPath, newPath) {
|
||||||
// Move content cache entry
|
|
||||||
const content = contentCache.get(oldPath);
|
const content = contentCache.get(oldPath);
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
contentCache.set(newPath, content);
|
contentCache.set(newPath, content);
|
||||||
contentCache.delete(oldPath);
|
contentCache.delete(oldPath);
|
||||||
}
|
}
|
||||||
// Move metadata
|
|
||||||
metadataCache.rename(oldPath, newPath);
|
metadataCache.rename(oldPath, newPath);
|
||||||
|
|
||||||
await transport.rename(oldPath, newPath);
|
await transport.rename(oldPath, newPath);
|
||||||
|
|
@ -154,7 +138,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
|
|
||||||
async copyFile(src, dest) {
|
async copyFile(src, dest) {
|
||||||
await transport.copyFile(src, dest);
|
await transport.copyFile(src, dest);
|
||||||
// Refresh metadata for dest
|
|
||||||
const meta = await transport.stat(dest);
|
const meta = await transport.stat(dest);
|
||||||
metadataCache.set(dest, meta);
|
metadataCache.set(dest, meta);
|
||||||
},
|
},
|
||||||
|
|
@ -169,7 +152,6 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async realpath(path) {
|
async realpath(path) {
|
||||||
// Empty path = vault root, return the vault base path
|
|
||||||
if (!path || path === "/" || path === ".") return "/";
|
if (!path || path === "/" || path === ".") return "/";
|
||||||
return transport.realpath(path);
|
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) {
|
export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
existsSync(path) {
|
existsSync(path) {
|
||||||
|
|
@ -32,7 +29,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
readFileSync(path, encoding) {
|
readFileSync(path, encoding) {
|
||||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||||
|
|
||||||
// Short-circuit: reading a directory is an error
|
|
||||||
const meta = metadataCache.get(path);
|
const meta = metadataCache.get(path);
|
||||||
if (meta && meta.type === "directory") {
|
if (meta && meta.type === "directory") {
|
||||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||||
|
|
@ -40,7 +36,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try content cache first
|
|
||||||
const cached = contentCache.get(path);
|
const cached = contentCache.get(path);
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
if (encoding === "utf8" || encoding === "utf-8") {
|
if (encoding === "utf8" || encoding === "utf-8") {
|
||||||
|
|
@ -51,7 +46,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: synchronous XHR
|
|
||||||
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
|
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
|
||||||
const data = transport.readFileSync(path, encoding);
|
const data = transport.readFileSync(path, encoding);
|
||||||
contentCache.set(path, data);
|
contentCache.set(path, data);
|
||||||
|
|
@ -61,7 +55,6 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
writeFileSync(path, data, encoding) {
|
writeFileSync(path, data, encoding) {
|
||||||
if (typeof encoding === "object") encoding = encoding?.encoding;
|
if (typeof encoding === "object") encoding = encoding?.encoding;
|
||||||
|
|
||||||
// Write to cache immediately (sync return)
|
|
||||||
contentCache.set(path, data);
|
contentCache.set(path, data);
|
||||||
const size =
|
const size =
|
||||||
typeof data === "string" ? data.length : data.byteLength || 0;
|
typeof data === "string" ? data.length : data.byteLength || 0;
|
||||||
|
|
@ -86,7 +79,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
contentCache.delete(path);
|
contentCache.delete(path);
|
||||||
metadataCache.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) => {
|
transport.unlink(path).catch((e) => {
|
||||||
if (e.code !== "ENOENT") {
|
if (e.code !== "ENOENT") {
|
||||||
console.error(
|
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";
|
const API_BASE = "/api/fs";
|
||||||
|
|
||||||
// Strip leading slashes from paths before sending to server
|
|
||||||
function normPath(p) {
|
function normPath(p) {
|
||||||
return (p || "").replace(/^\/+/, "");
|
return (p || "").replace(/^\/+/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert a Uint8Array to base64 without blowing the stack
|
|
||||||
function uint8ToBase64(bytes) {
|
function uint8ToBase64(bytes) {
|
||||||
let binary = "";
|
let binary = "";
|
||||||
const chunk = 8192;
|
const chunk = 8192;
|
||||||
|
|
@ -56,12 +49,10 @@ async function requestJson(method, endpoint, params = {}) {
|
||||||
return res.json();
|
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 = {}) {
|
function requestSync(method, endpoint, params = {}) {
|
||||||
const url = new URL(API_BASE + endpoint, window.location.origin);
|
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());
|
if (vaultId()) url.searchParams.set("vault", vaultId());
|
||||||
for (const [key, val] of Object.entries(params)) {
|
for (const [key, val] of Object.entries(params)) {
|
||||||
url.searchParams.set(key, val);
|
url.searchParams.set(key, val);
|
||||||
|
|
@ -71,7 +62,7 @@ function requestSync(method, endpoint, params = {}) {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open(method, url.toString(), false); // synchronous
|
xhr.open(method, url.toString(), false); // synchronous
|
||||||
|
|
||||||
if (method !== "GET") {
|
if (method !== "GET" && method !== "DELETE") {
|
||||||
xhr.setRequestHeader("Content-Type", "application/json");
|
xhr.setRequestHeader("Content-Type", "application/json");
|
||||||
xhr.send(JSON.stringify({ vault: vaultId(), ...params }));
|
xhr.send(JSON.stringify({ vault: vaultId(), ...params }));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -95,8 +86,6 @@ function requestSync(method, endpoint, params = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transport = {
|
export const transport = {
|
||||||
// --- Async methods (used by fs.promises) ---
|
|
||||||
|
|
||||||
async fetchTree(basePath) {
|
async fetchTree(basePath) {
|
||||||
return requestJson("GET", "/tree", basePath ? { path: basePath } : {});
|
return requestJson("GET", "/tree", basePath ? { path: basePath } : {});
|
||||||
},
|
},
|
||||||
|
|
@ -190,8 +179,6 @@ export const transport = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Sync methods (fallback) ---
|
|
||||||
|
|
||||||
readFileSync(path, encoding) {
|
readFileSync(path, encoding) {
|
||||||
const xhr = requestSync("GET", "/readFile", {
|
const xhr = requestSync("GET", "/readFile", {
|
||||||
path: normPath(path),
|
path: normPath(path),
|
||||||
|
|
@ -200,7 +187,6 @@ export const transport = {
|
||||||
if (encoding === "utf8" || encoding === "utf-8") {
|
if (encoding === "utf8" || encoding === "utf-8") {
|
||||||
return xhr.responseText;
|
return xhr.responseText;
|
||||||
}
|
}
|
||||||
// Binary: return as Uint8Array
|
|
||||||
const binary = xhr.responseText;
|
const binary = xhr.responseText;
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
for (let i = 0; i < binary.length; i++) {
|
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) {
|
export function createFsWatch(transport) {
|
||||||
const watchers = new Map(); // path -> Set<listener>
|
const watchers = new Map(); // path -> Set<listener>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
watch(path, options, listener) {
|
watch(path, options, listener) {
|
||||||
if (typeof options === 'function') {
|
if (typeof options === "function") {
|
||||||
listener = options;
|
listener = options;
|
||||||
options = {};
|
options = {};
|
||||||
}
|
}
|
||||||
|
|
@ -32,22 +27,28 @@ export function createFsWatch(transport) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
on() { return this; },
|
on() {
|
||||||
once() { return this; },
|
return this;
|
||||||
removeListener() { return this; },
|
},
|
||||||
|
once() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
removeListener() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Internal: called when transport receives a file-change event
|
// Internal: called when transport receives a file-change event
|
||||||
_dispatch(eventType, filePath) {
|
_dispatch(eventType, filePath) {
|
||||||
for (const [watchPath, listeners] of watchers) {
|
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;
|
const relativeName = filePath.slice(watchPath.length + 1) || filePath;
|
||||||
for (const fn of listeners) {
|
for (const fn of listeners) {
|
||||||
try {
|
try {
|
||||||
fn(eventType, relativeName);
|
fn(eventType, relativeName);
|
||||||
} catch (e) {
|
} 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 { electronShim } from "./electron/index.js";
|
||||||
import { remoteShim } from "./electron/remote/index.js";
|
import { remoteShim } from "./electron/remote/index.js";
|
||||||
import { fsShim } from "./fs/index.js";
|
import { fsShim } from "./fs/index.js";
|
||||||
import { pathShim } from "./path.js";
|
import { pathShim } from "./path.js";
|
||||||
import { urlShim } from "./url.js";
|
import { urlShim } from "./url.js";
|
||||||
import { cryptoShim } from "./crypto/index.js";
|
import { cryptoShim } from "./crypto/index.js";
|
||||||
import { btimeShim } from "./btime.js";
|
|
||||||
import { processShim } from "./process.js";
|
import { processShim } from "./process.js";
|
||||||
|
|
||||||
// Debug mode: wrap shims in Proxy to log all property accesses
|
|
||||||
const DEBUG = true;
|
const DEBUG = true;
|
||||||
const _accessLog = new Map(); // "module.property" -> count
|
const _accessLog = new Map(); // "module.property" -> count
|
||||||
|
|
||||||
|
|
@ -28,7 +22,7 @@ function wrapWithProxy(obj, name) {
|
||||||
const key = `${name}.${prop}`;
|
const key = `${name}.${prop}`;
|
||||||
_accessLog.set(key, (_accessLog.get(key) || 0) + 1);
|
_accessLog.set(key, (_accessLog.get(key) || 0) + 1);
|
||||||
if (!(prop in target)) {
|
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];
|
return target[prop];
|
||||||
|
|
@ -36,7 +30,6 @@ function wrapWithProxy(obj, name) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose access log for debugging in console: window.__shimLog()
|
|
||||||
window.__shimLog = function () {
|
window.__shimLog = function () {
|
||||||
const sorted = [..._accessLog.entries()].sort((a, b) => b[1] - a[1]);
|
const sorted = [..._accessLog.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
|
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
|
||||||
|
|
@ -60,7 +53,6 @@ const rawRegistry = {
|
||||||
path: pathShim,
|
path: pathShim,
|
||||||
url: urlShim,
|
url: urlShim,
|
||||||
crypto: cryptoShim,
|
crypto: cryptoShim,
|
||||||
btime: btimeShim,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const shimRegistry = {};
|
const shimRegistry = {};
|
||||||
|
|
@ -68,7 +60,6 @@ for (const [name, shim] of Object.entries(rawRegistry)) {
|
||||||
shimRegistry[name] = wrapWithProxy(shim, name);
|
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"]);
|
const throwOnRequire = new Set(["btime", "get-fonts", "vibrancy-win"]);
|
||||||
|
|
||||||
window.require = function (moduleName) {
|
window.require = function (moduleName) {
|
||||||
|
|
@ -84,9 +75,7 @@ window.require = function (moduleName) {
|
||||||
|
|
||||||
window.process = processShim;
|
window.process = processShim;
|
||||||
|
|
||||||
// Provide a global Buffer if needed
|
|
||||||
if (typeof window.Buffer === "undefined") {
|
if (typeof window.Buffer === "undefined") {
|
||||||
// TODO: evaluate if a full Buffer polyfill is needed or if Uint8Array suffices
|
|
||||||
window.Buffer = {
|
window.Buffer = {
|
||||||
from: function (data, encoding) {
|
from: function (data, encoding) {
|
||||||
if (typeof data === "string") {
|
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 () {
|
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");
|
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(
|
window.addEventListener(
|
||||||
"contextmenu",
|
"contextmenu",
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|
@ -138,11 +115,9 @@ window.addEventListener(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Read vault ID from URL query param (?vault=my-notes)
|
|
||||||
const _urlParams = new URLSearchParams(window.location.search);
|
const _urlParams = new URLSearchParams(window.location.search);
|
||||||
window.__currentVaultId = _urlParams.get("vault") || "";
|
window.__currentVaultId = _urlParams.get("vault") || "";
|
||||||
|
|
||||||
// Fetch vault config from server synchronously (before metadata cache)
|
|
||||||
(function initVaultConfig() {
|
(function initVaultConfig() {
|
||||||
try {
|
try {
|
||||||
const vaultParam = window.__currentVaultId
|
const vaultParam = window.__currentVaultId
|
||||||
|
|
@ -165,7 +140,6 @@ window.__currentVaultId = _urlParams.get("vault") || "";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Fetch vault list for IPC handlers
|
|
||||||
(function initVaultList() {
|
(function initVaultList() {
|
||||||
try {
|
try {
|
||||||
const xhr = new XMLHttpRequest();
|
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() {
|
(function initMetadataCache() {
|
||||||
try {
|
try {
|
||||||
const vaultParam = window.__currentVaultId
|
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.
|
// Configured for posix mode since vault paths are normalized to forward slashes.
|
||||||
|
|
||||||
import pathBrowserify from "path";
|
import pathBrowserify from "path";
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
// Shim for window.process
|
|
||||||
// Obsidian checks process.platform, process.versions.electron, etc.
|
|
||||||
|
|
||||||
export const processShim = {
|
export const processShim = {
|
||||||
platform: 'linux',
|
platform: "linux",
|
||||||
versions: {
|
versions: {
|
||||||
electron: '28.0.0',
|
electron: "28.0.0",
|
||||||
node: '18.18.0',
|
node: "18.18.0",
|
||||||
chrome: '120.0.0.0',
|
chrome: "120.0.0.0",
|
||||||
},
|
},
|
||||||
env: {},
|
env: {},
|
||||||
cwd: () => '/',
|
cwd: () => "/",
|
||||||
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
|
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
|
||||||
argv: [],
|
argv: [],
|
||||||
type: 'renderer',
|
type: "renderer",
|
||||||
resourcesPath: '/',
|
resourcesPath: "/",
|
||||||
stdout: { write: (s) => console.log(s) },
|
stdout: { write: (s) => console.log(s) },
|
||||||
stderr: { write: (s) => console.error(s) },
|
stderr: { write: (s) => console.error(s) },
|
||||||
on: () => {},
|
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.
|
// Shows list of vaults, create new, delete, switch.
|
||||||
|
|
||||||
export function showVaultManager() {
|
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;";
|
"color:var(--text-muted);border-radius:4px;padding:2px 8px;font-size:12px;cursor:pointer;";
|
||||||
del.addEventListener("click", (e) => {
|
del.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (!confirm('Delete vault "' + v.name + '"? This removes all files.'))
|
||||||
!confirm(
|
|
||||||
'Delete vault "' + v.name + '"? This removes all files.',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
const xhr2 = new XMLHttpRequest();
|
const xhr2 = new XMLHttpRequest();
|
||||||
xhr2.open(
|
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 = {
|
export const urlShim = {
|
||||||
URL: globalThis.URL,
|
URL: globalThis.URL,
|
||||||
URLSearchParams: globalThis.URLSearchParams,
|
URLSearchParams: globalThis.URLSearchParams,
|
||||||
|
|
||||||
pathToFileURL(p) {
|
pathToFileURL(p) {
|
||||||
// Return an object with .href matching Node's url.pathToFileURL behavior
|
// Return an object with .href matching Node's url.pathToFileURL behavior
|
||||||
const encoded = encodeURI(p.replace(/\\/g, '/'));
|
const encoded = encodeURI(p.replace(/\\/g, "/"));
|
||||||
const href = 'file:///' + encoded.replace(/^\/+/, '');
|
const href = "file:///" + encoded.replace(/^\/+/, "");
|
||||||
return { href, toString: () => href };
|
return { href, toString: () => href };
|
||||||
},
|
},
|
||||||
|
|
||||||
fileURLToPath(url) {
|
fileURLToPath(url) {
|
||||||
let str = typeof url === 'string' ? url : url.href || url.toString();
|
let str = typeof url === "string" ? url : url.href || url.toString();
|
||||||
if (str.startsWith('file:///')) {
|
if (str.startsWith("file:///")) {
|
||||||
str = str.slice(8);
|
str = str.slice(8);
|
||||||
} else if (str.startsWith('file://')) {
|
} else if (str.startsWith("file://")) {
|
||||||
str = str.slice(7);
|
str = str.slice(7);
|
||||||
}
|
}
|
||||||
return decodeURI(str);
|
return decodeURI(str);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue