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 @@
+
+ 🌍 Region (country, locale, keyboard)
+
+
+
+
+
+
+
+
+
+
+
+
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
+