Add a WiFi screen to the kiosk app (Shift+W+S+F+H)
With the virtual consoles turned off there was no way to join a WiFi network at the kiosk itself. Holding Shift+W+S+F+H together (or Ctrl+Alt+Shift+Super+W) now opens a PIN-protected WiFi screen: scan, pick or type a network, enter the password, connect. The root side is a small helper (kiosk-wifi-helper: status, scan, connect only) that the kiosk user may run through a validated sudoers.d rule. It writes the same netplan file as Core Settings -> WiFi and puts the previous one back if the new network gives no address. Installed by provisioning and Advanced -> Upgrade, removed by Complete Uninstall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
+169
-1
@@ -1,5 +1,5 @@
|
||||
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 path=require('path');
|
||||
const os=require('os');
|
||||
@@ -159,6 +159,7 @@ function showLockoutScreen(){
|
||||
|
||||
isLockedOut=true;
|
||||
console.log('[LOCKOUT] Showing lockout screen');
|
||||
closeWifiWindow();
|
||||
|
||||
// Detach all browser views to prevent content from being visible
|
||||
console.log('[LOCKOUT] Detaching all browser views for security');
|
||||
@@ -1189,7 +1190,170 @@ function showHiddenTab(index){
|
||||
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(){
|
||||
closeWifiWindow();
|
||||
if(pinWindow&&!pinWindow.isDestroyed()){
|
||||
pinWindow.close();
|
||||
pinWindow=null;
|
||||
@@ -1625,6 +1789,10 @@ async function createWindow(){
|
||||
globalShortcut.register('Control+Alt+Delete',showPowerMenu);
|
||||
globalShortcut.register('Control+Alt+P',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',()=>{
|
||||
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
|
||||
closeHTMLKeyboard();
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user