mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
consolidate build scripts, reorganize source into src/ directory, fix favicon injection
This commit is contained in:
138
src/services/vault-service.js
Normal file
138
src/services/vault-service.js
Normal 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
4
src/shims/btime.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Obsidian wraps this in try/catch: try{this.btime=window.require("btime")}catch(e){}
|
||||
// Returning null causes graceful degradation. mtime is used instead.
|
||||
|
||||
export const btimeShim = null;
|
||||
74
src/shims/crypto/create-hash.js
Normal file
74
src/shims/crypto/create-hash.js
Normal 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");
|
||||
}
|
||||
9
src/shims/crypto/index.js
Normal file
9
src/shims/crypto/index.js
Normal 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,
|
||||
};
|
||||
20
src/shims/crypto/random-bytes.js
Normal file
20
src/shims/crypto/random-bytes.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export function randomBytes(size) {
|
||||
const buf = new Uint8Array(size);
|
||||
crypto.getRandomValues(buf);
|
||||
|
||||
buf.toString = function (encoding) {
|
||||
if (encoding === "hex") {
|
||||
return Array.from(this)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
if (encoding === "base64") {
|
||||
return btoa(String.fromCharCode(...this));
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(this);
|
||||
};
|
||||
|
||||
return buf;
|
||||
}
|
||||
26
src/shims/crypto/scrypt.js
Normal file
26
src/shims/crypto/scrypt.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export function scrypt(password, salt, keylen, options, callback) {
|
||||
if (typeof options === "function") {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
|
||||
const N = options?.N || 32768;
|
||||
const r = options?.r || 8;
|
||||
const p = options?.p || 1;
|
||||
|
||||
if (window.scrypt && window.scrypt.scrypt) {
|
||||
const pwBytes =
|
||||
typeof password === "string"
|
||||
? new TextEncoder().encode(password)
|
||||
: password;
|
||||
const saltBytes =
|
||||
typeof salt === "string" ? new TextEncoder().encode(salt) : salt;
|
||||
|
||||
window.scrypt
|
||||
.scrypt(pwBytes, saltBytes, N, r, p, keylen)
|
||||
.then((result) => callback(null, new Uint8Array(result)))
|
||||
.catch((err) => callback(err));
|
||||
} else {
|
||||
callback(new Error("scrypt not available"));
|
||||
}
|
||||
}
|
||||
47
src/shims/electron/index.js
Normal file
47
src/shims/electron/index.js
Normal 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;
|
||||
},
|
||||
},
|
||||
};
|
||||
260
src/shims/electron/ipc-renderer.js
Normal file
260
src/shims/electron/ipc-renderer.js
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
43
src/shims/electron/remote/app.js
Normal file
43
src/shims/electron/remote/app.js
Normal file
@@ -0,0 +1,43 @@
|
||||
export const appShim = {
|
||||
getPath(name) {
|
||||
const paths = {
|
||||
userData: "/.obsidian",
|
||||
home: "/",
|
||||
documents: "/documents",
|
||||
desktop: "/desktop",
|
||||
temp: "/tmp",
|
||||
appData: "/.obsidian",
|
||||
};
|
||||
return paths[name] || "/";
|
||||
},
|
||||
|
||||
getVersion() {
|
||||
return window.__obsidianVersion || "0.0.0";
|
||||
},
|
||||
|
||||
getName() {
|
||||
return "Obsidian";
|
||||
},
|
||||
|
||||
getLocale() {
|
||||
return navigator.language || "en-US";
|
||||
},
|
||||
|
||||
isPackaged: true,
|
||||
|
||||
quit() {
|
||||
console.log("[shim:app] quit (stub)");
|
||||
},
|
||||
|
||||
relaunch() {
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
whenReady() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
on() {},
|
||||
once() {},
|
||||
removeListener() {},
|
||||
};
|
||||
40
src/shims/electron/remote/clipboard.js
Normal file
40
src/shims/electron/remote/clipboard.js
Normal 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(() => {});
|
||||
},
|
||||
};
|
||||
70
src/shims/electron/remote/dialog.js
Normal file
70
src/shims/electron/remote/dialog.js
Normal 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);
|
||||
},
|
||||
};
|
||||
50
src/shims/electron/remote/index.js
Normal file
50
src/shims/electron/remote/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { clipboardShim } from "./clipboard.js";
|
||||
import { shellShim } from "./shell.js";
|
||||
import { dialogShim } from "./dialog.js";
|
||||
import { menuShim, menuItemShim } from "./menu.js";
|
||||
import { appShim } from "./app.js";
|
||||
import { windowShim, webContentsShim } from "./window.js";
|
||||
import { themeShim } from "./theme.js";
|
||||
import { sessionShim } from "./session.js";
|
||||
import { systemPreferencesShim } from "./system-preferences.js";
|
||||
import { screenShim } from "./screen.js";
|
||||
import { nativeImageShim } from "./native-image.js";
|
||||
import { notificationShim } from "./notification.js";
|
||||
|
||||
export const remoteShim = {
|
||||
clipboard: clipboardShim,
|
||||
shell: shellShim,
|
||||
dialog: dialogShim,
|
||||
Menu: menuShim,
|
||||
MenuItem: menuItemShim,
|
||||
app: appShim,
|
||||
BrowserWindow: windowShim,
|
||||
nativeTheme: themeShim,
|
||||
session: sessionShim,
|
||||
systemPreferences: systemPreferencesShim,
|
||||
screen: screenShim,
|
||||
nativeImage: nativeImageShim,
|
||||
Notification: notificationShim,
|
||||
|
||||
safeStorage: {
|
||||
isEncryptionAvailable() {
|
||||
return false;
|
||||
},
|
||||
encryptString(plainText) {
|
||||
return Buffer.from(plainText);
|
||||
},
|
||||
decryptString(encrypted) {
|
||||
return encrypted.toString();
|
||||
},
|
||||
},
|
||||
|
||||
getCurrentWindow() {
|
||||
return windowShim._current();
|
||||
},
|
||||
|
||||
webContents: webContentsShim,
|
||||
|
||||
getCurrentWebContents() {
|
||||
return webContentsShim._current();
|
||||
},
|
||||
};
|
||||
53
src/shims/electron/remote/menu.js
Normal file
53
src/shims/electron/remote/menu.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export class menuShim {
|
||||
constructor() {
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
static buildFromTemplate(template) {
|
||||
const menu = new menuShim();
|
||||
menu.items = (template || []).map((item) => new menuItemShim(item));
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
static setApplicationMenu(menu) {
|
||||
console.log("[shim:Menu] setApplicationMenu (stub)");
|
||||
}
|
||||
|
||||
static getApplicationMenu() {
|
||||
return null;
|
||||
}
|
||||
|
||||
popup(options) {
|
||||
console.log("[shim:Menu] popup (stub)", options);
|
||||
}
|
||||
|
||||
append(menuItem) {
|
||||
this.items.push(menuItem);
|
||||
}
|
||||
|
||||
insert(pos, menuItem) {
|
||||
this.items.splice(pos, 0, menuItem);
|
||||
}
|
||||
|
||||
closePopup() {}
|
||||
}
|
||||
|
||||
export class menuItemShim {
|
||||
constructor(options = {}) {
|
||||
this.label = options.label || "";
|
||||
this.type = options.type || "normal";
|
||||
this.click = options.click || null;
|
||||
this.role = options.role || null;
|
||||
this.accelerator = options.accelerator || "";
|
||||
this.enabled = options.enabled !== false;
|
||||
this.visible = options.visible !== false;
|
||||
this.checked = !!options.checked;
|
||||
this.submenu = options.submenu
|
||||
? menuShim.buildFromTemplate(
|
||||
Array.isArray(options.submenu) ? options.submenu : [],
|
||||
)
|
||||
: null;
|
||||
this.id = options.id || "";
|
||||
}
|
||||
}
|
||||
20
src/shims/electron/remote/native-image.js
Normal file
20
src/shims/electron/remote/native-image.js
Normal 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));
|
||||
},
|
||||
};
|
||||
37
src/shims/electron/remote/notification.js
Normal file
37
src/shims/electron/remote/notification.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export class notificationShim {
|
||||
constructor(options = {}) {
|
||||
this.title = options.title || "";
|
||||
this.body = options.body || "";
|
||||
this.silent = options.silent || false;
|
||||
this._handlers = {};
|
||||
}
|
||||
|
||||
show() {
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(this.title, { body: this.body, silent: this.silent });
|
||||
} else if (
|
||||
"Notification" in window &&
|
||||
Notification.permission !== "denied"
|
||||
) {
|
||||
Notification.requestPermission().then((perm) => {
|
||||
if (perm === "granted") {
|
||||
new Notification(this.title, {
|
||||
body: this.body,
|
||||
silent: this.silent,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
on(event, handler) {
|
||||
this._handlers[event] = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return "Notification" in window;
|
||||
}
|
||||
}
|
||||
40
src/shims/electron/remote/screen.js
Normal file
40
src/shims/electron/remote/screen.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export const screenShim = {
|
||||
getPrimaryDisplay() {
|
||||
return {
|
||||
workAreaSize: {
|
||||
width: window.screen.availWidth,
|
||||
height: window.screen.availHeight,
|
||||
},
|
||||
size: { width: window.screen.width, height: window.screen.height },
|
||||
scaleFactor: window.devicePixelRatio || 1,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: window.screen.width,
|
||||
height: window.screen.height,
|
||||
},
|
||||
workArea: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: window.screen.availWidth,
|
||||
height: window.screen.availHeight,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getAllDisplays() {
|
||||
return [screenShim.getPrimaryDisplay()];
|
||||
},
|
||||
|
||||
getDisplayNearestPoint(point) {
|
||||
return screenShim.getPrimaryDisplay();
|
||||
},
|
||||
|
||||
getCursorScreenPoint() {
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
|
||||
on() {},
|
||||
once() {},
|
||||
removeListener() {},
|
||||
};
|
||||
20
src/shims/electron/remote/session.js
Normal file
20
src/shims/electron/remote/session.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export const sessionShim = {
|
||||
defaultSession: {
|
||||
clearCache() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
clearStorageData() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
setSpellCheckerLanguages(langs) {},
|
||||
getSpellCheckerLanguages() {
|
||||
return [];
|
||||
},
|
||||
|
||||
on() {},
|
||||
once() {},
|
||||
removeListener() {},
|
||||
},
|
||||
};
|
||||
15
src/shims/electron/remote/shell.js
Normal file
15
src/shims/electron/remote/shell.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const shellShim = {
|
||||
openExternal(url) {
|
||||
window.open(url, "_blank");
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
openPath(filePath) {
|
||||
console.log("[shim:shell] openPath (stub):", filePath);
|
||||
return Promise.resolve("");
|
||||
},
|
||||
|
||||
showItemInFolder(filePath) {
|
||||
console.log("[shim:shell] showItemInFolder (stub):", filePath);
|
||||
},
|
||||
};
|
||||
21
src/shims/electron/remote/system-preferences.js
Normal file
21
src/shims/electron/remote/system-preferences.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export const systemPreferencesShim = {
|
||||
getAccentColor() {
|
||||
return "0078d4"; // Default Windows accent blue
|
||||
},
|
||||
|
||||
isAeroGlassEnabled() {
|
||||
return false;
|
||||
},
|
||||
|
||||
getMediaAccessStatus(mediaType) {
|
||||
return "granted";
|
||||
},
|
||||
|
||||
askForMediaAccess(mediaType) {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
|
||||
on() {},
|
||||
once() {},
|
||||
removeListener() {},
|
||||
};
|
||||
64
src/shims/electron/remote/theme.js
Normal file
64
src/shims/electron/remote/theme.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const listeners = [];
|
||||
|
||||
const darkQuery =
|
||||
typeof window !== "undefined"
|
||||
? window.matchMedia("(prefers-color-scheme: dark)")
|
||||
: null;
|
||||
|
||||
if (darkQuery?.addEventListener) {
|
||||
darkQuery.addEventListener("change", () => {
|
||||
for (const fn of listeners) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const themeShim = {
|
||||
get shouldUseDarkColors() {
|
||||
return darkQuery ? darkQuery.matches : true;
|
||||
},
|
||||
|
||||
get themeSource() {
|
||||
return "system";
|
||||
},
|
||||
|
||||
set themeSource(val) {
|
||||
// No-op in browser; theme is controlled by OS
|
||||
},
|
||||
|
||||
on(event, callback) {
|
||||
if (event === "updated") {
|
||||
listeners.push(callback);
|
||||
}
|
||||
return themeShim;
|
||||
},
|
||||
|
||||
once(event, callback) {
|
||||
if (event === "updated") {
|
||||
const wrapped = () => {
|
||||
const idx = listeners.indexOf(wrapped);
|
||||
if (idx >= 0) {
|
||||
listeners.splice(idx, 1);
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
listeners.push(wrapped);
|
||||
}
|
||||
return themeShim;
|
||||
},
|
||||
|
||||
removeListener(event, callback) {
|
||||
const idx = listeners.indexOf(callback);
|
||||
if (idx >= 0) {
|
||||
listeners.splice(idx, 1);
|
||||
}
|
||||
|
||||
return themeShim;
|
||||
},
|
||||
|
||||
removeAllListeners() {
|
||||
listeners.length = 0;
|
||||
return themeShim;
|
||||
},
|
||||
};
|
||||
371
src/shims/electron/remote/window.js
Normal file
371
src/shims/electron/remote/window.js
Normal 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;
|
||||
},
|
||||
};
|
||||
24
src/shims/electron/web-frame.js
Normal file
24
src/shims/electron/web-frame.js
Normal file
@@ -0,0 +1,24 @@
|
||||
let currentZoom = 0;
|
||||
|
||||
export const webFrame = {
|
||||
getZoomLevel() {
|
||||
return currentZoom;
|
||||
},
|
||||
|
||||
setZoomLevel(level) {
|
||||
currentZoom = level;
|
||||
// Approximate Electron's zoom behavior via CSS zoom
|
||||
// Electron zoom level 0 = 100%, each step is ~20%
|
||||
const scale = Math.pow(1.2, level);
|
||||
document.body.style.zoom = scale;
|
||||
},
|
||||
|
||||
getZoomFactor() {
|
||||
return Math.pow(1.2, currentZoom);
|
||||
},
|
||||
|
||||
setZoomFactor(factor) {
|
||||
currentZoom = Math.log(factor) / Math.log(1.2);
|
||||
document.body.style.zoom = factor;
|
||||
},
|
||||
};
|
||||
20
src/shims/fs/constants.js
Normal file
20
src/shims/fs/constants.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Node.js fs.constants equivalents
|
||||
|
||||
export const constants = {
|
||||
F_OK: 0,
|
||||
R_OK: 4,
|
||||
W_OK: 2,
|
||||
X_OK: 1,
|
||||
|
||||
COPYFILE_EXCL: 1,
|
||||
COPYFILE_FICLONE: 2,
|
||||
COPYFILE_FICLONE_FORCE: 4,
|
||||
|
||||
O_RDONLY: 0,
|
||||
O_WRONLY: 1,
|
||||
O_RDWR: 2,
|
||||
O_CREAT: 64,
|
||||
O_EXCL: 128,
|
||||
O_TRUNC: 512,
|
||||
O_APPEND: 1024,
|
||||
};
|
||||
95
src/shims/fs/content-cache.js
Normal file
95
src/shims/fs/content-cache.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// In-memory content cache with simple LRU eviction
|
||||
// Stores file content fetched from the server.
|
||||
|
||||
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
export class ContentCache {
|
||||
constructor(maxSize = DEFAULT_MAX_SIZE) {
|
||||
this._cache = new Map(); // path -> { data, size, accessedAt }
|
||||
this._currentSize = 0;
|
||||
this._maxSize = maxSize;
|
||||
}
|
||||
|
||||
has(path) {
|
||||
return this._cache.has(this._normalize(path));
|
||||
}
|
||||
|
||||
get(path) {
|
||||
const entry = this._cache.get(this._normalize(path));
|
||||
if (entry) {
|
||||
entry.accessedAt = Date.now();
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
set(path, data) {
|
||||
const norm = this._normalize(path);
|
||||
const size = data ? data.length || data.byteLength || 0 : 0;
|
||||
|
||||
// Remove old entry if replacing
|
||||
if (this._cache.has(norm)) {
|
||||
this._currentSize -= this._cache.get(norm).size;
|
||||
}
|
||||
|
||||
// Evict LRU entries if needed
|
||||
while (this._currentSize + size > this._maxSize && this._cache.size > 0) {
|
||||
this._evictOne();
|
||||
}
|
||||
|
||||
this._cache.set(norm, { data, size, accessedAt: Date.now() });
|
||||
this._currentSize += size;
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
const norm = this._normalize(path);
|
||||
const entry = this._cache.get(norm);
|
||||
|
||||
if (entry) {
|
||||
this._currentSize -= entry.size;
|
||||
this._cache.delete(norm);
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate a path (remove from cache so next read fetches fresh)
|
||||
invalidate(path) {
|
||||
this.delete(path);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._cache.clear();
|
||||
this._currentSize = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._cache.size;
|
||||
}
|
||||
|
||||
get currentBytes() {
|
||||
return this._currentSize;
|
||||
}
|
||||
|
||||
_evictOne() {
|
||||
let oldest = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [key, entry] of this._cache) {
|
||||
if (entry.accessedAt < oldestTime) {
|
||||
oldest = key;
|
||||
oldestTime = entry.accessedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldest) {
|
||||
this.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
_normalize(p) {
|
||||
return (p || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
38
src/shims/fs/index.js
Normal file
38
src/shims/fs/index.js
Normal 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`);
|
||||
},
|
||||
};
|
||||
123
src/shims/fs/metadata-cache.js
Normal file
123
src/shims/fs/metadata-cache.js
Normal 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
201
src/shims/fs/promises.js
Normal 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
110
src/shims/fs/sync.js
Normal 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
224
src/shims/fs/transport.js
Normal file
@@ -0,0 +1,224 @@
|
||||
const API_BASE = "/api/fs";
|
||||
|
||||
function normPath(p) {
|
||||
return (p || "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function uint8ToBase64(bytes) {
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function vaultId() {
|
||||
return window.__currentVaultId || "";
|
||||
}
|
||||
|
||||
async function request(method, endpoint, params = {}) {
|
||||
const url = new URL(API_BASE + endpoint, window.location.origin);
|
||||
|
||||
const options = { method };
|
||||
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
if (vaultId()) {
|
||||
url.searchParams.set("vault", vaultId());
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
url.searchParams.set(key, val);
|
||||
}
|
||||
} else {
|
||||
options.headers = { "Content-Type": "application/json" };
|
||||
options.body = JSON.stringify({ vault: vaultId(), ...params });
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), options);
|
||||
if (!res.ok) {
|
||||
const err = await res
|
||||
.json()
|
||||
.catch(() => ({ error: res.statusText, code: "UNKNOWN" }));
|
||||
const e = new Error(err.error || res.statusText);
|
||||
e.code = err.code || "UNKNOWN";
|
||||
throw e;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function requestJson(method, endpoint, params = {}) {
|
||||
const res = await request(method, endpoint, params);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function requestSync(method, endpoint, params = {}) {
|
||||
const url = new URL(API_BASE + endpoint, window.location.origin);
|
||||
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
if (vaultId()) {
|
||||
url.searchParams.set("vault", vaultId());
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
url.searchParams.set(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url.toString(), false); // synchronous
|
||||
|
||||
if (method !== "GET" && method !== "DELETE") {
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
xhr.send(JSON.stringify({ vault: vaultId(), ...params }));
|
||||
} else {
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
if (xhr.status >= 400) {
|
||||
let err;
|
||||
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
err = new Error(body.error || "Request failed");
|
||||
err.code = body.code || "UNKNOWN";
|
||||
} catch {
|
||||
err = new Error("Request failed: " + xhr.status);
|
||||
err.code = "UNKNOWN";
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return xhr;
|
||||
}
|
||||
|
||||
export const transport = {
|
||||
async fetchTree(basePath) {
|
||||
return requestJson("GET", "/tree", basePath ? { path: basePath } : {});
|
||||
},
|
||||
|
||||
async stat(path) {
|
||||
return requestJson("GET", "/stat", { path: normPath(path) });
|
||||
},
|
||||
|
||||
async readdir(path) {
|
||||
return requestJson("GET", "/readdir", { path: normPath(path) });
|
||||
},
|
||||
|
||||
async readFile(path, encoding) {
|
||||
const res = await request("GET", "/readFile", {
|
||||
path: normPath(path),
|
||||
encoding: encoding || "",
|
||||
});
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const buf = await res.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
},
|
||||
|
||||
async writeFile(path, content, encoding) {
|
||||
const isText = typeof content === "string";
|
||||
return requestJson("POST", "/writeFile", {
|
||||
path: normPath(path),
|
||||
content: isText ? content : uint8ToBase64(content),
|
||||
encoding: encoding || (isText ? "utf-8" : "binary"),
|
||||
base64: !isText,
|
||||
});
|
||||
},
|
||||
|
||||
async appendFile(path, content) {
|
||||
return requestJson("POST", "/appendFile", {
|
||||
path: normPath(path),
|
||||
content,
|
||||
});
|
||||
},
|
||||
|
||||
async mkdir(path, recursive) {
|
||||
return requestJson("POST", "/mkdir", { path: normPath(path), recursive });
|
||||
},
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
return requestJson("POST", "/rename", {
|
||||
oldPath: normPath(oldPath),
|
||||
newPath: normPath(newPath),
|
||||
});
|
||||
},
|
||||
|
||||
async copyFile(src, dest) {
|
||||
return requestJson("POST", "/copyFile", {
|
||||
src: normPath(src),
|
||||
dest: normPath(dest),
|
||||
});
|
||||
},
|
||||
|
||||
async unlink(path) {
|
||||
return requestJson("DELETE", "/unlink", { path: normPath(path) });
|
||||
},
|
||||
|
||||
async rmdir(path) {
|
||||
return requestJson("DELETE", "/rmdir", { path: normPath(path) });
|
||||
},
|
||||
|
||||
async rm(path, recursive) {
|
||||
return requestJson("DELETE", "/rm", {
|
||||
path: normPath(path),
|
||||
recursive: recursive ? "true" : "false",
|
||||
});
|
||||
},
|
||||
|
||||
async access(path) {
|
||||
return requestJson("GET", "/access", { path: normPath(path) });
|
||||
},
|
||||
|
||||
async realpath(path) {
|
||||
const result = await requestJson("GET", "/realpath", {
|
||||
path: normPath(path),
|
||||
});
|
||||
return result.path;
|
||||
},
|
||||
|
||||
async utimes(path, atime, mtime) {
|
||||
return requestJson("POST", "/utimes", {
|
||||
path: normPath(path),
|
||||
atime,
|
||||
mtime,
|
||||
});
|
||||
},
|
||||
|
||||
readFileSync(path, encoding) {
|
||||
const xhr = requestSync("GET", "/readFile", {
|
||||
path: normPath(path),
|
||||
encoding: encoding || "",
|
||||
});
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
return xhr.responseText;
|
||||
}
|
||||
|
||||
const binary = xhr.responseText;
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
},
|
||||
|
||||
writeFileSync(path, content, encoding) {
|
||||
const isText = typeof content === "string";
|
||||
requestSync("POST", "/writeFile", {
|
||||
path: normPath(path),
|
||||
content: isText ? content : uint8ToBase64(content),
|
||||
encoding: encoding || (isText ? "utf-8" : "binary"),
|
||||
base64: !isText,
|
||||
});
|
||||
},
|
||||
};
|
||||
58
src/shims/fs/watch.js
Normal file
58
src/shims/fs/watch.js
Normal 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
262
src/shims/loader.js
Normal 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");
|
||||
15
src/shims/node/child_process.js
Normal file
15
src/shims/node/child_process.js
Normal file
@@ -0,0 +1,15 @@
|
||||
function notAvailable(name) {
|
||||
return function () {
|
||||
throw new Error(
|
||||
`child_process.${name}() is not available in the web version.`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const exec = notAvailable("exec");
|
||||
export const execSync = notAvailable("execSync");
|
||||
export const spawn = notAvailable("spawn");
|
||||
export const fork = notAvailable("fork");
|
||||
export const execFile = notAvailable("execFile");
|
||||
export const execFileSync = notAvailable("execFileSync");
|
||||
export const spawnSync = notAvailable("spawnSync");
|
||||
106
src/shims/node/events.js
Normal file
106
src/shims/node/events.js
Normal file
@@ -0,0 +1,106 @@
|
||||
export class EventEmitter {
|
||||
constructor() {
|
||||
this._events = {};
|
||||
}
|
||||
|
||||
on(event, listener) {
|
||||
if (!this._events[event]) {
|
||||
this._events[event] = [];
|
||||
}
|
||||
|
||||
this._events[event].push(listener);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event, listener) {
|
||||
const wrapped = (...args) => {
|
||||
this.removeListener(event, wrapped);
|
||||
listener.apply(this, args);
|
||||
};
|
||||
|
||||
wrapped._original = listener;
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
const listeners = this._events[event];
|
||||
|
||||
if (!listeners || listeners.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const fn of [...listeners]) {
|
||||
fn.apply(this, args);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
removeListener(event, listener) {
|
||||
const arr = this._events[event];
|
||||
if (!arr) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const idx = arr.findIndex(
|
||||
(fn) => fn === listener || fn._original === listener,
|
||||
);
|
||||
|
||||
if (idx >= 0) {
|
||||
arr.splice(idx, 1);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
off(event, listener) {
|
||||
return this.removeListener(event, listener);
|
||||
}
|
||||
|
||||
removeAllListeners(event) {
|
||||
if (event) {
|
||||
delete this._events[event];
|
||||
} else {
|
||||
this._events = {};
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
listeners(event) {
|
||||
return (this._events[event] || []).slice();
|
||||
}
|
||||
|
||||
listenerCount(event) {
|
||||
return (this._events[event] || []).length;
|
||||
}
|
||||
|
||||
addListener(event, listener) {
|
||||
return this.on(event, listener);
|
||||
}
|
||||
|
||||
prependListener(event, listener) {
|
||||
if (!this._events[event]) {
|
||||
this._events[event] = [];
|
||||
}
|
||||
|
||||
this._events[event].unshift(listener);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
eventNames() {
|
||||
return Object.keys(this._events);
|
||||
}
|
||||
|
||||
setMaxListeners() {
|
||||
return this;
|
||||
}
|
||||
|
||||
getMaxListeners() {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventEmitter;
|
||||
54
src/shims/node/http.js
Normal file
54
src/shims/node/http.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Minimal http/https stub. Plugins needing full http.request won't work,
|
||||
// but this prevents crashes for plugins that just import the module.
|
||||
|
||||
import { EventEmitter } from "./events.js";
|
||||
|
||||
export class IncomingMessage extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.headers = {};
|
||||
this.statusCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequest extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
end() {}
|
||||
write() {}
|
||||
abort() {}
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
export function request(options, callback) {
|
||||
const req = new ClientRequest();
|
||||
if (callback) {
|
||||
req.once("response", callback);
|
||||
}
|
||||
|
||||
// Immediately error. real HTTP requests need fetch or the proxy
|
||||
setTimeout(() => {
|
||||
req.emit(
|
||||
"error",
|
||||
new Error(
|
||||
"http.request is not available in the web version. Use requestUrl() instead.",
|
||||
),
|
||||
);
|
||||
}, 0);
|
||||
return req;
|
||||
}
|
||||
|
||||
export function get(options, callback) {
|
||||
const req = request(options, callback);
|
||||
req.end();
|
||||
return req;
|
||||
}
|
||||
|
||||
export function createServer() {
|
||||
throw new Error("http.createServer is not available in the web version.");
|
||||
}
|
||||
|
||||
export const Agent = class {};
|
||||
export const globalAgent = new Agent();
|
||||
19
src/shims/node/net.js
Normal file
19
src/shims/node/net.js
Normal file
@@ -0,0 +1,19 @@
|
||||
function notAvailable(name) {
|
||||
return function () {
|
||||
throw new Error(`net.${name}() is not available in the web version.`);
|
||||
};
|
||||
}
|
||||
|
||||
export const createServer = notAvailable("createServer");
|
||||
export const createConnection = notAvailable("createConnection");
|
||||
export const connect = notAvailable("connect");
|
||||
export class Socket {
|
||||
constructor() {
|
||||
throw new Error("net.Socket is not available in the web version.");
|
||||
}
|
||||
}
|
||||
export class Server {
|
||||
constructor() {
|
||||
throw new Error("net.Server is not available in the web version.");
|
||||
}
|
||||
}
|
||||
53
src/shims/node/os.js
Normal file
53
src/shims/node/os.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export function platform() {
|
||||
return "linux";
|
||||
}
|
||||
|
||||
export function arch() {
|
||||
return "x64";
|
||||
}
|
||||
|
||||
export function homedir() {
|
||||
return "/";
|
||||
}
|
||||
|
||||
export function tmpdir() {
|
||||
return "/tmp";
|
||||
}
|
||||
|
||||
export function hostname() {
|
||||
return "localhost";
|
||||
}
|
||||
|
||||
export function type() {
|
||||
return "Linux";
|
||||
}
|
||||
|
||||
export function release() {
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
export function cpus() {
|
||||
return [{ model: "browser", speed: 0 }];
|
||||
}
|
||||
|
||||
export function totalmem() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function freemem() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function networkInterfaces() {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function endianness() {
|
||||
return "LE";
|
||||
}
|
||||
|
||||
export function version() {
|
||||
return "v20.0.0";
|
||||
}
|
||||
|
||||
export const EOL = "\n";
|
||||
18
src/shims/path.js
Normal file
18
src/shims/path.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Path shim. delegates to path-browserify (bundled via esbuild alias)
|
||||
// Configured for posix mode since vault paths are normalized to forward slashes.
|
||||
|
||||
import pathBrowserify from "path";
|
||||
|
||||
const _origBasename = pathBrowserify.basename;
|
||||
|
||||
export const pathShim = {
|
||||
...pathBrowserify,
|
||||
basename(p, ext) {
|
||||
// Vault root "/" should return the vault name for display purposes
|
||||
if (p === "/" && window.__currentVaultId) {
|
||||
return window.__currentVaultId;
|
||||
}
|
||||
|
||||
return _origBasename(p, ext);
|
||||
},
|
||||
};
|
||||
19
src/shims/process.js
Normal file
19
src/shims/process.js
Normal 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
120
src/shims/request-url.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// Override window.requestUrl to proxy external requests through our server, bypassing CORS.
|
||||
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function proxyRequestUrl(request) {
|
||||
if (typeof request === "string") {
|
||||
request = { url: request };
|
||||
}
|
||||
|
||||
const isSameOrigin =
|
||||
request.url.startsWith(window.location.origin) ||
|
||||
request.url.startsWith("/");
|
||||
|
||||
// Same-origin requests don't need the proxy
|
||||
if (isSameOrigin) {
|
||||
const res = await fetch(request.url, {
|
||||
method: request.method || "GET",
|
||||
headers: request.headers || {},
|
||||
body: request.body,
|
||||
});
|
||||
|
||||
const arrayBuf = await res.arrayBuffer();
|
||||
|
||||
return makeResponse(
|
||||
request,
|
||||
res.status,
|
||||
Object.fromEntries(res.headers),
|
||||
arrayBuf,
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-origin: route through server proxy
|
||||
let body = request.body;
|
||||
let binary = false;
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
body = arrayBufferToBase64(body);
|
||||
binary = true;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: request.url,
|
||||
method: request.method || "GET",
|
||||
headers: request.headers || {},
|
||||
body,
|
||||
binary,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res
|
||||
.json()
|
||||
.catch(() => ({ error: "Proxy request failed" }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
|
||||
const proxyResult = await res.json();
|
||||
const arrayBuf = base64ToArrayBuffer(proxyResult.body);
|
||||
|
||||
return makeResponse(
|
||||
request,
|
||||
proxyResult.status,
|
||||
proxyResult.headers,
|
||||
arrayBuf,
|
||||
);
|
||||
}
|
||||
|
||||
function makeResponse(request, status, headers, arrayBuf) {
|
||||
const text = new TextDecoder().decode(arrayBuf);
|
||||
let json;
|
||||
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
|
||||
return { status, headers, arrayBuffer: arrayBuf, text, json };
|
||||
}
|
||||
|
||||
export function installRequestUrlShim() {
|
||||
// Obsidian sets window.requestUrl in app.js. We override it once the page loads.
|
||||
// Use a getter so it intercepts even if app.js sets it later.
|
||||
let _original = null;
|
||||
|
||||
Object.defineProperty(window, "requestUrl", {
|
||||
get() {
|
||||
return proxyRequestUrl;
|
||||
},
|
||||
set(val) {
|
||||
_original = val;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
24
src/shims/url.js
Normal file
24
src/shims/url.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export const urlShim = {
|
||||
URL: globalThis.URL,
|
||||
URLSearchParams: globalThis.URLSearchParams,
|
||||
|
||||
pathToFileURL(p) {
|
||||
// Return an object with .href matching Node's url.pathToFileURL behavior
|
||||
const encoded = encodeURI(p.replace(/\\/g, "/"));
|
||||
const href = "file:///" + encoded.replace(/^\/+/, "");
|
||||
|
||||
return { href, toString: () => href };
|
||||
},
|
||||
|
||||
fileURLToPath(url) {
|
||||
let str = typeof url === "string" ? url : url.href || url.toString();
|
||||
|
||||
if (str.startsWith("file:///")) {
|
||||
str = str.slice(8);
|
||||
} else if (str.startsWith("file://")) {
|
||||
str = str.slice(7);
|
||||
}
|
||||
|
||||
return decodeURI(str);
|
||||
},
|
||||
};
|
||||
74
src/ui/bootstrap.js
vendored
Normal file
74
src/ui/bootstrap.js
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
104
src/ui/components/display/ListItem.svelte
Normal file
104
src/ui/components/display/ListItem.svelte
Normal 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>
|
||||
94
src/ui/components/input/Button.svelte
Normal file
94
src/ui/components/input/Button.svelte
Normal 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>
|
||||
64
src/ui/components/input/SearchInput.svelte
Normal file
64
src/ui/components/input/SearchInput.svelte
Normal 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>
|
||||
85
src/ui/components/layout/ConfirmDialog.svelte
Normal file
85
src/ui/components/layout/ConfirmDialog.svelte
Normal 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>
|
||||
69
src/ui/components/layout/MessageDialog.svelte
Normal file
69
src/ui/components/layout/MessageDialog.svelte
Normal 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>
|
||||
130
src/ui/components/layout/Modal.svelte
Normal file
130
src/ui/components/layout/Modal.svelte
Normal 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>
|
||||
115
src/ui/components/layout/PromptDialog.svelte
Normal file
115
src/ui/components/layout/PromptDialog.svelte
Normal 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>
|
||||
101
src/ui/components/menu/PopoverMenu.svelte
Normal file
101
src/ui/components/menu/PopoverMenu.svelte
Normal 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
4
src/ui/index.js
Normal 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";
|
||||
407
src/ui/views/VaultManager.svelte
Normal file
407
src/ui/views/VaultManager.svelte
Normal 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">✓</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>
|
||||
Reference in New Issue
Block a user