Author SHA1 Message Date
Outis b836fd6771 Merge pull request #132 from outis1one/claude/optimistic-clarke-27gpk1
Back/forward buttons: adjustable opacity; Upgrade refreshes the Web UI copy
2026-11-06 13:23:30 -05:00
Claude fa0d602505 Back/forward buttons: adjustable opacity; Upgrade refreshes the Web UI copy
New historyButtonsOpacity setting (10-100 percent, default 50) in the
terminal menu (Display & Interaction) and the Web UI. Upgrade now also
copies the Web UI app to /opt/kiosk-webui and restarts it, so new Web UI
settings appear after an upgrade (it previously kept its old copy).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:21:50 +00:00
Outis e7cf15a802 Merge pull request #131 from outis1one/claude/optimistic-clarke-27gpk1
Page back/forward (Alt+Left/Right, mouse side buttons, optional on-screen buttons) and CLAUDE.md
2026-11-06 13:16:16 -05:00
Claude 441615da7a Add CLAUDE.md: no user data in the repo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 17:03:16 +00:00
Claude 1775220312 Page back/forward: Alt+Left/Right, mouse side buttons, optional on-screen buttons
Alt+Left/Right now go back/forward within the page (the standard browser
keys) instead of switching sites; sites switch with Ctrl+Tab, Ctrl+[ ] and
the standard Ctrl+PageUp/PageDown. New enableHistoryButtons option (off by
default) shows half-transparent back/forward buttons at the top middle.
Settable from the terminal menu and the Web UI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:47:53 +00:00
Outis 7ed2d4744c Merge pull request #130 from outis1one/claude/optimistic-clarke-27gpk1
Claude/optimistic clarke 27gpk1
2026-11-06 11:04:55 -05:00
Outis 9b646b99f8 Merge pull request #129 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 11:04:28 -05:00
Claude a2811cced6 Virtual consoles: really enabled by default, and Upgrade no longer resets the choice
provision_configure_display always installed 10-serverflags.conf with
DontVTSwitch=true, so Ctrl+Alt+F1-F8 never worked on a fresh install
despite the installer saying consoles are enabled by default, and every
Upgrade overwrote what Advanced -> Virtual Consoles had set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:04:13 +00:00
Claude 07e7d2866f WiFi screen: reach the root helper through a socket instead of sudo; adapter dropdown
The kiosk app can end up with the no-new-privileges flag, after which sudo
refuses to run ("The no new privileges flag is set"). The helper is now
served over a root-owned, kiosk-group-only unix socket (systemd socket
activation); sudo stays as a fallback. With several WiFi adapters the screen
shows a dropdown and lists all of them; status/scan/connect take an iface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:00:10 +00:00
Claude 0fe631b4ed WiFi helper: act on the connected adapter, not the first one alphabetically
With a built-in card plus a USB adapter, status reported 'Not connected'
for the idle built-in card while the USB adapter carried the connection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 15:55:03 +00:00
20 changed files with 552 additions and 32 deletions
+22
View File
@@ -0,0 +1,22 @@
# CLAUDE.md
## No user data in the repo
Never write anything specific to a person's kiosk, network or accounts into
this repository - not in code, tests, fixtures, docs, comments, commit
messages, branch names or pull request text.
That includes:
- WiFi network names (SSIDs), passwords, keys and tokens
- MAC addresses, IP addresses, hostnames and interface names taken from a
real machine
- usernames, email addresses, PINs and passwords
- the URLs of someone's own sites or services
- log output, screenshots or terminal output pasted in by a user
When an example, test or fixture needs a value like these, use an obvious
placeholder (`ExampleNet`, `192.0.2.10`, `aa:bb:cc:dd:ee:ff`, `wlan0`,
`example.com`) instead of a real one. When debugging from output a user
shares, keep it in the conversation or the scratchpad, not in a file in this
repo, and leave it out of commit messages.
+7 -2
View File
@@ -249,6 +249,7 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
- **1-finger swipe** (dual mode) - Navigate within page (arrow keys) - **1-finger swipe** (dual mode) - Navigate within page (arrow keys)
- **On-screen keyboard** - Auto-shows on text fields or click keyboard icon - **On-screen keyboard** - Auto-shows on text fields or click keyboard icon
- **Navigation menu** - Click key icon (top-left) to access site list and gesture cheat sheet - **Navigation menu** - Click key icon (top-left) to access site list and gesture cheat sheet
- **Back/forward buttons** (optional, off by default) - two half-transparent ◀ ▶ buttons at the top middle of the screen, for kiosks with no keyboard or mouse side buttons. They appear on a tap, click or mouse movement and fade after a few seconds. Turn on in Core Settings → Display & Interaction, or the Web UI, where you can also set how solid they look (opacity 10–100%, default 50).
### On-Screen Keyboard ### On-Screen Keyboard
- **HTML-based keyboard** with full QWERTY layout - **HTML-based keyboard** with full QWERTY layout
@@ -537,7 +538,9 @@ It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kios
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out. **About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
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. **More than one WiFi adapter** (e.g. a built-in card and a USB dongle): a *WiFi adapter* dropdown appears at the top, and the status box lists every adapter with what it's connected to. Status, scan and connect act on the one you pick; by default it's the adapter that's connected right now.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect and region only). The app reaches it through a root-owned socket, `/run/kiosk-wifi-helper.sock` (`kiosk-wifi-helper.socket`, group-restricted to the kiosk user, `0660`), rather than `sudo`: the app can end up running with Linux's "no new privileges" flag, and then sudo refuses with *"The no new privileges flag is set"*. A passwordless-sudo rule (`/etc/sudoers.d/kiosk-wifi`) stays as a fallback. Existing kiosks get both with Advanced → Upgrade.
--- ---
@@ -773,7 +776,9 @@ smb://WORKGROUP/COMPUTER/PrinterName
**Keyboard Shortcuts:** **Keyboard Shortcuts:**
- `Ctrl+Tab` or `Ctrl+]` - Next tab - `Ctrl+Tab` or `Ctrl+]` - Next tab
- `Ctrl+Shift+Tab` or `Ctrl+[` - Previous tab - `Ctrl+Shift+Tab` or `Ctrl+[` - Previous tab
- `Alt+Right/Left` - Next/Previous tab - `Ctrl+PageDown` / `Ctrl+PageUp` - Next/Previous tab (the standard browser keys)
- `Alt+Right` / `Alt+Left` - Page forward / back within the current site (the standard browser keys; they used to switch tabs)
- Mouse side buttons (back/forward) - the same page forward/back
- `F10` or `Ctrl+H` - Toggle hidden tabs - `F10` or `Ctrl+H` - Toggle hidden tabs
- `Escape` - Return to normal tabs (from hidden) - `Escape` - Return to normal tabs (from hidden)
- `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu - `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu
+111 -9
View File
@@ -1,5 +1,6 @@
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron'); const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
const {exec,spawn}=require('child_process'); const {exec,spawn}=require('child_process');
const net=require('net');
const fs=require('fs'); const fs=require('fs');
const path=require('path'); const path=require('path');
const os=require('os'); const os=require('os');
@@ -59,6 +60,8 @@ let allowNavigation='same-origin';
let enablePauseButton=true; let enablePauseButton=true;
let enableKeyboardButton=true; let enableKeyboardButton=true;
let enableNavButton=true; let enableNavButton=true;
let enableHistoryButtons=false;
let historyButtonsOpacity=50;
let enablePasswordProtection=false; let enablePasswordProtection=false;
let lockoutPassword=""; let lockoutPassword="";
let lockoutTimeout=0; let lockoutTimeout=0;
@@ -101,6 +104,11 @@ function loadConfig(){
enablePauseButton=(config.enablePauseButton!==false); enablePauseButton=(config.enablePauseButton!==false);
enableKeyboardButton=(config.enableKeyboardButton!==false); enableKeyboardButton=(config.enableKeyboardButton!==false);
enableNavButton=(config.enableNavButton!==false); enableNavButton=(config.enableNavButton!==false);
enableHistoryButtons=(config.enableHistoryButtons===true);
{
const pct=Number(config.historyButtonsOpacity);
historyButtonsOpacity=(Number.isInteger(pct)&&pct>=10&&pct<=100)?pct:50;
}
enablePasswordProtection=(config.enablePasswordProtection===true); enablePasswordProtection=(config.enablePasswordProtection===true);
lockoutPassword=config.lockoutPassword||""; lockoutPassword=config.lockoutPassword||"";
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
@@ -121,6 +129,7 @@ function loadConfig(){
console.log('[CONFIG] Navigation:',allowNavigation); console.log('[CONFIG] Navigation:',allowNavigation);
console.log('[CONFIG] Pause button:',enablePauseButton); console.log('[CONFIG] Pause button:',enablePauseButton);
console.log('[CONFIG] Keyboard button:',enableKeyboardButton); console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons,'opacity',historyButtonsOpacity+'%');
console.log('[CONFIG] Password protection:',enablePasswordProtection); console.log('[CONFIG] Password protection:',enablePasswordProtection);
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes'); console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime); if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
@@ -1144,6 +1153,27 @@ function returnToTabs(){
markActivity(); markActivity();
} }
// Page back/forward in the site that's on screen (Alt+Left / Alt+Right, the
// mouse's side buttons, and the optional on-screen buttons). Not while the
// screen is locked or a dialog (PIN, WiFi, power...) is open on top - the
// keys would silently navigate the page hidden behind it.
function historyGo(direction){
if(isLockedOut)return;
for(const w of [pinWindow,wifiWindow,promptWindow,pauseWindow]){
if(w&&!w.isDestroyed())return;
}
const view=showingHidden?hiddenViews[currentHiddenIndex]:views[currentIndex];
if(!view||!view.webContents||view.webContents.isDestroyed())return;
const wc=view.webContents;
const nav=wc.navigationHistory;
if(direction==='back'){
if(nav?nav.canGoBack():wc.canGoBack()){nav?nav.goBack():wc.goBack();}
}else{
if(nav?nav.canGoForward():wc.canGoForward()){nav?nav.goForward():wc.goForward();}
}
markActivity();
}
function showPinEntry(){ function showPinEntry(){
if(pinWindow&&!pinWindow.isDestroyed()){ if(pinWindow&&!pinWindow.isDestroyed()){
pinWindow.focus(); pinWindow.focus();
@@ -1215,7 +1245,56 @@ const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null; let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
function runWifiHelper(action,input){ const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
// Parse the helper's one JSON reply line (the last line of its output).
function parseWifiReply(out){
const line=out.trim().split('\n').pop();
return JSON.parse(line);
}
// Preferred route: a root-owned socket (kiosk-wifi-helper.socket). Unlike
// sudo it keeps working when this app has the "no new privileges" flag set.
// Resolves null when the socket isn't usable (not installed yet, or this
// user isn't allowed), so the caller falls back to sudo.
function runWifiHelperSocket(action,input){
return new Promise(resolve=>{
let out='',done=false;
const finish=result=>{if(!done){done=true;clearTimeout(killer);resolve(result);}};
const sock=net.connect({path:WIFI_SOCKET,allowHalfOpen:true});
const killer=setTimeout(()=>{sock.destroy();finish({ok:false,error:'The WiFi helper timed out'});},120000);
sock.on('connect',()=>{
// Request = action line, then the JSON body; half-close so the
// helper sees end of input.
sock.end(action+'\n'+(input?JSON.stringify(input):''));
});
sock.on('data',d=>{out+=d;});
sock.on('error',e=>{
if(out){
// A reset after the reply arrived - use the reply.
try{finish(parseWifiReply(out));return;}catch(_){}
}
if(!out&&['ENOENT','ECONNREFUSED','EACCES','EPERM'].includes(e.code)){
console.log('[WIFI] Helper socket unavailable ('+e.code+') - falling back to sudo');
finish(null);
}else{
finish({ok:false,error:'Could not reach the WiFi helper: '+e.message});
}
});
sock.on('close',()=>{
if(done)return;
try{
finish(parseWifiReply(out));
}catch(e){
console.error('[WIFI] helper socket output:',out);
finish({ok:false,error:'The WiFi helper gave no usable answer'});
}
});
});
}
// Fallback: the helper through passwordless sudo (/etc/sudoers.d/kiosk-wifi).
function runWifiHelperSudo(action,input){
return new Promise(resolve=>{ return new Promise(resolve=>{
let out='',err='',done=false; let out='',err='',done=false;
const finish=result=>{if(!done){done=true;resolve(result);}}; const finish=result=>{if(!done){done=true;resolve(result);}};
@@ -1232,12 +1311,11 @@ function runWifiHelper(action,input){
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});}); child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
child.on('close',()=>{ child.on('close',()=>{
clearTimeout(killer); clearTimeout(killer);
const line=out.trim().split('\n').pop();
try{ try{
finish(JSON.parse(line)); finish(parseWifiReply(out));
}catch(e){ }catch(e){
// Not our JSON - typically sudo refusing ("a password is required") // Not our JSON - typically sudo refusing ("a password is required",
// on a kiosk set up before this feature: Upgrade installs the rule. // or the "no new privileges" flag) - Upgrade installs the socket.
console.error('[WIFI] helper output:',out,err); 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'}); finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
} }
@@ -1247,6 +1325,12 @@ function runWifiHelper(action,input){
}); });
} }
async function runWifiHelper(action,input){
const viaSocket=await runWifiHelperSocket(action,input);
if(viaSocket!==null)return viaSocket;
return runWifiHelperSudo(action,input);
}
function resetWifiIdleTimer(){ function resetWifiIdleTimer(){
if(wifiIdleTimer)clearTimeout(wifiIdleTimer); if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
wifiIdleTimer=setTimeout(()=>{ wifiIdleTimer=setTimeout(()=>{
@@ -1327,7 +1411,10 @@ ipcMain.handle('wifi-request',async(event,req)=>{
return result; return result;
} }
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'}; if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='status'||action==='scan')return runWifiHelper(action); if(action==='ifaces')return runWifiHelper('ifaces');
if(action==='status'||action==='scan'){
return runWifiHelper(action,req.iface?{iface:String(req.iface)}:null);
}
if(action==='region-status')return runWifiHelper('region-status'); if(action==='region-status')return runWifiHelper('region-status');
if(action==='region-set'){ if(action==='region-set'){
const input={}; const input={};
@@ -1337,7 +1424,7 @@ ipcMain.handle('wifi-request',async(event,req)=>{
} }
if(action==='connect'){ if(action==='connect'){
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||''))); console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')}); const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||''),iface:req.iface?String(req.iface):''});
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error); console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
return result; return result;
} }
@@ -1468,6 +1555,11 @@ async function createWindow(){
mainWindow.on('focus',()=>markActivity()); mainWindow.on('focus',()=>markActivity());
mainWindow.webContents.on('before-input-event',()=>markActivity()); mainWindow.webContents.on('before-input-event',()=>markActivity());
// Mouse side buttons (back/forward). Reported by the window on Linux.
mainWindow.on('app-command',(event,command)=>{
if(command==='browser-backward')historyGo('back');
else if(command==='browser-forward')historyGo('forward');
});
if(!tabs.length){ if(!tabs.length){
mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>'); mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>');
@@ -1553,6 +1645,7 @@ async function createWindow(){
console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton); console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton);
view.webContents.send('nav-button-enabled',enableNavButton); view.webContents.send('nav-button-enabled',enableNavButton);
view.webContents.send('history-buttons-config',{enabled:enableHistoryButtons,opacity:historyButtonsOpacity});
console.log('[MAIN] Page loaded - sending nav-button-enabled: '+enableNavButton); console.log('[MAIN] Page loaded - sending nav-button-enabled: '+enableNavButton);
console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url); console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url);
@@ -1596,6 +1689,9 @@ async function createWindow(){
else if(action==='reload'){app.relaunch();app.quit();} else if(action==='reload'){app.relaunch();app.quit();}
}); });
ipcMain.on('toggle-hidden',toggleHidden); ipcMain.on('toggle-hidden',toggleHidden);
ipcMain.on('history-nav',(event,direction)=>{
if(direction==='back'||direction==='forward')historyGo(direction);
});
ipcMain.on('return-to-tabs',forceReturnToTabs); ipcMain.on('return-to-tabs',forceReturnToTabs);
ipcMain.on('user-activity',markActivity); ipcMain.on('user-activity',markActivity);
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();}); ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
@@ -1785,9 +1881,15 @@ async function createWindow(){
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();}); globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
globalShortcut.register('Control+]',()=>{nextTab();}); globalShortcut.register('Control+]',()=>{nextTab();});
globalShortcut.register('Control+[',()=>{prevTab();}); globalShortcut.register('Control+[',()=>{prevTab();});
globalShortcut.register('Alt+Right',()=>{nextTab();}); // The standard browser keys for switching tabs. (Alt+Left/Right used to
globalShortcut.register('Alt+Left',()=>{prevTab();}); // switch sites; they are now page back/forward, below.)
globalShortcut.register('Control+PageDown',()=>{nextTab();});
globalShortcut.register('Control+PageUp',()=>{prevTab();});
} }
// Standard browser keys for page back/forward.
globalShortcut.register('Alt+Left',()=>{historyGo('back');});
globalShortcut.register('Alt+Right',()=>{historyGo('forward');});
globalShortcut.register('F10',toggleHidden); globalShortcut.register('F10',toggleHidden);
globalShortcut.register('Control+H',toggleHidden); globalShortcut.register('Control+H',toggleHidden);
+80
View File
@@ -115,6 +115,24 @@ ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
} }
}); });
// On-screen back/forward buttons (top middle, half transparent) - an
// option for screens with no keyboard or mouse side buttons.
let historyButtonsEnabled=false;
let historyBar=null;
let historyBarHideTimer=null;
let historyOpacity=0.5; // 0.1 - 1.0, from the "opacity" setting (percent)
const historyHoverOpacity=()=>Math.min(1,historyOpacity+0.4);
ipcRenderer.on('history-buttons-config',(event,cfg)=>{
historyButtonsEnabled=!!(cfg&&cfg.enabled===true);
const pct=Number(cfg&&cfg.opacity);
historyOpacity=(pct>=10&&pct<=100)?pct/100:0.5;
console.log('[HISTORY-BTN] Back/forward buttons enabled: '+historyButtonsEnabled+', opacity '+historyOpacity);
if(historyBar){
historyBar.style.opacity=String(historyOpacity);
if(!historyButtonsEnabled)historyBar.style.display='none';
}
});
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{ ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
navButtonEnabled=enabled; navButtonEnabled=enabled;
console.log('[NAV-BTN] Navigation button enabled: '+enabled); console.log('[NAV-BTN] Navigation button enabled: '+enabled);
@@ -253,6 +271,56 @@ window.addEventListener('DOMContentLoaded',()=>{
document.body.appendChild(navButton); document.body.appendChild(navButton);
} }
// Back/forward buttons, top middle. The corners are often all taken by the
// menu, pause, keyboard and power buttons, so these sit between them.
const HISTORY_BAR_HIDE_DELAY=5000;
function createHistoryBar(){
if(historyBar)return;
historyBar=document.createElement('div');
historyBar.id='electron-history-bar';
historyBar.style.cssText=`
position:fixed;top:12px;left:50%;transform:translateX(-50%);
display:none;gap:14px;z-index:999999;opacity:${historyOpacity};
transition:opacity 0.15s;user-select:none;
`;
const makeButton=(label,direction,path)=>{
const b=document.createElement('div');
b.title=label;
b.innerHTML='<svg width="30" height="30" viewBox="0 0 24 24" fill="white"><path d="'+path+'"/></svg>';
b.style.cssText=`
width:56px;height:56px;background:rgba(44,62,80,0.9);
border:2px solid rgba(255,255,255,0.9);border-radius:50%;
display:flex;align-items:center;justify-content:center;cursor:pointer;
`;
b.addEventListener('click',(e)=>{
e.preventDefault();
e.stopPropagation();
ipcRenderer.send('history-nav',direction);
showHistoryBar();
});
return b;
};
historyBar.append(
makeButton('Back','back','M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z'),
makeButton('Forward','forward','M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z')
);
historyBar.addEventListener('mouseenter',()=>{historyBar.style.opacity=String(historyHoverOpacity());});
historyBar.addEventListener('mouseleave',()=>{historyBar.style.opacity=String(historyOpacity);});
document.body.appendChild(historyBar);
}
function showHistoryBar(){
if(!historyButtonsEnabled)return;
if(!historyBar)createHistoryBar();
if(!historyBar)return;
historyBar.style.display='flex';
if(historyBarHideTimer)clearTimeout(historyBarHideTimer);
historyBarHideTimer=setTimeout(()=>{
if(historyBar)historyBar.style.display='none';
historyBarHideTimer=null;
},HISTORY_BAR_HIDE_DELAY);
}
// Power button in top-right corner (follows same show/hide logic as nav button) // Power button in top-right corner (follows same show/hide logic as nav button)
let powerButton=null; let powerButton=null;
let powerButtonHideTimer=null; let powerButtonHideTimer=null;
@@ -918,11 +986,23 @@ window.addEventListener('DOMContentLoaded',()=>{
if(navButtonEnabled){ if(navButtonEnabled){
showNavButton(); showNavButton();
} }
showHistoryBar();
// Always show power button on user interaction // Always show power button on user interaction
showPowerButton(); showPowerButton();
} }
// Back/forward buttons also appear on plain mouse movement, for screens
// with a mouse but no touch (the other buttons wait for a click/keypress).
let lastHistoryMove=0;
document.addEventListener('mousemove',()=>{
if(!historyButtonsEnabled)return;
const now=Date.now();
if(now-lastHistoryMove<300)return;
lastHistoryMove=now;
showHistoryBar();
},{passive:true,capture:true});
// Show pause button on any user interaction // Show pause button on any user interaction
const pauseButtonTriggers=['mousedown','touchstart','keydown']; const pauseButtonTriggers=['mousedown','touchstart','keydown'];
pauseButtonTriggers.forEach(eventType=>{ pauseButtonTriggers.forEach(eventType=>{
+49 -8
View File
@@ -95,7 +95,11 @@
</div> </div>
<div id="wifi-step" class="hidden"> <div id="wifi-step" class="hidden">
<div id="status">Checking…</div> <div id="adapter-row" class="hidden">
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
<select id="adapter"></select>
</div>
<div id="status" style="margin-top: 12px">Checking…</div>
<div class="row" style="margin-top: 14px"> <div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label> <label style="margin: 0; flex: 1">Networks nearby</label>
@@ -139,6 +143,7 @@
const {ipcRenderer} = require('electron'); const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let busy = false; let busy = false;
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
function request(action, extra) { function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {})) return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
@@ -163,16 +168,47 @@
return '▂'; return '▂';
} }
function adapterLabel(a) {
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
}
// Fill the dropdown from the helper's adapter list. Shown only when
// there is more than one adapter; the choice sticks across refreshes.
function fillAdapters(r) {
const row = $('adapter-row');
const sel = $('adapter');
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
const names = r.adapters.map(a => a.name);
if (!names.includes(adapter)) adapter = r.selected || names[0];
sel.textContent = '';
for (const a of r.adapters) {
const o = document.createElement('option');
o.value = a.name;
o.textContent = adapterLabel(a);
if (a.name === adapter) o.selected = true;
sel.appendChild(o);
}
row.classList.toggle('hidden', r.adapters.length < 2);
return r;
}
async function refreshStatus() { async function refreshStatus() {
const s = await request('status'); const ad = fillAdapters(await request('ifaces'));
const s = await request('status', adapter ? {iface: adapter} : {});
const box = $('status'); const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; } if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; } if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = ''; box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); }; 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 (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
// Every adapter at a glance, the selected one marked.
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
} else {
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
line(`Adapter: ${s.iface}`);
}
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`); 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; if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
} }
@@ -181,7 +217,7 @@
setBusy(true); setBusy(true);
message('Scanning…', 'busy'); message('Scanning…', 'busy');
const list = $('networks'); const list = $('networks');
const r = await request('scan'); const r = await request('scan', adapter ? {iface: adapter} : {});
list.textContent = ''; list.textContent = '';
if (!r.ok) { if (!r.ok) {
message(r.error, 'error'); message(r.error, 'error');
@@ -217,7 +253,7 @@
if (!ssid) { message('Pick a network or type its name', 'error'); return; } if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true); setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy'); message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password}); const r = await request('connect', {ssid, password, iface: adapter});
if (r.ok) { if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok'); message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = ''; $('password').value = '';
@@ -287,14 +323,19 @@
message(''); message('');
$('pin-step').classList.add('hidden'); $('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden'); $('wifi-step').classList.remove('hidden');
refreshStatus(); refreshStatus().then(() => scan());
loadRegion(); loadRegion();
scan();
} }
$('pin-ok').addEventListener('click', unlock); $('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); }); $('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan); $('scan').addEventListener('click', scan);
$('adapter').addEventListener('change', () => {
adapter = $('adapter').value;
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
refreshStatus();
scan();
});
$('region-save').addEventListener('click', saveRegion); $('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale); $('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect); $('connect').addEventListener('click', connect);
+15 -2
View File
@@ -79,6 +79,8 @@ INACTIVITY_TIMEOUT=120
ENABLE_PAUSE_BUTTON="true" ENABLE_PAUSE_BUTTON="true"
ENABLE_KEYBOARD_BUTTON="true" ENABLE_KEYBOARD_BUTTON="true"
ENABLE_NAV_BUTTON="true" ENABLE_NAV_BUTTON="true"
ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=50
ENABLE_PASSWORD_PROTECTION="false" ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD="" LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0 LOCKOUT_TIMEOUT=0
@@ -203,7 +205,7 @@ load_existing_config() {
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin") ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin")
SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual") SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual")
local pause_btn keyboard_btn nav_btn password_enabled boot_password local pause_btn keyboard_btn nav_btn history_btn password_enabled boot_password
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false" [[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
@@ -213,6 +215,12 @@ load_existing_config() {
nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null) nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false" [[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false"
history_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableHistoryButtons // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$history_btn" == "true" ]] && ENABLE_HISTORY_BUTTONS="true" || ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=$(sudo -u "$KIOSK_USER" jq -r '.historyButtonsOpacity // 50' "$CONFIG_PATH" 2>/dev/null || echo "50")
[[ "$HISTORY_BUTTONS_OPACITY" =~ ^[0-9]+$ ]] && (( HISTORY_BUTTONS_OPACITY >= 10 && HISTORY_BUTTONS_OPACITY <= 100 )) || HISTORY_BUTTONS_OPACITY=50
password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null) password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false" [[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
@@ -263,6 +271,9 @@ save_config() {
local nav_btn_json="true" local nav_btn_json="true"
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false" [[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false"
local history_btn_json="false"
[[ "$ENABLE_HISTORY_BUTTONS" == "true" ]] && history_btn_json="true"
local password_json="false" local password_json="false"
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true" [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
@@ -301,6 +312,8 @@ save_config() {
--argjson enablePauseButton "$pause_btn_json" \ --argjson enablePauseButton "$pause_btn_json" \
--argjson enableKeyboardButton "$keyboard_btn_json" \ --argjson enableKeyboardButton "$keyboard_btn_json" \
--argjson enableNavButton "$nav_btn_json" \ --argjson enableNavButton "$nav_btn_json" \
--argjson enableHistoryButtons "$history_btn_json" \
--argjson historyButtonsOpacity "${HISTORY_BUTTONS_OPACITY:-50}" \
--argjson enablePasswordProtection "$password_json" \ --argjson enablePasswordProtection "$password_json" \
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \ --arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \ --argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
@@ -314,7 +327,7 @@ save_config() {
--arg autheliaURL "${AUTHELIA_URL:-}" \ --arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \ --arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \ --arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp" '. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enableHistoryButtons:$enableHistoryButtons,historyButtonsOpacity:$historyButtonsOpacity,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
if [[ ${#URLS[@]} -gt 0 ]]; then if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do for idx in "${!URLS[@]}"; do
+23 -1
View File
@@ -198,7 +198,12 @@ provision_configure_display() {
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can # a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
# flip this later, and does below if declined at the end of setup. # flip this later, and does below if declined at the end of setup.
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf # Only on first install: this file is also where Advanced -> Virtual
# Consoles records the admin's choice, and Upgrade re-runs this step -
# overwriting it would silently turn consoles back off (or on).
if [[ ! -f /etc/X11/xorg.conf.d/10-serverflags.conf ]]; then
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
fi
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755 provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755 provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
@@ -242,6 +247,23 @@ provision_install_wifi_helper() {
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect" log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
fi fi
rm -f "$tmp" rm -f "$tmp"
# The same helper through a root-owned socket, which is what the kiosk
# app uses first: it can end up running with the "no new privileges"
# flag, and sudo then refuses to run as root. The sudoers rule above
# stays as a fallback. Socket access is limited to the kiosk user's
# group (0660) - the same reach as the sudoers rule.
provision_install_file "usr/local/bin/kiosk-wifi-helper-socket" "$BIN_DIR/kiosk-wifi-helper-socket" 755
provision_install_file "etc/systemd/system/kiosk-wifi-helper@.service" "$SYSTEMD_DIR/kiosk-wifi-helper@.service"
provision_install_file "etc/systemd/system/kiosk-wifi-helper.socket" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
local kiosk_group
kiosk_group=$(id -gn "$KIOSK_USER" 2>/dev/null || echo "$KIOSK_USER")
sudo sed -i "s/@KIOSK_GROUP@/${kiosk_group}/" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
sudo systemctl daemon-reload
if ! sudo systemctl enable kiosk-wifi-helper.socket &>/dev/null || ! sudo systemctl restart kiosk-wifi-helper.socket; then
log_warning "Couldn't start the kiosk WiFi helper socket - the WiFi screen will fall back to sudo"
fi
# Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off. # Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off.
provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules
sudo udevadm control --reload-rules 2>/dev/null || true sudo udevadm control --reload-rules 2>/dev/null || true
+12
View File
@@ -121,6 +121,18 @@ action_upgrade() {
provision_configure_display provision_configure_display
provision_configure_firewall provision_configure_firewall
provision_configure_power_management provision_configure_power_management
# The Web UI runs from its own copy (/opt/kiosk-webui), not from
# this checkout - refresh it too, or new settings never show up
# there. Port, service and sudo rules are left as they are.
if [[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]]; then
log_info "Refreshing the Web UI..."
if webui_install_app_files; then
sudo systemctl restart kiosk-webui || log_warning "Web UI copied but did not restart - run: sudo systemctl restart kiosk-webui"
else
log_warning "Couldn't refresh the Web UI - the Web UI keeps its old version"
fi
fi
log_success "Setup refreshed" log_success "Setup refreshed"
echo echo
+2
View File
@@ -112,6 +112,8 @@ action_complete_uninstall() {
sudo rm -rf "$KIOSK_HOME" sudo rm -rf "$KIOSK_HOME"
echo "[6/12] Removing remaining systemd units..." echo "[6/12] Removing remaining systemd units..."
sudo systemctl disable --now kiosk-wifi-helper.socket 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.socket
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
sudo systemctl daemon-reload 2>/dev/null || true sudo systemctl daemon-reload 2>/dev/null || true
+33
View File
@@ -20,6 +20,7 @@ display_status() {
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")" echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")" echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")" echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
echo "Back/forward btns: $(onoff "$ENABLE_HISTORY_BUTTONS") (opacity ${HISTORY_BUTTONS_OPACITY}%)"
} }
display_menu_builder() { display_menu_builder() {
@@ -29,6 +30,8 @@ display_menu_builder() {
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))" "Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))" "Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))" "Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
"Toggle on-screen back/forward buttons (currently: $(onoff "$ENABLE_HISTORY_BUTTONS"))"
"Back/forward button opacity (currently: ${HISTORY_BUTTONS_OPACITY}%)"
) )
MENU_HANDLERS=( MENU_HANDLERS=(
action_set_touch_mode action_set_touch_mode
@@ -36,6 +39,8 @@ display_menu_builder() {
action_toggle_pause_button action_toggle_pause_button
action_toggle_keyboard_button action_toggle_keyboard_button
action_toggle_nav_button action_toggle_nav_button
action_toggle_history_buttons
action_set_history_opacity
) )
} }
@@ -131,3 +136,31 @@ action_toggle_nav_button() {
fi fi
save_config save_config
} }
# Page back/forward: Alt+Left / Alt+Right and the mouse's side buttons
# always work; this adds clickable buttons (top middle, half transparent,
# shown on mouse movement or a tap) for screens with no keyboard or mouse
# buttons. Off by default.
action_toggle_history_buttons() {
if [[ "$ENABLE_HISTORY_BUTTONS" == "true" ]]; then
ENABLE_HISTORY_BUTTONS="false"
log_warning "On-screen back/forward buttons disabled"
else
ENABLE_HISTORY_BUTTONS="true"
log_success "On-screen back/forward buttons enabled"
fi
save_config
}
# How see-through the back/forward buttons are: 100 = solid, 10 = faint.
# They get a little more solid while the mouse is over them.
action_set_history_opacity() {
echo
echo "How solid the on-screen back/forward buttons look (10 = faint, 100 = solid)."
HISTORY_BUTTONS_OPACITY=$(ask_integer "Opacity percent" "$HISTORY_BUTTONS_OPACITY" 10 100)
save_config
log_success "Back/forward button opacity: ${HISTORY_BUTTONS_OPACITY}%"
if [[ "$ENABLE_HISTORY_BUTTONS" != "true" ]]; then
log_info "The buttons are off - turn them on with the entry above."
fi
}
@@ -2,8 +2,10 @@ Section "ServerFlags"
# Disable Ctrl+Alt+Backspace (X server kill) # Disable Ctrl+Alt+Backspace (X server kill)
Option "DontZap" "true" Option "DontZap" "true"
# Disable VT switching (Ctrl+Alt+F1-F12) # Allow VT switching (Ctrl+Alt+F1-F8): virtual consoles are ENABLED by
Option "DontVTSwitch" "true" # default, as the installer says. Advanced -> Virtual Consoles (or the
# install prompt) turns this to "true" to disable them.
Option "DontVTSwitch" "false"
# Don't allow clients to disconnect on exit # Don't allow clients to disconnect on exit
Option "AllowClosedownGrabs" "false" Option "AllowClosedownGrabs" "false"
@@ -0,0 +1,14 @@
[Unit]
Description=Kiosk WiFi helper socket (used by the kiosk app's WiFi screen)
[Socket]
ListenStream=/run/kiosk-wifi-helper.sock
SocketUser=root
# Replaced with the kiosk user's group when installed (lib/provision.sh).
SocketGroup=@KIOSK_GROUP@
SocketMode=0660
Accept=yes
MaxConnections=4
[Install]
WantedBy=sockets.target
@@ -0,0 +1,10 @@
[Unit]
Description=Kiosk WiFi helper (one request)
[Service]
ExecStart=/usr/local/bin/kiosk-wifi-helper-socket
StandardInput=socket
StandardOutput=socket
StandardError=journal
# A connect waits up to ~30s for an address, plus netplan; never run forever.
RuntimeMaxSec=180
+104 -8
View File
@@ -4,10 +4,12 @@
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk # (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off. # itself when the console (Ctrl+Alt+F1..F6) is switched off.
# #
# The kiosk user may run this one script via passwordless sudo # The kiosk app reaches it through a root-owned socket
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a # (kiosk-wifi-helper.socket, group kiosk; see kiosk-wifi-helper-socket) -
# or, as a fallback, passwordless sudo (/etc/sudoers.d/kiosk-wifi) - it does a
# fixed set of things and nothing else: # fixed set of things and nothing else:
# #
# kiosk-wifi-helper ifaces {"adapters":[{"name","kind","connected","ssid","ip"}],"selected"}
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"} # kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]} # kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"} # kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
@@ -17,8 +19,10 @@
# WiFi country (regulatory domain), system # WiFi country (regulatory domain), system
# locale and keyboard layout; every field optional # locale and keyboard layout; every field optional
# #
# Every reply is one JSON line on stdout, exit 0 on success. connect # Every reply is one JSON line on stdout, exit 0 on success. status, scan
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh, # and connect accept an optional {"iface": "<adapter>"} in the stdin JSON
# to act on that adapter; without it the connected adapter is used (see
# pick_iface). 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 # 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 # 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 # that was working. The password comes in on stdin, never argv (argv is
@@ -168,8 +172,76 @@ default_country() {
echo "${c^^}" echo "${c^^}"
} }
# Every wireless interface, by what it is (see detect_iface).
list_wifi_ifaces() {
local d
for d in $SYS_CLASS/net/*; do
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
basename "$d"
fi
done
}
# The wireless adapter that is connected right now (associated and has an
# address), if any. With a built-in card plus a USB dongle, the connected
# one is often not the first in name order - status/scan/connect must act
# on the adapter that is actually carrying the connection.
connected_iface() {
local i
for i in $(list_wifi_ifaces); do
if [[ -n "$(current_ssid "$i")" && -n "$(iface_ip "$i")" ]]; then
echo "$i"
return 0
fi
done
return 1
}
# Which adapter to use: the connected one, else the one our netplan file
# configures, else the first found.
pick_iface() {
connected_iface || prefer_configured_iface || detect_iface
}
# The request body (JSON on stdin), read once in main. REQ_IFACE is the
# adapter the caller asked for, or empty.
REQ_INPUT=""
REQ_IFACE=""
# Only a real wireless interface name is accepted - never pass caller text
# to a command or a YAML file unchecked.
valid_iface() {
local i
[[ "$1" =~ ^[A-Za-z0-9._-]{1,15}$ ]] || return 1
for i in $(list_wifi_ifaces); do
[[ "$i" == "$1" ]] && return 0
done
return 1
}
# "usb" or "built-in", from where the device sits in sysfs.
iface_kind() {
if readlink -f "$SYS_CLASS/net/$1/device" 2>/dev/null | grep -q '/usb'; then
echo usb
else
echo built-in
fi
}
pick_requested_or_default_iface() {
if [[ -n "$REQ_IFACE" ]]; then
valid_iface "$REQ_IFACE" || return 1
echo "$REQ_IFACE"
else
pick_iface
fi
}
need_iface() { 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." if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
IFACE=$(pick_requested_or_default_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" command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
} }
@@ -216,7 +288,7 @@ set_country() {
do_region_set() { do_region_set() {
local input country locale keyboard local input country locale keyboard
input=$(head -c 4096) input="$REQ_INPUT"
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" 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" 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" keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -251,10 +323,27 @@ do_region_set() {
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}' '{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
} }
do_ifaces() {
local i rows="" sel
sel=$(pick_iface) || sel=""
for i in $(list_wifi_ifaces); do
local ssid ip
ssid=$(current_ssid "$i")
ip=$(iface_ip "$i")
rows+=$(jq -cn --arg n "$i" --arg k "$(iface_kind "$i")" --arg s "$ssid" --arg a "$ip" \
'{name:$n,kind:$k,connected:($s != "" and $a != ""),ssid:$s,ip:$a}')$'\n'
done
jq -cn --arg sel "$sel" --argjson a "$(printf '%s' "$rows" | jq -cs .)" \
'{ok:true,selected:$sel,adapters:$a}'
}
do_status() { do_status() {
local iface ssid="" ip="" configured local iface ssid="" ip="" configured
configured=$(configured_ssid) configured=$(configured_ssid)
if ! iface=$(detect_iface); then if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
if ! iface=$(pick_requested_or_default_iface); then
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}' jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
return 0 return 0
fi fi
@@ -351,7 +440,7 @@ PYEOF
do_connect() { do_connect() {
need_iface need_iface
local input ssid password local input ssid password
input=$(head -c 4096) input="$REQ_INPUT"
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given" 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" password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -454,7 +543,14 @@ trap 'rm -f "$TMP_SCAN"' EXIT
exec 9> "$LOCK_FILE" exec 9> "$LOCK_FILE"
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment" flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
# One JSON request body on stdin (not when run by hand from a terminal).
if [[ ! -t 0 ]]; then
REQ_INPUT=$(head -c 4096)
REQ_IFACE=$(jq -r '.iface // "" | strings' <<< "$REQ_INPUT" 2>/dev/null) || REQ_IFACE=""
fi
case "${1:-}" in case "${1:-}" in
ifaces) do_ifaces ;;
status) do_status ;; status) do_status ;;
scan) do_scan ;; scan) do_scan ;;
connect) do_connect ;; connect) do_connect ;;
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
################################################################################
# kiosk-wifi-helper-socket - socket front end for kiosk-wifi-helper.
#
# Run by systemd (kiosk-wifi-helper@.service, one instance per connection to
# /run/kiosk-wifi-helper.sock) as root, with the connection as stdin AND
# stdout. The kiosk app connects, sends
#
# <action>\n<JSON request body>
#
# and half-closes; the reply is the helper's one JSON line.
#
# Why a socket and not `sudo`: the kiosk app can end up running with the
# Linux "no new privileges" flag, and sudo then refuses to run as root
# ("The 'no new privileges' flag is set"). A root service reached through a
# socket isn't affected by that flag. Who may connect is decided by the
# socket's owner/group/mode (root:kiosk 0660) - the same people who could
# run the helper through the old sudoers rule, no more.
#
# Only the fixed actions below are passed on; anything else is refused.
################################################################################
set -uo pipefail
HELPER="${KIOSK_WIFI_HELPER:-/usr/local/bin/kiosk-wifi-helper}"
# First line only; bash's read doesn't consume past the newline on a
# socket, so the JSON body stays on stdin for the helper.
IFS= read -r -t 10 -n 32 action || action=""
case "$action" in
ifaces|status|scan|connect|region-status|region-set)
exec "$HELPER" "$action"
;;
*)
echo '{"ok":false,"error":"unknown action"}'
exit 1
;;
esac
+2
View File
@@ -40,6 +40,8 @@ const SCALAR_DEFAULTS = {
enablePauseButton: true, enablePauseButton: true,
enableKeyboardButton: true, enableKeyboardButton: true,
enableNavButton: true, enableNavButton: true,
enableHistoryButtons: false,
historyButtonsOpacity: 50,
enablePasswordProtection: false, enablePasswordProtection: false,
lockoutTimeout: 0, lockoutTimeout: 0,
lockoutAtTime: '', lockoutAtTime: '',
+4
View File
@@ -173,6 +173,8 @@ displayForm.addEventListener('submit', async (e) => {
enablePauseButton: fd.get('enablePauseButton') === 'on', enablePauseButton: fd.get('enablePauseButton') === 'on',
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on', enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
enableNavButton: fd.get('enableNavButton') === 'on', enableNavButton: fd.get('enableNavButton') === 'on',
enableHistoryButtons: fd.get('enableHistoryButtons') === 'on',
historyButtonsOpacity: parseInt(fd.get('historyButtonsOpacity'), 10),
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10), homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10), inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
}; };
@@ -282,6 +284,8 @@ function populateAll(config) {
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton; displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton; displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
displayForm.elements.enableNavButton.checked = !!config.enableNavButton; displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
displayForm.elements.enableHistoryButtons.checked = !!config.enableHistoryButtons;
displayForm.elements.historyButtonsOpacity.value = config.historyButtonsOpacity;
updateHomeTabOptions(config.tabs); updateHomeTabOptions(config.tabs);
homeTabSelect.value = String(config.homeTabIndex); homeTabSelect.value = String(config.homeTabIndex);
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60); displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
+5
View File
@@ -95,6 +95,7 @@
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label> <label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label> <label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label> <label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
<label class="checkbox"><input type="checkbox" name="enableHistoryButtons"> On-screen back/forward buttons (top middle)</label>
<fieldset> <fieldset>
<legend>Home page</legend> <legend>Home page</legend>
@@ -104,6 +105,10 @@
<option value="-1">Disabled</option> <option value="-1">Disabled</option>
</select> </select>
</label> </label>
<label>
Back/forward button opacity (10 = faint, 100 = solid)
<input type="number" name="historyButtonsOpacity" min="10" max="100" step="5">
</label>
<label> <label>
Inactivity timeout (minutes) Inactivity timeout (minutes)
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1"> <input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
+6
View File
@@ -70,6 +70,12 @@ app.put('/api/config', (req, res) => {
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton; if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton; if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton; if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
if (body.enableHistoryButtons !== undefined) patch.enableHistoryButtons = !!body.enableHistoryButtons;
if (body.historyButtonsOpacity !== undefined) {
const pct = Number(body.historyButtonsOpacity);
if (!Number.isInteger(pct) || pct < 10 || pct > 100) return badRequest(res, 'Button opacity must be 10-100 percent');
patch.historyButtonsOpacity = pct;
}
let tabCount; let tabCount;
if (body.tabs !== undefined) { if (body.tabs !== undefined) {
+11
View File
@@ -75,6 +75,17 @@ check('saveConfig merge preserves fields this app never tracks (the previously-f
assert.strictEqual(onDisk.enableNavButton, false); assert.strictEqual(onDisk.enableNavButton, false);
}); });
check('enableHistoryButtons defaults off and round-trips', () => {
fs.rmSync(process.env.CONFIG_PATH, { force: true });
assert.strictEqual(loadConfig().enableHistoryButtons, false);
saveConfig({ enableHistoryButtons: true });
assert.strictEqual(JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')).enableHistoryButtons, true);
assert.strictEqual(loadConfig().enableHistoryButtons, true);
assert.strictEqual(loadConfig().historyButtonsOpacity, 50);
saveConfig({ historyButtonsOpacity: 80 });
assert.strictEqual(loadConfig().historyButtonsOpacity, 80);
});
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => { check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
const result = saveConfig({ const result = saveConfig({
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }], tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],