526 lines
23 KiB
HTML
526 lines
23 KiB
HTML
|
|
<!doctype html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8" />
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
|
|
<title>Provisioning — Settings</title>
|
||
|
|
<style>
|
||
|
|
/* Light + dark palette; the OS picks via prefers-color-scheme. color-scheme
|
||
|
|
also makes native form controls (checkbox, select, textarea) adapt. */
|
||
|
|
:root {
|
||
|
|
color-scheme: light dark;
|
||
|
|
--bg: #f5f5f7; --surface: #ffffff; --surface-2: #f5f5f7;
|
||
|
|
--text: #1d1d1f; --text-2: #6e6e73; --text-3: #8e8e93;
|
||
|
|
--border: #d2d2d7; --border-2: #e5e5ea; --row-border: #f2f2f7;
|
||
|
|
--badge-bg: #f2f2f7; --badge-fg: #636366;
|
||
|
|
--accent: #007aff; --accent-hover: #0071e3; --danger: #ff3b30; --ok: #34c759; --warn: #ff9f0a;
|
||
|
|
--header-bg: rgba(255,255,255,0.8);
|
||
|
|
--toast-bg: #1d1d1f; --toast-fg: #ffffff;
|
||
|
|
--shadow: rgba(0,0,0,0.08);
|
||
|
|
}
|
||
|
|
@media (prefers-color-scheme: dark) {
|
||
|
|
:root {
|
||
|
|
--bg: #1c1c1e; --surface: #2c2c2e; --surface-2: #242426;
|
||
|
|
--text: #f5f5f7; --text-2: #aeaeb2; --text-3: #8e8e93;
|
||
|
|
--border: #3a3a3c; --border-2: #303032; --row-border: #38383a;
|
||
|
|
--badge-bg: #3a3a3c; --badge-fg: #c7c7cc;
|
||
|
|
--accent: #0a84ff; --accent-hover: #409cff; --danger: #ff453a; --ok: #30d158; --warn: #ff9f0a;
|
||
|
|
--header-bg: rgba(28,28,30,0.8);
|
||
|
|
--toast-bg: #f5f5f7; --toast-fg: #1c1c1e;
|
||
|
|
--shadow: rgba(0,0,0,0.4);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
|
body {
|
||
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||
|
|
font-size: 13px;
|
||
|
|
background: var(--bg);
|
||
|
|
color: var(--text);
|
||
|
|
min-height: 100vh;
|
||
|
|
}
|
||
|
|
header {
|
||
|
|
background: var(--header-bg);
|
||
|
|
backdrop-filter: blur(12px);
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
padding: 0 20px;
|
||
|
|
height: 44px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
position: sticky;
|
||
|
|
top: 0;
|
||
|
|
z-index: 10;
|
||
|
|
}
|
||
|
|
header h1 { font-size: 13px; font-weight: 600; color: var(--text); }
|
||
|
|
main { padding: 20px; max-width: 720px; margin: 0 auto; }
|
||
|
|
section { background: var(--surface); border-radius: 10px; overflow: hidden; margin-bottom: 20px; box-shadow: 0 1px 3px var(--shadow); }
|
||
|
|
section h2 { font-size: 11px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.04em; padding: 12px 16px 8px; background: var(--surface-2); border-bottom: 1px solid var(--border-2); }
|
||
|
|
.row { display: flex; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--row-border); gap: 12px; }
|
||
|
|
.row:last-child { border-bottom: none; }
|
||
|
|
.row label { flex: 1; color: var(--text); }
|
||
|
|
.row .sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||
|
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||
|
|
.status-dot.healthy { background: var(--ok); }
|
||
|
|
.status-dot.starting { background: var(--warn); }
|
||
|
|
.status-dot.unhealthy, .status-dot.unreachable { background: var(--danger); }
|
||
|
|
.status-dot.stopped { background: var(--text-3); }
|
||
|
|
.status-dot.connected { background: var(--ok); }
|
||
|
|
.status-dot.connecting { background: var(--warn); }
|
||
|
|
.status-dot.disconnected { background: var(--danger); }
|
||
|
|
.status-dot.disabled { background: var(--text-3); }
|
||
|
|
.badge { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--badge-bg); color: var(--badge-fg); }
|
||
|
|
.badge.active { background: var(--accent); color: #fff; }
|
||
|
|
button {
|
||
|
|
font-size: 12px;
|
||
|
|
font-family: inherit;
|
||
|
|
padding: 5px 12px;
|
||
|
|
border-radius: 6px;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
background: var(--surface);
|
||
|
|
cursor: pointer;
|
||
|
|
color: var(--text);
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
button:hover { background: var(--surface-2); }
|
||
|
|
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
|
|
button.primary:hover { background: var(--accent-hover); }
|
||
|
|
button.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||
|
|
button.danger:hover { filter: brightness(0.92); }
|
||
|
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
|
/* Native checkbox — a desktop-appropriate affordance (not a mobile slider). */
|
||
|
|
input.check { width: 17px; height: 17px; flex-shrink: 0; accent-color: var(--accent); cursor: pointer; margin: 0; }
|
||
|
|
.secret-input { font-family: monospace; font-size: 12px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; width: 180px; background: var(--surface); color: var(--text); }
|
||
|
|
.field { font-size: 12px; font-family: inherit; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); }
|
||
|
|
textarea.field { font-family: ui-monospace, monospace; line-height: 1.4; }
|
||
|
|
#toast {
|
||
|
|
position: fixed;
|
||
|
|
bottom: 20px; left: 50%;
|
||
|
|
transform: translateX(-50%) translateY(80px);
|
||
|
|
background: var(--toast-bg);
|
||
|
|
color: var(--toast-fg);
|
||
|
|
padding: 8px 16px;
|
||
|
|
border-radius: 8px;
|
||
|
|
font-size: 12px;
|
||
|
|
opacity: 0;
|
||
|
|
transition: transform 0.25s, opacity 0.25s;
|
||
|
|
pointer-events: none;
|
||
|
|
z-index: 100;
|
||
|
|
}
|
||
|
|
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
|
|
#toast.err { background: var(--danger); color: #fff; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<header><h1>Provisioning — Settings</h1></header>
|
||
|
|
<main>
|
||
|
|
|
||
|
|
<section>
|
||
|
|
<h2>Status</h2>
|
||
|
|
<div class="row">
|
||
|
|
<div class="status-dot" id="daemon-dot"></div>
|
||
|
|
<label>Daemon<div class="sub" id="daemon-label">checking…</div></label>
|
||
|
|
<button id="btn-start" data-act="daemon" data-arg="start_daemon">Start</button>
|
||
|
|
<button id="btn-stop" data-act="daemon" data-arg="stop_daemon">Stop</button>
|
||
|
|
<button id="btn-restart" data-act="daemon" data-arg="restart_daemon">Restart</button>
|
||
|
|
</div>
|
||
|
|
<div class="row">
|
||
|
|
<div class="status-dot" id="nats-dot"></div>
|
||
|
|
<label>NATS<div class="sub" id="nats-label">checking…</div></label>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section>
|
||
|
|
<h2>Connections</h2>
|
||
|
|
<div id="conn-list"></div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section>
|
||
|
|
<h2>Launchers</h2>
|
||
|
|
<div class="row">
|
||
|
|
<label>Scope<div class="sub">Where the entry appears in the tray menu</div></label>
|
||
|
|
<select id="launcher-scope" class="field" data-change="scope"></select>
|
||
|
|
<button class="primary" data-act="launcher-add">+ Add</button>
|
||
|
|
</div>
|
||
|
|
<div id="launcher-list"></div>
|
||
|
|
<div id="launcher-form"></div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section>
|
||
|
|
<h2>Authentication</h2>
|
||
|
|
<div id="auth-section">
|
||
|
|
<div class="row"><label>Loading…</label></div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section>
|
||
|
|
<h2>Notifications</h2>
|
||
|
|
<div class="row">
|
||
|
|
<label>Enable notifications<div class="sub">Master switch for all OS notifications below</div></label>
|
||
|
|
<input type="checkbox" class="check" id="notif-enabled" data-change="notif:enabled">
|
||
|
|
</div>
|
||
|
|
<div class="row">
|
||
|
|
<label>Tool completed<div class="sub">Notify when a daemon tool finishes successfully</div></label>
|
||
|
|
<input type="checkbox" class="check" id="notif-invoked" data-change="notif:on_tool_invoked">
|
||
|
|
</div>
|
||
|
|
<div class="row">
|
||
|
|
<label>Tool failed<div class="sub">Notify when a daemon tool returns an error</div></label>
|
||
|
|
<input type="checkbox" class="check" id="notif-failed" data-change="notif:on_tool_failed">
|
||
|
|
</div>
|
||
|
|
<div class="row">
|
||
|
|
<label>Config reloaded<div class="sub">Notify when the daemon reloads its config. Needs a live NATS connection — has no effect while on the REST polling fallback</div></label>
|
||
|
|
<input type="checkbox" class="check" id="notif-config" data-change="notif:on_config_changed">
|
||
|
|
</div>
|
||
|
|
<div class="row">
|
||
|
|
<label>Only on failure<div class="sub">Mute the “Tool completed” notifications above and notify only when a tool fails</div></label>
|
||
|
|
<input type="checkbox" class="check" id="notif-only-fail" data-change="notif:only_failures">
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
</main>
|
||
|
|
<div id="toast"></div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
const invoke = window.__TAURI_INTERNALS__?.invoke
|
||
|
|
?? (() => Promise.reject('Tauri IPC not available'));
|
||
|
|
|
||
|
|
let settings = null;
|
||
|
|
|
||
|
|
// ── Bootstrap ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
async function init() {
|
||
|
|
try {
|
||
|
|
settings = await invoke('get_settings');
|
||
|
|
renderConnections(settings);
|
||
|
|
renderAuth(settings);
|
||
|
|
renderNotifPrefs(settings.notifications);
|
||
|
|
scopeOptions();
|
||
|
|
renderLaunchers();
|
||
|
|
} catch (e) {
|
||
|
|
toast('Failed to load settings: ' + e, true);
|
||
|
|
}
|
||
|
|
await refreshStatus();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refreshStatus() {
|
||
|
|
try {
|
||
|
|
const s = await invoke('get_status');
|
||
|
|
setDot('daemon-dot', s.daemon);
|
||
|
|
setDot('nats-dot', s.nats);
|
||
|
|
document.getElementById('daemon-label').textContent = s.daemon;
|
||
|
|
document.getElementById('nats-label').textContent = s.nats;
|
||
|
|
// Daemon controls are only meaningful for local connections.
|
||
|
|
const isLocal = settings?.connections
|
||
|
|
?.find(c => c.id === settings?.active)
|
||
|
|
?.kind === 'local';
|
||
|
|
['btn-start','btn-stop','btn-restart'].forEach(id => {
|
||
|
|
document.getElementById(id).disabled = !isLocal;
|
||
|
|
});
|
||
|
|
} catch (_) {}
|
||
|
|
setTimeout(refreshStatus, 4000);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Connections ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function renderConnections(s) {
|
||
|
|
const list = document.getElementById('conn-list');
|
||
|
|
list.innerHTML = '';
|
||
|
|
for (const c of s.connections) {
|
||
|
|
const isActive = c.id === s.active;
|
||
|
|
const row = document.createElement('div');
|
||
|
|
row.className = 'row';
|
||
|
|
row.innerHTML = `
|
||
|
|
<label>${esc(c.label)}<div class="sub">${esc(c.url)}</div></label>
|
||
|
|
<span class="badge${isActive ? ' active' : ''}">${isActive ? 'active' : c.kind}</span>
|
||
|
|
${isActive ? '' : `<button data-act="switch" data-arg="${esc(c.id)}">Switch</button>`}
|
||
|
|
`;
|
||
|
|
list.appendChild(row);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function switchConn(id) {
|
||
|
|
try {
|
||
|
|
await invoke('set_active_connection', { id });
|
||
|
|
settings = await invoke('get_settings');
|
||
|
|
renderConnections(settings);
|
||
|
|
renderAuth(settings);
|
||
|
|
scopeOptions();
|
||
|
|
renderLaunchers();
|
||
|
|
toast('Switched to ' + id);
|
||
|
|
} catch (e) {
|
||
|
|
toast('Switch failed: ' + e, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Auth ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function renderAuth(s) {
|
||
|
|
const section = document.getElementById('auth-section');
|
||
|
|
section.innerHTML = '';
|
||
|
|
for (const c of s.connections) {
|
||
|
|
if (c.auth.mode === 'solo') continue;
|
||
|
|
const row = document.createElement('div');
|
||
|
|
row.className = 'row';
|
||
|
|
const modeLabel = c.auth.mode === 'jwt' ? 'JWT token' : 'Password';
|
||
|
|
row.innerHTML = `
|
||
|
|
<label>${esc(c.label)}<div class="sub">${modeLabel}</div></label>
|
||
|
|
<input class="secret-input" type="password" placeholder="Enter ${modeLabel.toLowerCase()}…"
|
||
|
|
id="secret-${esc(c.id)}" />
|
||
|
|
<button class="primary" data-act="login" data-arg="${esc(c.id)}">Save</button>
|
||
|
|
<button class="danger" data-act="logout" data-arg="${esc(c.id)}">Clear</button>
|
||
|
|
`;
|
||
|
|
section.appendChild(row);
|
||
|
|
}
|
||
|
|
if (section.innerHTML === '') {
|
||
|
|
section.innerHTML = '<div class="row"><label style="color:#8e8e93">All connections use solo mode — no credentials required.</label></div>';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function doLogin(id) {
|
||
|
|
const secret = document.getElementById('secret-' + id)?.value ?? '';
|
||
|
|
if (!secret) { toast('Secret cannot be empty', true); return; }
|
||
|
|
try {
|
||
|
|
await invoke('login', { payload: { id, secret } });
|
||
|
|
document.getElementById('secret-' + id).value = '';
|
||
|
|
toast('Credential saved for ' + id);
|
||
|
|
} catch (e) {
|
||
|
|
toast('Login failed: ' + e, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function doLogout(id) {
|
||
|
|
try {
|
||
|
|
await invoke('logout', { id });
|
||
|
|
toast('Credential cleared for ' + id);
|
||
|
|
} catch (e) {
|
||
|
|
toast('Logout failed: ' + e, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Launchers ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
let launcherEditIndex = -1;
|
||
|
|
|
||
|
|
function scopeOptions() {
|
||
|
|
const sel = document.getElementById('launcher-scope');
|
||
|
|
const prev = sel.value;
|
||
|
|
sel.innerHTML = '<option value="global">Global (all connections)</option>' +
|
||
|
|
settings.connections.map(c => `<option value="conn:${esc(c.id)}">${esc(c.label)}</option>`).join('');
|
||
|
|
sel.value = prev || ('conn:' + settings.active);
|
||
|
|
if (!sel.value) sel.value = 'global';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Returns the live launchers array (a reference into `settings`) for a scope,
|
||
|
|
// creating the field if absent. Falls back to global for an unknown scope so
|
||
|
|
// a push is never silently dropped into a throwaway array.
|
||
|
|
function launchersFor(scopeVal) {
|
||
|
|
if (!scopeVal || scopeVal === 'global') return settings.launchers || (settings.launchers = []);
|
||
|
|
const id = scopeVal.slice('conn:'.length);
|
||
|
|
const c = settings.connections.find(x => x.id === id);
|
||
|
|
if (!c) return settings.launchers || (settings.launchers = []);
|
||
|
|
return c.launchers || (c.launchers = []);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderLaunchers() {
|
||
|
|
const scopeVal = document.getElementById('launcher-scope').value || 'global';
|
||
|
|
const list = document.getElementById('launcher-list');
|
||
|
|
const arr = launchersFor(scopeVal);
|
||
|
|
list.innerHTML = arr.length ? '' :
|
||
|
|
'<div class="row"><label style="color:#8e8e93">No launchers in this scope.</label></div>';
|
||
|
|
arr.forEach((l, i) => {
|
||
|
|
const kind = l.kind || 'tool';
|
||
|
|
const detail = (l.target || '') +
|
||
|
|
(kind === 'command' && l.args && l.args.length ? ' ' + l.args.join(' ') : '');
|
||
|
|
const row = document.createElement('div');
|
||
|
|
row.className = 'row';
|
||
|
|
row.innerHTML = `
|
||
|
|
<label>${esc(l.label)}<div class="sub">${esc(detail)}</div></label>
|
||
|
|
<span class="badge">${esc(kind)}</span>
|
||
|
|
<button data-act="launcher-edit" data-arg="${i}">Edit</button>
|
||
|
|
<button class="danger" data-act="launcher-delete" data-arg="${i}">Delete</button>
|
||
|
|
`;
|
||
|
|
list.appendChild(row);
|
||
|
|
});
|
||
|
|
document.getElementById('launcher-form').innerHTML = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function launcherAdd() {
|
||
|
|
if (!settings) { toast('Settings not loaded yet', true); return; }
|
||
|
|
// Ensure the scope <select> is populated (defensive against any earlier
|
||
|
|
// init failure) so the new launcher lands in a real settings array.
|
||
|
|
const sel = document.getElementById('launcher-scope');
|
||
|
|
if (!sel.options.length) scopeOptions();
|
||
|
|
launcherShowForm(-1, { kind: 'url', label: '', target: '', args: [], params: null, show_output: false, confirm: false });
|
||
|
|
}
|
||
|
|
|
||
|
|
function launcherEditAt(i) {
|
||
|
|
const arr = launchersFor(document.getElementById('launcher-scope').value);
|
||
|
|
launcherShowForm(i, Object.assign({}, arr[i]));
|
||
|
|
}
|
||
|
|
|
||
|
|
function launcherShowForm(index, l) {
|
||
|
|
launcherEditIndex = index;
|
||
|
|
const kind = l.kind || 'tool';
|
||
|
|
const paramsText = l.params && Object.keys(l.params).length ? JSON.stringify(l.params, null, 2) : '';
|
||
|
|
document.getElementById('launcher-form').innerHTML = `
|
||
|
|
<div class="row"><label>Kind</label>
|
||
|
|
<select id="lf-kind" class="field" data-change="kind">
|
||
|
|
<option value="url">URL (open in browser)</option>
|
||
|
|
<option value="command">Command (run + show output)</option>
|
||
|
|
<option value="tool">Tool (daemon /api/v1)</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div class="row"><label>Label</label><input id="lf-label" class="field" style="width:260px" value="${esc(l.label || '')}"></div>
|
||
|
|
<div class="row"><label id="lf-target-label">Target</label><input id="lf-target" class="field" style="width:260px" value="${esc(l.target || '')}"></div>
|
||
|
|
<div class="row" id="lf-args-row"><label>Arguments<div class="sub">one per line</div></label><textarea id="lf-args" class="field" style="width:260px;height:60px;resize:vertical">${esc((l.args || []).join('\n'))}</textarea></div>
|
||
|
|
<div class="row" id="lf-show-row"><label>Show output<div class="sub">Capture stdout/stderr and open the output window</div></label><input type="checkbox" class="check" id="lf-show" ${l.show_output ? 'checked' : ''}></div>
|
||
|
|
<div class="row" id="lf-params-row"><label>Params (JSON)</label><textarea id="lf-params" class="field" style="width:260px;height:60px;resize:vertical">${esc(paramsText)}</textarea></div>
|
||
|
|
<div class="row"><label>Confirm before running<div class="sub">Ask before this launcher runs</div></label><input type="checkbox" class="check" id="lf-confirm" ${l.confirm ? 'checked' : ''}></div>
|
||
|
|
<div class="row" style="justify-content:flex-end">
|
||
|
|
<button data-act="launcher-cancel">Cancel</button>
|
||
|
|
<button class="primary" data-act="launcher-save">${index < 0 ? 'Add' : 'Save'}</button>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
document.getElementById('lf-kind').value = kind;
|
||
|
|
launcherKindToggle();
|
||
|
|
}
|
||
|
|
|
||
|
|
function launcherKindToggle() {
|
||
|
|
const k = document.getElementById('lf-kind').value;
|
||
|
|
document.getElementById('lf-args-row').style.display = k === 'command' ? '' : 'none';
|
||
|
|
document.getElementById('lf-show-row').style.display = k === 'command' ? '' : 'none';
|
||
|
|
document.getElementById('lf-params-row').style.display = k === 'tool' ? '' : 'none';
|
||
|
|
document.getElementById('lf-target-label').textContent =
|
||
|
|
k === 'url' ? 'URL' : k === 'command' ? 'Program' : 'Tool name';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function launcherSave() {
|
||
|
|
const arr = launchersFor(document.getElementById('launcher-scope').value);
|
||
|
|
const kind = document.getElementById('lf-kind').value;
|
||
|
|
const label = document.getElementById('lf-label').value.trim();
|
||
|
|
const target = document.getElementById('lf-target').value.trim();
|
||
|
|
if (!label || !target) { toast('Label and target are required', true); return; }
|
||
|
|
|
||
|
|
const l = { kind, label, target, confirm: document.getElementById('lf-confirm').checked };
|
||
|
|
if (kind === 'command') {
|
||
|
|
l.args = document.getElementById('lf-args').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||
|
|
l.show_output = document.getElementById('lf-show').checked;
|
||
|
|
}
|
||
|
|
if (kind === 'tool') {
|
||
|
|
const raw = document.getElementById('lf-params').value.trim();
|
||
|
|
if (raw) {
|
||
|
|
try { l.params = JSON.parse(raw); }
|
||
|
|
catch (_) { toast('Params must be valid JSON', true); return; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (launcherEditIndex >= 0) {
|
||
|
|
l.id = arr[launcherEditIndex].id;
|
||
|
|
arr[launcherEditIndex] = l;
|
||
|
|
} else {
|
||
|
|
l.id = slug(label) + '-' + Math.random().toString(36).slice(2, 6);
|
||
|
|
arr.push(l);
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await invoke('save_settings', { settings });
|
||
|
|
toast('Launcher saved');
|
||
|
|
renderLaunchers();
|
||
|
|
} catch (e) { toast('Save failed: ' + e, true); }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function launcherDelete(i) {
|
||
|
|
launchersFor(document.getElementById('launcher-scope').value).splice(i, 1);
|
||
|
|
try {
|
||
|
|
await invoke('save_settings', { settings });
|
||
|
|
toast('Launcher deleted');
|
||
|
|
renderLaunchers();
|
||
|
|
} catch (e) { toast('Delete failed: ' + e, true); }
|
||
|
|
}
|
||
|
|
|
||
|
|
function slug(s) {
|
||
|
|
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'launcher';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Notifications ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function renderNotifPrefs(prefs) {
|
||
|
|
document.getElementById('notif-enabled').checked = prefs.enabled;
|
||
|
|
document.getElementById('notif-invoked').checked = prefs.on_tool_invoked;
|
||
|
|
document.getElementById('notif-failed').checked = prefs.on_tool_failed;
|
||
|
|
document.getElementById('notif-config').checked = prefs.on_config_changed;
|
||
|
|
document.getElementById('notif-only-fail').checked = prefs.only_failures;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveNotifPref(key, value) {
|
||
|
|
if (!settings) return;
|
||
|
|
settings.notifications[key] = value;
|
||
|
|
try {
|
||
|
|
await invoke('save_settings', { settings });
|
||
|
|
toast('Saved');
|
||
|
|
} catch (e) {
|
||
|
|
toast('Save failed: ' + e, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Generic command wrapper ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
async function cmd(name) {
|
||
|
|
try {
|
||
|
|
await invoke(name);
|
||
|
|
toast(name.replace(/_/g, ' ') + ' sent');
|
||
|
|
} catch (e) {
|
||
|
|
toast(e, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Utilities ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function setDot(id, status) {
|
||
|
|
const el = document.getElementById(id);
|
||
|
|
el.className = 'status-dot ' + (status ?? 'stopped');
|
||
|
|
}
|
||
|
|
|
||
|
|
function esc(s) {
|
||
|
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
|
|
}
|
||
|
|
|
||
|
|
let toastTimer = null;
|
||
|
|
function toast(msg, isErr = false) {
|
||
|
|
const el = document.getElementById('toast');
|
||
|
|
el.textContent = msg;
|
||
|
|
el.classList.toggle('err', isErr); // colors come from CSS (theme-aware)
|
||
|
|
el.classList.add('show');
|
||
|
|
clearTimeout(toastTimer);
|
||
|
|
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
|
||
|
|
}
|
||
|
|
|
||
|
|
// CSP-safe event delegation. Tauri 2 hashes inline <script> blocks, which
|
||
|
|
// makes the browser ignore 'unsafe-inline' and BLOCK inline on* handlers —
|
||
|
|
// so every action is wired via data-act / data-change, not onclick/onchange.
|
||
|
|
document.addEventListener('click', (e) => {
|
||
|
|
const el = e.target.closest('[data-act]');
|
||
|
|
if (!el) return;
|
||
|
|
const arg = el.dataset.arg;
|
||
|
|
switch (el.dataset.act) {
|
||
|
|
case 'daemon': cmd(arg); break;
|
||
|
|
case 'switch': switchConn(arg); break;
|
||
|
|
case 'login': doLogin(arg); break;
|
||
|
|
case 'logout': doLogout(arg); break;
|
||
|
|
case 'launcher-add': launcherAdd(); break;
|
||
|
|
case 'launcher-edit': launcherEditAt(Number(arg)); break;
|
||
|
|
case 'launcher-delete': launcherDelete(Number(arg)); break;
|
||
|
|
case 'launcher-save': launcherSave(); break;
|
||
|
|
case 'launcher-cancel': document.getElementById('launcher-form').innerHTML = ''; break;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
document.addEventListener('change', (e) => {
|
||
|
|
const el = e.target.closest('[data-change]');
|
||
|
|
if (!el) return;
|
||
|
|
const c = el.dataset.change;
|
||
|
|
if (c === 'scope') renderLaunchers();
|
||
|
|
else if (c === 'kind') launcherKindToggle();
|
||
|
|
else if (c.startsWith('notif:')) saveNotifPref(c.slice(6), el.checked);
|
||
|
|
});
|
||
|
|
|
||
|
|
init();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|