Files
Claude 07e7d2866f WiFi screen: reach the root helper through a socket instead of sudo; adapter dropdown
The kiosk app can end up with the no-new-privileges flag, after which sudo
refuses to run ("The no new privileges flag is set"). The helper is now
served over a root-owned, kiosk-group-only unix socket (systemd socket
activation); sudo stays as a fallback. With several WiFi adapters the screen
shows a dropdown and lists all of them; status/scan/connect take an iface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:00:10 +00:00

366 lines
13 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WiFi</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #2c3e50;
color: #ecf0f1;
height: 100vh;
overflow-y: auto;
padding: 24px;
}
h2 { text-align: center; margin-bottom: 16px; font-size: 26px; }
.hidden { display: none !important; }
label { display: block; margin: 14px 0 6px; font-size: 16px; color: #bdc3c7; }
input {
width: 100%;
padding: 14px;
font-size: 20px;
border: 2px solid #34495e;
border-radius: 10px;
background: #34495e;
color: #ecf0f1;
outline: none;
}
select {
width: 100%; padding: 14px; font-size: 18px; border: 2px solid #34495e;
border-radius: 10px; background: #34495e; color: #ecf0f1;
}
input:focus { border-color: #3498db; }
.row { display: flex; gap: 10px; align-items: center; }
.row input { flex: 1; }
button {
padding: 14px 20px;
font-size: 18px;
border: none;
border-radius: 10px;
font-weight: bold;
cursor: pointer;
color: #fff;
background: #34495e;
}
button:disabled { opacity: 0.5; cursor: default; }
.primary { background: #27ae60; }
.scan { background: #2980b9; }
.close { background: #e74c3c; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 20px; }
#status {
background: #34495e;
border-radius: 10px;
padding: 12px 14px;
font-size: 16px;
line-height: 1.5;
}
#message { margin-top: 16px; font-size: 17px; font-weight: bold; text-align: center; min-height: 24px; }
#message.error { color: #e74c3c; }
#message.ok { color: #2ecc71; }
#message.busy { color: #f1c40f; }
#networks {
list-style: none;
max-height: 260px;
overflow-y: auto;
border-radius: 10px;
background: #34495e;
margin-top: 8px;
}
#networks li {
display: flex;
justify-content: space-between;
padding: 12px 14px;
font-size: 18px;
border-bottom: 1px solid #2c3e50;
cursor: pointer;
}
#networks li:last-child { border-bottom: none; }
#networks li.selected { background: #2980b9; }
#networks li .meta { color: #bdc3c7; font-size: 15px; white-space: nowrap; margin-left: 12px; }
#networks li.empty { color: #95a5a6; cursor: default; }
.hint { text-align: center; font-size: 13px; color: #95a5a6; margin-top: 18px; }
</style>
</head>
<body>
<h2>📶 WiFi</h2>
<div id="pin-step" class="hidden">
<label for="pin" id="pin-label">Enter the PIN</label>
<input id="pin" type="password" inputmode="numeric" autocomplete="off">
<div class="actions">
<button class="primary" id="pin-ok">✓ OK</button>
<button class="close" data-close>✗ Close</button>
</div>
</div>
<div id="wifi-step" class="hidden">
<div id="adapter-row" class="hidden">
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
<select id="adapter"></select>
</div>
<div id="status" style="margin-top: 12px">Checking…</div>
<div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label>
<button class="scan" id="scan">⟳ Scan</button>
</div>
<ul id="networks"><li class="empty">Scanning…</li></ul>
<label for="ssid">Network name</label>
<input id="ssid" type="text" autocomplete="off" spellcheck="false">
<label for="password">Password (empty for an open network)</label>
<div class="row">
<input id="password" type="password" autocomplete="off" spellcheck="false">
<button id="show">Show</button>
</div>
<div class="actions">
<button class="primary" id="connect">Connect</button>
<button class="close" data-close>✗ Close</button>
</div>
</div>
<details id="region-panel" class="hidden" style="margin-top: 18px">
<summary style="font-size: 17px; cursor: pointer; color: #bdc3c7">🌍 Region (country, locale, keyboard)</summary>
<label for="country">WiFi country (2 letters, e.g. US) - needed to join many 5 GHz networks</label>
<input id="country" type="text" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
<label for="locale">Language / locale</label>
<select id="locale"></select>
<label for="keyboard">Keyboard layout</label>
<select id="keyboard"></select>
<div class="actions">
<button class="primary" id="region-save">Save region</button>
<button id="region-from-locale">Country from locale</button>
</div>
</details>
<div id="message"></div>
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
<script>
const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id);
let busy = false;
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
.catch(e => ({ok: false, error: String(e && e.message || e)}));
}
function message(text, kind) {
const m = $('message');
m.textContent = text || '';
m.className = kind || '';
}
function setBusy(on) {
busy = on;
for (const id of ['scan', 'connect', 'pin-ok', 'region-save']) $(id).disabled = on;
}
function signalBars(dbm) {
if (dbm >= -55) return '▂▄▆█';
if (dbm >= -67) return '▂▄▆';
if (dbm >= -78) return '▂▄';
return '▂';
}
function adapterLabel(a) {
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
}
// Fill the dropdown from the helper's adapter list. Shown only when
// there is more than one adapter; the choice sticks across refreshes.
function fillAdapters(r) {
const row = $('adapter-row');
const sel = $('adapter');
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
const names = r.adapters.map(a => a.name);
if (!names.includes(adapter)) adapter = r.selected || names[0];
sel.textContent = '';
for (const a of r.adapters) {
const o = document.createElement('option');
o.value = a.name;
o.textContent = adapterLabel(a);
if (a.name === adapter) o.selected = true;
sel.appendChild(o);
}
row.classList.toggle('hidden', r.adapters.length < 2);
return r;
}
async function refreshStatus() {
const ad = fillAdapters(await request('ifaces'));
const s = await request('status', adapter ? {iface: adapter} : {});
const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
// Every adapter at a glance, the selected one marked.
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
} else {
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
line(`Adapter: ${s.iface}`);
}
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
}
async function scan() {
if (busy) return;
setBusy(true);
message('Scanning…', 'busy');
const list = $('networks');
const r = await request('scan', adapter ? {iface: adapter} : {});
list.textContent = '';
if (!r.ok) {
message(r.error, 'error');
list.innerHTML = '<li class="empty">No scan results</li>';
} else {
message('');
if (!r.networks.length) list.innerHTML = '<li class="empty">No networks found - type the name below</li>';
for (const n of r.networks) {
const li = document.createElement('li');
const name = document.createElement('span');
name.textContent = n.ssid;
const meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = (n.secure ? '🔒 ' : '') + signalBars(n.signal);
li.append(name, meta);
li.addEventListener('click', () => {
for (const other of list.children) other.classList.remove('selected');
li.classList.add('selected');
$('ssid').value = n.ssid;
$('password').value = '';
$('password').focus();
});
list.appendChild(li);
}
}
setBusy(false);
}
async function connect() {
if (busy) return;
const ssid = $('ssid').value;
const password = $('password').value;
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password, iface: adapter});
if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = '';
} else {
message(r.error || 'Connection failed', 'error');
}
setBusy(false);
refreshStatus();
}
function fillSelect(sel, items, current) {
sel.textContent = '';
const list = items.slice();
if (current && !list.includes(current)) list.unshift(current);
for (const v of list) {
const o = document.createElement('option');
o.value = v; o.textContent = v;
if (v === current) o.selected = true;
sel.appendChild(o);
}
}
async function loadRegion() {
const r = await request('region-status');
if (!r.ok) return;
$('country').value = r.country || '';
fillSelect($('locale'), r.locales || [], r.locale);
fillSelect($('keyboard'), r.keyboards || [], r.keyboard);
$('region-panel').classList.remove('hidden');
}
async function saveRegion() {
if (busy) return;
setBusy(true);
message('Saving region…', 'busy');
const r = await request('region-set', {
country: $('country').value.trim(),
locale: $('locale').value,
keyboard: $('keyboard').value,
});
setBusy(false);
if (r.ok) {
message(r.restart ? '✓ Saved. Locale and keyboard apply after a restart; the country applies now.' : '✓ Saved', 'ok');
loadRegion();
} else {
message(r.error || 'Could not save', 'error');
}
}
// en_US.UTF-8 -> US
function countryFromLocale() {
const m = /_([A-Z]{2})/.exec($('locale').value);
if (m) $('country').value = m[1];
else message("That locale doesn't name a country", 'error');
}
async function unlock() {
if (busy) return;
setBusy(true);
const r = await request('unlock', {pin: $('pin').value});
setBusy(false);
$('pin').value = '';
if (r.ok) { showWifi(); } else { message(r.error, 'error'); $('pin').focus(); }
}
function showWifi() {
message('');
$('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden');
refreshStatus().then(() => scan());
loadRegion();
}
$('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan);
$('adapter').addEventListener('change', () => {
adapter = $('adapter').value;
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
refreshStatus();
scan();
});
$('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect);
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
$('show').addEventListener('click', () => {
const p = $('password');
p.type = p.type === 'password' ? 'text' : 'password';
$('show').textContent = p.type === 'password' ? 'Show' : 'Hide';
});
for (const b of document.querySelectorAll('[data-close]')) b.addEventListener('click', () => request('close'));
request('needs-pin').then(r => {
if (r.ok && r.needsPin) {
if (r.codeType === 'password') {
$('pin-label').textContent = 'Enter the kiosk password';
$('pin').removeAttribute('inputmode');
}
$('pin-step').classList.remove('hidden');
$('pin').focus();
} else {
showWifi();
}
});
</script>
</body>
</html>