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 f412d92..357dc5e 100644
--- a/menus/wifi.sh
+++ b/menus/wifi.sh
@@ -142,6 +142,42 @@ wifi_choose_iface() {
fi
}
+# What the adapter is actually doing, printed after a failed connect so the
+# cause (regulatory domain, weak signal, DHCP, driver) is visible, not guessed.
+wifi_debug_dump() {
+ local i="$1"
+ echo "--- link ---"
+ iw dev "$i" link 2>&1 | head -8
+ echo "--- regulatory domain ---"
+ iw reg get 2>&1 | grep -m2 "country\|global" || true
+ echo "--- wpa_supplicant log (last 15) ---"
+ journalctl -b -u "netplan-wpa-$i" -n 15 --no-pager -o cat 2>&1 | cut -c1-160
+ echo "--- networkd ---"
+ networkctl status "$i" --no-pager 2>&1 | head -12
+ echo "(full log: /var/lib/kiosk-firstboot/install.log, or journalctl -b -u 'netplan-wpa-*')"
+}
+
+# Two-letter country code for the WiFi regulatory domain: the one already
+# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
+# Without a country set, Linux uses the restrictive "world" domain: many
+# 5 GHz channels become listen-only, so a network shows up in scans but
+# the adapter can't join it.
+wifi_default_country() {
+ local c=""
+ 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)
+ [[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
+ fi
+ echo "${c^^}"
+}
+
wifi_menu() {
local rc=0
wifi_configure || rc=$?
@@ -336,7 +372,17 @@ wifi_configure() {
return 1
fi
- apply_wifi_config "$wifi_iface" "$ssid" "$password"
+ local country c_in
+ country=$(wifi_default_country)
+ read -r -p "Country code for WiFi regulations (2 letters, e.g. US) [${country:-none}]: " c_in
+ c_in=${c_in:-$country}
+ c_in=${c_in^^}
+ if [[ -n "$c_in" && ! "$c_in" =~ ^[A-Z]{2}$ ]]; then
+ log_warning "'$c_in' isn't a 2-letter country code - skipping it"
+ c_in=""
+ fi
+
+ apply_wifi_config "$wifi_iface" "$ssid" "$password" "$c_in"
}
# apply_wifi_config IFACE SSID PASSWORD
@@ -346,6 +392,7 @@ apply_wifi_config() {
local wifi_iface="$1"
local ssid="$2"
local password="$3"
+ local country="${4:-}"
# Our own file, merged by netplan with the others (wired config etc).
local netplan_file="$WIFI_NETPLAN_FILE"
@@ -369,7 +416,8 @@ network:
dhcp4: true
dhcp6: false
optional: true
- access-points:
+${country:+ regulatory-domain: \"$country\"
+} access-points:
$(wifi_yaml_quote "$ssid"):
password: $(wifi_yaml_quote "$password")
EOF
@@ -402,14 +450,15 @@ WATCHEOF
rm -f "$temp_plan"
sudo bash -c "$(declare -f wifi_drop_stale_entries); NETPLAN_DIR='$NETPLAN_DIR' WIFI_NETPLAN_FILE='$netplan_file' wifi_drop_stale_entries"
+ [[ -n "$country" ]] && sudo iw reg set "$country" 2>/dev/null
echo "Applying configuration..."
local netplan_log
netplan_log=$(mktemp)
if sudo netplan apply > "$netplan_log" 2>&1; then
cat "$netplan_log"
- echo "Waiting for $wifi_iface to connect (up to 30 seconds)..."
+ echo "Waiting for $wifi_iface to connect (up to 45 seconds)..."
local _
- for _ in {1..30}; do
+ for _ in {1..45}; do
wifi_iface_has_ip "$wifi_iface" && break
sleep 1
done
@@ -422,11 +471,12 @@ WATCHEOF
why=$(wifi_diagnose "$wifi_iface")
if [[ -n "$why" ]]; then
log_error "Couldn't connect to $ssid: $why"
+ elif iw dev "$wifi_iface" link 2>/dev/null | grep -q "^Connected"; then
+ log_error "Joined the network but got no address - the router's DHCP isn't answering (or is slow)"
else
- echo "Wrong password or network name is the usual cause."
+ echo "Not joined, and the log doesn't say why - not necessarily the password."
fi
- echo "Details:"
- echo " sudo journalctl -b -u 'netplan-wpa-*' -u systemd-networkd -n 30 --no-pager"
+ wifi_debug_dump "$wifi_iface"
fi
else
log_error "netplan apply failed"
diff --git a/provision/files/usr/local/bin/kiosk-wifi-helper b/provision/files/usr/local/bin/kiosk-wifi-helper
index 9099c68..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}"
@@ -139,11 +146,111 @@ except Exception:
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
}
+
+# Two-letter country code for the WiFi regulatory domain: the one already
+# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
+# Without a country set, Linux uses the restrictive "world" domain: many
+# 5 GHz channels become listen-only, so a network shows up in scans but
+# the adapter can't join it.
+default_country() {
+ local c=""
+ 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 "$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^^}"
+}
+
need_iface() {
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
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)
@@ -264,7 +371,9 @@ do_connect() {
unblock
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
- local backup="" new
+ local country backup="" new
+ country=$(default_country)
+ [[ -n "$country" ]] && iw reg set "$country" 2>/dev/null
mkdir -p "$NETPLAN_DIR"
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
@@ -281,6 +390,7 @@ do_connect() {
echo " dhcp4: true"
echo " dhcp6: false"
echo " optional: true"
+ [[ -n "$country" ]] && echo " regulatory-domain: \"$country\""
echo " access-points:"
if [[ -n "$password" ]]; then
echo " $(yaml_quote "$ssid"):"
@@ -348,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
+