consolidate build scripts, reorganize source into src/ directory, fix favicon injection

This commit is contained in:
Nystik
2026-03-20 23:46:17 +01:00
parent 2add5238b8
commit 0747a4540d
56 changed files with 46 additions and 45 deletions

View File

@@ -0,0 +1,138 @@
const API_BASE = "/api/vault";
async function fetchJson(url, options) {
const res = await fetch(url, options);
if (!res.ok) {
const data = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(data.error || "Request failed");
}
return res.json();
}
export const vaultService = {
getCurrentVaultId() {
return window.__currentVaultId || "";
},
async listVaults() {
const list = await fetchJson(API_BASE + "/list");
window.__vaultList = list;
return list;
},
listVaultsSync() {
const xhr = new XMLHttpRequest();
xhr.open("GET", API_BASE + "/list", false);
xhr.send();
if (xhr.status === 200) {
const list = JSON.parse(xhr.responseText);
window.__vaultList = list;
return list;
}
return [];
},
async createVault(name) {
await fetchJson(API_BASE + "/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
return this.listVaults();
},
createVaultSync(name) {
const xhr = new XMLHttpRequest();
xhr.open("POST", API_BASE + "/create", false);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ name }));
if (xhr.status >= 400) {
return null;
}
return true;
},
async renameVault(id, newName) {
await fetchJson(API_BASE + "/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vault: id, name: newName }),
});
this._migrateLocalStorage(id, newName);
if (id === this.getCurrentVaultId()) {
window.__currentVaultId = newName;
if (window.__vaultConfig) {
window.__vaultConfig.id = newName;
}
history.replaceState(null, "", "/?vault=" + encodeURIComponent(newName));
}
return this.listVaults();
},
async deleteVault(id) {
await fetchJson(API_BASE + "/remove?vault=" + encodeURIComponent(id), {
method: "DELETE",
});
const wasCurrentVault = id === this.getCurrentVaultId();
await this.listVaults();
return { wasCurrentVault };
},
deleteVaultSync(id) {
const xhr = new XMLHttpRequest();
xhr.open(
"DELETE",
API_BASE + "/remove?vault=" + encodeURIComponent(id),
false,
);
xhr.send();
return xhr.status < 400;
},
openVault(id) {
localStorage.setItem("last-vault", id);
const target = window.parent !== window ? window.parent : window;
target.location.href = "/?vault=" + encodeURIComponent(id);
},
_migrateLocalStorage(oldId, newId) {
const pluginKey = "enable-plugin-";
const oldVal = localStorage.getItem(pluginKey + oldId);
if (oldVal !== null) {
localStorage.setItem(pluginKey + newId, oldVal);
localStorage.removeItem(pluginKey + oldId);
}
if (localStorage.getItem("last-vault") === oldId) {
localStorage.setItem("last-vault", newId);
}
},
};

4
src/shims/btime.js Normal file
View File

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

View File

@@ -0,0 +1,74 @@
export function createHash(algorithm) {
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") {
data = new TextEncoder().encode(data);
}
const merged = new Uint8Array(inputData.length + data.length);
merged.set(inputData);
merged.set(data, inputData.length);
inputData = merged;
return this;
},
digest(encoding) {
console.warn("[shim:crypto] createHash.digest - using placeholder");
const hash = simpleHash(inputData);
if (encoding === "hex") {
return hash;
}
if (encoding === "base64") {
return btoa(hash);
}
return hash;
},
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 === "base64") {
return btoa(String.fromCharCode(...hashArray));
}
return hashArray;
},
};
}
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");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
// stub
export const clipboardShim = {
readText() {
return "";
},
writeText(text) {
navigator.clipboard.writeText(text).catch((e) => {
console.warn("[shim:clipboard] writeText failed:", e);
});
},
readHTML() {
return "";
},
writeHTML(html) {
console.log("[shim:clipboard] writeHTML (stub)");
},
readImage() {
return { isEmpty: () => true, toPNG: () => new Uint8Array(0) };
},
writeImage(image) {
console.log("[shim:clipboard] writeImage (stub)");
},
has(format) {
return false;
},
read(format) {
return "";
},
clear() {
navigator.clipboard.writeText("").catch(() => {});
},
};

View File

@@ -0,0 +1,70 @@
import {
showMessageDialog,
showConfirmDialog,
showPromptDialog,
} from "../../../ui/bootstrap.js";
export const dialogShim = {
async showOpenDialog(browserWindow, 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) {
if (typeof browserWindow === "object" && !options) {
options = browserWindow;
}
const defaultName =
options?.defaultPath?.split(/[\/\\]/).pop() || "download";
const name = await showPromptDialog(
"Save File",
"Save as:",
"filename",
defaultName,
"Save",
);
if (!name) {
return { canceled: true, filePath: undefined };
}
return { canceled: false, filePath: "/downloads/" + name };
},
async showMessageBox(browserWindow, options) {
if (typeof browserWindow === "object" && !options) {
options = browserWindow;
}
console.log("[shim:dialog] showMessageBox:", options);
const message = options.message || "";
const detail = options.detail || "";
const buttons = options.buttons || ["OK"];
const fullMessage = message + (detail ? "\n\n" + detail : "");
if (buttons.length <= 1) {
await showMessageDialog(options.title || "Message", fullMessage);
return { response: 0, checkboxChecked: false };
}
const result = await showConfirmDialog(
options.title || "Confirm",
message,
detail,
buttons[0],
);
return {
response: result ? 0 : 1,
checkboxChecked: false,
};
},
showErrorBox(title, content) {
console.error("[shim:dialog] Error:", title, content);
showMessageDialog(title, content);
},
};

