Files
ignis/packages/bridge/src/settings/general-tab.js
2026-06-06 20:40:42 +02:00

363 lines
9.4 KiB
JavaScript

import { Setting, Notice } from "obsidian";
import { isDemoMode } from "../demo-guards.js";
import { stripBuildMetadata, isNewer } from "../util/version.js";
import { ListEditorModal } from "./list-editor-modal.js";
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
const GITHUB_API_LATEST =
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
function getVersion() {
return window.__ignis?.version || "unknown";
}
async function checkForUpdate(currentVersion) {
try {
const res = await fetch(GITHUB_API_LATEST);
if (!res.ok) {
return null;
}
const data = await res.json();
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
const current = stripBuildMetadata(currentVersion);
if (isNewer(latest, current)) {
return { version: latest, url: data.html_url };
}
return null;
} catch {
return null;
}
}
function display(containerEl, app) {
const version = getVersion();
const header = containerEl.createDiv("ignis-header");
const logo = header.createEl("img", {
cls: "ignis-header-logo",
attr: { src: "/assets/ignis.webp", alt: "Ignis" },
});
const info = header.createDiv("ignis-header-info");
info.createEl("div", { text: "Ignis", cls: "ignis-header-title" });
info.createEl("div", {
text: "Obsidian server bridge",
cls: "ignis-header-subtitle",
});
const right = header.createDiv("ignis-header-right");
const versionCol = right.createDiv("ignis-header-version-col");
versionCol.createEl("span", {
text: `Version ${version}`,
cls: "ignis-header-version",
});
const updateIndicator = versionCol.createEl("a", {
text: "Checking...",
cls: "ignis-update-indicator",
attr: { target: "_blank", rel: "noopener noreferrer" },
});
const githubLink = right.createEl("a", {
cls: "ignis-github-link",
href: GITHUB_URL,
attr: { target: "_blank", "aria-label": "GitHub" },
});
const githubIcon = githubLink.createEl("img", {
cls: "ignis-github-icon",
attr: { src: "/assets/github.svg", alt: "GitHub" },
});
checkForUpdate(version).then((latest) => {
if (latest) {
updateIndicator.textContent = `v${latest.version} available`;
updateIndicator.addClass("ignis-update-available");
updateIndicator.href = latest.url;
} else {
updateIndicator.textContent = "Up to date";
}
});
addServerStatus(containerEl);
addServerSettings(containerEl, app);
}
const STATUS_LABELS = {
open: "Connected",
connecting: "Connecting...",
closed: "Disconnected",
};
const STATUS_DOT_CLASSES = {
open: "ignis-status-connected",
connecting: "ignis-status-connecting",
closed: "ignis-status-disconnected",
};
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) {
const ws = window.__ignis.ws;
const items = createSettingGroup(containerEl);
const setting = new Setting(items).setName("Server status");
const dotEl = setting.controlEl.createEl("span", {
cls: "ignis-status-dot",
});
const labelEl = setting.controlEl.createEl("span", {
cls: "ignis-status-label",
});
function render(state) {
dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed;
}
render(ws.isOpen() ? "open" : "closed");
const unsub = ws.onStateChange(render);
// Detach when the settings tab DOM goes away.
const observer = new MutationObserver(() => {
if (!containerEl.isConnected) {
unsub();
observer.disconnect();
}
});
observer.observe(containerEl.parentElement || document.body, {
childList: true,
subtree: true,
});
}
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 }) {
let committed = value;
new Setting(containerEl)
.setName(name)
.setDesc(desc)
.addText((text) => {
text.setValue(String(value));
// Commit only on change.
const commit = () => {
const n = parseInt(text.getValue(), 10);
if (!Number.isInteger(n) || n < 0 || n === committed) {
return;
}
committed = n;
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",
"api.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;
}
export { display };