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:
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user