Files
bsncubed f267c430d9 Add installer, launch-to-browser, and switching from the web GUI
Three features plus the design-system swap.

Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap
with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches
the app's asInvoker manifest, and it is also what lets the in-app updater
replace the .exe later without elevation — a Program Files install could not.
It offers a desktop shortcut and an optional start-with-Windows entry, and
reuses the app's own single-instance mutex as AppMutex so setup notices a
running copy, since a running .exe cannot be overwritten. Uninstall leaves
presets.json, config.json and the log in place.

Launching. Opening ipswap opens the editor in a browser, starting the tray
first if it is not already up; if it is, the running copy opens the browser and
the second process exits. internal/instance does this with a loopback control
listener whose port and token live in a mode-600 session.json, plus a
session-local named mutex on Windows to settle a launch race. A stale session
file from a crash is detected by a failed call and treated as "no primary", so
it can never wedge startup. The Run-key entry now passes --background, because
a browser tab at every login is not wanted.

Switching from the browser. New endpoints for active state, apply preview and
apply. The browser confirms against the same before/after text the tray's
MessageBox shows, built from a live read at prompt time. Apply requires the
session token in a header like every other mutation — it changes the machine's
network, so it is not a weaker case than editing a preset. A netsh refusal for
lack of elevation comes back as 409 with a flag, so the page can point at
"Relaunch as administrator" instead of showing a generic failure.

To avoid two copies of that sequence, internal/switcher now owns everything
between "the user said yes" and "the adapter changed", and both front ends call
it. It serialises applies: two interleaved netsh sequences on one adapter would
leave it matching neither preset, and there are now two ways to start one.

CSS. apointless.css is vendored from bsncubed/css and left untouched; the
previous file was the boarding-pass stylesheet and its class names did not
match this markup. ipswap.css adds only what the design system does not ship —
page chrome, tables, modals, stat tiles, a success alert — on its tokens, so
re-pulling apointless.css restyles the app.

Not verified: the installer is uncompiled (no Inno Setup or wine on this box)
and the UI is not visually rendered (headless Firefox hangs here). Both are
checked as far as the tooling allows — assets and endpoints serve, and every
DOM id the JS touches exists in the markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:32:15 +10:00

534 lines
17 KiB
JavaScript

