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>
This commit is contained in:
2026-08-20 14:32:15 +10:00
parent a8bbf91223
commit f267c430d9
21 changed files with 2330 additions and 565 deletions
+144 -104
View File
@@ -4,77 +4,114 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ipswap — presets</title>
<link rel="stylesheet" href="/app.css">
<!-- apointless.css is vendored from bsncubed/css; ipswap.css adds only what
it does not ship. Keep that order. -->
<link rel="stylesheet" href="/apointless.css">
<link rel="stylesheet" href="/ipswap.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<h1>ipswap</h1>
<div class="dim">Preset editor · this page closes itself when you stop using it</div>
</div>
<div class="btn-row">
<button id="theme-toggle" class="ghost small">Light</button>
<button id="btn-settings" class="ghost small">Settings</button>
</div>
</header>
<header class="topbar">
<div class="brand">
<h1>ipswap</h1>
<span class="version" id="version"></span>
</div>
<div class="row">
<button id="btn-theme" class="btn btn-ghost btn-sm">Light</button>
<button id="btn-settings" class="btn btn-sm">Settings</button>
</div>
</header>
<main class="page">
<div id="alerts"></div>
<div class="stat-row" id="stats"></div>
<section class="stats">
<div class="stat">
<span class="label">Active preset</span>
<span class="value" id="stat-active">—</span>
</div>
<div class="stat">
<span class="label">Presets</span>
<span class="value" id="stat-count">—</span>
</div>
<div class="stat">
<span class="label">Groups</span>
<span class="value" id="stat-groups">—</span>
</div>
</section>
<div class="card">
<div class="btn-row" style="justify-content: space-between;">
<h2 style="margin:0">Presets</h2>
<div class="btn-row">
<button id="btn-import">Import…</button>
<button id="btn-export">Export</button>
<button id="btn-new" class="primary">New preset</button>
</div>
<div class="row-between">
<h2>Presets</h2>
<div class="row">
<button id="btn-import" class="btn btn-sm">Import…</button>
<button id="btn-export" class="btn btn-sm">Export</button>
<button id="btn-new" class="btn btn-primary btn-sm">New preset</button>
</div>
</div>
<div id="groups"></div>
<div id="groups" class="stack" style="margin-top:12px"></div>
<div class="section-head"><h2>Adapters</h2></div>
<div class="card">
<h2>Adapters on this machine</h2>
<div class="table-scroll">
<table>
<thead>
<tr><th>Name</th><th>Description</th><th>Current IPv4</th><th>State</th></tr>
</thead>
<tbody id="adapters"><tr><td colspan="4"><span class="spinner"></span></td></tr></tbody>
<tbody id="adapters">
<tr><td colspan="4"><span class="busy"><span class="spinner"></span> Reading adapters…</span></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- preset editor -->
<!-- Apply confirmation. This is the browser's equivalent of the tray's
MessageBox and shows the identical before/after text. -->
<dialog id="confirm">
<div class="modal-head">
<h2 id="confirm-title">Apply preset</h2>
</div>
<div class="modal-body">
<div class="alert alert-warn" style="margin-bottom:14px">
Applying is destructive: afterwards the adapter has exactly these addresses and nothing else.
</div>
<pre class="code-block" id="confirm-diff"></pre>
<div id="confirm-error" style="margin-top:12px"></div>
</div>
<div class="modal-foot">
<button type="button" class="btn btn-ghost" id="confirm-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-go">Apply</button>
</div>
</dialog>
<!-- Preset editor -->
<dialog id="editor">
<form method="dialog" id="editor-form">
<div class="modal-head">
<h2 id="editor-title">New preset</h2>
</div>
<div class="modal-body">
<div id="editor-error"></div>
<div class="field-row">
<div class="field-grid">
<div class="field">
<label for="f-name">Name</label>
<input id="f-name" required placeholder="Artist frame — control">
<label class="label" for="f-name">Name</label>
<input class="input" id="f-name" placeholder="Artist frame — control">
</div>
<div class="field">
<label for="f-group">Group</label>
<input id="f-group" list="group-list" placeholder="Riedel">
<label class="label" for="f-group">Group</label>
<input class="input" id="f-group" list="group-list" placeholder="Riedel">
<datalist id="group-list"></datalist>
</div>
</div>
<div class="field-row">
<div class="field-grid">
<div class="field">
<label for="f-adapter">Adapter</label>
<select id="f-adapter"></select>
<label class="label" for="f-adapter">Adapter</label>
<select class="input" id="f-adapter"></select>
</div>
<div class="field">
<label for="f-mode">Mode</label>
<select id="f-mode">
<label class="label" for="f-mode">Mode</label>
<select class="input" id="f-mode">
<option value="static">Static</option>
<option value="dhcp">DHCP</option>
</select>
@@ -82,109 +119,112 @@
</div>
<div id="static-fields">
<div class="field-row">
<div class="field-grid">
<div class="field">
<label for="f-address">Address</label>
<input id="f-address" placeholder="192.168.42.100">
<label class="label" for="f-address">Address</label>
<input class="input" id="f-address" placeholder="192.168.42.100">
</div>
<div class="field">
<label for="f-prefix">Mask <span class="dim">(/24 or 255.255.255.0)</span></label>
<input id="f-prefix" placeholder="/24">
<label class="label" for="f-prefix">Mask — /24 or 255.255.255.0</label>
<input class="input" id="f-prefix" placeholder="/24">
</div>
</div>
<div class="field-row">
<div class="field-grid">
<div class="field">
<label for="f-gateway">Gateway <span class="dim">(optional)</span></label>
<input id="f-gateway" placeholder="192.168.42.1">
<label class="label" for="f-gateway">Gateway — optional</label>
<input class="input" id="f-gateway" placeholder="192.168.42.1">
</div>
<div class="field">
<label for="f-metric">Gateway metric <span class="dim">(0 = automatic)</span></label>
<input id="f-metric" type="number" min="0" value="0">
<label class="label" for="f-metric">Gateway metric — 0 is automatic</label>
<input class="input" id="f-metric" type="number" min="0" value="0">
</div>
</div>
<div class="field">
<label>Secondary addresses</label>
<div class="sec-list" id="secondaries"></div>
<button type="button" id="btn-add-sec" class="small" style="margin-top:8px">Add secondary</button>
<label class="label">Secondary addresses</label>
<div id="secondaries"></div>
<button type="button" id="btn-add-sec" class="btn btn-sm">Add secondary</button>
</div>
</div>
<div class="field">
<label for="f-dns-mode">DNS</label>
<select id="f-dns-mode">
<label class="label" for="f-dns-mode">DNS</label>
<select class="input" id="f-dns-mode">
<option value="static">Static</option>
<option value="dhcp">From DHCP</option>
</select>
</div>
<div class="field" id="dns-servers-field">
<label for="f-dns">DNS servers <span class="dim">(comma separated, order matters)</span></label>
<input id="f-dns" placeholder="192.168.42.1, 1.1.1.1">
<label class="label" for="f-dns">DNS servers — comma separated, order matters</label>
<input class="input" id="f-dns" placeholder="192.168.42.1, 1.1.1.1">
</div>
<div class="field">
<label for="f-notes">Notes</label>
<input id="f-notes" placeholder="Frame A, rack 3">
<label class="label" for="f-notes">Notes</label>
<input class="input" id="f-notes" placeholder="Frame A, rack 3">
</div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-save" class="primary">Save</button>
</div>
</form>
</div>
<div class="modal-foot">
<button type="button" class="btn btn-ghost" id="btn-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save">Save</button>
</div>
</dialog>
<!-- settings -->
<!-- Settings -->
<dialog id="settings">
<h2>Settings</h2>
<div class="field">
<label for="s-mask">Mask display</label>
<select id="s-mask">
<option value="prefix">Prefix (/24)</option>
<option value="dotted">Dotted (255.255.255.0)</option>
</select>
<div class="modal-head"><h2>Settings</h2></div>
<div class="modal-body">
<div class="field">
<label class="label" for="s-mask">Mask display</label>
<select class="input" id="s-mask">
<option value="prefix">Prefix — /24</option>
<option value="dotted">Dotted — 255.255.255.0</option>
</select>
</div>
<div class="check">
<input type="checkbox" id="s-startup">
<label for="s-startup">Start ipswap when Windows starts</label>
</div>
<div class="check">
<input type="checkbox" id="s-updates">
<label for="s-updates">Check for updates on startup</label>
</div>
<div class="field">
<label class="label" for="s-repo">Update repository — blank disables</label>
<input class="input" id="s-repo" placeholder="https://gitea.apointless.space/bsncubed/ipswap">
</div>
</div>
<div class="field check">
<input type="checkbox" id="s-startup">
<label for="s-startup">Start ipswap when Windows starts</label>
</div>
<div class="field check">
<input type="checkbox" id="s-updates">
<label for="s-updates">Check for updates on startup</label>
</div>
<div class="field">
<label for="s-repo">Update repository <span class="dim">(https://host/owner/repo — blank disables)</span></label>
<input id="s-repo" placeholder="https://gitea.apointless.space/bsncubed/ipswap">
</div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-settings-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-settings-save" class="primary">Save</button>
<div class="modal-foot">
<button type="button" class="btn btn-ghost" id="btn-settings-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-settings-save">Save</button>
</div>
</dialog>
<!-- import -->
<!-- Import -->
<dialog id="import">
<h2>Import presets</h2>
<div class="alert info">
The file is validated in full before anything is written. If it is rejected, nothing changes.
<div class="modal-head"><h2>Import presets</h2></div>
<div class="modal-body">
<div class="alert alert-info" style="margin-bottom:14px">
The file is validated in full before anything is written. If it is rejected, nothing changes.
</div>
<div class="field">
<label class="label" for="i-file">Preset pack (.json)</label>
<input class="input" type="file" id="i-file" accept="application/json,.json">
</div>
<div class="field">
<label class="label" for="i-mode">Mode</label>
<select class="input" id="i-mode">
<option value="merge">Merge — keep what I have, add these</option>
<option value="replace">Replace — discard my existing presets</option>
</select>
</div>
<div id="import-error"></div>
</div>
<div class="field">
<label for="i-file">Preset pack (.json)</label>
<input type="file" id="i-file" accept="application/json,.json">
</div>
<div class="field">
<label for="i-mode">Mode</label>
<select id="i-mode">
<option value="merge">Merge — keep what I have, add these</option>
<option value="replace">Replace — discard my existing presets</option>
</select>
</div>
<div id="import-error"></div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-import-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-import-go" class="primary">Import</button>
<div class="modal-foot">
<button type="button" class="btn btn-ghost" id="btn-import-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-import-go">Import</button>
</div>
</dialog>