View File

@@ -0,0 +1,50 @@
import { clipboardShim } from "./clipboard.js";
import { shellShim } from "./shell.js";
import { dialogShim } from "./dialog.js";
import { menuShim, menuItemShim } from "./menu.js";
import { appShim } from "./app.js";
import { windowShim, webContentsShim } from "./window.js";
import { themeShim } from "./theme.js";
import { sessionShim } from "./session.js";
import { systemPreferencesShim } from "./system-preferences.js";
import { screenShim } from "./screen.js";
import { nativeImageShim } from "./native-image.js";
import { notificationShim } from "./notification.js";
export const remoteShim = {
clipboard: clipboardShim,
shell: shellShim,
dialog: dialogShim,
Menu: menuShim,
MenuItem: menuItemShim,
app: appShim,
BrowserWindow: windowShim,
nativeTheme: themeShim,
session: sessionShim,
systemPreferences: systemPreferencesShim,
screen: screenShim,
nativeImage: nativeImageShim,
Notification: notificationShim,
safeStorage: {
isEncryptionAvailable() {
return false;
},
encryptString(plainText) {
return Buffer.from(plainText);
},
decryptString(encrypted) {
return encrypted.toString();
},
},
getCurrentWindow() {
return windowShim._current();
},
webContents: webContentsShim,
getCurrentWebContents() {
return webContentsShim._current();
},
};

View File

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

View File

@@ -0,0 +1,20 @@
export const nativeImageShim = {
createFromBuffer(buffer) {
return {
isEmpty: () => !buffer || buffer.length === 0,
getSize: () => ({ width: 0, height: 0 }),
toPNG: () => buffer || new Uint8Array(0),
toJPEG: (quality) => buffer || new Uint8Array(0),
toDataURL: () => "",
};
},
createFromPath(filePath) {
// TODO: could fetch from server and create image
return nativeImageShim.createFromBuffer(new Uint8Array(0));
},
createEmpty() {
return nativeImageShim.createFromBuffer(new Uint8Array(0));
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
src/shims/fs/constants.js Normal file
View File

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

View File

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

38
src/shims/fs/index.js Normal file
View File

@@ -0,0 +1,38 @@
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();
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport);
export const fsShim = {
promises: fsPromises,
existsSync: fsSync.existsSync,
readFileSync: fsSync.readFileSync,
writeFileSync: fsSync.writeFileSync,
unlinkSync: fsSync.unlinkSync,
accessSync: fsSync.accessSync,
statSync: fsSync.statSync,
readdirSync: fsSync.readdirSync,
watch: fsWatch.watch,
constants,
_metadataCache: metadataCache,
_contentCache: contentCache,
async _init(basePath) {
const tree = await transport.fetchTree(basePath);
metadataCache.populate(tree);
console.log(`[shim:fs] Initialized with ${metadataCache.size} entries`);
},
};

View File

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

201
src/shims/fs/promises.js Normal file
View File

@@ -0,0 +1,201 @@
export function createFsPromises(metadataCache, contentCache, transport) {
return {
async stat(path) {
const cached = metadataCache.toStat(path);
if (cached) {
return cached;
}
const meta = await transport.stat(path);
metadataCache.set(path, meta);
return metadataCache.toStat(path);
},
async lstat(path) {
// No symlinks in our context
return this.stat(path);
},
async readdir(path) {
const meta = metadataCache.get(path);
if (meta && meta.type === "file") {
return [];
}
if (!meta && path && path !== "/" && path !== ".") {
const e = new Error(
`ENOENT: no such file or directory, scandir '${path}'`,
);
e.code = "ENOENT";
throw e;
}
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
async readFile(path, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const wantText = encoding === "utf8" || encoding === "utf-8";
const 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;
}
if (!meta && path) {
const e = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
e.code = "ENOENT";
throw e;
}
const cached = contentCache.get(path);
if (cached !== null) {
if (wantText) {
return typeof cached === "string"
? cached
: new TextDecoder().decode(cached);
}
// binary. ensure we return a proper Uint8Array with .buffer
if (typeof cached === "string") {
return new TextEncoder().encode(cached);
}
return cached;
}
const data = await transport.readFile(path, encoding);
contentCache.set(path, data);
return data;
},
async writeFile(path, data, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
contentCache.set(path, data);
const size =
typeof data === "string" ? data.length : data.byteLength || 0;
metadataCache.set(path, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(),
});
const result = await transport.writeFile(path, data, encoding);
if (result.mtime) {
metadataCache.set(path, {
type: "file",
size: result.size || size,
mtime: result.mtime,
ctime: metadataCache.get(path)?.ctime || Date.now(),
});
}
},
async appendFile(path, data, encoding) {
contentCache.invalidate(path);
await transport.appendFile(path, data);
const meta = await transport.stat(path);
metadataCache.set(path, meta);
},
async unlink(path) {
contentCache.delete(path);
metadataCache.delete(path);
await transport.unlink(path);
},
async rename(oldPath, newPath) {
const content = contentCache.get(oldPath);
if (content !== null) {
contentCache.set(newPath, content);
contentCache.delete(oldPath);
}
metadataCache.rename(oldPath, newPath);
await transport.rename(oldPath, newPath);
},
async mkdir(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
metadataCache.set(path, { type: "directory" });
await transport.mkdir(path, recursive);
},
async rmdir(path) {
metadataCache.delete(path);
await transport.rmdir(path);
},
async rm(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
metadataCache.delete(path);
contentCache.delete(path);
await transport.rm(path, recursive);
},
async copyFile(src, dest) {
await transport.copyFile(src, dest);
const meta = await transport.stat(dest);
metadataCache.set(dest, meta);
},
async access(path) {
if (metadataCache.has(path)) {
return;
}
const e = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
e.code = "ENOENT";
throw e;
},
async realpath(path) {
if (!path || path === "/" || path === ".") {
return "/";
}
return transport.realpath(path);
},
async utimes(path, atime, mtime) {
await transport.utimes(path, atime, mtime);
const meta = metadataCache.get(path);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(path, meta);
}
},
};
}

