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:
@@ -1328,6 +1328,13 @@ ipcMain.handle('wifi-request',async(event,req)=>{
|
|||||||
}
|
}
|
||||||
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
|
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
|
||||||
if(action==='status'||action==='scan')return runWifiHelper(action);
|
if(action==='status'||action==='scan')return runWifiHelper(action);
|
||||||
|
if(action==='region-status')return runWifiHelper('region-status');
|
||||||
|
if(action==='region-set'){
|
||||||
|
const input={};
|
||||||
|
for(const k of['country','locale','keyboard'])if(req[k])input[k]=String(req[k]);
|
||||||
|
console.log('[WIFI] Region set:',JSON.stringify(input));
|
||||||
|
return runWifiHelper('region-set',input);
|
||||||
|
}
|
||||||
if(action==='connect'){
|
if(action==='connect'){
|
||||||
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
|
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
|
||||||
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
|
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
|
||||||
|
|||||||
+68
-1
@@ -26,6 +26,10 @@
|
|||||||
color: #ecf0f1;
|
color: #ecf0f1;
|
||||||
outline: none;
|
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; }
|
input:focus { border-color: #3498db; }
|
||||||
.row { display: flex; gap: 10px; align-items: center; }
|
.row { display: flex; gap: 10px; align-items: center; }
|
||||||
.row input { flex: 1; }
|
.row input { flex: 1; }
|
||||||
@@ -114,6 +118,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="message"></div>
|
||||||
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
|
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
|
||||||
|
|
||||||
@@ -135,7 +153,7 @@
|
|||||||
|
|
||||||
function setBusy(on) {
|
function setBusy(on) {
|
||||||
busy = on;
|
busy = on;
|
||||||
for (const id of ['scan', 'connect', 'pin-ok']) $(id).disabled = on;
|
for (const id of ['scan', 'connect', 'pin-ok', 'region-save']) $(id).disabled = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
function signalBars(dbm) {
|
function signalBars(dbm) {
|
||||||
@@ -210,6 +228,52 @@
|
|||||||
refreshStatus();
|
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() {
|
async function unlock() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -224,12 +288,15 @@
|
|||||||
$('pin-step').classList.add('hidden');
|
$('pin-step').classList.add('hidden');
|
||||||
$('wifi-step').classList.remove('hidden');
|
$('wifi-step').classList.remove('hidden');
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
loadRegion();
|
||||||
scan();
|
scan();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('pin-ok').addEventListener('click', unlock);
|
$('pin-ok').addEventListener('click', unlock);
|
||||||
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
|
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
|
||||||
$('scan').addEventListener('click', scan);
|
$('scan').addEventListener('click', scan);
|
||||||
|
$('region-save').addEventListener('click', saveRegion);
|
||||||
|
$('region-from-locale').addEventListener('click', countryFromLocale);
|
||||||
$('connect').addEventListener('click', connect);
|
$('connect').addEventListener('click', connect);
|
||||||
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
|
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
|
||||||
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
|
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ wifi_default_country() {
|
|||||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "$c" && -r /etc/modprobe.d/kiosk-regdom.conf ]]; then
|
||||||
|
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' /etc/modprobe.d/kiosk-regdom.conf | head -1)
|
||||||
|
fi
|
||||||
if [[ -z "$c" ]]; then
|
if [[ -z "$c" ]]; then
|
||||||
local loc="${LANG:-}"
|
local loc="${LANG:-}"
|
||||||
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
|
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
|
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
|
||||||
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
|
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
|
||||||
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
|
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
|
||||||
|
# kiosk-wifi-helper region-status
|
||||||
|
# {"country","locale","keyboard","locales":[],"keyboards":[]}
|
||||||
|
# kiosk-wifi-helper region-set stdin {"country"?,"locale"?,"keyboard"?} -> {"ok",...}
|
||||||
|
# WiFi country (regulatory domain), system
|
||||||
|
# locale and keyboard layout; every field optional
|
||||||
#
|
#
|
||||||
# Every reply is one JSON line on stdout, exit 0 on success. connect
|
# Every reply is one JSON line on stdout, exit 0 on success. connect
|
||||||
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
|
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
|
||||||
@@ -24,6 +29,8 @@ set -uo pipefail
|
|||||||
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
|
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
|
||||||
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
|
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
|
||||||
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
||||||
|
REGDOM_CONF="${KIOSK_REGDOM_CONF:-/etc/modprobe.d/kiosk-regdom.conf}"
|
||||||
|
LOCALE_FILE="${KIOSK_LOCALE_FILE:-/etc/default/locale}"
|
||||||
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
|
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
|
||||||
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
|
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
|
||||||
|
|
||||||
@@ -150,9 +157,12 @@ default_country() {
|
|||||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "$c" && -f "$REGDOM_CONF" ]]; then
|
||||||
|
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' "$REGDOM_CONF" | head -1)
|
||||||
|
fi
|
||||||
if [[ -z "$c" ]]; then
|
if [[ -z "$c" ]]; then
|
||||||
local loc="${LANG:-}"
|
local loc="${LANG:-}"
|
||||||
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
|
[[ -r "$LOCALE_FILE" ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1)
|
||||||
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||||
fi
|
fi
|
||||||
echo "${c^^}"
|
echo "${c^^}"
|
||||||
@@ -163,6 +173,84 @@ need_iface() {
|
|||||||
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
|
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---- Region: WiFi country, system locale, keyboard layout -----------------
|
||||||
|
|
||||||
|
current_locale() {
|
||||||
|
[[ -r "$LOCALE_FILE" ]] || return 0
|
||||||
|
sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
current_keyboard() {
|
||||||
|
localectl status 2>/dev/null | sed -n 's/^[[:space:]]*X11 Layout:[[:space:]]*//p' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locales that are generated on this system (setting any other one would
|
||||||
|
# leave the whole system on a locale that doesn't exist).
|
||||||
|
list_locales() {
|
||||||
|
locale -a 2>/dev/null | grep -i 'utf-\?8' | sed 's/\.[Uu][Tt][Ff]-\?8$/.UTF-8/' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
list_keyboards() {
|
||||||
|
localectl list-x11-keymap-layouts 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
do_region_status() {
|
||||||
|
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||||
|
--argjson ls "$(list_locales | jq -R . | jq -cs .)" \
|
||||||
|
--argjson ks "$(list_keyboards | jq -R . | jq -cs .)" \
|
||||||
|
'{ok:true,country:$c,locale:$l,keyboard:$k,locales:$ls,keyboards:$ks}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist the WiFi country: kernel option for the regulatory domain (so it
|
||||||
|
# applies from boot, before any WiFi config exists), the netplan file if
|
||||||
|
# there is one (so wpa_supplicant gets it too), and now via iw.
|
||||||
|
set_country() {
|
||||||
|
local c="$1"
|
||||||
|
printf '# Written by kiosk-wifi-helper (Region setting)\noptions cfg80211 ieee80211_regdom=%s\n' "$c" > "$REGDOM_CONF"
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
sed -i '/^[[:space:]]*regulatory-domain:/d' "$WIFI_NETPLAN_FILE"
|
||||||
|
sed -i "s/^\([[:space:]]*\)optional: true\$/&\n\1regulatory-domain: \"$c\"/" "$WIFI_NETPLAN_FILE"
|
||||||
|
fi
|
||||||
|
iw reg set "$c" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
do_region_set() {
|
||||||
|
local input country locale keyboard
|
||||||
|
input=$(head -c 4096)
|
||||||
|
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
country=${country^^}
|
||||||
|
|
||||||
|
if [[ -n "$country" ]]; then
|
||||||
|
[[ "$country" =~ ^[A-Z]{2}$ ]] || reply_error "A country code is 2 letters, e.g. US"
|
||||||
|
# Reject codes that aren't real countries when the tz database is here.
|
||||||
|
if [[ -r /usr/share/zoneinfo/iso3166.tab ]] && ! grep -q "^$country[[:space:]]" /usr/share/zoneinfo/iso3166.tab; then
|
||||||
|
reply_error "$country isn't a known country code"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -n "$locale" ]]; then
|
||||||
|
list_locales | grep -qxF -- "$locale" || reply_error "Locale $locale isn't installed on this system"
|
||||||
|
fi
|
||||||
|
if [[ -n "$keyboard" ]]; then
|
||||||
|
list_keyboards | grep -qxF -- "$keyboard" || reply_error "Unknown keyboard layout: $keyboard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local err
|
||||||
|
[[ -n "$country" ]] && set_country "$country"
|
||||||
|
if [[ -n "$locale" ]]; then
|
||||||
|
err=$(localectl set-locale "LANG=$locale" 2>&1) || reply_error "Couldn't set the locale: ${err:0:200}"
|
||||||
|
fi
|
||||||
|
if [[ -n "$keyboard" ]]; then
|
||||||
|
err=$(localectl set-x11-keymap "$keyboard" 2>&1) || reply_error "Couldn't set the keyboard: ${err:0:200}"
|
||||||
|
# Console layout too, when there's a console map of the same name.
|
||||||
|
localectl list-keymaps 2>/dev/null | grep -qxF -- "$keyboard" && localectl set-keymap "$keyboard" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||||
|
--argjson restart "$([[ -n "$locale$keyboard" ]] && echo true || echo false)" \
|
||||||
|
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
|
||||||
|
}
|
||||||
|
|
||||||
do_status() {
|
do_status() {
|
||||||
local iface ssid="" ip="" configured
|
local iface ssid="" ip="" configured
|
||||||
configured=$(configured_ssid)
|
configured=$(configured_ssid)
|
||||||
@@ -370,5 +458,7 @@ case "${1:-}" in
|
|||||||
status) do_status ;;
|
status) do_status ;;
|
||||||
scan) do_scan ;;
|
scan) do_scan ;;
|
||||||
connect) do_connect ;;
|
connect) do_connect ;;
|
||||||
|
region-status) do_region_status ;;
|
||||||
|
region-set) do_region_set ;;
|
||||||
*) reply_error "unknown action: ${1:-}" ;;
|
*) reply_error "unknown action: ${1:-}" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -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 */
|
/* Init */
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
@@ -657,6 +714,7 @@ async function init() {
|
|||||||
}
|
}
|
||||||
await loadAddonStatus();
|
await loadAddonStatus();
|
||||||
renderAddons();
|
renderAddons();
|
||||||
|
loadRegion();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
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>
|
<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
|
Addons
|
||||||
</button>
|
</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">
|
<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>
|
<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
|
Update
|
||||||
@@ -192,6 +196,35 @@
|
|||||||
<div id="addons-list"></div>
|
<div id="addons-list"></div>
|
||||||
</section>
|
</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 -->
|
<!-- Update -->
|
||||||
<section class="page" id="page-update">
|
<section class="page" id="page-update">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const { ACTIONS } = require('./lib/actions');
|
|||||||
const { startJob, getJob } = require('./lib/jobs');
|
const { startJob, getJob } = require('./lib/jobs');
|
||||||
|
|
||||||
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
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 SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -253,6 +254,40 @@ app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
|
|||||||
req.on('close', () => job.listeners.delete(listener));
|
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 PORT = process.env.PORT || 8090;
|
||||||
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
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-'));
|
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-'));
|
||||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
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');
|
const app = require('../server');
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
@@ -240,6 +243,31 @@ async function main() {
|
|||||||
assert.strictEqual(body.lockoutTimeout, 20);
|
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();
|
server.close();
|
||||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
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