Merge pull request #120 from outis1one/claude/happy-knuth-hwnvbn

Claude/happy knuth hwnvbn
This commit is contained in:
Outis
2026-10-09 15:53:06 -04:00
committed by GitHub
7 changed files with 1059 additions and 1 deletions
+36
View File
@@ -138,6 +138,27 @@ Notes:
times, quiet hours and power schedules drift with it. times, quiet hours and power schedules drift with it.
- `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it. - `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it.
**Booting it from Easy2Boot (or another multiboot USB)?** Some of their boot
modes need the ISO stored in one unbroken piece on the drive, and a 3.5 GB
file copied onto a well-used drive usually isn't. The symptom is a brief
black screen and straight back to the menu. From Linux, with the drive
plugged in:
```bash
sudo iso/e2b-contig.sh --check /media/$USER/E2B/_ISO/LINUX/ubuntu-26.04.1-kiosk-offline-amd64.iso
sudo iso/e2b-contig.sh /media/$USER/E2B/_ISO/LINUX/ubuntu-26.04.1-kiosk-offline-amd64.iso
```
It rewrites just that file in one piece using ordinary file operations
(never the filesystem's own structures), and keeps the original if anything
doesn't verify. It temporarily fills the drive's free space to do it, so
it takes a few minutes. `--move-out` lets it park the original on this
computer when there isn't room for a second copy. NTFS, FAT32 and exFAT;
on NTFS mounted through `ntfs-3g` it can usually read the layout, and if
not it tells you to remount with the kernel's `ntfs3` driver. Also check
the copy isn't simply damaged: `sha256sum` it on the build machine and on
the drive.
**No Linux machine handy?** Build it on GitHub instead: **Actions → Build **No Linux machine handy?** Build it on GitHub instead: **Actions → Build
kiosk ISO → Run workflow**, pick a mode, and download the ISO from the run's kiosk ISO → Run workflow**, pick a mode, and download the ISO from the run's
**Artifacts** once it finishes (about 10 minutes; kept for 3 days by default). **Artifacts** once it finishes (about 10 minutes; kept for 3 days by default).
@@ -471,6 +492,19 @@ Both can be used at the same time — they serve different purposes:
- **Multi-method WiFi scan** - nmcli, iw, wpa_cli fallbacks - **Multi-method WiFi scan** - nmcli, iw, wpa_cli fallbacks
- **Watchdog support** - Auto-revert bad WiFi configs - **Watchdog support** - Auto-revert bad WiFi configs
- **Emergency hotspot** - Fallback if no internet - **Emergency hotspot** - Fallback if no internet
- **WiFi from the kiosk screen** - hold **Shift+W+S+F+H** together (or press Ctrl+Alt+Shift+Super+W) to join a WiFi network without the admin console - see below
### WiFi from the kiosk screen (consoles turned off)
With virtual consoles switched off, there's no way to reach the admin menu at the kiosk itself. To join a WiFi network anyway, plug in a keyboard and **hold Shift, W, S, F and H all at once** (or press Ctrl+Alt+Shift+Super+W). A WiFi screen opens:
1. Enter the hidden-sites PIN (the same one as F10 - default `1234`; skipped if the PIN is turned off).
2. Pick a network from the scan (strongest first, 🔒 = needs a password), or type its name.
3. Type the password (empty for an open network) and press **Connect**.
It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kiosk-wifi.yaml`), so the connection persists across reboots. If the kiosk gets no address within 30 seconds (usually a wrong password) the previous WiFi setting is put back. Esc or Close shuts the screen; it also closes itself after 5 minutes untouched, and five wrong PINs close it. The keys only work as a chord (typing W-S-F-H one at a time does nothing), and they don't interfere with the tab or power shortcuts.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade.
--- ---
@@ -1491,6 +1525,8 @@ full migration pass.
- **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI. - **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI.
- **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up. - **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up.
- **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing. - **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing.
- **New: WiFi from the kiosk screen** - hold Shift+W+S+F+H (or Ctrl+Alt+Shift+Super+W) to scan for and join a WiFi network right on the kiosk, PIN-protected, for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen".
- **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot.
- **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet. - **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet.
- **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure. - **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure.
- **New: Core Settings → Time Server (NTP)** - use a time server on your network, go back to Ubuntu's, or set the clock by hand; for networks without internet. - **New: Core Settings → Time Server (NTP)** - use a time server on your network, go back to Ubuntu's, or set the clock by hand; for networks without internet.
+303
View File
@@ -0,0 +1,303 @@
#!/bin/bash
################################################################################
# iso/e2b-contig.sh - Make a file on a USB drive contiguous, from Linux.
#
# Easy2Boot (and other multiboot loaders) can only boot some ISO files when
# they sit on the drive in one unbroken piece. A big ISO copied onto a
# well-used drive usually lands in several pieces, and Linux has no
# defragmenter for NTFS/FAT32/exFAT. This rewrites the one file so it is
# contiguous, using only ordinary file operations - it never writes
# filesystem structures directly, so the worst case is "still not
# contiguous", not a damaged drive.
#
# How: a plain copy just fills the drive's scattered free gaps again. So
# 1. fill the free space with temporary filler files (plugging every gap),
# 2. find the longest physically unbroken run among them and free exactly
# that run,
# 3. write the copy - the only free space left is that run,
# 4. delete the fillers, verify the copy's SHA-256, swap it in.
# This writes about as much data as the drive has free (progress shown) -
# minutes on USB 3, longer on USB 2 or a nearly empty big drive.
#
# If there's no unbroken run as big as the file while the original is still
# on the drive, --move-out first parks the original on this computer's disk
# (checksummed), deletes it from the drive to free its pieces, and retries.
#
# Usage:
# sudo iso/e2b-contig.sh [--check] [--move-out] [--temp-dir DIR] [--yes] FILE...
#
# --check Only report whether each file is contiguous.
# --move-out Allowed to park the original on this computer if needed
# (default temp dir: /var/tmp).
# --temp-dir D Where to park it.
# --yes Don't ask for confirmation.
#
# Works with NTFS (ntfs-3g or the ntfs3 kernel driver), FAT32 and exFAT -
# anything filefrag can map. Run as root (filefrag needs it on most of
# these filesystems).
################################################################################
set -uo pipefail
CHECK_ONLY=0
MOVE_OUT=0
ASSUME_YES=0
TEMP_DIR=/var/tmp
# Filler size. Small enough that fillers fit inside the drive's free gaps
# (a filler spanning two gaps can't be part of an unbroken run); kept in
# subfolders of 1000 because FAT32 caps the number of files per folder.
CHUNK_MB=4
die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_ONLY=1; shift ;;
--move-out) MOVE_OUT=1; shift ;;
--temp-dir) TEMP_DIR="$2"; shift 2 ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
-*) die "unknown option: $1" ;;
*) FILES+=("$1"); shift ;;
esac
done
(( ${#FILES[@]} )) || die "no file given - usage: sudo $0 [--check] FILE..."
[[ $EUID -eq 0 ]] || die "run with sudo (filefrag needs root on NTFS/FAT/exFAT)"
for cmd in filefrag findmnt blkid sha256sum dd awk df stat; do
command -v "$cmd" &>/dev/null || die "'$cmd' not found (packages: e2fsprogs, util-linux, coreutils)"
done
# extents FILE -> "physical_start physical_end" per extent (filesystem
# blocks), in file order. Empty output = couldn't map (unsupported).
extents() {
filefrag -e "$1" 2>/dev/null | awk '
/^ *[0-9]+:/ {
gsub(/\.\./, " "); gsub(/:/, " ")
# fields: idx logical_start logical_end physical_start physical_end length ...
print $4, $5
}'
}
# Block size filefrag reports in.
fs_block_size() {
filefrag -e "$1" 2>/dev/null | sed -n 's/.*(\([0-9]*\) blocks of \([0-9]*\) bytes).*/\2/p' | head -1
}
# pieces FILE -> number of physically separate pieces (adjacent extents
# count as one; filefrag splits long runs into several extents).
pieces() {
extents "$1" | awk 'NR==1 {n=1; prev=$2; next} { if ($1 != prev + 1) n++; prev=$2 } END { print n+0 }'
}
confirm() {
(( ASSUME_YES )) && return 0
local reply
read -r -p "$1 [y/N] " reply
[[ "${reply,,}" == y || "${reply,,}" == yes ]]
}
FILL_DIR=""
TMP_COPY=""
cleanup() {
[[ -n "$FILL_DIR" && -d "$FILL_DIR" ]] && rm -rf -- "$FILL_DIR"
[[ -n "$TMP_COPY" && -f "$TMP_COPY" ]] && rm -f -- "$TMP_COPY"
sync
}
trap cleanup EXIT
trap 'echo; echo "Interrupted - removing temporary files..."; exit 130' INT TERM
# fill_and_carve MOUNT NEED_BYTES -> 0 if it left exactly one free run of at
# least NEED_BYTES (fillers stay in $FILL_DIR until cleanup), 1 if no such run.
fill_and_carve() {
local mnt="$1" need="$2"
FILL_DIR="$mnt/.e2b-contig-fill.$$"
mkdir -p "$FILL_DIR" || return 1
local free_mb
free_mb=$(( $(df -B1M --output=avail "$mnt" | tail -1) ))
info "Filling $free_mb MB of free space with temporary files to plug the gaps..."
local n=0 written=0 fname
while :; do
n=$((n + 1))
fname="$FILL_DIR/d$(printf %04d $((n / 1000)))/f$(printf %07d $n)"
mkdir -p "${fname%/*}" 2>/dev/null || break
# dd stops (keeping what it wrote) when the drive is full.
dd if=/dev/zero of="$fname" bs=1M count="$CHUNK_MB" status=none 2>/dev/null
local rc=$?
local sz
sz=$(stat -c %s "$fname" 2>/dev/null || echo 0)
written=$((written + sz))
(( n % 25 == 0 )) && printf '\r %d / %d MB' "$((written / 1048576))" "$free_mb"
(( rc != 0 || sz < CHUNK_MB * 1048576 )) && break
done
printf '\r %d / %d MB\n' "$((written / 1048576))" "$free_mb"
sync
# Single-piece fillers sorted by physical position; the longest chain
# where each starts right after the previous ends is the best run.
local bs
bs=$(fs_block_size "$FILL_DIR/d0000/f0000001")
[[ -n "$bs" ]] || { echo " Couldn't map the filler files (filefrag unsupported here)."; return 1; }
# The map lives on this computer, not on the (now full) drive.
local f list
list=$(mktemp) || return 1
info "Mapping where the filler files landed..."
for f in "$FILL_DIR"/d*/f*; do
[[ -s "$f" ]] || continue
# One filefrag call per filler (there can be thousands): keep it only
# if it's a single piece, as "start end path".
extents "$f" | awk -v f="$f" '
NR == 1 { s = $1; e = $2; n = 1; next }
{ if ($1 == e + 1) e = $2; else n++ }
END { if (NR > 0 && n == 1) print s, e, f }' >> "$list"
done
local best
best=$(sort -n "$list" | awk -v bs="$bs" '
{ if (NR == 1 || $1 != prev_end + 1) { start_line = NR; run_bytes = 0; files = "" }
run_bytes += ($2 - $1 + 1) * bs; files = files " " $3; prev_end = $2
if (run_bytes > best_bytes) { best_bytes = run_bytes; best_files = files } }
END { print best_bytes + 0, best_files }')
rm -f -- "$list"
local best_bytes=${best%% *}
info "Longest unbroken free run found: $((best_bytes / 1048576)) MB (need $((need / 1048576)) MB)"
if (( best_bytes < need )); then
return 1
fi
# Free just enough of that run (from its start) for the file - keeping the
# rest plugged so the copy can't wander elsewhere.
local freed=0 g
for g in ${best#* }; do
(( freed >= need )) && break
freed=$((freed + $(stat -c %s "$g")))
rm -f -- "$g"
done
sync
return 0
}
# restore_parked PARKED FILE SUM - copy a parked original back onto the
# drive (not contiguous - just safe) and only delete the parked copy once
# the restored one verifies.
restore_parked() {
local parked="$1" file="$2" sum="$3"
info "Putting the original back..."
if cp -- "$parked" "$file" && [[ "$(sha256sum "$file" | cut -d' ' -f1)" == "$sum" ]]; then
rm -f -- "$parked"
echo " Original restored (unchanged, still in pieces)."
else
echo " !! Couldn't verify the restored file - the original is kept safe at $parked"
fi
}
# make_contiguous FILE
make_contiguous() {
local file="$1"
[[ -f "$file" ]] || { echo "$file: not a file"; return 1; }
file=$(realpath "$file")
local mnt fstype dev
read -r dev fstype mnt < <(findmnt -n -o SOURCE,FSTYPE,TARGET --target "$file")
local size
size=$(stat -c %s "$file")
local p
p=$(pieces "$file")
echo
echo "$file"
echo " drive: $dev ($fstype) mounted at $mnt, size $((size / 1048576)) MB"
if [[ -z "$(extents "$file")" ]]; then
echo " Can't read this file's layout through this driver ($fstype)."
# FUSE drivers (fusefat, exfat-fuse) can't map files; the kernel
# drivers can - and ntfs-3g usually can, but ntfs3 always can.
local real kdrv=""
real=$(blkid -o value -s TYPE "$dev" 2>/dev/null)
case "$real" in
ntfs) kdrv=ntfs3 ;;
exfat) kdrv=exfat ;;
vfat|msdos) kdrv=vfat ;;
esac
if [[ -n "$kdrv" ]]; then
echo " Remount it with the kernel's $kdrv driver and retry:"
echo " sudo umount $dev && sudo mount -t $kdrv $dev $mnt"
fi
return 1
fi
if [[ "$p" == 1 ]]; then
echo " Contiguous (1 piece) - nothing to do."
return 0
fi
echo " NOT contiguous: $p pieces."
(( CHECK_ONLY )) && return 2
confirm " Rewrite it in one piece? (temporarily fills the drive's free space)" || { echo " Skipped."; return 2; }
info "Checksumming the original..."
local sum
sum=$(sha256sum "$file" | cut -d' ' -f1)
local source="$file" parked=""
if ! fill_and_carve "$mnt" "$size"; then
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
if (( ! MOVE_OUT )); then
echo " Not enough unbroken free space with the original still on the drive."
echo " Re-run with --move-out to park the original on this computer meanwhile,"
echo " or free up space on the drive (move a few other large files off)."
return 1
fi
local avail
avail=$(( $(df -B1 --output=avail "$TEMP_DIR" | tail -1) ))
(( avail > size )) || { echo " Not enough room in $TEMP_DIR to park it ($((size / 1048576)) MB needed)."; return 1; }
parked="$TEMP_DIR/$(basename "$file").e2b-contig-parked"
info "Parking the original in $parked..."
cp -- "$file" "$parked" && [[ "$(sha256sum "$parked" | cut -d' ' -f1)" == "$sum" ]] \
|| { rm -f -- "$parked"; echo " Parking copy failed its checksum - nothing changed."; return 1; }
rm -f -- "$file"; sync
source="$parked"
if ! fill_and_carve "$mnt" "$size"; then
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
echo " Still no unbroken run big enough."
restore_parked "$parked" "$file" "$sum"
echo " Free up space on the drive (it needs one unbroken $((size / 1048576)) MB area) and retry."
return 1
fi
fi
TMP_COPY="$(dirname "$file")/.e2b-contig.$$.tmp"
info "Writing the copy into the freed run..."
if ! dd if="$source" of="$TMP_COPY" bs=4M conv=fsync status=progress; then
echo " Copy failed."
rm -f -- "$TMP_COPY"; TMP_COPY=""; rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
return 1
fi
rm -rf -- "$FILL_DIR"; FILL_DIR=""
sync
local newp
newp=$(pieces "$TMP_COPY")
info "Verifying..."
if [[ "$(sha256sum "$TMP_COPY" | cut -d' ' -f1)" != "$sum" ]]; then
echo " !! The copy doesn't match the original's checksum - discarding it."
rm -f -- "$TMP_COPY"; TMP_COPY=""; sync
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
return 1
fi
if [[ "$newp" != 1 ]]; then
echo " The copy still came out in $newp pieces (was $p)."
fi
# Same directory, so a rename: the data stays where it was written.
mv -f -- "$TMP_COPY" "$file" && TMP_COPY=""
[[ -n "$parked" ]] && rm -f -- "$parked"
sync
if [[ "$(pieces "$file")" == 1 ]]; then
echo " ✓ Contiguous now (1 piece), checksum verified."
return 0
fi
echo " Checksum verified, but still $(pieces "$file") pieces - see the notes above."
return 1
}
rc=0
for f in "${FILES[@]}"; do
make_contiguous "$f" || rc=1
done
exit "$rc"
+169 -1
View File
@@ -1,5 +1,5 @@
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron'); const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
const {exec}=require('child_process'); const {exec,spawn}=require('child_process');
const fs=require('fs'); const fs=require('fs');
const path=require('path'); const path=require('path');
const os=require('os'); const os=require('os');
@@ -159,6 +159,7 @@ function showLockoutScreen(){
isLockedOut=true; isLockedOut=true;
console.log('[LOCKOUT] Showing lockout screen'); console.log('[LOCKOUT] Showing lockout screen');
closeWifiWindow();
// Detach all browser views to prevent content from being visible // Detach all browser views to prevent content from being visible
console.log('[LOCKOUT] Detaching all browser views for security'); console.log('[LOCKOUT] Detaching all browser views for security');
@@ -1189,7 +1190,170 @@ function showHiddenTab(index){
showingHidden=true; showingHidden=true;
} }
// WiFi screen (hold Shift+W+S+F+H, or Ctrl+Alt+Shift+Super+W): join a WiFi network from the kiosk
// itself, for when the console (Ctrl+Alt+F1..F6) is switched off and the
// admin menu can't be reached. Gated by the hidden-tab PIN (.jitsi-pin,
// same rules as pin-entry.html: no file = 1234, NOPIN = none). The root
// work is done by /usr/local/bin/kiosk-wifi-helper, which the kiosk user
// may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi).
const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
const WIFI_MAX_PIN_TRIES=5;
let wifiWindow=null,wifiUnlocked=false,wifiPinTries=0,wifiIdleTimer=null;
function readHiddenPin(){
try{
const stored=fs.readFileSync(path.join(__dirname,'.jitsi-pin'),'utf8').trim();
if(stored==='NOPIN')return null;
if(stored)return stored;
}catch(e){}
return '1234';
}
function runWifiHelper(action,input){
return new Promise(resolve=>{
let out='',err='',done=false;
const finish=result=>{if(!done){done=true;resolve(result);}};
let child;
try{
child=spawn('sudo',['-n',WIFI_HELPER,action],{stdio:['pipe','pipe','pipe']});
}catch(e){
finish({ok:false,error:'Could not run the WiFi helper: '+e.message});
return;
}
const killer=setTimeout(()=>{child.kill();finish({ok:false,error:'The WiFi helper timed out'});},120000);
child.stdout.on('data',d=>{out+=d;});
child.stderr.on('data',d=>{err+=d;});
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
child.on('close',()=>{
clearTimeout(killer);
const line=out.trim().split('\n').pop();
try{
finish(JSON.parse(line));
}catch(e){
// Not our JSON - typically sudo refusing ("a password is required")
// on a kiosk set up before this feature: Upgrade installs the rule.
console.error('[WIFI] helper output:',out,err);
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
}
});
child.stdin.on('error',()=>{});
child.stdin.end(input?JSON.stringify(input):'');
});
}
function resetWifiIdleTimer(){
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
wifiIdleTimer=setTimeout(()=>{
console.log('[WIFI] Closing after 5 minutes idle');
closeWifiWindow();
},WIFI_IDLE_TIMEOUT);
}
function closeWifiWindow(){
if(wifiWindow&&!wifiWindow.isDestroyed())wifiWindow.close();
}
function showWifiWindow(){
if(isLockedOut)return;
if(wifiWindow&&!wifiWindow.isDestroyed()){
wifiWindow.focus();
return;
}
wifiUnlocked=(readHiddenPin()===null);
wifiPinTries=0;
let width=720,height=820;
if(mainWindow&&!mainWindow.isDestroyed()){
const[w,h]=mainWindow.getContentSize();
width=Math.min(width,w);
height=Math.min(height,h);
}
wifiWindow=new BrowserWindow({
width,height,
frame:false,
alwaysOnTop:true,
parent:mainWindow,
modal:true,
webPreferences:{nodeIntegration:true,contextIsolation:false}
});
wifiWindow.setMenu(null);
wifiWindow.loadFile(path.join(__dirname,'wifi.html'));
wifiWindow.on('closed',()=>{
if(wifiIdleTimer){clearTimeout(wifiIdleTimer);wifiIdleTimer=null;}
wifiWindow=null;
wifiUnlocked=false;
});
resetWifiIdleTimer();
console.log('[WIFI] WiFi screen opened');
}
// Shift+W+S+F+H, all held together, opens the WiFi screen. A globalShortcut
// can only have one non-modifier key, so this watches the raw key events
// of every page/window instead (before-input-event): a key counts as held
// from its keyDown (autorepeat keeps refreshing it) until its keyUp, or
// for 1.5s if the keyUp never arrives (focus moved mid-press). Physical
// keys (input.code), so it's the same keys on any keyboard layout. The
// key presses that complete the chord are swallowed, not typed into the
// page.
const WIFI_CHORD_KEYS=['KeyW','KeyS','KeyF','KeyH'];
const WIFI_CHORD_HOLD_MS=1500;
const wifiChordDown=new Map();
function wifiChordInput(event,input){
if(!WIFI_CHORD_KEYS.includes(input.code))return;
if(input.type==='keyUp'){wifiChordDown.delete(input.code);return;}
if(input.type!=='keyDown')return;
const now=Date.now();
wifiChordDown.set(input.code,now);
if(!input.shift||input.control||input.alt||input.meta)return;
const all=WIFI_CHORD_KEYS.every(k=>now-(wifiChordDown.get(k)||0)<WIFI_CHORD_HOLD_MS);
if(!all)return;
event.preventDefault();
if(input.isAutoRepeat)return;
wifiChordDown.clear();
console.log('[WIFI] Shift+W+S+F+H pressed');
showWifiWindow();
}
app.on('web-contents-created',(e,contents)=>{
contents.on('before-input-event',wifiChordInput);
});
ipcMain.handle('wifi-request',async(event,req)=>{
if(!wifiWindow||wifiWindow.isDestroyed()||event.sender!==wifiWindow.webContents){
return{ok:false,error:'Not allowed'};
}
resetWifiIdleTimer();
const action=req&&req.action;
if(action==='close'){closeWifiWindow();return{ok:true};}
if(action==='needs-pin')return{ok:true,needsPin:!wifiUnlocked};
if(action==='unlock'){
if(wifiUnlocked)return{ok:true};
if(String(req.pin||'')===readHiddenPin()){
wifiUnlocked=true;
return{ok:true};
}
wifiPinTries++;
console.log('[WIFI] Wrong PIN, attempt',wifiPinTries);
if(wifiPinTries>=WIFI_MAX_PIN_TRIES){
setTimeout(closeWifiWindow,1500);
return{ok:false,error:'Too many wrong PINs'};
}
return{ok:false,error:'Incorrect PIN'};
}
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='status'||action==='scan')return runWifiHelper(action);
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||'')});
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
return result;
}
return{ok:false,error:'Unknown request'};
});
function forceReturnToTabs(){ function forceReturnToTabs(){
closeWifiWindow();
if(pinWindow&&!pinWindow.isDestroyed()){ if(pinWindow&&!pinWindow.isDestroyed()){
pinWindow.close(); pinWindow.close();
pinWindow=null; pinWindow=null;
@@ -1625,6 +1789,10 @@ async function createWindow(){
globalShortcut.register('Control+Alt+Delete',showPowerMenu); globalShortcut.register('Control+Alt+Delete',showPowerMenu);
globalShortcut.register('Control+Alt+P',showPowerMenu); globalShortcut.register('Control+Alt+P',showPowerMenu);
globalShortcut.register('Control+Alt+Escape',showPowerMenu); globalShortcut.register('Control+Alt+Escape',showPowerMenu);
// Five keys on purpose - not something a visitor hits by accident.
if(!globalShortcut.register('Control+Alt+Shift+Super+W',showWifiWindow)){
console.error('[WIFI] Could not register Ctrl+Alt+Shift+Super+W');
}
globalShortcut.register('Control+K',()=>{ globalShortcut.register('Control+K',()=>{
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
closeHTMLKeyboard(); closeHTMLKeyboard();
+253
View File
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WiFi</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #2c3e50;
color: #ecf0f1;
height: 100vh;
overflow-y: auto;
padding: 24px;
}
h2 { text-align: center; margin-bottom: 16px; font-size: 26px; }
.hidden { display: none !important; }
label { display: block; margin: 14px 0 6px; font-size: 16px; color: #bdc3c7; }
input {
width: 100%;
padding: 14px;
font-size: 20px;
border: 2px solid #34495e;
border-radius: 10px;
background: #34495e;
color: #ecf0f1;
outline: none;
}
input:focus { border-color: #3498db; }
.row { display: flex; gap: 10px; align-items: center; }
.row input { flex: 1; }
button {
padding: 14px 20px;
font-size: 18px;
border: none;
border-radius: 10px;
font-weight: bold;
cursor: pointer;
color: #fff;
background: #34495e;
}
button:disabled { opacity: 0.5; cursor: default; }
.primary { background: #27ae60; }
.scan { background: #2980b9; }
.close { background: #e74c3c; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 20px; }
#status {
background: #34495e;
border-radius: 10px;
padding: 12px 14px;
font-size: 16px;
line-height: 1.5;
}
#message { margin-top: 16px; font-size: 17px; font-weight: bold; text-align: center; min-height: 24px; }
#message.error { color: #e74c3c; }
#message.ok { color: #2ecc71; }
#message.busy { color: #f1c40f; }
#networks {
list-style: none;
max-height: 260px;
overflow-y: auto;
border-radius: 10px;
background: #34495e;
margin-top: 8px;
}
#networks li {
display: flex;
justify-content: space-between;
padding: 12px 14px;
font-size: 18px;
border-bottom: 1px solid #2c3e50;
cursor: pointer;
}
#networks li:last-child { border-bottom: none; }
#networks li.selected { background: #2980b9; }
#networks li .meta { color: #bdc3c7; font-size: 15px; white-space: nowrap; margin-left: 12px; }
#networks li.empty { color: #95a5a6; cursor: default; }
.hint { text-align: center; font-size: 13px; color: #95a5a6; margin-top: 18px; }
</style>
</head>
<body>
<h2>📶 WiFi</h2>
<div id="pin-step" class="hidden">
<label for="pin">Enter the PIN</label>
<input id="pin" type="password" inputmode="numeric" autocomplete="off">
<div class="actions">
<button class="primary" id="pin-ok">✓ OK</button>
<button class="close" data-close>✗ Close</button>
</div>
</div>
<div id="wifi-step" class="hidden">
<div id="status">Checking…</div>
<div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label>
<button class="scan" id="scan">⟳ Scan</button>
</div>
<ul id="networks"><li class="empty">Scanning…</li></ul>
<label for="ssid">Network name</label>
<input id="ssid" type="text" autocomplete="off" spellcheck="false">
<label for="password">Password (empty for an open network)</label>
<div class="row">
<input id="password" type="password" autocomplete="off" spellcheck="false">
<button id="show">Show</button>
</div>
<div class="actions">
<button class="primary" id="connect">Connect</button>
<button class="close" data-close>✗ Close</button>
</div>
</div>
<div id="message"></div>
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
<script>
const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id);
let busy = false;
function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
.catch(e => ({ok: false, error: String(e && e.message || e)}));
}
function message(text, kind) {
const m = $('message');
m.textContent = text || '';
m.className = kind || '';
}
function setBusy(on) {
busy = on;
for (const id of ['scan', 'connect', 'pin-ok']) $(id).disabled = on;
}
function signalBars(dbm) {
if (dbm >= -55) return '▂▄▆█';
if (dbm >= -67) return '▂▄▆';
if (dbm >= -78) return '▂▄';
return '▂';
}
async function refreshStatus() {
const s = await request('status');
const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
line(`Adapter: ${s.iface}`);
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
}
async function scan() {
if (busy) return;
setBusy(true);
message('Scanning…', 'busy');
const list = $('networks');
const r = await request('scan');
list.textContent = '';
if (!r.ok) {
message(r.error, 'error');
list.innerHTML = '<li class="empty">No scan results</li>';
} else {
message('');
if (!r.networks.length) list.innerHTML = '<li class="empty">No networks found - type the name below</li>';
for (const n of r.networks) {
const li = document.createElement('li');
const name = document.createElement('span');
name.textContent = n.ssid;
const meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = (n.secure ? '🔒 ' : '') + signalBars(n.signal);
li.append(name, meta);
li.addEventListener('click', () => {
for (const other of list.children) other.classList.remove('selected');
li.classList.add('selected');
$('ssid').value = n.ssid;
$('password').value = '';
$('password').focus();
});
list.appendChild(li);
}
}
setBusy(false);
}
async function connect() {
if (busy) return;
const ssid = $('ssid').value;
const password = $('password').value;
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password});
if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = '';
} else {
message(r.error || 'Connection failed', 'error');
}
setBusy(false);
refreshStatus();
}
async function unlock() {
if (busy) return;
setBusy(true);
const r = await request('unlock', {pin: $('pin').value});
setBusy(false);
$('pin').value = '';
if (r.ok) { showWifi(); } else { message(r.error, 'error'); $('pin').focus(); }
}
function showWifi() {
message('');
$('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden');
refreshStatus();
scan();
}
$('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan);
$('connect').addEventListener('click', connect);
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
$('show').addEventListener('click', () => {
const p = $('password');
p.type = p.type === 'password' ? 'text' : 'password';
$('show').textContent = p.type === 'password' ? 'Show' : 'Hide';
});
for (const b of document.querySelectorAll('[data-close]')) b.addEventListener('click', () => request('close'));
request('needs-pin').then(r => {
if (r.ok && r.needsPin) {
$('pin-step').classList.remove('hidden');
$('pin').focus();
} else {
showWifi();
}
});
</script>
</body>
</html>
+24
View File
@@ -216,10 +216,34 @@ provision_configure_display() {
sudo udevadm control --reload-rules sudo udevadm control --reload-rules
provision_configure_lightdm_autologin provision_configure_lightdm_autologin
provision_install_wifi_helper
log_success "Display configured" log_success "Display configured"
} }
# The kiosk app's WiFi screen (Ctrl+Alt+Shift+Super+W) - joining WiFi from
# the kiosk itself when the console is switched off. The app runs as the
# kiosk user; the netplan write is done by this one root helper, which
# the kiosk user may run through sudo and nothing else (it only does
# status/scan/connect - see its header). Here rather than its own
# provisioning step so Advanced -> Upgrade, which re-runs this function,
# installs it on an existing kiosk too. The sudoers rule is validated
# before it goes in place, like the Web UI's: a broken sudoers.d file
# breaks sudo for everyone.
provision_install_wifi_helper() {
provision_install_file "usr/local/bin/kiosk-wifi-helper" "$BIN_DIR/kiosk-wifi-helper" 755
local tmp
tmp=$(mktemp)
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${BIN_DIR}/kiosk-wifi-helper" > "$tmp"
if sudo visudo -c -f "$tmp" &>/dev/null; then
sudo mkdir -p "$SUDOERS_D_DIR"
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi"
else
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Shift+Super+W won't be able to connect"
fi
rm -f "$tmp"
}
provision_configure_firewall() { provision_configure_firewall() {
echo "[6/10] Configuring firewall..." echo "[6/10] Configuring firewall..."
sudo ufw --force enable sudo ufw --force enable
+1
View File
@@ -116,6 +116,7 @@ action_complete_uninstall() {
echo "[7/12] Removing remaining scripts..." echo "[7/12] Removing remaining scripts..."
sudo rm -f "$BIN_DIR"/kiosk-* sudo rm -f "$BIN_DIR"/kiosk-*
sudo rm -f "$SUDOERS_D_DIR/kiosk-wifi"
sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules
sudo udevadm control --reload-rules 2>/dev/null || true sudo udevadm control --reload-rules 2>/dev/null || true
+273
View File
@@ -0,0 +1,273 @@
#!/bin/bash
################################################################################
# kiosk-wifi-helper - the root side of the kiosk app's WiFi screen
# (Ctrl+Alt+Shift+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
#
# The kiosk user may run this one script via passwordless sudo
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a
# fixed set of things and nothing else:
#
# 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"}
#
# 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,
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface
# gets no address - a typo in the password doesn't lose a WiFi network
# that was working. The password comes in on stdin, never argv (argv is
# visible to every user in ps).
################################################################################
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"
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
reply_error() {
jq -cn --arg e "$1" '{ok:false,error:$e}'
exit 1
}
# The first wireless interface, by what it is (sysfs wireless/ or
# phy80211), falling back to the wl* naming convention. Same as
# menus/wifi.sh's wifi_detect_iface.
detect_iface() {
local d
for d in $SYS_CLASS/net/*; do
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
basename "$d"
return 0
fi
done
for d in $SYS_CLASS/net/wl*; do
[[ -e "$d" ]] || continue
basename "$d"
return 0
done
return 1
}
# Clear an rfkill soft block (a hard block can't be cleared from software).
unblock() {
local r
for r in $SYS_CLASS/rfkill/rfkill*; do
[[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
[[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]] && echo 0 > "$r/soft" 2>/dev/null
done
return 0
}
hard_blocked() {
local r
for r in $SYS_CLASS/rfkill/rfkill*; do
[[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
[[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]] && return 0
done
return 1
}
iface_ip() {
ip -4 -o addr show dev "$1" 2>/dev/null \
| awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}'
}
# YAML single-quoted scalar - the quote itself doubled is the only escape.
yaml_quote() {
local v="$1"
printf "'%s'" "${v//\'/\'\'}"
}
# iw prints a backslash, a leading/trailing space and anything
# unprintable in an SSID as \xNN (a backslash itself is always \x5c,
# so there are no other escapes) - %b turns those back into the bytes.
unescape_ssid() {
printf '%b' "$1"
}
# The SSID the interface is associated with right now, if any.
current_ssid() {
unescape_ssid "$(iw dev "$1" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p' | head -1)"
}
# The SSID in our netplan file, if any (the first access point's key).
configured_ssid() {
[[ -f "$WIFI_NETPLAN_FILE" ]] || return 0
python3 -I -c '
import sys, yaml
try:
d = yaml.safe_load(open(sys.argv[1]))
for w in (d.get("network", {}).get("wifis") or {}).values():
for ssid in (w.get("access-points") or {}):
print(ssid); sys.exit(0)
except Exception:
pass' "$WIFI_NETPLAN_FILE" 2>/dev/null
}
need_iface() {
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"
}
do_status() {
local iface ssid="" ip="" configured
configured=$(configured_ssid)
if ! iface=$(detect_iface); then
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
return 0
fi
ssid=$(current_ssid "$iface")
ip=$(iface_ip "$iface")
jq -cn --arg i "$iface" --arg s "$ssid" --arg a "$ip" --arg c "$configured" \
'{ok:true,iface:$i,connected:($s != "" and $a != ""),ssid:$s,ip:$a,configured:$c}'
}
do_scan() {
need_iface
unblock
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
ip link set "$IFACE" up 2>/dev/null || reply_error "Couldn't switch on $IFACE"
# "Device or resource busy" while wpa_supplicant is mid-scan - retry.
local out="" try
for try in 1 2 3; do
out=$(iw dev "$IFACE" scan 2>/dev/null) && break
out=""
sleep 2
done
# wpa_supplicant owns the interface and refuses iw: use its last scan.
if [[ -z "$out" ]] && command -v wpa_cli >/dev/null; then
wpa_cli -i "$IFACE" scan >/dev/null 2>&1
sleep 4
wpa_cli -i "$IFACE" scan_results 2>/dev/null | awk -F'\t' '
NR > 1 && $5 != "" { secure = ($4 ~ /WPA|WEP|RSN|SAE/) ? 1 : 0; print $3 "\t" secure "\t" $5 }' \
> "$TMP_SCAN"
else
# BSS blocks: signal, SSID, and RSN/WPA/Privacy for "secured".
printf '%s\n' "$out" | awk '
function flush() { if (ssid != "") print sig "\t" sec "\t" ssid; ssid = ""; sig = -100; sec = 0 }
/^BSS / { flush() }
/^\tsignal:/ { sig = int($2) }
/^\tSSID: / { sub(/^\tSSID: /, ""); ssid = $0 }
/^\t(RSN|WPA):/ { sec = 1 }
/^\tcapability:.*Privacy/ { sec = 1 }
END { flush() }' \
| while IFS=$'\t' read -r sig sec ssid; do
printf '%s\t%s\t%s\n' "$sig" "$sec" "$(unescape_ssid "$ssid")"
done > "$TMP_SCAN"
fi
# Strongest first, one entry per SSID.
jq -cR --arg i "$IFACE" -n '
[inputs | split("\t") | select(length >= 3)
| {signal: (.[0] | tonumber? // -100), secure: (.[1] == "1"), ssid: (.[2:] | join("\t"))}]
| group_by(.ssid) | map(max_by(.signal)) | sort_by(-.signal)
| {ok: true, iface: $i, networks: .}' < "$TMP_SCAN"
}
do_connect() {
need_iface
local input ssid password
input=$(head -c 4096)
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
local ssid_bytes
ssid_bytes=$(printf '%s' "$ssid" | wc -c)
(( ssid_bytes >= 1 && ssid_bytes <= 32 )) || reply_error "A network name is 1-32 characters"
# No control characters (newlines etc.) in either - they can't be in a
# real SSID/passphrase and would only serve to break the YAML.
[[ "$ssid$password" == *[[:cntrl:]]* ]] && reply_error "The name or password contains an invalid character"
if [[ -n "$password" ]]; then
local plen=${#password}
if ! (( plen >= 8 && plen <= 63 )) && ! [[ "$password" =~ ^[0-9A-Fa-f]{64}$ ]]; then
reply_error "A WiFi password is 8-63 characters"
fi
fi
unblock
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
local backup="" new
mkdir -p "$NETPLAN_DIR"
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
cp -p "$WIFI_NETPLAN_FILE" "$backup"
fi
new=$(mktemp "$NETPLAN_DIR/.kiosk-wifi.XXXXXX")
{
echo "network:"
echo " version: 2"
echo " renderer: networkd"
echo " wifis:"
echo " $IFACE:"
echo " dhcp4: true"
echo " dhcp6: false"
echo " optional: true"
echo " access-points:"
if [[ -n "$password" ]]; then
echo " $(yaml_quote "$ssid"):"
echo " password: $(yaml_quote "$password")"
else
echo " $(yaml_quote "$ssid"): {}"
fi
} > "$new"
chmod 0600 "$new"
mv -f "$new" "$WIFI_NETPLAN_FILE"
local err
if ! err=$(netplan apply 2>&1); then
restore "$backup"
reply_error "netplan apply failed: ${err:0:300}"
fi
local i addr="" now
for (( i = 0; i < CONNECT_WAIT; i++ )); do
now=$(current_ssid "$IFACE")
addr=$(iface_ip "$IFACE")
[[ "$now" == "$ssid" && -n "$addr" ]] && break
addr=""
sleep 1
done
if [[ -z "$addr" ]]; then
restore "$backup"
if [[ -n "$backup" ]]; then
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range. The previous WiFi setting was put back."
fi
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range."
fi
jq -cn --arg s "$ssid" --arg a "$addr" --arg i "$IFACE" '{ok:true,ssid:$s,ip:$a,iface:$i}'
}
# Put the previous netplan WiFi file back (or remove ours if there was
# none) and re-apply, after a failed connect.
restore() {
if [[ -n "$1" && -f "$1" ]]; then
mv -f "$1" "$WIFI_NETPLAN_FILE"
else
rm -f "$WIFI_NETPLAN_FILE"
fi
netplan apply >/dev/null 2>&1
}
[[ $EUID -eq 0 ]] || reply_error "must run as root (sudo)"
command -v jq >/dev/null || { echo '{"ok":false,"error":"jq is not installed"}'; exit 1; }
TMP_SCAN=$(mktemp)
trap 'rm -f "$TMP_SCAN"' EXIT
# One at a time - two overlapping netplan applies/backups would race.
exec 9> "$LOCK_FILE"
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
case "${1:-}" in
status) do_status ;;
scan) do_scan ;;
connect) do_connect ;;
*) reply_error "unknown action: ${1:-}" ;;
esac