110
src/shims/fs/sync.js Normal file
View File

@@ -0,0 +1,110 @@
export function createFsSync(metadataCache, contentCache, transport) {
return {
existsSync(path) {
return metadataCache.has(path);
},
statSync(path) {
const stat = metadataCache.toStat(path);
if (!stat) {
const err = new Error(
`ENOENT: no such file or directory, stat '${path}'`,
);
err.code = "ENOENT";
throw err;
}
return stat;
},
accessSync(path, mode) {
if (!metadataCache.has(path)) {
const err = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
err.code = "ENOENT";
throw err;
}
},
readFileSync(path, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const 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;
}
const cached = contentCache.get(path);
if (cached !== null) {
if (encoding === "utf8" || encoding === "utf-8") {
return typeof cached === "string"
? cached
: new TextDecoder().decode(cached);
}
return cached;
}
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
const data = transport.readFileSync(path, encoding);
contentCache.set(path, data);
return data;
},
writeFileSync(path, data, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
contentCache.set(path, data);
const size =
typeof data === "string" ? data.length : data.byteLength || 0;
metadataCache.set(path, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(),
});
// Fire-and-forget async send to server
transport.writeFile(path, data, encoding).catch((e) => {
console.error(
"[shim:fs] writeFileSync background save failed:",
path,
e,
);
});
},
unlinkSync(path) {
contentCache.delete(path);
metadataCache.delete(path);
// Fire-and-forget. suppress ENOENT (file already gone)
transport.unlink(path).catch((e) => {
if (e.code !== "ENOENT") {
console.error(
"[shim:fs] unlinkSync background delete failed:",
path,
e,
);
}
});
},
readdirSync(path) {
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
};
}

224
src/shims/fs/transport.js Normal file
View File

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

58
src/shims/fs/watch.js Normal file
View File

@@ -0,0 +1,58 @@
export function createFsWatch(transport) {
const watchers = new Map(); // path -> Set<listener>
return {
watch(path, options, listener) {
if (typeof options === "function") {
listener = options;
options = {};
}
if (!watchers.has(path)) {
watchers.set(path, new Set());
}
watchers.get(path).add(listener);
// TODO: send watch subscription to server via transport
// Return a watcher-like object
return {
close() {
const set = watchers.get(path);
if (set) {
set.delete(listener);
if (set.size === 0) {
watchers.delete(path);
// TODO: send unwatch to server
}
}
},
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 + "/")) {
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);
}
}
}
}
},
};
}

262
src/shims/loader.js Normal file
View File

