Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eadf266f9 | ||
|
|
886745a979 | ||
|
|
e0fa69d470 | ||
|
|
84e8f074e8 | ||
|
|
551df48687 | ||
|
|
f09bd6bb95 | ||
|
|
9c1f22382e | ||
|
|
b836fd6771 | ||
|
|
fa0d602505 | ||
|
|
e7cf15a802 | ||
|
|
441615da7a | ||
|
|
1775220312 | ||
|
|
7ed2d4744c | ||
|
|
9b646b99f8 | ||
|
|
a2811cced6 | ||
|
|
07e7d2866f | ||
|
|
0fe631b4ed |
@@ -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.
|
||||||
@@ -91,7 +91,7 @@ Useful options (`iso/build-iso.sh --help` for all):
|
|||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--unattended` | First-boot install takes every default with no prompts (skips the Core Settings menu) and reboots into the kiosk. Set sites afterwards in the Web UI (`http://<ip>:8090`) or `./install.sh`. |
|
| `--unattended` | First-boot install takes every default with no prompts (skips the Core Settings menu) and reboots into the kiosk. Set sites afterwards in the Web UI (`http://<ip>:8888`) or `./install.sh`. |
|
||||||
| `--username NAME [--password PASS]` | Pre-set the admin account (password is prompted for if omitted; only a hash goes on the ISO). Not `kiosk`. |
|
| `--username NAME [--password PASS]` | Pre-set the admin account (password is prompted for if omitted; only a hash goes on the ISO). Not `kiosk`. |
|
||||||
| `--hostname`, `--ssh-key FILE`, `--locale`, `--keyboard` | Pre-set those. |
|
| `--hostname`, `--ssh-key FILE`, `--locale`, `--keyboard` | Pre-set those. |
|
||||||
| `--auto-network` / `--auto-storage` | Skip the network screen (DHCP on ethernet) / wipe the largest disk without asking. |
|
| `--auto-network` / `--auto-storage` | Skip the network screen (DHCP on ethernet) / wipe the largest disk without asking. |
|
||||||
@@ -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 / home / forward buttons** (optional, off by default) - three half-transparent buttons (◀ 🏠 ▶) at the top middle of the screen; the home button returns the site on screen to the address it was set up with under Sites & Page Timing, 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
|
||||||
@@ -506,6 +507,7 @@ Host myserver
|
|||||||
- **Virtual consoles** (Ctrl+Alt+F1-F8) - Optional, configurable during install
|
- **Virtual consoles** (Ctrl+Alt+F1-F8) - Optional, configurable during install
|
||||||
- **X server key combinations disabled** (Ctrl+Alt+Backspace)
|
- **X server key combinations disabled** (Ctrl+Alt+Backspace)
|
||||||
- **Right-click disabled** in kiosk app
|
- **Right-click disabled** in kiosk app
|
||||||
|
- **Web UI login** - when password protection is on, the Web UI asks for the same PIN/password as the lock screen
|
||||||
- **Screen blanking disabled** with schedule awareness
|
- **Screen blanking disabled** with schedule awareness
|
||||||
- **DPMS management** - Aggressive keep-alive with schedule respect
|
- **DPMS management** - Aggressive keep-alive with schedule respect
|
||||||
|
|
||||||
@@ -537,7 +539,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 +777,10 @@ 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)
|
||||||
|
- `Alt+Home` - Back to the current site's own start address (the one set under Sites & Page Timing)
|
||||||
|
- 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
|
||||||
@@ -1579,7 +1586,7 @@ full migration pass.
|
|||||||
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
|
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
|
||||||
|
|
||||||
**Previous (v2.17.0):**
|
**Previous (v2.17.0):**
|
||||||
- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
|
- **Web UI now installs by default** during first-time provisioning (port 8888 for new installs, 8090 on older ones; no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
|
||||||
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
|
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
|
||||||
- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell.
|
- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell.
|
||||||
- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped.
|
- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped.
|
||||||
@@ -1590,7 +1597,7 @@ full migration pass.
|
|||||||
**Previous (v2.16.0):**
|
**Previous (v2.16.0):**
|
||||||
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
|
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
|
||||||
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
|
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
|
||||||
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
|
- **Login follows the kiosk's password protection.** With password protection on (Core Settings → Password Protection & Lockout), the Web UI asks for the same PIN/password the lock screen uses (a session lasts 12 hours; 5 wrong tries in a row slow it down like the lock screen does; changing the code signs every browser out). With protection off there is no login, so beyond a trusted LAN turn protection on or put it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth — Authelia integration is out of scope for this repo. Direct LAN access with protection off has no authentication at all — treat it like SSH access to the kiosk.
|
||||||
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up.
|
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up.
|
||||||
- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is.
|
- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is.
|
||||||
- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass.
|
- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass.
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
# ~/ubuntu-based-kiosk - interactively by default (same prompts as a
|
# ~/ubuntu-based-kiosk - interactively by default (same prompts as a
|
||||||
# manual install), or with --unattended taking every default and
|
# manual install), or with --unattended taking every default and
|
||||||
# rebooting straight into the kiosk. Configure sites afterwards via
|
# rebooting straight into the kiosk. Configure sites afterwards via
|
||||||
# the Web UI (http://<ip>:8090) or ./install.sh.
|
# the Web UI (http://<ip>:8888) or ./install.sh.
|
||||||
#
|
#
|
||||||
# Nothing in the original ISO is changed except /boot/grub/grub.cfg
|
# Nothing in the original ISO is changed except /boot/grub/grub.cfg
|
||||||
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
|
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
|
||||||
|
|||||||
+181
-20
@@ -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);
|
||||||
@@ -763,6 +772,23 @@ function rotateToNextSite(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The address a site is loaded from: its configured URL, with the site's
|
||||||
|
// own login filled in (if it has one) and local files/folders turned into
|
||||||
|
// the kiosk's viewer addresses. Used when a site's view is first created
|
||||||
|
// and by the home button, so both always agree.
|
||||||
|
function buildTabUrl(t){
|
||||||
|
let url=t.url;
|
||||||
|
if(t.username&&t.password){
|
||||||
|
try{
|
||||||
|
const u=new URL(t.url);
|
||||||
|
u.username=t.username;
|
||||||
|
u.password=t.password;
|
||||||
|
url=u.toString();
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
return resolveLocalSite(url);
|
||||||
|
}
|
||||||
|
|
||||||
function attachView(i){
|
function attachView(i){
|
||||||
closeHTMLKeyboard();
|
closeHTMLKeyboard();
|
||||||
|
|
||||||
@@ -1144,6 +1170,75 @@ 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 historyBlocked(){
|
||||||
|
if(isLockedOut)return true;
|
||||||
|
for(const w of [pinWindow,wifiWindow,promptWindow,pauseWindow]){
|
||||||
|
if(w&&!w.isDestroyed())return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyGo(direction){
|
||||||
|
if(historyBlocked())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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tab (index into tabs) behind the n-th hidden view: hidden sites are the
|
||||||
|
// ones with duration -1, in order.
|
||||||
|
function hiddenViewIndexToTabIndex(h){
|
||||||
|
let n=0;
|
||||||
|
for(let i=0;i<tabs.length;i++){
|
||||||
|
if(parseInt(tabs[i].duration)===-1){
|
||||||
|
if(n===h)return i;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home button / Alt+Home: back to the address this site was set up with
|
||||||
|
// (Core Settings -> Sites & Page Timing), whatever page the user has
|
||||||
|
// browsed to since. Acts on the site that's on screen, so with several
|
||||||
|
// sites each one goes to its own address.
|
||||||
|
function historyHome(){
|
||||||
|
if(historyBlocked())return;
|
||||||
|
const view=showingHidden?hiddenViews[currentHiddenIndex]:views[currentIndex];
|
||||||
|
if(!view||!view.webContents||view.webContents.isDestroyed())return;
|
||||||
|
const tabIdx=showingHidden?hiddenViewIndexToTabIndex(currentHiddenIndex):viewIndexToTabIndex(currentIndex);
|
||||||
|
if(tabIdx<0||!tabs[tabIdx])return;
|
||||||
|
const target=buildTabUrl(tabs[tabIdx]);
|
||||||
|
if(view.webContents.getURL()!==target){
|
||||||
|
view.webContents.loadURL(target).catch(()=>{});
|
||||||
|
}
|
||||||
|
markActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power menu -> Lock now: put up the lock screen right away (the same one
|
||||||
|
// the inactivity timeout shows). Only when password protection is on and a
|
||||||
|
// code is set - showLockoutScreen refuses otherwise, so a kiosk with no
|
||||||
|
// code can't be locked with nothing to unlock it.
|
||||||
|
function lockNow(){
|
||||||
|
if(!enablePasswordProtection||!lockoutPassword){
|
||||||
|
console.log('[LOCKOUT] Lock now ignored - password protection is off');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[LOCKOUT] Lock now requested from the power menu');
|
||||||
|
showLockoutScreen('unlock');
|
||||||
|
}
|
||||||
|
|
||||||
function showPinEntry(){
|
function showPinEntry(){
|
||||||
if(pinWindow&&!pinWindow.isDestroyed()){
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
||||||
pinWindow.focus();
|
pinWindow.focus();
|
||||||
@@ -1215,7 +1310,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 +1376,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 +1390,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 +1476,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 +1489,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;
|
||||||
}
|
}
|
||||||
@@ -1406,7 +1558,7 @@ function showPowerMenu(){
|
|||||||
|
|
||||||
// For normal mode, use custom overlay with 30-second timeout
|
// For normal mode, use custom overlay with 30-second timeout
|
||||||
console.log('[POWER] Showing custom power menu overlay');
|
console.log('[POWER] Showing custom power menu overlay');
|
||||||
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim()};
|
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim(),canLock:!!(enablePasswordProtection&&lockoutPassword)};
|
||||||
// Send to all views (preload.js runs in views, not mainWindow)
|
// Send to all views (preload.js runs in views, not mainWindow)
|
||||||
for(const view of views){
|
for(const view of views){
|
||||||
if(view&&view.webContents&&!view.webContents.isDestroyed()){
|
if(view&&view.webContents&&!view.webContents.isDestroyed()){
|
||||||
@@ -1468,6 +1620,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>');
|
||||||
@@ -1487,16 +1644,7 @@ async function createWindow(){
|
|||||||
|
|
||||||
mainWindow.addBrowserView(view);
|
mainWindow.addBrowserView(view);
|
||||||
|
|
||||||
let url=t.url;
|
const url=buildTabUrl(t);
|
||||||
if(t.username&&t.password){
|
|
||||||
try{
|
|
||||||
const u=new URL(t.url);
|
|
||||||
u.username=t.username;
|
|
||||||
u.password=t.password;
|
|
||||||
url=u.toString();
|
|
||||||
}catch(e){}
|
|
||||||
}
|
|
||||||
url=resolveLocalSite(url);
|
|
||||||
|
|
||||||
const initialOrigin=new URL(t.url).origin;
|
const initialOrigin=new URL(t.url).origin;
|
||||||
|
|
||||||
@@ -1553,6 +1701,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);
|
||||||
@@ -1594,8 +1743,13 @@ async function createWindow(){
|
|||||||
if(action==='shutdown')exec('systemctl poweroff');
|
if(action==='shutdown')exec('systemctl poweroff');
|
||||||
else if(action==='restart')exec('systemctl reboot');
|
else if(action==='restart')exec('systemctl reboot');
|
||||||
else if(action==='reload'){app.relaunch();app.quit();}
|
else if(action==='reload'){app.relaunch();app.quit();}
|
||||||
|
else if(action==='lock')lockNow();
|
||||||
});
|
});
|
||||||
ipcMain.on('toggle-hidden',toggleHidden);
|
ipcMain.on('toggle-hidden',toggleHidden);
|
||||||
|
ipcMain.on('history-nav',(event,direction)=>{
|
||||||
|
if(direction==='back'||direction==='forward')historyGo(direction);
|
||||||
|
else if(direction==='home')historyHome();
|
||||||
|
});
|
||||||
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,10 +1939,17 @@ 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('Alt+Home',()=>{historyHome();});
|
||||||
|
|
||||||
globalShortcut.register('F10',toggleHidden);
|
globalShortcut.register('F10',toggleHidden);
|
||||||
globalShortcut.register('Control+H',toggleHidden);
|
globalShortcut.register('Control+H',toggleHidden);
|
||||||
globalShortcut.register('Escape',forceReturnToTabs);
|
globalShortcut.register('Escape',forceReturnToTabs);
|
||||||
|
|||||||
@@ -115,6 +115,24 @@ ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On-screen back / home / 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,57 @@ 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:18px;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('Home (this site\'s start page)','home','M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z'),
|
||||||
|
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;
|
||||||
@@ -597,6 +666,16 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
ipcRenderer.send('power-action','reload');
|
ipcRenderer.send('power-action','reload');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only offered when password protection is on (set in showPowerMenu).
|
||||||
|
const lockBtn=document.createElement('button');
|
||||||
|
lockBtn.id='power-menu-lock';
|
||||||
|
lockBtn.textContent='🔒 Lock now';
|
||||||
|
lockBtn.style.cssText=btnStyle+'background:#8e44ad;color:white;display:none;';
|
||||||
|
lockBtn.addEventListener('click',()=>{
|
||||||
|
hidePowerMenu();
|
||||||
|
ipcRenderer.send('power-action','lock');
|
||||||
|
});
|
||||||
|
|
||||||
const cancelBtn=document.createElement('button');
|
const cancelBtn=document.createElement('button');
|
||||||
cancelBtn.textContent='Cancel';
|
cancelBtn.textContent='Cancel';
|
||||||
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
|
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
|
||||||
@@ -604,6 +683,7 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
hidePowerMenu();
|
hidePowerMenu();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
buttonsDiv.appendChild(lockBtn);
|
||||||
buttonsDiv.appendChild(shutdownBtn);
|
buttonsDiv.appendChild(shutdownBtn);
|
||||||
buttonsDiv.appendChild(restartBtn);
|
buttonsDiv.appendChild(restartBtn);
|
||||||
buttonsDiv.appendChild(reloadBtn);
|
buttonsDiv.appendChild(reloadBtn);
|
||||||
@@ -639,6 +719,8 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
}
|
}
|
||||||
infoDiv.innerHTML=infoText;
|
infoDiv.innerHTML=infoText;
|
||||||
}
|
}
|
||||||
|
const lockBtn=document.getElementById('power-menu-lock');
|
||||||
|
if(lockBtn)lockBtn.style.display=(info&&info.canLock)?'':'none';
|
||||||
|
|
||||||
powerMenu.style.display='flex';
|
powerMenu.style.display='flex';
|
||||||
powerMenuVisible=true;
|
powerMenuVisible=true;
|
||||||
@@ -918,11 +1000,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
@@ -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
@@ -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
|
||||||
|
|||||||
+26
-4
@@ -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
|
||||||
@@ -306,7 +328,7 @@ provision_configure_webui() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local port="8090"
|
local port="8888"
|
||||||
webui_write_env_file "$port"
|
webui_write_env_file "$port"
|
||||||
webui_write_unit_file
|
webui_write_unit_file
|
||||||
webui_write_helper_script
|
webui_write_helper_script
|
||||||
@@ -402,8 +424,8 @@ run_first_time_install() {
|
|||||||
echo " - Audio support (PipeWire)"
|
echo " - Audio support (PipeWire)"
|
||||||
echo " - Time synchronization (NTP)"
|
echo " - Time synchronization (NTP)"
|
||||||
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
|
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
|
||||||
echo " addon install/reconfigure and Update - no login of its own,"
|
echo " addon install/reconfigure and Update - asks for the kiosk's"
|
||||||
echo " see Addons -> Web UI)"
|
echo " PIN/password when password protection is on, see Addons -> Web UI)"
|
||||||
echo
|
echo
|
||||||
echo "OPTIONAL (configure after install, via Addons):"
|
echo "OPTIONAL (configure after install, via Addons):"
|
||||||
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
||||||
|
|||||||
+18
-12
@@ -30,12 +30,15 @@
|
|||||||
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
|
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
|
||||||
# stays terminal-only for now.
|
# stays terminal-only for now.
|
||||||
#
|
#
|
||||||
# No login of its own, by design: this addon assumes it'll be put behind
|
# Login: when the kiosk's password protection is on (Core Settings ->
|
||||||
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
|
# Password Protection & Lockout), the Web UI asks for the same PIN/password
|
||||||
# same way other self-hosted apps get protected - Authelia integration
|
# the lock screen uses (webui/lib/auth.js). With protection off it has no
|
||||||
# is explicitly out of scope for this repo (Authelia runs elsewhere).
|
# login, so on a network you don't fully trust, turn protection on or put
|
||||||
# Direct LAN access with no proxy in front has no authentication at all -
|
# it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth in
|
||||||
# treat it the same as SSH access to this kiosk, which is also the
|
# front, the same way other self-hosted apps get protected - Authelia
|
||||||
|
# integration is explicitly out of scope for this repo (Authelia runs
|
||||||
|
# elsewhere). Direct LAN access with protection off has no authentication
|
||||||
|
# at all - treat it the same as SSH access to this kiosk, which is also the
|
||||||
# access level the privileged helper effectively grants if reached
|
# access level the privileged helper effectively grants if reached
|
||||||
# without a proxy in front: bounded to its fixed action list, not a
|
# without a proxy in front: bounded to its fixed action list, not a
|
||||||
# root shell, but real system mutation all the same.
|
# root shell, but real system mutation all the same.
|
||||||
@@ -59,9 +62,9 @@ webui_is_active() {
|
|||||||
|
|
||||||
webui_port() {
|
webui_port() {
|
||||||
if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then
|
if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then
|
||||||
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8090"
|
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8888"
|
||||||
else
|
else
|
||||||
echo "8090"
|
echo "8888"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,14 +277,15 @@ action_webui_install() {
|
|||||||
echo "This installs a small web app for editing Sites & Page Timing,"
|
echo "This installs a small web app for editing Sites & Page Timing,"
|
||||||
echo "Display & Interaction, and Password Protection & Lockout from a"
|
echo "Display & Interaction, and Password Protection & Lockout from a"
|
||||||
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
|
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
|
||||||
echo "and Asterisk Intercom, and checking for updates. It has no login"
|
echo "and Asterisk Intercom, and checking for updates. When the kiosk's"
|
||||||
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
|
echo "password protection is on it asks for the same PIN/password; when it"
|
||||||
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
|
echo "is off there is no login - turn protection on, or put it behind your"
|
||||||
|
echo "own reverse proxy (e.g. Caddy + Authelia), beyond a trusted LAN."
|
||||||
echo
|
echo
|
||||||
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
local port
|
local port
|
||||||
port=$(ask_integer "Port to listen on" "8090" 1024 65535)
|
port=$(ask_integer "Port to listen on" "8888" 1024 65535)
|
||||||
|
|
||||||
echo "Installing app files and npm dependencies..."
|
echo "Installing app files and npm dependencies..."
|
||||||
if ! webui_install_app_files; then
|
if ! webui_install_app_files; then
|
||||||
@@ -321,6 +325,8 @@ action_webui_reconfigure() {
|
|||||||
webui_write_env_file "$port"
|
webui_write_env_file "$port"
|
||||||
sudo systemctl restart kiosk-webui
|
sudo systemctl restart kiosk-webui
|
||||||
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||||
|
# The old port is no longer used - close it.
|
||||||
|
sudo ufw delete allow "${current_port}/tcp" 2>/dev/null || true
|
||||||
log_success "Web UI now listening on port ${port}"
|
log_success "Web UI now listening on port ${port}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/home/fwd 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/home/forward buttons (currently: $(onoff "$ENABLE_HISTORY_BUTTONS"))"
|
||||||
|
"Back/home/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/home/forward buttons disabled"
|
||||||
|
else
|
||||||
|
ENABLE_HISTORY_BUTTONS="true"
|
||||||
|
log_success "On-screen back/home/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/home/forward buttons look (10 = faint, 100 = solid)."
|
||||||
|
HISTORY_BUTTONS_OPACITY=$(ask_integer "Opacity percent" "$HISTORY_BUTTONS_OPACITY" 10 100)
|
||||||
|
save_config
|
||||||
|
log_success "Back/home/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
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ sites_status() {
|
|||||||
else
|
else
|
||||||
echo "Auto-rotation: off (all pages manual/hidden)"
|
echo "Auto-rotation: off (all pages manual/hidden)"
|
||||||
fi
|
fi
|
||||||
|
echo "Keys on the kiosk: Alt+Left back, Alt+Right forward, Alt+Home this page's own address,"
|
||||||
|
echo " Ctrl+Tab / Ctrl+PageDown next site, Ctrl+Shift+Tab / Ctrl+PageUp previous"
|
||||||
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
|
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
|
||||||
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
|
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// webui/lib/auth.js - login for the Web UI, using the SAME code the kiosk's
|
||||||
|
// lock screen uses (Core Settings -> Password Protection & Lockout: a PIN or
|
||||||
|
// a password).
|
||||||
|
//
|
||||||
|
// Only when the kiosk's password protection is on. With it off, the Web UI
|
||||||
|
// is open as before (it is meant for a trusted LAN, or behind a reverse
|
||||||
|
// proxy with its own login - see server.js).
|
||||||
|
//
|
||||||
|
// A correct code gives the browser a random session cookie (kept in memory;
|
||||||
|
// a restart of the service logs everyone out). Wrong codes are slowed down
|
||||||
|
// the same way the lock screen does it: after 5 wrong tries in a row, a
|
||||||
|
// wait of 30 s that doubles up to 5 minutes. Changing the kiosk's code logs
|
||||||
|
// every browser out, because sessions are tied to the stored code.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { readLockSettings, hashPassword } = require('./config');
|
||||||
|
|
||||||
|
const COOKIE = 'kioskwebui';
|
||||||
|
const SESSION_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
|
const FREE_TRIES = 5;
|
||||||
|
const BASE_WAIT_S = 30;
|
||||||
|
const MAX_WAIT_S = 300;
|
||||||
|
|
||||||
|
const sessions = new Map(); // token -> { expires, fp }
|
||||||
|
let failures = 0;
|
||||||
|
let blockedUntil = 0;
|
||||||
|
|
||||||
|
// A short fingerprint of the stored code: sessions stop working when it changes.
|
||||||
|
const fingerprint = (hash) => crypto.createHash('sha256').update('fp:' + hash).digest('hex');
|
||||||
|
|
||||||
|
function parseCookies(header) {
|
||||||
|
const out = {};
|
||||||
|
for (const part of String(header || '').split(';')) {
|
||||||
|
const i = part.indexOf('=');
|
||||||
|
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSession(req, lock) {
|
||||||
|
const token = parseCookies(req.headers.cookie)[COOKIE];
|
||||||
|
if (!token) return null;
|
||||||
|
const s = sessions.get(token);
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.expires < Date.now() || s.fp !== fingerprint(lock.hash)) {
|
||||||
|
sessions.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(res, token, maxAgeS) {
|
||||||
|
// HttpOnly + SameSite=Strict: not readable by page scripts, and not sent
|
||||||
|
// on requests that start from another site. No Secure flag - this is
|
||||||
|
// normally plain http on a LAN.
|
||||||
|
res.setHeader('Set-Cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueSession(res, lock) {
|
||||||
|
for (const [t, s] of sessions) if (s.expires < Date.now()) sessions.delete(t);
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
sessions.set(token, { expires: Date.now() + SESSION_MS, fp: fingerprint(lock.hash) });
|
||||||
|
setCookie(res, token, SESSION_MS / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeMatches(code, hash) {
|
||||||
|
const a = Buffer.from(hashPassword(code), 'hex');
|
||||||
|
const b = Buffer.from(hash, 'hex');
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/login { code }
|
||||||
|
function login(req, res) {
|
||||||
|
const lock = readLockSettings();
|
||||||
|
if (!lock.enabled) return res.json({ ok: true, required: false });
|
||||||
|
|
||||||
|
const wait = Math.ceil((blockedUntil - Date.now()) / 1000);
|
||||||
|
if (wait > 0) {
|
||||||
|
return res.status(429).json({ ok: false, waitSeconds: wait, error: `Too many wrong tries - wait ${wait}s` });
|
||||||
|
}
|
||||||
|
const code = req.body && typeof req.body.code === 'string' ? req.body.code : '';
|
||||||
|
if (code && codeMatches(code, lock.hash)) {
|
||||||
|
failures = 0;
|
||||||
|
issueSession(res, lock);
|
||||||
|
return res.json({ ok: true });
|
||||||
|
}
|
||||||
|
failures++;
|
||||||
|
const what = lock.codeType === 'pin' ? 'PIN' : 'password';
|
||||||
|
if (failures >= FREE_TRIES) {
|
||||||
|
const waitS = Math.min(MAX_WAIT_S, BASE_WAIT_S * 2 ** (failures - FREE_TRIES));
|
||||||
|
blockedUntil = Date.now() + waitS * 1000;
|
||||||
|
return res.status(429).json({ ok: false, waitSeconds: waitS, error: `Too many wrong tries - wait ${waitS}s` });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ ok: false, error: `Incorrect ${what}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/logout
|
||||||
|
function logout(req, res) {
|
||||||
|
const token = parseCookies(req.headers.cookie)[COOKIE];
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
setCookie(res, '', 0);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/auth - open to everyone: tells the page whether to show a login.
|
||||||
|
function status(req, res) {
|
||||||
|
const lock = readLockSettings();
|
||||||
|
res.json({
|
||||||
|
required: lock.enabled,
|
||||||
|
authed: !lock.enabled || !!currentSession(req, lock),
|
||||||
|
codeType: lock.codeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate for everything else. With protection off it lets everything through.
|
||||||
|
// Without a session: API calls get a 401, page requests get the login page.
|
||||||
|
function requireLogin(loginPage) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const lock = readLockSettings();
|
||||||
|
if (!lock.enabled) return next();
|
||||||
|
if (currentSession(req, lock)) return next();
|
||||||
|
|
||||||
|
// The few things the login page itself needs.
|
||||||
|
if (req.path === '/api/login' || req.path === '/api/auth' || req.path === '/style.css') return next();
|
||||||
|
if (req.path.startsWith('/api/')) {
|
||||||
|
return res.status(401).json({ error: 'Login required', loginRequired: true });
|
||||||
|
}
|
||||||
|
res.status(401).sendFile(loginPage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a successful save that left protection on (e.g. the code was just
|
||||||
|
// set or changed), keep the person who saved it logged in.
|
||||||
|
function keepLoggedInAfterSave(res) {
|
||||||
|
const lock = readLockSettings();
|
||||||
|
if (lock.enabled) issueSession(res, lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireLogin, login, logout, status, keepLoggedInAfterSave, _sessions: sessions };
|
||||||
+16
-1
@@ -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: '',
|
||||||
@@ -177,4 +179,17 @@ function saveConfig(patch) {
|
|||||||
return loadConfig();
|
return loadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
|
// What the Web UI login needs: whether the kiosk's password protection is
|
||||||
|
// on and the stored SHA-256 of its unlock code (the same one the lock screen
|
||||||
|
// checks). Only used server-side to verify a login - never sent to a browser.
|
||||||
|
function readLockSettings() {
|
||||||
|
const existing = readExisting();
|
||||||
|
const hash = typeof existing.lockoutPassword === 'string' ? existing.lockoutPassword : '';
|
||||||
|
return {
|
||||||
|
enabled: existing.enablePasswordProtection === true && hash.length > 0,
|
||||||
|
hash,
|
||||||
|
codeType: existing.lockoutCodeType === 'pin' ? 'pin' : 'password',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadConfig, saveConfig, readLockSettings, hashPassword, CONFIG_PATH, SCALAR_DEFAULTS };
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// Signed out (or the kiosk's code changed): any 401 from the server sends
|
||||||
|
// the page back to the login screen.
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const res = await nativeFetch(...args);
|
||||||
|
if (res.status === 401 && !String(args[0]).includes('/api/login')) location.reload();
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||||
@@ -32,6 +41,23 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
|
|||||||
|
|
||||||
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
||||||
|
|
||||||
|
// When the kiosk's password protection is on, this page is behind the same
|
||||||
|
// PIN/password - say so, and offer Sign out.
|
||||||
|
fetch('/api/auth').then((r) => r.json()).then((s) => {
|
||||||
|
if (!s.required) return;
|
||||||
|
const foot = document.getElementById('sidebar-footer');
|
||||||
|
foot.textContent = "Signed in with the kiosk's " + (s.codeType === 'pin' ? 'PIN' : 'password') + '. ';
|
||||||
|
const out = document.createElement('a');
|
||||||
|
out.href = '#';
|
||||||
|
out.textContent = 'Sign out';
|
||||||
|
out.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
foot.appendChild(out);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
/* Config API (Sites / Display / Lockout) */
|
/* Config API (Sites / Display / Lockout) */
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
@@ -173,6 +199,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 +310,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);
|
||||||
|
|||||||
+18
-1
@@ -46,7 +46,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
|
<div class="sidebar-footer" id="sidebar-footer">Open to anyone on this network while the kiosk's password protection is off. Turn it on (Lockout) and this page asks for the same PIN/password. Beyond a trusted LAN, front it with your own reverse proxy + Authelia.</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
@@ -65,6 +65,18 @@
|
|||||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Keyboard shortcuts</h2>
|
||||||
|
<p class="hint" style="margin-bottom:10px">For a keyboard on the kiosk. They act on the page that's on screen, and don't work while the screen is locked.</p>
|
||||||
|
<table class="shortcuts">
|
||||||
|
<tr><td><kbd>Alt</kbd> + <kbd>Left</kbd></td><td>Back (previous page in this site)</td></tr>
|
||||||
|
<tr><td><kbd>Alt</kbd> + <kbd>Right</kbd></td><td>Forward</td></tr>
|
||||||
|
<tr><td><kbd>Alt</kbd> + <kbd>Home</kbd></td><td>Home: back to this page's own address, as set above</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl</kbd> + <kbd>Tab</kbd> / <kbd>Ctrl</kbd> + <kbd>PageDown</kbd></td><td>Next site</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd> / <kbd>Ctrl</kbd> + <kbd>PageUp</kbd></td><td>Previous site</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="hint" style="margin-top:10px">Screens with no keyboard can show on-screen back / home / forward buttons: Display → "On-screen back / home / forward buttons". A mouse's side buttons also go back and forward.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Display & Interaction -->
|
<!-- Display & Interaction -->
|
||||||
@@ -95,6 +107,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 / home / forward buttons (top middle)</label>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Home page</legend>
|
<legend>Home page</legend>
|
||||||
@@ -104,6 +117,10 @@
|
|||||||
<option value="-1">Disabled</option>
|
<option value="-1">Disabled</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Back/home/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">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Kiosk Web UI - sign in</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<style>
|
||||||
|
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||||
|
.login { width: min(360px, 92vw); padding: 28px; border-radius: 14px; background: var(--surface, #fff);
|
||||||
|
box-shadow: 0 6px 30px rgba(0,0,0,.18); }
|
||||||
|
.login h1 { font-size: 20px; margin: 0 0 6px; }
|
||||||
|
.login p { margin: 0 0 16px; opacity: .75; font-size: 14px; }
|
||||||
|
.login input { width: 100%; box-sizing: border-box; padding: 12px; font-size: 18px; margin-bottom: 12px; }
|
||||||
|
.login button { width: 100%; padding: 12px; font-size: 16px; cursor: pointer; }
|
||||||
|
#err { color: #c0392b; min-height: 20px; margin-top: 10px; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="login" id="form">
|
||||||
|
<h1>Kiosk Web UI</h1>
|
||||||
|
<p id="hint">Enter the kiosk's code to continue.</p>
|
||||||
|
<input id="code" type="password" autocomplete="current-password" autofocus>
|
||||||
|
<button type="submit" id="go">Sign in</button>
|
||||||
|
<div id="err"></div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
fetch('/api/auth').then((r) => r.json()).then((s) => {
|
||||||
|
if (s.authed) { location.href = '/'; return; }
|
||||||
|
if (s.codeType === 'pin') {
|
||||||
|
$('hint').textContent = "Enter the kiosk's PIN to continue.";
|
||||||
|
$('code').setAttribute('inputmode', 'numeric');
|
||||||
|
} else {
|
||||||
|
$('hint').textContent = "Enter the kiosk's password to continue.";
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
$('form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
$('go').disabled = true;
|
||||||
|
$('err').textContent = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: $('code').value }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.ok) { location.href = '/'; return; }
|
||||||
|
$('err').textContent = data.error || 'Sign in failed';
|
||||||
|
$('code').value = '';
|
||||||
|
$('code').focus();
|
||||||
|
} catch (err) {
|
||||||
|
$('err').textContent = 'Could not reach the kiosk';
|
||||||
|
}
|
||||||
|
$('go').disabled = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -393,3 +393,14 @@ button.danger:hover { background: var(--danger-soft); }
|
|||||||
.sidebar-footer { display: none; }
|
.sidebar-footer { display: none; }
|
||||||
.main { padding: 20px; }
|
.main { padding: 20px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard shortcuts list on the Sites page */
|
||||||
|
.shortcuts { border-collapse: collapse; width: 100%; font-size: 13.5px; }
|
||||||
|
.shortcuts td { padding: 6px 10px 6px 0; vertical-align: top; }
|
||||||
|
.shortcuts td:first-child { white-space: nowrap; }
|
||||||
|
kbd {
|
||||||
|
display: inline-block; padding: 1px 6px; font: inherit; font-size: 12px;
|
||||||
|
border: 1px solid var(--border, #cfd4da); border-bottom-width: 2px; border-radius: 4px;
|
||||||
|
background: var(--surface-2, rgba(127,127,127,.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-7
@@ -6,11 +6,12 @@
|
|||||||
// no privileged system mutation involved (see menus/addon_webui.sh's
|
// no privileged system mutation involved (see menus/addon_webui.sh's
|
||||||
// header for why the rest of Core Settings/Addons/Advanced aren't here).
|
// header for why the rest of Core Settings/Addons/Advanced aren't here).
|
||||||
//
|
//
|
||||||
// No login of its own by design: Authelia runs elsewhere, and the admin
|
// Login: when the kiosk's password protection is on, the Web UI asks for the
|
||||||
// site goes behind the user's own Caddy reverse proxy with Authelia
|
// same PIN/password the lock screen uses (lib/auth.js); with it off it is
|
||||||
// forward-auth in front of it, the same way every other self-hosted app
|
// open. Beyond that, no login of its own: Authelia runs elsewhere, and the
|
||||||
// they run is protected. This process only binds where it's told to
|
// admin site can go behind the user's own Caddy reverse proxy with Authelia
|
||||||
// (BIND_ADDR/PORT below) and trusts whatever's in front of it.
|
// forward-auth in front of it. This process only binds where it's told to
|
||||||
|
// (BIND_ADDR/PORT below).
|
||||||
//
|
//
|
||||||
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
|
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
|
||||||
// installs) - the same user Electron runs as, and the owner of
|
// installs) - the same user Electron runs as, and the owner of
|
||||||
@@ -22,6 +23,7 @@ const express = require('express');
|
|||||||
const { loadConfig, saveConfig } = require('./lib/config');
|
const { loadConfig, saveConfig } = require('./lib/config');
|
||||||
const { ACTIONS } = require('./lib/actions');
|
const { ACTIONS } = require('./lib/actions');
|
||||||
const { startJob, getJob } = require('./lib/jobs');
|
const { startJob, getJob } = require('./lib/jobs');
|
||||||
|
const auth = require('./lib/auth');
|
||||||
|
|
||||||
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||||
const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
|
const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
|
||||||
@@ -29,6 +31,14 @@ const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'su
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '256kb' }));
|
app.use(express.json({ limit: '256kb' }));
|
||||||
|
|
||||||
|
// When the kiosk's password protection is on, the Web UI asks for the same
|
||||||
|
// PIN/password (see lib/auth.js). With it off, nothing changes.
|
||||||
|
app.post('/api/login', auth.login);
|
||||||
|
app.post('/api/logout', auth.logout);
|
||||||
|
app.get('/api/auth', auth.status);
|
||||||
|
app.use(auth.requireLogin(path.join(__dirname, 'public', 'login.html')));
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
const SWIPE_MODES = ['dual', 'standard'];
|
const SWIPE_MODES = ['dual', 'standard'];
|
||||||
@@ -70,6 +80,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) {
|
||||||
@@ -173,7 +189,11 @@ app.put('/api/config', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res.json(saveConfig(patch));
|
const saved = saveConfig(patch);
|
||||||
|
// If this save set or changed the kiosk's code, every old session
|
||||||
|
// just stopped working - keep the person who saved it signed in.
|
||||||
|
auth.keepLoggedInAfterSave(res);
|
||||||
|
res.json(saved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('saveConfig failed:', e);
|
console.error('saveConfig failed:', e);
|
||||||
res.status(500).json({ error: 'Failed to save configuration' });
|
res.status(500).json({ error: 'Failed to save configuration' });
|
||||||
@@ -288,7 +308,7 @@ app.put('/api/region', (req, res) => {
|
|||||||
runRegionHelper('region-set', input, res);
|
runRegionHelper('region-set', input, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8090;
|
const PORT = process.env.PORT || 8888;
|
||||||
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helpe
|
|||||||
|
|
||||||
const app = require('../server');
|
const app = require('../server');
|
||||||
|
|
||||||
|
// These tests turn password protection on partway through, and the Web UI
|
||||||
|
// then (correctly) wants a login. A browser keeps the session cookie the
|
||||||
|
// server hands out on a save; Node's fetch doesn't, so keep it here.
|
||||||
|
const nodeFetch = global.fetch;
|
||||||
|
let sessionCookie = '';
|
||||||
|
global.fetch = async (url, opts = {}) => {
|
||||||
|
const headers = { ...(opts.headers || {}) };
|
||||||
|
if (sessionCookie) headers.cookie = sessionCookie;
|
||||||
|
const res = await nodeFetch(url, { ...opts, headers });
|
||||||
|
const set = res.headers.get('set-cookie');
|
||||||
|
if (set) sessionCookie = set.split(';')[0];
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
async function check(label, fn) {
|
async function check(label, fn) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// webui/test/auth.test.js - the Web UI login: with the kiosk's password
|
||||||
|
// protection off the UI is open; with it on, everything needs the kiosk's
|
||||||
|
// PIN/password. Starts the real server on a random port against a scratch
|
||||||
|
// config.json. Run with: node test/auth.test.js
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-auth-test-'));
|
||||||
|
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||||
|
process.env.SUDO_CMD = '';
|
||||||
|
|
||||||
|
const app = require('../server');
|
||||||
|
const { saveConfig } = require('../lib/config');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
async function check(label, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(`PASS: ${label}`);
|
||||||
|
} catch (e) {
|
||||||
|
failures++;
|
||||||
|
console.log(`FAIL: ${label} - ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = app.listen(0, '127.0.0.1');
|
||||||
|
await new Promise((resolve) => server.once('listening', resolve));
|
||||||
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
const post = (url, body, cookie) => fetch(base + url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
});
|
||||||
|
const get = (url, cookie) => fetch(base + url, { headers: cookie ? { cookie } : {} });
|
||||||
|
const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
|
||||||
|
|
||||||
|
await check('protection off: no login needed, /api/auth says so', async () => {
|
||||||
|
assert.strictEqual((await get('/api/config')).status, 200);
|
||||||
|
const a = await (await get('/api/auth')).json();
|
||||||
|
assert.strictEqual(a.required, false);
|
||||||
|
assert.strictEqual(a.authed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'hunter22' });
|
||||||
|
|
||||||
|
await check('protection on: API calls are refused without a login', async () => {
|
||||||
|
const res = await get('/api/config');
|
||||||
|
assert.strictEqual(res.status, 401);
|
||||||
|
assert.strictEqual((await res.json()).loginRequired, true);
|
||||||
|
assert.strictEqual((await post('/api/actions/anything/run')).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('protection on: pages get the login screen, not the app', async () => {
|
||||||
|
const res = await get('/');
|
||||||
|
assert.strictEqual(res.status, 401);
|
||||||
|
const html = await res.text();
|
||||||
|
assert.ok(html.includes('id="code"'));
|
||||||
|
assert.ok(!html.includes('nav-item'));
|
||||||
|
assert.strictEqual((await get('/app.js')).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('/api/auth reports a login is required, and the code type', async () => {
|
||||||
|
const a = await (await get('/api/auth')).json();
|
||||||
|
assert.strictEqual(a.required, true);
|
||||||
|
assert.strictEqual(a.authed, false);
|
||||||
|
assert.strictEqual(a.codeType, 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('a wrong code is refused and gives no session', async () => {
|
||||||
|
const res = await post('/api/login', { code: 'nope' });
|
||||||
|
assert.strictEqual(res.status, 401);
|
||||||
|
assert.strictEqual(res.headers.get('set-cookie'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
let cookie = '';
|
||||||
|
await check('the right code gives an HttpOnly session that unlocks the API', async () => {
|
||||||
|
const res = await post('/api/login', { code: 'hunter22' });
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const raw = res.headers.get('set-cookie');
|
||||||
|
assert.ok(/HttpOnly/i.test(raw) && /SameSite=Strict/i.test(raw));
|
||||||
|
cookie = cookieOf(res);
|
||||||
|
assert.strictEqual((await get('/api/config', cookie)).status, 200);
|
||||||
|
assert.strictEqual((await get('/', cookie)).status, 200);
|
||||||
|
assert.strictEqual((await (await get('/api/auth', cookie)).json()).authed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('the stored code hash is never sent to the browser', async () => {
|
||||||
|
const text = await (await get('/api/config', cookie)).text();
|
||||||
|
assert.ok(!text.includes('lockoutPassword"'));
|
||||||
|
assert.ok(!/[0-9a-f]{64}/.test(text));
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('saving settings keeps you signed in', async () => {
|
||||||
|
const res = await fetch(`${base}/api/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', cookie },
|
||||||
|
body: JSON.stringify({ enableNavButton: false }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('changing the kiosk code signs every browser out', async () => {
|
||||||
|
saveConfig({ newLockoutPassword: 'another-one' });
|
||||||
|
assert.strictEqual((await get('/api/config', cookie)).status, 401);
|
||||||
|
assert.strictEqual((await post('/api/login', { code: 'hunter22' })).status, 401);
|
||||||
|
const ok = await post('/api/login', { code: 'another-one' });
|
||||||
|
assert.strictEqual(ok.status, 200);
|
||||||
|
cookie = cookieOf(ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('setting a new code from the Web UI keeps that browser signed in', async () => {
|
||||||
|
const res = await fetch(`${base}/api/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', cookie },
|
||||||
|
body: JSON.stringify({ newLockoutPassword: 'third-code' }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const fresh = cookieOf(res);
|
||||||
|
assert.ok(fresh && fresh !== cookie);
|
||||||
|
assert.strictEqual((await get('/api/config', fresh)).status, 200);
|
||||||
|
assert.strictEqual((await get('/api/config', cookie)).status, 401);
|
||||||
|
cookie = fresh;
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('sign out ends the session', async () => {
|
||||||
|
assert.strictEqual((await post('/api/logout', {}, cookie)).status, 200);
|
||||||
|
assert.strictEqual((await get('/api/config', cookie)).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('five wrong codes in a row slow logins down - even the right code waits', async () => {
|
||||||
|
let last;
|
||||||
|
for (let i = 0; i < 5; i++) last = await post('/api/login', { code: 'wrong' + i });
|
||||||
|
assert.strictEqual(last.status, 429);
|
||||||
|
assert.ok((await last.json()).waitSeconds >= 30);
|
||||||
|
assert.strictEqual((await post('/api/login', { code: 'third-code' })).status, 429);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||||
|
if (failures > 0) {
|
||||||
|
console.log(`${failures} FAILURE(S)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('ALL DONE');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -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' }],
|
||||||
|
|||||||
Reference in New Issue
Block a user