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
366 lines
13 KiB
HTML
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>
|