/*
* ipswap preset editor.
*
* Plain DOM code with no build step: the whole app is served from an embed.FS
* inside a single .exe, and a Node toolchain in CI for a few hundred lines of
* JavaScript is not a trade worth making.
*/
// The token arrives once in the query string. The server also sets it as a
// SameSite=Strict cookie, but mutations additionally require it as a header —
// a cookie alone would let any page in the browser POST to this port.
const TOKEN = new URLSearchParams(location.search).get("t") || "";
const state = {
presets: [],
groups: [],
adapters: [],
settings: null,
active: { active_id: "", active_name: "" },
editing: null,
pendingApply: null,
};
const $ = (id) => document.getElementById(id);
// --- theme ---
function applyTheme(light) {
document.documentElement.classList.toggle("light", light);
$("btn-theme").textContent = light ? "Dark" : "Light";
localStorage.setItem("ipswap-theme", light ? "light" : "dark");
}
applyTheme(localStorage.getItem("ipswap-theme") === "light");
$("btn-theme").addEventListener("click", () =>
applyTheme(!document.documentElement.classList.contains("light"))
);
// --- api ---
async function api(path, opts = {}) {
const headers = Object.assign({}, opts.headers);
if (opts.method && opts.method !== "GET") {
headers["X-Ipswap-Token"] = TOKEN;
if (opts.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
}
const res = await fetch(path, Object.assign({}, opts, { headers }));
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
// The auth middleware writes plain text, not JSON.
if (!res.ok) throw new Error(text || res.statusText);
}
if (!res.ok) {
const err = new Error((data && data.error) || text || res.statusText);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
// The server shuts down five minutes after the last one of these.
setInterval(() => api("/api/heartbeat", { method: "POST" }).catch(() => {}), 30_000);
// --- alerts ---
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
);
}
// kind is one of ok, error, warn, info — matching apointless.css's alert
// modifiers, with alert-ok supplied by ipswap.css.
function alertBox(kind, message, container = "alerts") {
const host = $(container);
host.innerHTML = `<div class="alert alert-${kind}">${escapeHTML(message)}</div>`;
if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000);
}
function clearAlert(container) {
$(container).innerHTML = "";
}
// --- formatting ---
function maskOf(prefix) {
if (state.settings && state.settings.mask_style === "dotted") {
const m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return `${m >>> 24}.${(m >>> 16) & 255}.${(m >>> 8) & 255}.${m & 255}`;
}
return "/" + prefix;
}
function addrText(a) {
const m = maskOf(a.prefix);
return m.startsWith("/") ? `${a.address}${m}` : `${a.address} ${m}`;
}
// --- rendering ---
function renderStats() {
$("stat-active").textContent = state.active.active_name || "unmatched";
$("stat-count").textContent = state.presets.length;
$("stat-groups").textContent = state.groups.length;
}
function renderPresets() {
const host = $("groups");
if (!state.presets.length) {
host.innerHTML = `<div class="card"><div class="empty">No presets yet. Create one, or import a pack from a colleague.</div></div>`;
return;
}
const byGroup = new Map();
for (const p of state.presets) {
const g = p.group && p.group.trim() ? p.group : "Ungrouped";
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g).push(p);
}
let html = "";
for (const [group, presets] of byGroup) {
html += `<div class="card">
<div class="card-header">
<h2>${escapeHTML(group)}</h2>
<span class="badge">${presets.length}</span>
</div>
<div class="table-scroll">
<table>
<thead><tr>
<th></th><th>Name</th><th>Adapter</th><th>Address</th><th>Gateway</th><th>DNS</th><th></th>
</tr></thead>
<tbody>`;
for (const p of presets) {
const isDHCP = p.mode === "dhcp";
const isActive = p.id === state.active.active_id;
const addr = isDHCP
? `<span class="badge badge-accent">DHCP</span>`
: escapeHTML(addrText(p.primary)) +
(p.secondary && p.secondary.length ? ` <span class="badge">+${p.secondary.length}</span>` : "");
const dns =
p.dns.mode === "dhcp"
? `<span class="badge">from DHCP</span>`
: escapeHTML((p.dns.servers || []).join(", "));
html += `<tr class="${isActive ? "is-active" : ""}">
<td>${isActive ? '<span class="badge badge-ok"><span class="dot dot-pulse"></span>live</span>' : ""}</td>
<td>${escapeHTML(p.name)}${p.notes ? `<div class="faint">${escapeHTML(p.notes)}</div>` : ""}</td>
<td class="addr">${escapeHTML(p.adapter)}</td>
<td class="addr">${addr}</td>
<td class="addr">${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")}</td>
<td class="addr">${dns}</td>
<td class="actions">
<button class="btn btn-sm btn-primary" data-apply="${escapeHTML(p.id)}" ${isActive ? "disabled" : ""}>
${isActive ? "In use" : "Use"}
</button>
<button class="btn btn-sm" data-edit="${escapeHTML(p.id)}">Edit</button>
<button class="btn btn-sm btn-ghost" data-del="${escapeHTML(p.id)}">Delete</button>
</td>
</tr>`;
}
html += `</tbody></table></div></div>`;
}
host.innerHTML = html;
host.querySelectorAll("[data-apply]").forEach((b) =>
b.addEventListener("click", () => openConfirm(b.dataset.apply))
);
host.querySelectorAll("[data-edit]").forEach((b) =>
b.addEventListener("click", () => openEditor(b.dataset.edit))
);
host.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => deletePreset(b.dataset.del))
);
}
function renderAdapters() {
const body = $("adapters");
if (!state.adapters.length) {
body.innerHTML = `<tr><td colspan="4" class="faint">No adapters found.</td></tr>`;
return;
}
body.innerHTML = state.adapters
.map(
(a) => `<tr>
<td class="addr">${escapeHTML(a.name)}</td>
<td class="faint">${escapeHTML(a.description || "")}</td>
<td class="addr">${escapeHTML(a.current || "—")}</td>
<td>${a.up ? '<span class="badge badge-ok">up</span>' : '<span class="badge">down</span>'}</td>
</tr>`
)
.join("");
}
// --- loading ---
async function loadAll() {
const [presets, adapters, settings, active] = await Promise.all([
api("/api/presets"),
api("/api/adapters"),
api("/api/settings"),
api("/api/active"),
]);
state.presets = presets.presets || [];
state.groups = presets.groups || [];
state.adapters = adapters.adapters || [];
state.settings = settings;
state.active = active || { active_id: "", active_name: "" };
renderStats();
renderPresets();
renderAdapters();
$("group-list").innerHTML = state.groups.map((g) => `<option value="${escapeHTML(g)}">`).join("");
$("f-adapter").innerHTML = state.adapters
.map((a) => `<option value="${escapeHTML(a.name)}">${escapeHTML(a.name)}</option>`)
.join("");
}
// Keep the "live" marker honest without a reload: something outside ipswap can
// change the network at any time.
setInterval(async () => {
try {
const active = await api("/api/active");
if (active.active_id !== state.active.active_id) {
state.active = active;
renderStats();
renderPresets();
}
} catch {
// A dropped poll is not worth reporting; the next one will tell us.
}
}, 15_000);
// --- apply ---
const confirmDlg = $("confirm");
async function openConfirm(id) {
const p = state.presets.find((x) => x.id === id);
state.pendingApply = id;
$("confirm-title").textContent = `Apply "${p ? p.name : id}"`;
$("confirm-diff").textContent = "Reading the adapter…";
clearAlert("confirm-error");
$("confirm-go").disabled = true;
confirmDlg.showModal();
try {
// Read live at prompt time, exactly as the tray does. A stale CURRENT
// block would undermine the only check before a destructive change.
const prev = await api(`/api/presets/${encodeURIComponent(id)}/preview`);
$("confirm-diff").textContent = prev.text;
$("confirm-go").disabled = false;
} catch (e) {
$("confirm-diff").textContent = "";
alertBox("error", e.message, "confirm-error");
}
}
$("confirm-cancel").addEventListener("click", () => {
state.pendingApply = null;
confirmDlg.close();
});
$("confirm-go").addEventListener("click", async () => {
const id = state.pendingApply;
if (!id) return;
const btn = $("confirm-go");
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span> Applying…`;
clearAlert("confirm-error");
try {
const active = await api(`/api/presets/${encodeURIComponent(id)}/apply`, { method: "POST" });
state.active = active;
confirmDlg.close();
await loadAll();
alertBox("ok", `Applied "${active.active_name || "preset"}".`);
} catch (e) {
// The server answers 409 when netsh refused for lack of an elevated token,
// which has a specific next step rather than being a generic failure.
if (e.data && e.data.elevation_required) {
alertBox(
"warn",
"This needs administrator rights. Use \"Relaunch as administrator\" in the tray menu, then try again.",
"confirm-error"
);
} else {
alertBox("error", e.message, "confirm-error");
}
btn.disabled = false;
} finally {
btn.textContent = "Apply";
}
});
// --- editor ---
const editor = $("editor");
function secondaryRow(addr = "", prefix = "") {
const div = document.createElement("div");
div.className = "sec-row";
div.innerHTML = `
<input class="input sec-addr" placeholder="10.0.10.50" value="${escapeHTML(addr)}">
<input class="input narrow sec-prefix" placeholder="/24" value="${escapeHTML(prefix)}">
<button type="button" class="btn btn-sm btn-ghost sec-del">Remove</button>`;
div.querySelector(".sec-del").addEventListener("click", () => div.remove());
return div;
}
function openEditor(id) {
const p = id ? state.presets.find((x) => x.id === id) : null;
state.editing = p ? p.id : null;
$("editor-title").textContent = p ? "Edit preset" : "New preset";
clearAlert("editor-error");
$("f-name").value = p ? p.name : "";
$("f-group").value = p ? p.group || "" : "";
$("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || "";
$("f-mode").value = p ? p.mode : "static";
$("f-notes").value = p ? p.notes || "" : "";
const prim = (p && p.primary) || {};
$("f-address").value = prim.address || "";
$("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : "";
$("f-gateway").value = prim.gateway || "";
$("f-metric").value = prim.gateway_metric || 0;
const secs = $("secondaries");
secs.innerHTML = "";
for (const s of (p && p.secondary) || []) secs.appendChild(secondaryRow(s.address, maskOf(s.prefix)));
$("f-dns-mode").value = p ? p.dns.mode : "static";
$("f-dns").value = p ? (p.dns.servers || []).join(", ") : "";
syncModeVisibility();
editor.showModal();
}
function syncModeVisibility() {
$("static-fields").hidden = $("f-mode").value === "dhcp";
$("dns-servers-field").hidden = $("f-dns-mode").value !== "static";
}
$("f-mode").addEventListener("change", syncModeVisibility);
$("f-dns-mode").addEventListener("change", syncModeVisibility);
$("btn-add-sec").addEventListener("click", () => $("secondaries").appendChild(secondaryRow()));
$("btn-new").addEventListener("click", () => openEditor(null));
$("btn-cancel").addEventListener("click", () => editor.close());
// Masks are parsed here so both spellings are accepted client-side, mirroring
// ParsePrefix in Go. Malformed input is caught before the request goes out.
function parsePrefix(s) {
s = (s || "").trim().replace(/^\//, "");
if (!s) throw new Error("mask is required");
if (s.includes(".")) {
const parts = s.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
throw new Error(`"${s}" is not a dotted-quad netmask`);
}
const m = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
let ones = 0;
while (ones < 32 && m & (1 << (31 - ones))) ones++;
if (ones < 32 && (m << ones) >>> 0) throw new Error(`"${s}" is not a contiguous netmask`);
return ones;
}
const n = Number(s);
if (!Number.isInteger(n) || n < 0 || n > 32) throw new Error(`"${s}" is not a prefix length`);
return n;
}
async function savePreset() {
const mode = $("f-mode").value;
const dnsMode = $("f-dns-mode").value;
let body;
try {
body = {
id: state.editing || "",
name: $("f-name").value.trim(),
group: $("f-group").value.trim(),
adapter: $("f-adapter").value,
mode,
notes: $("f-notes").value.trim(),
dns: {
mode: dnsMode,
servers:
dnsMode === "static"
? $("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean)
: undefined,
},
};
if (mode === "static") {
body.primary = {
address: $("f-address").value.trim(),
prefix: parsePrefix($("f-prefix").value),
gateway: $("f-gateway").value.trim() || undefined,
gateway_metric: Number($("f-metric").value) || undefined,
};
const secs = [];
for (const row of document.querySelectorAll("#secondaries .sec-row")) {
const a = row.querySelector(".sec-addr").value.trim();
if (!a) continue;
secs.push({ address: a, prefix: parsePrefix(row.querySelector(".sec-prefix").value) });
}
if (secs.length) body.secondary = secs;
}
} catch (e) {
alertBox("error", e.message, "editor-error");
return;
}
try {
const path = state.editing ? `/api/presets/${encodeURIComponent(state.editing)}` : "/api/presets";
await api(path, { method: state.editing ? "PUT" : "POST", body: JSON.stringify(body) });
editor.close();
await loadAll();
alertBox("ok", "Preset saved.");
} catch (e) {
alertBox("error", e.message, "editor-error");
}
}
$("btn-save").addEventListener("click", savePreset);
async function deletePreset(id) {
const p = state.presets.find((x) => x.id === id);
if (!confirm(`Delete preset "${p ? p.name : id}"?`)) return;
try {
await api(`/api/presets/${encodeURIComponent(id)}`, { method: "DELETE" });
await loadAll();
alertBox("ok", "Preset deleted.");
} catch (e) {
alertBox("error", e.message);
}
}
// --- settings ---
const settingsDlg = $("settings");
$("btn-settings").addEventListener("click", () => {
const s = state.settings || {};
$("s-mask").value = s.mask_style || "prefix";
$("s-startup").checked = !!s.start_with_windows;
$("s-updates").checked = !!s.check_updates;
$("s-repo").value = s.update_repo || "";
settingsDlg.showModal();
});
$("btn-settings-cancel").addEventListener("click", () => settingsDlg.close());
$("btn-settings-save").addEventListener("click", async () => {
try {
await api("/api/settings", {
method: "PUT",
body: JSON.stringify({
mask_style: $("s-mask").value,
start_with_windows: $("s-startup").checked,
check_updates: $("s-updates").checked,
update_repo: $("s-repo").value.trim(),
}),
});
settingsDlg.close();
await loadAll();
alertBox("ok", "Settings saved.");
} catch (e) {
alertBox("error", e.message);
}
});
// --- import / export ---
$("btn-export").addEventListener("click", () => {
// A plain navigation, so the browser's own download UI picks the location.
location.href = "/api/export";
});
const importDlg = $("import");
$("btn-import").addEventListener("click", () => {
clearAlert("import-error");
importDlg.showModal();
});
$("btn-import-cancel").addEventListener("click", () => importDlg.close());
$("btn-import-go").addEventListener("click", async () => {
const file = $("i-file").files[0];
if (!file) {
alertBox("error", "Choose a file first.", "import-error");
return;
}
const mode = $("i-mode").value;
if (mode === "replace" && !confirm("Replace discards every preset you currently have. Continue?")) return;
try {
const res = await api(`/api/import?mode=${mode}`, { method: "POST", body: await file.text() });
importDlg.close();
await loadAll();
let msg = `Imported ${res.added} preset${res.added === 1 ? "" : "s"}.`;
if (res.renamed && res.renamed.length) {
msg += ` ${res.renamed.length} had an id collision and were kept alongside the originals.`;
}
alertBox("ok", msg);
} catch (e) {
alertBox("error", e.message, "import-error");
}
});
// --- go ---
loadAll().catch((e) => alertBox("error", `Could not load: ${e.message}`));