diff --git a/kiosk-app/main.js b/kiosk-app/main.js index 30dd880..14fd168 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -1328,6 +1328,13 @@ ipcMain.handle('wifi-request',async(event,req)=>{ } if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'}; 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'){ console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||''))); const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')}); diff --git a/kiosk-app/wifi.html b/kiosk-app/wifi.html index a4ae1fe..dbb6277 100644 --- a/kiosk-app/wifi.html +++ b/kiosk-app/wifi.html @@ -26,6 +26,10 @@ 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; } @@ -114,6 +118,20 @@ + +
Esc closes this screen. Closes by itself after 5 minutes untouched.
@@ -135,7 +153,7 @@ function setBusy(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) { @@ -210,6 +228,52 @@ 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); @@ -224,12 +288,15 @@ $('pin-step').classList.add('hidden'); $('wifi-step').classList.remove('hidden'); refreshStatus(); + loadRegion(); scan(); } $('pin-ok').addEventListener('click', unlock); $('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); }); $('scan').addEventListener('click', 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(); }); diff --git a/menus/wifi.sh b/menus/wifi.sh index b73d174..357dc5e 100644 --- a/menus/wifi.sh +++ b/menus/wifi.sh @@ -167,6 +167,9 @@ wifi_default_country() { 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) 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 local loc="${LANG:-}" [[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1) diff --git a/provision/files/usr/local/bin/kiosk-wifi-helper b/provision/files/usr/local/bin/kiosk-wifi-helper index dd66c19..f1d7d10 100755 --- a/provision/files/usr/local/bin/kiosk-wifi-helper +++ b/provision/files/usr/local/bin/kiosk-wifi-helper @@ -11,6 +11,11 @@ # kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"} # kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]} # 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 # 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}" SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests 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}" CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}" @@ -150,9 +157,12 @@ default_country() { 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) 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 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]}" fi 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" } +# ---- 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() { local iface ssid="" ip="" configured configured=$(configured_ssid) @@ -370,5 +458,7 @@ case "${1:-}" in status) do_status ;; scan) do_scan ;; connect) do_connect ;; + region-status) do_region_status ;; + region-set) do_region_set ;; *) reply_error "unknown action: ${1:-}" ;; esac diff --git a/webui/public/app.js b/webui/public/app.js index 0d6a939..0a3a947 100644 --- a/webui/public/app.js +++ b/webui/public/app.js @@ -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(); diff --git a/webui/public/index.html b/webui/public/index.html index d898f4a..313452a 100644 --- a/webui/public/index.html +++ b/webui/public/index.html @@ -36,6 +36,10 @@ Addons + + + +

Locale and keyboard apply after a restart; the WiFi country applies immediately.

+ + + +