@@ -0,0 +1,262 @@
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 { processShim } from "./process.js";
import { installRequestUrlShim } from "./request-url.js";
import {
registerPopupWindow,
unregisterPopupWindow,
} from "./electron/remote/window.js";
import * as childProcessShim from "./node/child_process.js";
import * as eventsShim from "./node/events.js";
import * as osShim from "./node/os.js";
import * as netShim from "./node/net.js";
import * as httpShim from "./node/http.js";
import { vaultService } from "../services/vault-service.js";
const DEBUG = true;
const _accessLog = new Map(); // "module.property" -> count
function wrapWithProxy(obj, name) {
if (!DEBUG || !obj || typeof obj !== "object") {
return obj;
}
return new Proxy(obj, {
get(target, prop) {
if (
typeof prop === "string" &&
prop !== "then" &&
prop !== "toJSON" &&
!prop.startsWith("_")
) {
const key = `${name}.${prop}`;
_accessLog.set(key, (_accessLog.get(key) || 0) + 1);
if (!(prop in target)) {
console.warn(`[shim:MISS] ${key} - property not found on shim`);
}
}
return target[prop];
},
});
}
window.__shimLog = function () {
const sorted = [..._accessLog.entries()].sort((a, b) => b[1] - a[1]);
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
};
window.__shimMisses = function () {
const sorted = [..._accessLog.entries()]
.filter(([k]) => {
const [mod, prop] = k.split(".");
const shim = rawRegistry[mod];
return shim && !(prop in shim);
})
.sort((a, b) => b[1] - a[1]);
console.table(sorted.map(([k, v]) => ({ api: k, calls: v })));
};
const rawRegistry = {
electron: electronShim,
"@electron/remote": remoteShim,
"original-fs": fsShim,
fs: fsShim,
path: pathShim,
url: urlShim,
crypto: cryptoShim,
child_process: childProcessShim,
events: eventsShim,
os: osShim,
net: netShim,
http: httpShim,
https: httpShim,
};
const shimRegistry = {};
for (const [name, shim] of Object.entries(rawRegistry)) {
shimRegistry[name] = wrapWithProxy(shim, name);
}
const throwOnRequire = new Set(["btime", "get-fonts", "vibrancy-win"]);
window.require = function (moduleName) {
if (throwOnRequire.has(moduleName)) {
throw new Error(`Cannot find module '${moduleName}'`);
}
if (shimRegistry[moduleName]) {
return shimRegistry[moduleName];
}
console.warn("[ignis] Unshimmed require:", moduleName);
return wrapWithProxy({}, `UNKNOWN(${moduleName})`);
};
window.process = processShim;
if (typeof window.Buffer === "undefined") {
window.Buffer = {
from: function (data, encoding) {
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
return new Uint8Array(data);
},
concat: function (arrays) {
const total = arrays.reduce((sum, a) => sum + a.length, 0);
const result = new Uint8Array(total);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
},
isBuffer: function (obj) {
return obj instanceof Uint8Array;
},
};
}
window.close = function () {
console.log("[ignis] window.close() blocked");
};
window.__popupIframe = null;
const _originalOpen = window.open;
window.open = function (url, target, features) {
if (url === "about:blank" || (features && features.includes("popup"))) {
console.log("[ignis] intercepted popup:", url, features);
registerPopupWindow();
const iframe = document.createElement("iframe");
iframe.style.cssText =
"position:fixed;left:-9999px;width:0;height:0;border:none;";
document.body.appendChild(iframe);
window.__popupIframe = iframe;
const iframeWin = iframe.contentWindow;
iframeWin.require = window.require;
iframeWin.module = window.module;
iframeWin.Buffer = window.Buffer;
iframeWin.process = window.process;
iframeWin.global = iframeWin;
iframeWin.globalEnhance = window.globalEnhance;
iframeWin.close = function () {
unregisterPopupWindow();
iframe.remove();
window.__popupIframe = null;
};
return iframeWin;
}
return _originalOpen.call(window, url, target, features);
};
// hacky fix to prevent browser from showing context menu while allowing obsidian context menu
window.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
Object.defineProperty(e, "defaultPrevented", { get: () => false });
},
true,
);
const _urlParams = new URLSearchParams(window.location.search);
window.__currentVaultId =
_urlParams.get("vault") || localStorage.getItem("last-vault") || "";
(function initVaultConfig() {
try {
const vaultParam = window.__currentVaultId
? "?vault=" + encodeURIComponent(window.__currentVaultId)
: "";
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/vault/info" + vaultParam, false);
xhr.send();
if (xhr.status === 200) {
const info = JSON.parse(xhr.responseText);
window.__currentVaultId = info.id;
localStorage.setItem("last-vault", info.id);
window.__obsidianVersion = info.version || "0.0.0";
window.__vaultConfig = {
id: info.id,
path: "/",
};
console.log("[ignis] Vault:", window.__vaultConfig);
console.log("[ignis] Obsidian version:", window.__obsidianVersion);
} else {
console.warn("[ignis] No vault found, will show manager");
}
} catch (e) {
console.error("[ignis] Failed to fetch vault config:", e);
}
})();
(function initVaultList() {
try {
vaultService.listVaultsSync();
} catch (e) {
window.__vaultList = [];
}
})();
(function initMetadataCache() {
try {
const vaultParam = window.__currentVaultId
? "?vault=" + encodeURIComponent(window.__currentVaultId)
: "";
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/fs/tree" + vaultParam, false);
xhr.send();
if (xhr.status === 200) {
const tree = JSON.parse(xhr.responseText);
fsShim._metadataCache.populate(tree);
fsShim._metadataCache.set("", { type: "directory" });
fsShim._metadataCache.set("/", { type: "directory" });
console.log(
"[ignis] Metadata cache populated:",
fsShim._metadataCache.size,
"entries",
);
} else {
console.error("[ignis] Failed to fetch metadata tree:", xhr.status);
}
} catch (e) {
console.error("[ignis] Failed to init metadata cache:", e);
}
})();
installRequestUrlShim();
console.log("[ignis] Shim loader initialized");

View File

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

106
src/shims/node/events.js Normal file
View File

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

54
src/shims/node/http.js Normal file
View File

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

19
src/shims/node/net.js Normal file
View File

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

53
src/shims/node/os.js Normal file
View File

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

18
src/shims/path.js Normal file
View File

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

19
src/shims/process.js Normal file
View File

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

120
src/shims/request-url.js Normal file
View File

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

24
src/shims/url.js Normal file
View File

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

