mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
add server settings UI and enforcement
This commit is contained in:
@@ -213,7 +213,6 @@ const wss = setupWebSocket(server, {
|
|||||||
getVaultPath: config.getVaultPath,
|
getVaultPath: config.getVaultPath,
|
||||||
originAllowlist: settings.get("wsOrigins"),
|
originAllowlist: settings.get("wsOrigins"),
|
||||||
});
|
});
|
||||||
app.set("wss", wss);
|
|
||||||
wireDemoWebSocket(server);
|
wireDemoWebSocket(server);
|
||||||
|
|
||||||
async function gracefulShutdown(signal) {
|
async function gracefulShutdown(signal) {
|
||||||
|
|||||||
@@ -100,16 +100,20 @@ router.post("/", async (req, res) => {
|
|||||||
return res.status(400).json({ error: "Missing url" });
|
return res.status(400).json({ error: "Missing url" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const proxyMode = settings.get("proxyMode");
|
||||||
|
|
||||||
|
if (proxyMode === "disabled") {
|
||||||
|
return res.status(403).json({ error: "Proxy is disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assertPublicUrl(url);
|
await assertPublicUrl(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(e.statusCode || 400).json({ error: e.message });
|
return res.status(e.statusCode || 400).json({ error: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a host allowlist is defined , the proxy only reaches those hosts.
|
if (proxyMode === "allowlist") {
|
||||||
const allowlist = settings.get("proxyAllowlist");
|
const allowlist = settings.get("proxyAllowlist");
|
||||||
|
|
||||||
if (allowlist.length > 0) {
|
|
||||||
const host = new URL(url).hostname;
|
const host = new URL(url).hostname;
|
||||||
|
|
||||||
if (!allowlist.includes(host)) {
|
if (!allowlist.includes(host)) {
|
||||||
|
|||||||
@@ -12,11 +12,21 @@ const NUMBER_KEYS = [
|
|||||||
"writeCoalesceMs",
|
"writeCoalesceMs",
|
||||||
"maxBodyBytes",
|
"maxBodyBytes",
|
||||||
];
|
];
|
||||||
const LIST_KEYS = ["proxyAllowlist", "wsOrigins"];
|
const LIST_KEYS = ["proxyAllowlist"];
|
||||||
|
|
||||||
function validate(body) {
|
function validate(body) {
|
||||||
const clean = {};
|
const clean = {};
|
||||||
|
|
||||||
|
if (body.proxyMode !== undefined) {
|
||||||
|
if (!settings.PROXY_MODES.includes(body.proxyMode)) {
|
||||||
|
throw new Error(
|
||||||
|
`proxyMode must be one of: ${settings.PROXY_MODES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clean.proxyMode = body.proxyMode;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of NUMBER_KEYS) {
|
for (const key of NUMBER_KEYS) {
|
||||||
if (body[key] === undefined) {
|
if (body[key] === undefined) {
|
||||||
continue;
|
continue;
|
||||||
@@ -57,14 +67,8 @@ function validate(body) {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySettings(effective, req) {
|
function applySettings(effective) {
|
||||||
writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs });
|
writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs });
|
||||||
|
|
||||||
const wss = req.app.get("wss");
|
|
||||||
|
|
||||||
if (wss && typeof wss.setOriginAllowlist === "function") {
|
|
||||||
wss.setOriginAllowlist(effective.wsOrigins);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/", (req, res) => {
|
router.get("/", (req, res) => {
|
||||||
@@ -81,7 +85,7 @@ router.post("/", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effective = settings.update(clean);
|
const effective = settings.update(clean);
|
||||||
applySettings(effective, req);
|
applySettings(effective);
|
||||||
|
|
||||||
// Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values.
|
// Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values.
|
||||||
bootstrapRoutes.invalidateAll();
|
bootstrapRoutes.invalidateAll();
|
||||||
|
|||||||
@@ -12,13 +12,20 @@ const DEFAULTS = {
|
|||||||
inputCacheTtlMs: 5 * 60 * 1000,
|
inputCacheTtlMs: 5 * 60 * 1000,
|
||||||
writeCoalesceMs: 5000,
|
writeCoalesceMs: 5000,
|
||||||
maxBodyBytes: 50 * 1024 * 1024,
|
maxBodyBytes: 50 * 1024 * 1024,
|
||||||
// Empty arrays mean "no restriction": any proxy host, any WS origin.
|
// "any" reaches any public host, "allowlist" restricts to proxyAllowlist, "disabled" blocks all proxying.
|
||||||
|
proxyMode: "any",
|
||||||
|
// Empty allows any public host.
|
||||||
proxyAllowlist: [],
|
proxyAllowlist: [],
|
||||||
wsOrigins: [],
|
wsOrigins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROXY_MODES = ["any", "allowlist", "disabled"];
|
||||||
|
|
||||||
const KEYS = Object.keys(DEFAULTS);
|
const KEYS = Object.keys(DEFAULTS);
|
||||||
|
|
||||||
|
// Env vars only; never persisted to the settings file.
|
||||||
|
const ENV_ONLY_KEYS = ["wsOrigins"];
|
||||||
|
|
||||||
// Hard ceiling for request bodies.
|
// Hard ceiling for request bodies.
|
||||||
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
|
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -56,6 +63,10 @@ function loadFile() {
|
|||||||
const clean = {};
|
const clean = {};
|
||||||
|
|
||||||
for (const key of KEYS) {
|
for (const key of KEYS) {
|
||||||
|
if (ENV_ONLY_KEYS.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed[key] !== undefined) {
|
if (parsed[key] !== undefined) {
|
||||||
clean[key] = parsed[key];
|
clean[key] = parsed[key];
|
||||||
}
|
}
|
||||||
@@ -80,7 +91,7 @@ function get(key) {
|
|||||||
// Merge validated changes into the persisted file and return the new effective settings.
|
// Merge validated changes into the persisted file and return the new effective settings.
|
||||||
function update(partial) {
|
function update(partial) {
|
||||||
for (const [key, value] of Object.entries(partial)) {
|
for (const [key, value] of Object.entries(partial)) {
|
||||||
if (KEYS.includes(key)) {
|
if (KEYS.includes(key) && !ENV_ONLY_KEYS.includes(key)) {
|
||||||
fileOverrides[key] = value;
|
fileOverrides[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,4 +102,13 @@ function update(partial) {
|
|||||||
return getAll();
|
return getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { DEFAULTS, KEYS, MAX_BODY_BACKSTOP, getAll, get, update };
|
module.exports = {
|
||||||
|
DEFAULTS,
|
||||||
|
KEYS,
|
||||||
|
ENV_ONLY_KEYS,
|
||||||
|
PROXY_MODES,
|
||||||
|
MAX_BODY_BACKSTOP,
|
||||||
|
getAll,
|
||||||
|
get,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
|||||||
@@ -51,4 +51,4 @@ function stopDemoGuards() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startDemoGuards, stopDemoGuards };
|
module.exports = { startDemoGuards, stopDemoGuards, isDemoMode };
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const { Setting } = require("obsidian");
|
const { Setting, Notice } = require("obsidian");
|
||||||
|
const { isDemoMode } = require("../demo-guards");
|
||||||
|
const { stripBuildMetadata, isNewer } = require("../util/version");
|
||||||
|
const { ListEditorModal } = require("./list-editor-modal");
|
||||||
|
|
||||||
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
|
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
|
||||||
const GITHUB_API_LATEST =
|
const GITHUB_API_LATEST =
|
||||||
@@ -8,11 +11,6 @@ function getVersion() {
|
|||||||
return window.__ignis?.version || "unknown";
|
return window.__ignis?.version || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
|
||||||
function stripBuildMetadata(version) {
|
|
||||||
return (version || "").split("+")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkForUpdate(currentVersion) {
|
async function checkForUpdate(currentVersion) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(GITHUB_API_LATEST);
|
const res = await fetch(GITHUB_API_LATEST);
|
||||||
@@ -25,7 +23,7 @@ async function checkForUpdate(currentVersion) {
|
|||||||
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||||
const current = stripBuildMetadata(currentVersion);
|
const current = stripBuildMetadata(currentVersion);
|
||||||
|
|
||||||
if (latest && latest !== current) {
|
if (isNewer(latest, current)) {
|
||||||
return { version: latest, url: data.html_url };
|
return { version: latest, url: data.html_url };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +86,7 @@ function display(containerEl, app) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addServerStatus(containerEl);
|
addServerStatus(containerEl);
|
||||||
|
addServerSettings(containerEl, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
@@ -102,10 +101,26 @@ const STATUS_DOT_CLASSES = {
|
|||||||
closed: "ignis-status-disconnected",
|
closed: "ignis-status-disconnected",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Obsidian's grouped-settings container. The .setting-group > .setting-items
|
||||||
|
// structure renders its child settings inside one shared box, with an optional
|
||||||
|
// heading as a sibling above the box. The shipped stylesheet supplies the
|
||||||
|
// styling from theme variables, so the rows need no custom CSS.
|
||||||
|
function createSettingGroup(containerEl, heading) {
|
||||||
|
const group = containerEl.createDiv("setting-group");
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
new Setting(group).setName(heading).setHeading();
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.createDiv("setting-items");
|
||||||
|
}
|
||||||
|
|
||||||
function addServerStatus(containerEl) {
|
function addServerStatus(containerEl) {
|
||||||
const ws = window.__ignis.ws;
|
const ws = window.__ignis.ws;
|
||||||
|
|
||||||
const setting = new Setting(containerEl).setName("Server status");
|
const items = createSettingGroup(containerEl);
|
||||||
|
|
||||||
|
const setting = new Setting(items).setName("Server status");
|
||||||
|
|
||||||
const dotEl = setting.controlEl.createEl("span", {
|
const dotEl = setting.controlEl.createEl("span", {
|
||||||
cls: "ignis-status-dot",
|
cls: "ignis-status-dot",
|
||||||
@@ -138,4 +153,204 @@ function addServerStatus(containerEl) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const MINUTE = 60 * 1000;
|
||||||
|
|
||||||
|
function addServerSettings(containerEl, app) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
const items = createSettingGroup(containerEl);
|
||||||
|
|
||||||
|
new Setting(items)
|
||||||
|
.setName("Server settings")
|
||||||
|
.setDesc("Server settings are disabled in demo mode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = containerEl.createEl("p", {
|
||||||
|
text: "Loading server settings...",
|
||||||
|
cls: "setting-item-description",
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
||||||
|
.then((current) => {
|
||||||
|
loading.remove();
|
||||||
|
renderServerSettings(containerEl, current, app);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.setText("Failed to load server settings.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServerSettings(containerEl, current, app) {
|
||||||
|
const caching = createSettingGroup(containerEl, "Caching");
|
||||||
|
|
||||||
|
numberField(caching, {
|
||||||
|
name: "Content cache (MB)",
|
||||||
|
desc: "Browser cache of file content. Applies after reload.",
|
||||||
|
value: Math.round(current.contentCacheBytes / MB),
|
||||||
|
key: "contentCacheBytes",
|
||||||
|
toStored: (n) => n * MB,
|
||||||
|
});
|
||||||
|
|
||||||
|
numberField(caching, {
|
||||||
|
name: "Input cache (MB)",
|
||||||
|
desc: "Cache for files picked for import. Applies after reload.",
|
||||||
|
value: Math.round(current.inputCacheBytes / MB),
|
||||||
|
key: "inputCacheBytes",
|
||||||
|
toStored: (n) => n * MB,
|
||||||
|
});
|
||||||
|
|
||||||
|
numberField(caching, {
|
||||||
|
name: "Input cache TTL (minutes)",
|
||||||
|
desc: "How long picked files stay cached. Applies after reload.",
|
||||||
|
value: Math.round(current.inputCacheTtlMs / MINUTE),
|
||||||
|
key: "inputCacheTtlMs",
|
||||||
|
toStored: (n) => n * MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const security = createSettingGroup(containerEl, "Security");
|
||||||
|
|
||||||
|
numberField(security, {
|
||||||
|
name: "Max request body (MB)",
|
||||||
|
desc: "Largest request the server accepts.",
|
||||||
|
value: Math.round(current.maxBodyBytes / MB),
|
||||||
|
key: "maxBodyBytes",
|
||||||
|
toStored: (n) => n * MB,
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyAccessField(security, current, app);
|
||||||
|
|
||||||
|
const advanced = createSettingGroup(containerEl, "Advanced");
|
||||||
|
|
||||||
|
numberField(advanced, {
|
||||||
|
name: "Write coalesce window (ms)",
|
||||||
|
desc: "Debounce window for rapid writes on slow filesystems. 0 disables.",
|
||||||
|
value: current.writeCoalesceMs,
|
||||||
|
key: "writeCoalesceMs",
|
||||||
|
toStored: (n) => n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist a single setting. The server validates, applies the live ones, and saves.
|
||||||
|
async function saveSetting(partial) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(partial),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "Save failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(`Failed to save setting: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberField(containerEl, { name, desc, value, key, toStored }) {
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(name)
|
||||||
|
.setDesc(desc)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setValue(String(value));
|
||||||
|
|
||||||
|
// Commit on blur or Enter, the way a native number setting behaves.
|
||||||
|
const commit = () => {
|
||||||
|
const n = parseInt(text.getValue(), 10);
|
||||||
|
|
||||||
|
if (Number.isInteger(n) && n >= 0) {
|
||||||
|
saveSetting({ [key]: toStored(n) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
text.inputEl.addEventListener("blur", commit);
|
||||||
|
text.inputEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy access mode plus the allowlist row, which only shows in "allowlist" mode.
|
||||||
|
function proxyAccessField(parent, current, app) {
|
||||||
|
let mode = current.proxyMode || "any";
|
||||||
|
|
||||||
|
const setting = new Setting(parent)
|
||||||
|
.setName("Proxy access")
|
||||||
|
.setDesc(
|
||||||
|
"Which external hosts Obsidian may reach through the server's CORS proxy.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowlistSetting = listField(parent, {
|
||||||
|
name: "Proxy host allowlist",
|
||||||
|
desc: "Hostnames the proxy may reach, matched exactly.",
|
||||||
|
value: current.proxyAllowlist,
|
||||||
|
key: "proxyAllowlist",
|
||||||
|
app,
|
||||||
|
modal: {
|
||||||
|
placeholder: "api.example.com",
|
||||||
|
emptyNote: "No hosts yet.",
|
||||||
|
recommended: {
|
||||||
|
note: "Restricting the proxy stops Obsidian's plugin and theme browser and updates from working unless their hosts are allowed.",
|
||||||
|
hosts: ["releases.obsidian.md", "github.com", "raw.githubusercontent.com"],
|
||||||
|
buttonText: "Add recommended hosts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyVisibility = () => {
|
||||||
|
allowlistSetting.settingEl.style.display =
|
||||||
|
mode === "allowlist" ? "" : "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
setting.addDropdown((dd) => {
|
||||||
|
dd.addOption("any", "Any public host");
|
||||||
|
dd.addOption("allowlist", "Allowlist only");
|
||||||
|
dd.addOption("disabled", "Disabled");
|
||||||
|
dd.setValue(mode);
|
||||||
|
|
||||||
|
dd.onChange((value) => {
|
||||||
|
mode = value;
|
||||||
|
saveSetting({ proxyMode: value });
|
||||||
|
applyVisibility();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
applyVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listField(containerEl, { name, desc, value, key, app, modal }) {
|
||||||
|
let current = [...(value || [])];
|
||||||
|
|
||||||
|
const setting = new Setting(containerEl).setName(name).setDesc(desc);
|
||||||
|
|
||||||
|
const setLabel = (btn) =>
|
||||||
|
btn.setButtonText(current.length ? `Edit (${current.length})` : "Edit");
|
||||||
|
|
||||||
|
setting.addButton((btn) => {
|
||||||
|
setLabel(btn);
|
||||||
|
|
||||||
|
btn.onClick(() => {
|
||||||
|
new ListEditorModal(app, {
|
||||||
|
title: name,
|
||||||
|
placeholder: modal.placeholder,
|
||||||
|
emptyNote: modal.emptyNote,
|
||||||
|
recommended: modal.recommended,
|
||||||
|
values: current,
|
||||||
|
onChange: (next) => {
|
||||||
|
current = next;
|
||||||
|
saveSetting({ [key]: current });
|
||||||
|
setLabel(btn);
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { display };
|
module.exports = { display };
|
||||||
|
|||||||
134
packages/bridge/src/settings/list-editor-modal.js
Normal file
134
packages/bridge/src/settings/list-editor-modal.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
const { Modal, Setting, Notice } = require("obsidian");
|
||||||
|
|
||||||
|
// Modal editor for a list of string entries (the proxy host allowlist).
|
||||||
|
class ListEditorModal extends Modal {
|
||||||
|
constructor(app, opts) {
|
||||||
|
super(app);
|
||||||
|
this.opts = opts;
|
||||||
|
this.values = [...(opts.values || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
this.titleEl.setText(this.opts.title);
|
||||||
|
|
||||||
|
if (this.opts.recommended) {
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.setDesc(this.opts.recommended.note)
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText(
|
||||||
|
this.opts.recommended.buttonText || "Add recommended",
|
||||||
|
)
|
||||||
|
.onClick(() => this.addRecommended()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listEl = this.contentEl.createDiv("ignis-list-editor");
|
||||||
|
this.renderList();
|
||||||
|
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.setName("Add entry")
|
||||||
|
.addText((text) => {
|
||||||
|
this.input = text;
|
||||||
|
text.setPlaceholder(this.opts.placeholder || "");
|
||||||
|
|
||||||
|
text.inputEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addCurrent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText("Add")
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => this.addCurrent()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntry(entry) {
|
||||||
|
if (this.values.includes(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.values.push(entry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCurrent() {
|
||||||
|
const entry = this.input.getValue().trim();
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.addEntry(entry)) {
|
||||||
|
new Notice("That entry is already in the list.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.input.setValue("");
|
||||||
|
this.input.inputEl.focus();
|
||||||
|
this.commit();
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecommended() {
|
||||||
|
let added = 0;
|
||||||
|
|
||||||
|
for (const host of this.opts.recommended.hosts) {
|
||||||
|
if (this.addEntry(host)) {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added > 0) {
|
||||||
|
this.commit();
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notice(
|
||||||
|
added > 0
|
||||||
|
? `Added ${added} host${added === 1 ? "" : "s"}.`
|
||||||
|
: "All recommended hosts are already in the list.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(entry) {
|
||||||
|
this.values = this.values.filter((v) => v !== entry);
|
||||||
|
this.commit();
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
this.listEl.empty();
|
||||||
|
|
||||||
|
if (this.values.length === 0) {
|
||||||
|
this.listEl.createDiv({
|
||||||
|
text: this.opts.emptyNote,
|
||||||
|
cls: "ignis-list-empty",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of this.values) {
|
||||||
|
new Setting(this.listEl).setName(entry).addExtraButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setIcon("trash-2")
|
||||||
|
.setTooltip("Remove")
|
||||||
|
.onClick(() => this.remove(entry)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
this.opts.onChange([...this.values]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ListEditorModal };
|
||||||
39
packages/bridge/src/util/version.js
Normal file
39
packages/bridge/src/util/version.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Version comparison helpers for the update check.
|
||||||
|
|
||||||
|
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
||||||
|
function stripBuildMetadata(version) {
|
||||||
|
return (version || "").split("+")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse X.Y.Z to [major, minor, patch], or null when it isn't three integers.
|
||||||
|
function parseSemver(version) {
|
||||||
|
const parts = (version || "").split(".");
|
||||||
|
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nums = parts.slice(0, 3).map((p) => parseInt(p, 10));
|
||||||
|
|
||||||
|
return nums.some((n) => !Number.isInteger(n)) ? null : nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True only when latest is strictly newer than current.
|
||||||
|
function isNewer(latest, current) {
|
||||||
|
const a = parseSemver(latest);
|
||||||
|
const b = parseSemver(current);
|
||||||
|
|
||||||
|
if (!a || !b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return a[i] > b[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { stripBuildMetadata, parseSemver, isNewer };
|
||||||
@@ -141,3 +141,18 @@
|
|||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ignis-list-editor {
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--size-4-3);
|
||||||
|
margin-bottom: var(--size-4-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ignis-list-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
padding: var(--size-4-3) 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,14 +14,10 @@ function setupWebSocket(server, opts = {}) {
|
|||||||
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
let originSet = toOriginSet(originAllowlist);
|
const originSet = toOriginSet(originAllowlist);
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
wss.setOriginAllowlist = function (list) {
|
|
||||||
originSet = toOriginSet(list);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global message handlers: type -> handler(msg, ws).
|
// Global message handlers: type -> handler(msg, ws).
|
||||||
wss.messageHandlers = new Map();
|
wss.messageHandlers = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export class ContentCache {
|
|||||||
this._maxSize = maxSize;
|
this._maxSize = maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMaxSize(maxSize) {
|
||||||
|
this._maxSize = maxSize;
|
||||||
|
|
||||||
|
while (this._currentSize > this._maxSize && this._cache.size > 0) {
|
||||||
|
this._evictOne();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
has(path) {
|
has(path) {
|
||||||
return this._cache.has(this._normalize(path));
|
return this._cache.has(this._normalize(path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
import { normalize } from "../util/path.js";
|
import { normalize } from "../util/path.js";
|
||||||
|
|
||||||
const MAX_SIZE = 200 * 1024 * 1024;
|
let MAX_SIZE = 200 * 1024 * 1024;
|
||||||
const TTL_MS = 5 * 60 * 1000;
|
let TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const cache = new Map(); // path -> { data, size, createdAt }
|
const cache = new Map(); // path -> { data, size, createdAt }
|
||||||
let currentSize = 0;
|
let currentSize = 0;
|
||||||
@@ -112,6 +112,20 @@ export function inputCacheClear() {
|
|||||||
currentSize = 0;
|
currentSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setInputCacheLimits({ maxSize, ttlMs }) {
|
||||||
|
if (Number.isFinite(maxSize)) {
|
||||||
|
MAX_SIZE = maxSize;
|
||||||
|
|
||||||
|
while (currentSize > MAX_SIZE && cache.size > 0) {
|
||||||
|
evictOldest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(ttlMs)) {
|
||||||
|
TTL_MS = ttlMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isInputCachePath(path) {
|
export function isInputCachePath(path) {
|
||||||
const norm = normalize(path);
|
const norm = normalize(path);
|
||||||
return norm.startsWith(".obsidian/imports/");
|
return norm.startsWith(".obsidian/imports/");
|
||||||
|
|||||||
@@ -8,11 +8,26 @@ import {
|
|||||||
initWorkspacePatch,
|
initWorkspacePatch,
|
||||||
} from "./workspace.js";
|
} from "./workspace.js";
|
||||||
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||||
|
import { setInputCacheLimits } from "./fs/input-cache.js";
|
||||||
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
||||||
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
||||||
|
|
||||||
let bootstrapVirtualPlugins = [];
|
let bootstrapVirtualPlugins = [];
|
||||||
|
|
||||||
|
// Cache sizes come from the bootstrap response and are applied at page load.
|
||||||
|
// The server owns the rest of the settings and applies them on its side.
|
||||||
|
function applyServerSettings(s) {
|
||||||
|
if (!s) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(s.contentCacheBytes)) {
|
||||||
|
fsShim._contentCache.setMaxSize(s.contentCacheBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputCacheLimits({ maxSize: s.inputCacheBytes, ttlMs: s.inputCacheTtlMs });
|
||||||
|
}
|
||||||
|
|
||||||
export function getBootstrapVirtualPlugins() {
|
export function getBootstrapVirtualPlugins() {
|
||||||
return bootstrapVirtualPlugins;
|
return bootstrapVirtualPlugins;
|
||||||
}
|
}
|
||||||
@@ -212,6 +227,7 @@ export function initialize() {
|
|||||||
applyTree(bootstrap.tree);
|
applyTree(bootstrap.tree);
|
||||||
applyCoreSyncGuard(bootstrap.plugins);
|
applyCoreSyncGuard(bootstrap.plugins);
|
||||||
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
||||||
|
applyServerSettings(bootstrap.settings);
|
||||||
|
|
||||||
// Race the indexer: batch-fetch text content into ContentCache so
|
// Race the indexer: batch-fetch text content into ContentCache so
|
||||||
// Obsidian's startup indexing reads hit the cache instead of the network.
|
// Obsidian's startup indexing reads hit the cache instead of the network.
|
||||||
|
|||||||
Reference in New Issue
Block a user