Region setting (WiFi country, locale, keyboard) on the Super+W screen and Web UI

kiosk-wifi-helper gains region-status/region-set. The country is persisted as
a cfg80211 regdom module option and in the WiFi netplan file; locale and
keyboard go through localectl, validated against what's installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
This commit is contained in:
Claude
2026-10-16 17:48:41 +00:00
parent 9221fc85aa
commit d0d39e48bb
9 changed files with 331 additions and 2 deletions
+58
View File
@@ -643,6 +643,63 @@ document.getElementById('run-upgrade').addEventListener('click', async (e) => {
}
});
/* ---------------------------------------------------------------------- */
/* Region */
/* ---------------------------------------------------------------------- */
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;
o.selected = v === current;
sel.appendChild(o);
}
}
async function loadRegion() {
try {
const res = await fetch('/api/region');
const r = await res.json();
if (!res.ok) throw new Error(r.error || 'Could not read the region');
document.getElementById('region-country').value = r.country || '';
fillSelect(document.getElementById('region-locale'), r.locales || [], r.locale);
fillSelect(document.getElementById('region-keyboard'), r.keyboards || [], r.keyboard);
} catch (e) {
showMessage(e.message, true);
}
}
document.getElementById('region-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
const res = await fetch('/api/region', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
country: document.getElementById('region-country').value.trim(),
locale: document.getElementById('region-locale').value,
keyboard: document.getElementById('region-keyboard').value,
}),
});
const r = await res.json();
if (!res.ok) throw new Error(r.error || 'Could not save');
showMessage(r.restart ? 'Region saved - restart for the locale and keyboard to apply' : 'Region saved');
loadRegion();
} catch (e) {
showMessage(e.message, true);
}
});
document.getElementById('region-from-locale').addEventListener('click', () => {
const m = /_([A-Z]{2})/.exec(document.getElementById('region-locale').value);
if (m) document.getElementById('region-country').value = m[1];
else showMessage("That locale doesn't name a country", true);
});
/* ---------------------------------------------------------------------- */
/* Init */
/* ---------------------------------------------------------------------- */
@@ -657,6 +714,7 @@ async function init() {
}
await loadAddonStatus();
renderAddons();
loadRegion();
}
init();
+33
View File
@@ -36,6 +36,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
Addons
</button>
<button class="nav-item" data-page="region">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
Region
</button>
<button class="nav-item" data-page="update">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
Update
@@ -192,6 +196,35 @@
<div id="addons-list"></div>
</section>
<!-- Region -->
<section class="page" id="page-region">
<div class="page-header">
<h1>Region</h1>
<p>WiFi country, system language and keyboard layout. The WiFi country decides which channels the adapter may join - without it many 5 GHz networks show up in scans but won't connect.</p>
</div>
<div class="card">
<form id="region-form">
<label>
WiFi country (2 letters, e.g. US)
<input type="text" id="region-country" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
</label>
<label>
Language / locale
<select id="region-locale"></select>
</label>
<label>
Keyboard layout
<select id="region-keyboard"></select>
</label>
<div class="button-row">
<button type="submit">Save region</button>
<button type="button" id="region-from-locale" class="secondary">Country from locale</button>
</div>
<p class="hint">Locale and keyboard apply after a restart; the WiFi country applies immediately.</p>
</form>
</div>
</section>
<!-- Update -->
<section class="page" id="page-update">
<div class="page-header">
+35
View File
@@ -24,6 +24,7 @@ const { ACTIONS } = require('./lib/actions');
const { startJob, getJob } = require('./lib/jobs');
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
const app = express();
@@ -253,6 +254,40 @@ app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
req.on('close', () => job.listeners.delete(listener));
});
/* ---------------------------------------------------------------------- */
/* Region: WiFi country, locale, keyboard (root work in kiosk-wifi-helper) */
/* ---------------------------------------------------------------------- */
function runRegionHelper(action, input, res) {
const cmd = SUDO_CMD || WIFI_HELPER_PATH;
const args = SUDO_CMD ? ['-n', WIFI_HELPER_PATH, action] : [action];
const child = execFile(cmd, args, { timeout: 30_000 }, (err, stdout, stderr) => {
let body = null;
try { body = JSON.parse(stdout.trim().split('\n').pop()); } catch (e) { /* not our JSON */ }
if (!body) {
console.error(`${action} failed:`, stderr || (err && err.message));
return res.status(500).json({ error: 'The region helper is not available - run Advanced -> Upgrade from the admin menu' });
}
res.status(body.ok ? 200 : 400).json(body);
});
child.stdin.on('error', () => {});
child.stdin.end(input ? JSON.stringify(input) : '');
}
app.get('/api/region', (req, res) => runRegionHelper('region-status', null, res));
app.put('/api/region', (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const input = {};
for (const k of ['country', 'locale', 'keyboard']) {
if (body[k] === undefined || body[k] === '') continue;
if (typeof body[k] !== 'string' || body[k].length > 64) return badRequest(res, `${k} must be a short string`);
input[k] = body[k];
}
if (!Object.keys(input).length) return badRequest(res, 'Nothing to change');
runRegionHelper('region-set', input, res);
});
const PORT = process.env.PORT || 8090;
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
+28
View File
@@ -13,6 +13,9 @@ const assert = require('assert');
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helper.sh');
const app = require('../server');
let failures = 0;
@@ -240,6 +243,31 @@ async function main() {
assert.strictEqual(body.lockoutTimeout, 20);
});
await check('GET /api/region returns the helper\'s status', async () => {
const res = await fetch(`${base}/api/region`);
assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).country, 'US');
});
await check('PUT /api/region passes only the given fields to the helper', async () => {
const res = await fetch(`${base}/api/region`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country: 'DE', locale: '', bogus: 'x' }),
});
assert.strictEqual(res.status, 200);
assert.deepStrictEqual(JSON.parse((await res.json()).echo), { country: 'DE' });
});
await check('PUT /api/region with nothing to change is a 400', async () => {
const res = await fetch(`${base}/api/region`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
assert.strictEqual(res.status, 400);
});
server.close();
fs.rmSync(scratchDir, { recursive: true, force: true });
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Stand-in for kiosk-wifi-helper in api.test.js: echoes the action and stdin.
input=$(cat)
case "$1" in
region-status) echo '{"ok":true,"country":"US","locale":"en_US.UTF-8","keyboard":"us","locales":["en_US.UTF-8"],"keyboards":["us"]}' ;;
region-set) jq -cn --arg i "$input" '{ok:true,echo:$i,restart:true}' ;;
*) echo '{"ok":false,"error":"unknown"}' ;;
esac