74
src/ui/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,74 @@
import { vaultService } from "../services/vault-service.js";
export function showVaultManager() {
if (!document.querySelector(".workspace")) return;
if (document.querySelector(".vault-manager-overlay")) return;
new window.IgnisUI.VaultManager({
target: document.body,
props: { vaultService },
});
}
export function showMessageDialog(title, message) {
return new Promise((resolve) => {
const dialog = new window.IgnisUI.MessageDialog({
target: document.body,
props: { title, message },
});
dialog.$on("confirm", () => {
dialog.$destroy();
resolve();
});
});
}
export function showConfirmDialog(
title,
message,
description,
confirmText = "OK",
) {
return new Promise((resolve) => {
const dialog = new window.IgnisUI.ConfirmDialog({
target: document.body,
props: { title, message, description, confirmText },
});
dialog.$on("confirm", () => {
dialog.$destroy();
resolve(true);
});
dialog.$on("cancel", () => {
dialog.$destroy();
resolve(false);
});
});
}
export function showPromptDialog(
title,
label,
placeholder = "",
value = "",
confirmText = "OK",
) {
return new Promise((resolve) => {
const dialog = new window.IgnisUI.PromptDialog({
target: document.body,
props: { title, label, placeholder, value, confirmText },
});
dialog.$on("confirm", (event) => {
dialog.$destroy();
resolve(event.detail);
});
dialog.$on("cancel", () => {
dialog.$destroy();
resolve(null);
});
});
}

View File

@@ -0,0 +1,104 @@
<script>
import { createEventDispatcher } from "svelte";
export let primary = "";
export let secondary = "";
export let active = false;
export let clickable = true;
const dispatch = createEventDispatcher();
function onClick() {
if (clickable) {
dispatch("click");
}
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="list-item" class:active class:clickable on:click={onClick}>
{#if $$slots.icon}
<div class="item-icon">
<slot name="icon" />
</div>
{/if}
<div class="item-content">
{#if $$slots.default}
<slot />
{:else}
<span class="item-primary">{primary}</span>
{#if secondary}
<span class="item-secondary">{secondary}</span>
{/if}
{/if}
</div>
{#if $$slots.action}
<div class="item-action">
<slot name="action" />
</div>
{/if}
</div>
<style>
.list-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.4rem 0.5rem 1rem;
margin: 0 0.2rem;
background: var(--background-primary);
border-radius: 0.5rem;
border: 1px solid transparent;
transition:
background 0.1s,
border-color 0.1s;
}
.list-item.clickable {
cursor: pointer;
}
.list-item.clickable:hover {
background: var(--background-modifier-hover);
border-color: var(--background-modifier-border);
}
.item-icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--text-muted);
opacity: 0.6;
}
.item-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.item-primary {
font-weight: 600;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-secondary {
font-size: 0.8125rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-action {
flex-shrink: 0;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,94 @@
<script>
import { createEventDispatcher } from "svelte";
export let variant = "primary";
export let disabled = false;
export let title = "";
export let type = "button";
const dispatch = createEventDispatcher();
function onClick(e) {
if (!disabled) {
dispatch("click", e);
}
}
</script>
<button class="btn {variant}" {type} {disabled} {title} on:click={onClick}>
{#if $$slots.icon}
<span class="btn-icon">
<slot name="icon" />
</span>
{/if}
<slot />
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border-radius: 0.375rem;
box-shadow: none;
transition: background 0.1s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon {
display: flex;
align-items: center;
}
.primary {
padding: 0.375rem 1rem;
border: none;
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.secondary {
padding: 0.375rem 0.75rem;
border: 1px solid var(--background-modifier-border);
background: none;
color: var(--text-muted);
}
.secondary:hover:not(:disabled) {
color: var(--text-normal);
background: var(--background-modifier-hover);
}
.ghost {
padding: 0.375rem 0.5rem;
border: none;
background: none;
color: var(--interactive-accent);
}
.ghost:hover:not(:disabled) {
background: var(--background-modifier-hover);
}
.danger {
padding: 0.375rem 1rem;
border: none;
background: var(--text-error, #e93147);
color: #fff;
}
.danger:hover:not(:disabled) {
filter: brightness(1.1);
}
</style>

View File

@@ -0,0 +1,64 @@
<script>
import { createEventDispatcher } from "svelte";
import { Search } from "lucide-svelte";
export let value = "";
export let placeholder = "Search";
const dispatch = createEventDispatcher();
function onInput(e) {
dispatch("input", e.target.value);
}
</script>
<div class="search-input">
<span class="search-icon">
<Search size="0.875rem" />
</span>
<input type="text" {placeholder} {value} on:input={onInput} />
</div>
<style>
.search-input {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 0.625rem;
color: var(--text-muted);
pointer-events: none;
margin-top: 0.2rem;
}
input {
width: 100%;
padding: 0.375rem 0.625rem 0.375rem 1.875rem;
border-radius: 0.375rem;
border: 1px solid var(--background-primary);
background: var(--background-primary);
color: var(--text-normal);
font-size: 0.8125rem;
outline: none;
box-shadow: none;
}
input:hover {
background: var(--background-modifier-form-field);
}
input::placeholder {
color: var(--text-muted);
}
input:focus {
border-color: var(--interactive-accent);
}
input:focus:hover {
background: var(--background-primary);
}
</style>

View File

@@ -0,0 +1,85 @@
<script>
import { createEventDispatcher } from "svelte";
import Modal from "./Modal.svelte";
import Button from "../input/Button.svelte";
export let title = "";
export let message = "";
export let description = "";
export let confirmText = "Confirm";
export let confirmVariant = "primary";
export let width = "500px";
const dispatch = createEventDispatcher();
let modalRef;
function onConfirm() {
dispatch("confirm");
}
function onCancel() {
modalRef.dismiss();
dispatch("cancel");
}
function onEscape() {
onCancel();
}
export function dismiss() {
modalRef.dismiss();
}
</script>
<Modal {title} {width} bind:this={modalRef} on:escape={onEscape} closeOnOverlayClick={false}>
<svelte:fragment slot="icon">
<slot name="icon" />
</svelte:fragment>
<div class="confirm-body">
<p class="confirm-message">{message}</p>
{#if description}
<p class="confirm-description">{description}</p>
{/if}
</div>
<svelte:fragment slot="footer">
<div class="confirm-footer">
<Button variant="secondary" on:click={onCancel}>Cancel</Button>
<Button variant={confirmVariant} on:click={onConfirm}>
<svelte:fragment slot="icon">
<slot name="confirmIcon" />
</svelte:fragment>
{confirmText}
</Button>
</div>
</svelte:fragment>
</Modal>
<style>
.confirm-body {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.confirm-message {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-normal);
}
.confirm-description {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
}
.confirm-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,69 @@
<script>
import { createEventDispatcher } from "svelte";
import Modal from "./Modal.svelte";
import Button from "../input/Button.svelte";
import { CircleAlert } from "lucide-svelte";
export let title = "Message";
export let message = "";
export let width = "500px";
const dispatch = createEventDispatcher();
let modalRef;
function onConfirm() {
dispatch("confirm");
modalRef.dismiss();
}
function onEscape() {
onConfirm();
}
export function dismiss() {
modalRef.dismiss();
}
</script>
<Modal
{title}
{width}
bind:this={modalRef}
on:escape={onEscape}
closeOnOverlayClick={false}
>
<svelte:fragment slot="icon">
<CircleAlert size="1.25rem" />
</svelte:fragment>
<div class="message-body">
<p class="message-text">{message}</p>
</div>
<svelte:fragment slot="footer">
<div class="message-footer">
<Button variant="primary" on:click={onConfirm}>OK</Button>
</div>
</svelte:fragment>
</Modal>
<style>
.message-body {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.message-text {
margin: 0;
font-size: 0.9375rem;
color: var(--text-normal);
line-height: 1.5;
white-space: pre-wrap;
}
.message-footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,130 @@
<script>
import { createEventDispatcher } from "svelte";
import { X } from "lucide-svelte";
export let title = "";
export let width = "600px";
export let closeOnOverlayClick = true;
const dispatch = createEventDispatcher();
let overlayEl;
function close() {
if (overlayEl) {
overlayEl.remove();
}
dispatch("close");
}
function onOverlayClick(e) {
if (e.target === overlayEl && closeOnOverlayClick) {
close();
}
}
function onKeydown(e) {
if (e.key === "Escape") {
dispatch("escape");
}
}
export function dismiss() {
close();
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="modal-overlay"
bind:this={overlayEl}
on:click={onOverlayClick}
on:keydown={onKeydown}
>
<div class="modal-shell" style="width: min({width}, 90vw);">
<div class="modal-header">
<div class="header-left">
<slot name="icon" />
<span class="header-title">{title}</span>
</div>
<button class="close-btn" on:click={close} title="Close">
<X size="1.125rem" />
</button>
</div>
<slot />
{#if $$slots.footer}
<div class="modal-footer">
<slot name="footer" />
</div>
{/if}
</div>
</div>
<style>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 99999;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-interface);
}
.modal-shell {
background: var(--background-secondary);
color: var(--text-normal);
border-radius: 0.75rem;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem 0.5rem 1.5rem;
background: var(--background-primary);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 0.625rem;
color: var(--text-muted);
}
.header-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-normal);
}
.close-btn {
background: none;
border: none;
box-shadow: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
}
.close-btn:hover {
color: var(--text-normal);
}
.modal-footer {
padding: 0.8rem 1.5rem 0.8rem;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,115 @@
<script>
import { createEventDispatcher } from "svelte";
import Modal from "./Modal.svelte";
import Button from "../input/Button.svelte";
export let title = "";
export let label = "";
export let value = "";
export let placeholder = "";
export let confirmText = "Confirm";
export let width = "500px";
const dispatch = createEventDispatcher();
let modalRef;
function onConfirm() {
dispatch("confirm", value);
}
function onCancel() {
modalRef.dismiss();
dispatch("cancel");
}
function onEscape() {
onCancel();
}
function onKeydown(e) {
if (e.key === "Enter") {
onConfirm();
}
}
export function dismiss() {
modalRef.dismiss();
}
</script>
<Modal
{title}
{width}
bind:this={modalRef}
on:escape={onEscape}
closeOnOverlayClick={false}
>
<svelte:fragment slot="icon">
<slot name="icon" />
</svelte:fragment>
<div class="prompt-body">
<label class="prompt-label" for="prompt-input">{label}</label>
<!-- svelte-ignore a11y-autofocus -->
<input
id="prompt-input"
class="prompt-input"
type="text"
{placeholder}
bind:value
on:keydown={onKeydown}
autofocus
/>
</div>
<svelte:fragment slot="footer">
<div class="prompt-footer">
<Button variant="secondary" on:click={onCancel}>Cancel</Button>
<Button variant="primary" on:click={onConfirm}>
<svelte:fragment slot="icon">
<slot name="confirmIcon" />
</svelte:fragment>
{confirmText}
</Button>
</div>
</svelte:fragment>
</Modal>
<style>
.prompt-body {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.prompt-label {
display: block;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-normal);
margin-bottom: 0.75rem;
}
.prompt-input {
width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
font-size: 1rem;
outline: none;
box-shadow: none;
box-sizing: border-box;
}
.prompt-input:focus {
border-color: var(--interactive-accent);
}
.prompt-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,101 @@
<script>
import { createEventDispatcher } from "svelte";
import { EllipsisVertical } from "lucide-svelte";
export let open = false;
export let items = [];
const dispatch = createEventDispatcher();
function onTriggerClick(e) {
e.stopPropagation();
dispatch("toggle");
}
function onItemClick(e, item) {
e.stopPropagation();
dispatch("select", item);
}
</script>
<div class="popover-wrapper">
<button class="popover-trigger" on:click={onTriggerClick} title="Options">
<EllipsisVertical size="1rem" />
</button>
{#if open}
<div class="popover-panel">
{#each items as item}
<button
class="popover-item"
class:danger={item.danger}
on:click={(e) => onItemClick(e, item)}
>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.popover-wrapper {
position: relative;
}
.popover-trigger {
background: none;
border: none;
box-shadow: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.375rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.popover-trigger:hover {
color: var(--text-normal);
}
.popover-panel {
position: absolute;
right: 0;
top: 100%;
z-index: 10;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 0.375rem;
padding: 0.25rem;
min-width: 7.5rem;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
}
.popover-item {
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
border: none;
background: none;
box-shadow: none;
color: var(--text-normal);
font-size: 0.8125rem;
cursor: pointer;
border-radius: 0.25rem;
text-align: left;
}
.popover-item:hover {
background: var(--background-modifier-hover);
}
.popover-item.danger {
color: var(--text-error, #e93147);
}
.popover-item.danger:hover {
background: rgba(233, 49, 71, 0.1);
}
</style>

4
src/ui/index.js Normal file
View File

@@ -0,0 +1,4 @@
export { default as VaultManager } from "./views/VaultManager.svelte";
export { default as MessageDialog } from "./components/layout/MessageDialog.svelte";
export { default as ConfirmDialog } from "./components/layout/ConfirmDialog.svelte";
export { default as PromptDialog } from "./components/layout/PromptDialog.svelte";

View File

@@ -0,0 +1,407 @@
<script>
import { onMount } from "svelte";
import {
Vault,
Folder,
Plus,
SquarePlus,
PenLine,
Trash2,
Check,
} from "lucide-svelte";
import Modal from "../components/layout/Modal.svelte";
import PromptDialog from "../components/layout/PromptDialog.svelte";
import ConfirmDialog from "../components/layout/ConfirmDialog.svelte";
import MessageDialog from "../components/layout/MessageDialog.svelte";
import SearchInput from "../components/input/SearchInput.svelte";
import Button from "../components/input/Button.svelte";
import ListItem from "../components/display/ListItem.svelte";
import PopoverMenu from "../components/menu/PopoverMenu.svelte";
export let vaultService;
let vaults = [];
let searchQuery = "";
let openMenuId = null;
let modalRef;
let activeDialog = null;
let targetVault = null;
let dialogValue = "";
let errorMessage = "";
let pendingReload = false;
const menuItems = [
{ id: "rename", label: "Rename" },
{ id: "delete", label: "Delete", danger: true },
];
let currentVaultId = vaultService.getCurrentVaultId();
$: deleteMessage = targetVault
? 'Are you sure you want to delete "' + targetVault.name + '"?'
: "";
$: filteredVaults = searchQuery
? vaults.filter((v) =>
v.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: vaults;
async function refreshVaults() {
try {
vaults = await vaultService.listVaults();
} catch {
vaults = [];
}
}
function openVault(vault) {
if (vault.id === currentVaultId) {
modalRef.dismiss();
return;
}
vaultService.openVault(vault.id);
}
function toggleMenu(vaultId) {
if (openMenuId === vaultId) {
openMenuId = null;
} else {
openMenuId = vaultId;
}
}
function onMenuSelect(vault, item) {
openMenuId = null;
if (item.id === "rename") {
showRenameDialog(vault);
} else if (item.id === "delete") {
showDeleteDialog(vault);
}
}
function showCreateDialog() {
dialogValue = "";
activeDialog = "create";
}
function showRenameDialog(vault) {
targetVault = vault;
dialogValue = vault.name;
activeDialog = "rename";
}
function showDeleteDialog(vault) {
targetVault = vault;
activeDialog = "delete";
}
function closeDialog() {
activeDialog = null;
targetVault = null;
dialogValue = "";
}
async function onCreateConfirm(e) {
const name = e.detail.trim();
if (!name) {
return;
}
try {
vaults = await vaultService.createVault(name);
closeDialog();
} catch (err) {
errorMessage = "Failed to create vault: " + err.message;
activeDialog = "error";
}
}
async function onRenameConfirm(e) {
const trimmed = e.detail.trim();
if (!trimmed || trimmed === targetVault.name) {
closeDialog();
return;
}
const wasCurrentVault = targetVault.id === currentVaultId;
try {
vaults = await vaultService.renameVault(targetVault.id, trimmed);
closeDialog();
if (wasCurrentVault) {
currentVaultId = vaultService.getCurrentVaultId();
pendingReload = true;
}
} catch (err) {
errorMessage = "Failed to rename vault: " + err.message;
activeDialog = "error";
}
}
async function onDeleteConfirm() {
try {
const { wasCurrentVault } = await vaultService.deleteVault(
targetVault.id,
);
closeDialog();
vaults = await vaultService.listVaults();
if (wasCurrentVault) {
vaultService.openVault("");
}
} catch (err) {
errorMessage = "Failed to delete vault: " + err.message;
activeDialog = "error";
}
}
function onModalClose() {
if (pendingReload) {
window.location.reload();
}
}
function onEscape() {
if (openMenuId) {
openMenuId = null;
} else {
modalRef.dismiss();
}
}
onMount(() => {
refreshVaults();
});
</script>
<Modal
title="Vault Manager"
width="600px"
bind:this={modalRef}
on:escape={onEscape}
on:close={onModalClose}
closeOnOverlayClick={false}
>
<svelte:fragment slot="icon">
<Vault size="1.25rem" />
</svelte:fragment>
<div class="section-header">
<h3>Vaults</h3>
<div class="search-wrapper">
<SearchInput
value={searchQuery}
on:input={(e) => {
searchQuery = e.detail;
}}
/>
</div>
</div>
<div class="section-body">
<div class="vault-list">
{#if vaults.length === 0}
<div class="empty">No vaults yet. Create one below.</div>
{:else if filteredVaults.length === 0}
<div class="empty">No vaults match your search.</div>
{:else}
{#each filteredVaults as vault (vault.id)}
<ListItem
primary={vault.name}
secondary={vault.path}
active={vault.id === currentVaultId}
on:click={() => openVault(vault)}
>
<svelte:fragment slot="icon">
<Folder size="1.5rem" />
</svelte:fragment>
<svelte:fragment slot="default">
<span class="vault-name">
{vault.name}
{#if vault.id === currentVaultId}
<span class="active-label">(active)</span>
<span class="active-check">&#10003;</span>
{/if}
</span>
<span class="vault-path">{vault.path}</span>
</svelte:fragment>
<svelte:fragment slot="action">
<PopoverMenu
open={openMenuId === vault.id}
items={menuItems}
on:toggle={() => toggleMenu(vault.id)}
on:select={(e) => onMenuSelect(vault, e.detail)}
/>
</svelte:fragment>
</ListItem>
{/each}
{/if}
</div>
</div>
<svelte:fragment slot="footer">
<div class="footer-right">
<Button variant="ghost" on:click={showCreateDialog}>
<svelte:fragment slot="icon">
<Plus size="1rem" />
</svelte:fragment>
Create New Vault
</Button>
</div>
</svelte:fragment>
</Modal>
{#if activeDialog === "create"}
<PromptDialog
title="Create Vault"
label="Vault Name:"
placeholder="My New Vault"
confirmText="Create Vault"
bind:value={dialogValue}
on:confirm={onCreateConfirm}
on:cancel={closeDialog}
>
<svelte:fragment slot="icon">
<SquarePlus size="1.25rem" />
</svelte:fragment>
<svelte:fragment slot="confirmIcon">
<Plus size="0.875rem" />
</svelte:fragment>
</PromptDialog>
{/if}
{#if activeDialog === "rename"}
<PromptDialog
title="Rename Item"
label="New Name:"
confirmText="Save"
bind:value={dialogValue}
on:confirm={onRenameConfirm}
on:cancel={closeDialog}
>
<svelte:fragment slot="icon">
<PenLine size="1.25rem" />
</svelte:fragment>
<svelte:fragment slot="confirmIcon">
<Check size="0.875rem" />
</svelte:fragment>
</PromptDialog>
{/if}
{#if activeDialog === "delete" && targetVault}
<ConfirmDialog
title="Delete Confirmation"
message={deleteMessage}
description="This action cannot be undone. All notes and linked files within this vault will be permanently removed from your system."
confirmText="Confirm Delete"
confirmVariant="danger"
on:confirm={onDeleteConfirm}
on:cancel={closeDialog}
>
<svelte:fragment slot="icon">
<Trash2 size="1.25rem" />
</svelte:fragment>
<svelte:fragment slot="confirmIcon">
<Check size="0.875rem" />
</svelte:fragment>
</ConfirmDialog>
{/if}
{#if activeDialog === "error"}
<MessageDialog
title="Error"
message={errorMessage}
on:confirm={() => {
activeDialog = null;
errorMessage = "";
}}
/>
{/if}
<style>
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 2rem 0rem 1.5rem;
flex-shrink: 0;
}
.section-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-normal);
}
.search-wrapper {
width: 11rem;
}
.section-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem 1.1rem 0rem 1rem;
}
.vault-list {
flex: 1;
overflow-y: auto;
scrollbar-gutter: stable;
min-height: 300px;
max-height: 300px;
padding: 0rem 0 1rem 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.empty {
color: var(--text-muted);
padding: 2rem 1rem;
text-align: center;
font-size: 0.875rem;
}
.vault-name {
font-weight: 600;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-label {
color: var(--interactive-accent);
font-weight: 400;
font-size: 0.875rem;
margin-left: 0.375rem;
}
.active-check {
color: var(--interactive-accent);
font-size: 0.875rem;
margin-left: 0.125rem;
}
.vault-path {
font-size: 0.8125rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-right {
display: flex;
justify-content: flex-end;
}
</style>