Author SHA1 Message Date
Outis c6532c1367 Merge pull request #137 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 22:20:00 -05:00
Outis 9eadf266f9 Merge pull request #136 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 17:15:49 -05:00
Outis 886745a979 Merge pull request #135 from outis1one/claude/optimistic-clarke-27gpk1
Web UI login with the kiosk's PIN/password; shortcuts list on the Sites page
2026-11-06 13:48:43 -05:00
Claude e0fa69d470 Web UI login with the kiosk's PIN/password; shortcuts list on the Sites page
When the kiosk's password protection is on, the Web UI now asks for the same
PIN/password the lock screen uses: random session cookie (12 h, HttpOnly,
SameSite=Strict), the lock screen's wrong-code slowdown, sessions end when
the kiosk's code changes, sign out. With protection off nothing changes.
The Sites page (Web UI and terminal menu) lists the back/forward/home and
site-switching keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:47:00 +00:00
Outis 84e8f074e8 Merge pull request #134 from outis1one/claude/optimistic-clarke-27gpk1
Web UI default port 8888; Lock now in the power menu
2026-11-06 13:37:28 -05:00
Claude 551df48687 Web UI default port 8888; Lock now in the power menu
New installs put the Web UI on port 8888 instead of 8090 (existing installs
keep their port; change it under Addons -> Web UI, which now also closes the
old port in the firewall). The power menu gets a Lock now button, shown only
when password protection is on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:37:13 +00:00
Outis f09bd6bb95 Merge pull request #133 from outis1one/claude/optimistic-clarke-27gpk1
Home button between back and forward; Alt+Home
2026-11-06 13:31:51 -05:00
Claude 9c1f22382e Home button between back and forward; Alt+Home
The on-screen buttons are now back / home / forward. Home (and Alt+Home)
loads the site on screen from the address it was set up with under Sites &
Page Timing, hidden sites included. A shared buildTabUrl() builds that
address for both the first load and the home button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:31:38 +00:00
Outis b836fd6771 Merge pull request #132 from outis1one/claude/optimistic-clarke-27gpk1
Back/forward buttons: adjustable opacity; Upgrade refreshes the Web UI copy
2026-11-06 13:23:30 -05:00
Claude fa0d602505 Back/forward buttons: adjustable opacity; Upgrade refreshes the Web UI copy
New historyButtonsOpacity setting (10-100 percent, default 50) in the
terminal menu (Display & Interaction) and the Web UI. Upgrade now also
copies the Web UI app to /opt/kiosk-webui and restarts it, so new Web UI
settings appear after an upgrade (it previously kept its old copy).

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 15:55:03 +00:00
36 changed files with 1146 additions and 238 deletions
+22
View File
@@ -0,0 +1,22 @@
# CLAUDE.md
## No user data in the repo
Never write anything specific to a person's kiosk, network or accounts into
this repository - not in code, tests, fixtures, docs, comments, commit
messages, branch names or pull request text.
That includes:
- WiFi network names (SSIDs), passwords, keys and tokens
- MAC addresses, IP addresses, hostnames and interface names taken from a
real machine
- usernames, email addresses, PINs and passwords
- the URLs of someone's own sites or services
- log output, screenshots or terminal output pasted in by a user
When an example, test or fixture needs a value like these, use an obvious
placeholder (`ExampleNet`, `192.0.2.10`, `aa:bb:cc:dd:ee:ff`, `wlan0`,
`example.com`) instead of a real one. When debugging from output a user
shares, keep it in the conversation or the scratchpad, not in a file in this
repo, and leave it out of commit messages.
+12 -5
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,6 @@
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron'); const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
const {exec,spawn}=require('child_process'); const {exec,spawn}=require('child_process');
const net=require('net');
const fs=require('fs'); const fs=require('fs');
const path=require('path'); const path=require('path');
const os=require('os'); const os=require('os');
@@ -59,6 +60,8 @@ let allowNavigation='same-origin';
let enablePauseButton=true; let enablePauseButton=true;
let enableKeyboardButton=true; let enableKeyboardButton=true;
let enableNavButton=true; let enableNavButton=true;
let enableHistoryButtons=false;
let historyButtonsOpacity=50;
let enablePasswordProtection=false; let enablePasswordProtection=false;
let lockoutPassword=""; let lockoutPassword="";
let lockoutTimeout=0; let lockoutTimeout=0;
@@ -101,6 +104,11 @@ function loadConfig(){
enablePauseButton=(config.enablePauseButton!==false); enablePauseButton=(config.enablePauseButton!==false);
enableKeyboardButton=(config.enableKeyboardButton!==false); enableKeyboardButton=(config.enableKeyboardButton!==false);
enableNavButton=(config.enableNavButton!==false); enableNavButton=(config.enableNavButton!==false);
enableHistoryButtons=(config.enableHistoryButtons===true);
{
const pct=Number(config.historyButtonsOpacity);
historyButtonsOpacity=(Number.isInteger(pct)&&pct>=10&&pct<=100)?pct:50;
}
enablePasswordProtection=(config.enablePasswordProtection===true); enablePasswordProtection=(config.enablePasswordProtection===true);
lockoutPassword=config.lockoutPassword||""; lockoutPassword=config.lockoutPassword||"";
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
@@ -121,6 +129,7 @@ function loadConfig(){
console.log('[CONFIG] Navigation:',allowNavigation); console.log('[CONFIG] Navigation:',allowNavigation);
console.log('[CONFIG] Pause button:',enablePauseButton); console.log('[CONFIG] Pause button:',enablePauseButton);
console.log('[CONFIG] Keyboard button:',enableKeyboardButton); console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons,'opacity',historyButtonsOpacity+'%');
console.log('[CONFIG] Password protection:',enablePasswordProtection); console.log('[CONFIG] Password protection:',enablePasswordProtection);
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes'); console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime); if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
@@ -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);
+94
View File
@@ -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=>{
+48 -7
View File
@@ -95,7 +95,11 @@
</div> </div>
<div id="wifi-step" class="hidden"> <div id="wifi-step" class="hidden">
<div id="status">Checking…</div> <div id="adapter-row" class="hidden">
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
<select id="adapter"></select>
</div>
<div id="status" style="margin-top: 12px">Checking…</div>
<div class="row" style="margin-top: 14px"> <div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label> <label style="margin: 0; flex: 1">Networks nearby</label>
@@ -139,6 +143,7 @@
const {ipcRenderer} = require('electron'); const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let busy = false; let busy = false;
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
function request(action, extra) { function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {})) return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
@@ -163,16 +168,47 @@
return '▂'; return '▂';
} }
function adapterLabel(a) {
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
}
// Fill the dropdown from the helper's adapter list. Shown only when
// there is more than one adapter; the choice sticks across refreshes.
function fillAdapters(r) {
const row = $('adapter-row');
const sel = $('adapter');
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
const names = r.adapters.map(a => a.name);
if (!names.includes(adapter)) adapter = r.selected || names[0];
sel.textContent = '';
for (const a of r.adapters) {
const o = document.createElement('option');
o.value = a.name;
o.textContent = adapterLabel(a);
if (a.name === adapter) o.selected = true;
sel.appendChild(o);
}
row.classList.toggle('hidden', r.adapters.length < 2);
return r;
}
async function refreshStatus() { async function refreshStatus() {
const s = await request('status'); const ad = fillAdapters(await request('ifaces'));
const s = await request('status', adapter ? {iface: adapter} : {});
const box = $('status'); const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; } if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; } if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = ''; box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); }; const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
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(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
line(`Adapter: ${s.iface}`); line(`Adapter: ${s.iface}`);
}
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured; if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
} }
@@ -181,7 +217,7 @@
setBusy(true); setBusy(true);
message('Scanning…', 'busy'); message('Scanning…', 'busy');
const list = $('networks'); const list = $('networks');
const r = await request('scan'); const r = await request('scan', adapter ? {iface: adapter} : {});
list.textContent = ''; list.textContent = '';
if (!r.ok) { if (!r.ok) {
message(r.error, 'error'); message(r.error, 'error');
@@ -217,7 +253,7 @@
if (!ssid) { message('Pick a network or type its name', 'error'); return; } if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true); setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy'); message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password}); const r = await request('connect', {ssid, password, iface: adapter});
if (r.ok) { if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok'); message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = ''; $('password').value = '';
@@ -287,14 +323,19 @@
message(''); message('');
$('pin-step').classList.add('hidden'); $('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden'); $('wifi-step').classList.remove('hidden');
refreshStatus(); refreshStatus().then(() => scan());
loadRegion(); loadRegion();
scan();
} }
$('pin-ok').addEventListener('click', unlock); $('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); }); $('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan); $('scan').addEventListener('click', scan);
$('adapter').addEventListener('change', () => {
adapter = $('adapter').value;
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
refreshStatus();
scan();
});
$('region-save').addEventListener('click', saveRegion); $('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale); $('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect); $('connect').addEventListener('click', connect);
+15 -2
View File
@@ -79,6 +79,8 @@ INACTIVITY_TIMEOUT=120
ENABLE_PAUSE_BUTTON="true" ENABLE_PAUSE_BUTTON="true"
ENABLE_KEYBOARD_BUTTON="true" ENABLE_KEYBOARD_BUTTON="true"
ENABLE_NAV_BUTTON="true" ENABLE_NAV_BUTTON="true"
ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=50
ENABLE_PASSWORD_PROTECTION="false" ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD="" LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0 LOCKOUT_TIMEOUT=0
@@ -203,7 +205,7 @@ load_existing_config() {
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin") ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin")
SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual") SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual")
local pause_btn keyboard_btn nav_btn password_enabled boot_password local pause_btn keyboard_btn nav_btn history_btn password_enabled boot_password
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false" [[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
@@ -213,6 +215,12 @@ load_existing_config() {
nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null) nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false" [[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false"
history_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableHistoryButtons // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$history_btn" == "true" ]] && ENABLE_HISTORY_BUTTONS="true" || ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=$(sudo -u "$KIOSK_USER" jq -r '.historyButtonsOpacity // 50' "$CONFIG_PATH" 2>/dev/null || echo "50")
[[ "$HISTORY_BUTTONS_OPACITY" =~ ^[0-9]+$ ]] && (( HISTORY_BUTTONS_OPACITY >= 10 && HISTORY_BUTTONS_OPACITY <= 100 )) || HISTORY_BUTTONS_OPACITY=50
password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null) password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false" [[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
@@ -263,6 +271,9 @@ save_config() {
local nav_btn_json="true" local nav_btn_json="true"
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false" [[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false"
local history_btn_json="false"
[[ "$ENABLE_HISTORY_BUTTONS" == "true" ]] && history_btn_json="true"
local password_json="false" local password_json="false"
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true" [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
@@ -301,6 +312,8 @@ save_config() {
--argjson enablePauseButton "$pause_btn_json" \ --argjson enablePauseButton "$pause_btn_json" \
--argjson enableKeyboardButton "$keyboard_btn_json" \ --argjson enableKeyboardButton "$keyboard_btn_json" \
--argjson enableNavButton "$nav_btn_json" \ --argjson enableNavButton "$nav_btn_json" \
--argjson enableHistoryButtons "$history_btn_json" \
--argjson historyButtonsOpacity "${HISTORY_BUTTONS_OPACITY:-50}" \
--argjson enablePasswordProtection "$password_json" \ --argjson enablePasswordProtection "$password_json" \
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \ --arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \ --argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
@@ -314,7 +327,7 @@ save_config() {
--arg autheliaURL "${AUTHELIA_URL:-}" \ --arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \ --arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \ --arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp" '. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enableHistoryButtons:$enableHistoryButtons,historyButtonsOpacity:$historyButtonsOpacity,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
if [[ ${#URLS[@]} -gt 0 ]]; then if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do for idx in "${!URLS[@]}"; do
+25 -3
View File
@@ -198,7 +198,12 @@ provision_configure_display() {
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can # a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
# flip this later, and does below if declined at the end of setup. # flip this later, and does below if declined at the end of setup.
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
# 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 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"
-3
View File
@@ -3,6 +3,3 @@
# Extra CA certificates dropped in for building behind a proxy # Extra CA certificates dropped in for building behind a proxy
koha/certs/*.crt koha/certs/*.crt
quickadd/certs/*.crt quickadd/certs/*.crt
# Generated by install.sh --domain
Caddyfile
caddy-root.crt
+4 -33
View File
@@ -12,9 +12,9 @@ it never overwrites your `.env` (passwords) or your data.
| What | Address | Login | | What | Address | Login |
|---|---|---| |---|---|---|
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8098` | `admin` and the generated password | | **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8081` | `admin` and the generated password |
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8098/add/` | same login (asked once more) | | **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8081/add/` | same login (asked once more) |
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8097` | none | | **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8080` | none |
For home use you only need the first address: sign in to **Your library**, and use the **Add books** For home use you only need the first address: sign in to **Your library**, and use the **Add books**
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
@@ -24,7 +24,7 @@ staff sites the same URL shapes, so they cannot share one address; Add books can
Ports move up to the next free number if one is taken; the final addresses are printed Ports move up to the next free number if one is taken; the final addresses are printed
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
(`8099`), which is handy behind a proxy but not needed otherwise. (`8082`), which is handy behind a proxy but not needed otherwise.
## What it is made of ## What it is made of
@@ -210,11 +210,6 @@ Staff who are signed in to the library site (and allowed to edit the catalogue)
## The home page (the Room for Books site at `/add/`) ## The home page (the Room for Books site at `/add/`)
**One address.** `http://<ip>:<port>/` (and later `https://your-domain/` behind Caddy) opens this page; there is no separate
port to remember. Koha's own staff screens live on the same address at `/cgi-bin/koha/mainpage.pl`, reached from the menu
(*Koha (full staff screens)*). Everyone signs in at the same prompt: staff with the admin login, borrowers with their card number
and password, the guest with the guest login.
One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons
(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu** (**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu**
(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import, (top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import,
@@ -290,27 +285,3 @@ points, quiz number and interest level fill in by themselves; check them and pre
The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
it into AR's search; AR's own page cannot be pre-filled from here. it into AR's search; AR's own page cannot be pre-filled from here.
### Same ports on every install
New installs of this stack use the same three ports whether you install it from the kiosk add-on or from `ubuntu-post-install`:
**8098** is the one to use (the Room for Books home page, with Koha behind the menu), 8099 is Add books direct, and 8097 is the
public catalogue, which stays closed. If one is taken, the next free port is used and the installer prints it. Existing installs
keep the ports they already have.
## HTTPS with Caddy (and the live camera scanner)
Phones only allow a live camera on an HTTPS page. One command adds Caddy in front of the library:
```bash
# a real domain that points at this machine (ports 80 and 443 reachable): the certificate is automatic
sudo ./install.sh --domain library.example.com
# a name only your own network knows (library.home.lan): Caddy makes its own certificate authority
sudo ./install.sh --domain library.home.lan --lan-tls # then install caddy-root.crt on your phones
```
It writes `Caddyfile`, starts a `caddy` container (the compose `caddy` profile), sets `STAFF_URL=https://<name>` and closes the plain-HTTP
ports to the network (`BIND_ADDR=127.0.0.1`; add `--keep-direct` to leave them open). Then use `https://<name>/`: everything works
as before, and the **Live scan** buttons appear on Add books and Lend / return. Behind your own Caddy instead, point it at the library
port: `library.example.com { reverse_proxy <this machine>:8098 }` and set `STAFF_URL=https://library.example.com` in `.env`.
+5 -24
View File
@@ -46,8 +46,8 @@ services:
KOHA_ITEM_TYPES: ${ITEM_TYPES:-} KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports: ports:
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it. # The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8097}:80" - "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8098}:8081" - "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
volumes: volumes:
- koha-data:/var/lib/koha - koha-data:/var/lib/koha
@@ -67,36 +67,17 @@ services:
VIEWER_USER: ${VIEWER_USER:-guest} VIEWER_USER: ${VIEWER_USER:-guest}
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-} VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME} KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8097} OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8098} STAFF_PORT: ${STAFF_PORT:-8081}
OPAC_URL: ${OPAC_URL:-} OPAC_URL: ${OPAC_URL:-}
STAFF_URL: ${STAFF_URL:-} STAFF_URL: ${STAFF_URL:-}
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
ports: ports:
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8099}:8082" - "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082"
volumes: volumes:
- quickadd-data:/data - quickadd-data:/data
# HTTPS with an automatic certificate (and so the live camera on phones): only started when
# `./install.sh --domain <name>` has set COMPOSE_PROFILES=caddy in .env.
caddy:
profiles: ["caddy"]
image: caddy:2
container_name: library-caddy
restart: unless-stopped
depends_on:
- koha
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes: volumes:
db-data: db-data:
koha-data: koha-data:
quickadd-data: quickadd-data:
caddy-data:
caddy-config:
+6 -68
View File
@@ -5,16 +5,13 @@
# sudo ./install.sh --status # show URLs and container health # sudo ./install.sh --status # show URLs and container health
# sudo ./install.sh --update # rebuild with the latest Koha and restart # sudo ./install.sh --update # rebuild with the latest Koha and restart
# sudo ./install.sh --stop | --start # sudo ./install.sh --stop | --start
# sudo ./install.sh --domain library.example.com # HTTPS via Caddy (certificate is automatic)
# sudo ./install.sh --domain library.home.lan --lan-tls # HTTPS for a name only your network knows (own CA)
# add --keep-direct to also keep the plain-HTTP ports open on the network
# #
# Optional environment variables: # Optional environment variables:
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue # LIBRARY_NAME="Smith Family Library" name shown on the catalogue
# LIBRARY_CODE=HOME short code for the library (letters/digits) # LIBRARY_CODE=HOME short code for the library (letters/digits)
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only) # SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
# ITEM_TYPES="CD=Compact disc" extra item types (first install only) # ITEM_TYPES="CD=Compact disc" extra item types (first install only)
# OPAC_PORT=8097 STAFF_PORT=8098 preferred host ports (next free is used) # OPAC_PORT=8080 STAFF_PORT=8081 preferred host ports (next free is used)
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building # EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
set -euo pipefail set -euo pipefail
@@ -82,10 +79,6 @@ show_status() {
fi fi
dc ps dc ps
echo echo
if [ -n "${DOMAIN:-}" ]; then
echo " Your library (HTTPS): https://$DOMAIN <- use this one: the live camera scanner needs it"
echo " (Koha's own screens: https://$DOMAIN/cgi-bin/koha/mainpage.pl, or the menu inside)"
fi
echo " Your library: http://$ip:$STAFF_PORT" echo " Your library: http://$ip:$STAFF_PORT"
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people" echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
@@ -97,52 +90,6 @@ show_status() {
echo echo
} }
# --- HTTPS (Caddy) options --------------------------------------------------------
DOMAIN_ARG="" LAN_TLS=0 KEEP_DIRECT=0 ACTION=""
while [ $# -gt 0 ]; do
case "$1" in
--domain) DOMAIN_ARG="${2:-}"; [ -n "$DOMAIN_ARG" ] || { c_err "--domain needs a name"; exit 1; }; shift 2 ;;
--lan-tls) LAN_TLS=1; shift ;;
--keep-direct) KEEP_DIRECT=1; shift ;;
*) ACTION="${ACTION:+$ACTION }$1"; shift ;;
esac
done
set -- $ACTION
if [ -n "$DOMAIN_ARG" ] && ! printf '%s' "$DOMAIN_ARG" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$'; then
c_err "That does not look like a host name: $DOMAIN_ARG"; exit 1
fi
# Write (or replace) KEY=VALUE in .env
env_set() { if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi; }
configure_caddy() {
local name="$DOMAIN_ARG"
# Caddy needs 80 and 443 (it is not moved to other ports: browsers expect these).
if ! dc ps --status running --services 2>/dev/null | grep -qx caddy; then
for p in 80 443; do
port_in_use "$p" && { c_err "Port $p is already in use on this machine - Caddy needs it. Stop what is using it, or use your own reverse proxy (see README)."; exit 1; }
done
fi
{
echo "# Generated by install.sh --domain - Caddy fetches and renews the certificate by itself."
echo "$name {"
[ "$LAN_TLS" = 1 ] && echo " tls internal"
echo " encode zstd gzip"
echo " reverse_proxy koha:8081"
echo "}"
} > Caddyfile
env_set DOMAIN "$name"
env_set COMPOSE_PROFILES caddy
env_set STAFF_URL "https://$name"
[ "$KEEP_DIRECT" = 1 ] || env_set BIND_ADDR 127.0.0.1
c_ok "Caddy will serve https://$name"
if [ "$LAN_TLS" = 1 ]; then
c_info "Own-CA certificate: phones must trust Caddy's root, which is copied to $HERE/caddy-root.crt after start."
else
c_info "The name must point at this machine (DNS) and ports 80/443 must reach it, or the certificate cannot be issued."
fi
}
case "${1:-}" in case "${1:-}" in
--status) show_status; exit 0 ;; --status) show_status; exit 0 ;;
--stop) dc stop; exit 0 ;; --stop) dc stop; exit 0 ;;
@@ -170,9 +117,9 @@ LIBRARY_CODE=${LIBRARY_CODE:-HOME}
SHELVES="${SHELVES:-}" SHELVES="${SHELVES:-}"
ITEM_TYPES="${ITEM_TYPES:-}" ITEM_TYPES="${ITEM_TYPES:-}"
TZ=${TZ_NOW} TZ=${TZ_NOW}
OPAC_PORT=$(free_port "${OPAC_PORT:-8097}") OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8098}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8099}") QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
BIND_ADDR=${BIND_ADDR:-0.0.0.0} BIND_ADDR=${BIND_ADDR:-0.0.0.0}
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network. # The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
OPAC_BIND=${OPAC_BIND:-127.0.0.1} OPAC_BIND=${OPAC_BIND:-127.0.0.1}
@@ -192,7 +139,7 @@ else
c_info "Re-using existing .env" c_info "Re-using existing .env"
# Settings added in later versions: append them to an older .env. # Settings added in later versions: append them to an older .env.
if ! grep -q '^QUICKADD_PORT=' .env; then if ! grep -q '^QUICKADD_PORT=' .env; then
echo "QUICKADD_PORT=$(free_port 8099)" >> .env echo "QUICKADD_PORT=$(free_port 8082)" >> .env
fi fi
if ! grep -q '^VIEWER_PASSWORD=' .env; then if ! grep -q '^VIEWER_PASSWORD=' .env; then
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env { echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
@@ -205,8 +152,6 @@ else
fi fi
fi fi
[ -n "$DOMAIN_ARG" ] && configure_caddy
if [ -n "${EXTRA_CA:-}" ]; then if [ -n "${EXTRA_CA:-}" ]; then
cp "$EXTRA_CA" koha/certs/extra-ca.crt cp "$EXTRA_CA" koha/certs/extra-ca.crt
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
@@ -225,7 +170,7 @@ with_retry dc up -d
c_info "First start installs the database - waiting until Koha reports healthy..." c_info "First start installs the database - waiting until Koha reports healthy..."
for i in $(seq 1 180); do for i in $(seq 1 180); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)" health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)"
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8099}/healthz" 2>/dev/null; then READY=1; break; fi if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8082}/healthz" 2>/dev/null; then READY=1; break; fi
# One restart is tolerated (Docker's restart policy); a crash loop is not. # One restart is tolerated (Docker's restart policy); a crash loop is not.
if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1 c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
@@ -235,12 +180,5 @@ done
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; } [ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
c_ok "Your library is running." c_ok "Your library is running."
if [ -n "${DOMAIN:-}" ] && grep -q '^ tls internal' Caddyfile 2>/dev/null; then
for i in $(seq 1 20); do
docker cp library-caddy:/data/caddy/pki/authorities/local/root.crt "$HERE/caddy-root.crt" >/dev/null 2>&1 && break
sleep 2
done
[ -f "$HERE/caddy-root.crt" ] && c_info "Install this certificate on phones/computers that should trust https://$DOMAIN: $HERE/caddy-root.crt"
fi
echo echo
show_status show_status
-3
View File
@@ -78,9 +78,6 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
cat > /etc/koha/apache-quickadd.conf << APACHE cat > /etc/koha/apache-quickadd.conf << APACHE
<IfModule mod_proxy_http.c> <IfModule mod_proxy_http.c>
RedirectMatch ^/add\$ /add/ RedirectMatch ^/add\$ /add/
# The bare address (http://ip:port/ or https://your-domain/) opens the Room for Books home page;
# Koha's own staff screens stay at /cgi-bin/koha/mainpage.pl.
RedirectMatch ^/\$ /add/
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/ ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/ ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
${SSO_BLOCK} ${SSO_BLOCK}
Binary file not shown.
+2 -8
View File
@@ -985,8 +985,7 @@ def _loan_rows(cos):
due = (c.get("due_date") or "")[:10] due = (c.get("due_date") or "")[:10]
rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "", rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "",
"biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due, "biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due,
"overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0, "overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0})
"checkout_date": (c.get("checkout_date") or "")[:10]})
return rows return rows
@@ -1720,7 +1719,6 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Permissions-Policy", "camera=(self), microphone=()")
self.send_header("Referrer-Policy", "no-referrer") self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", self.send_header("Content-Security-Policy",
"default-src 'self'; img-src 'self' https: data:; " "default-src 'self'; img-src 'self' https: data:; "
@@ -1755,11 +1753,7 @@ class Handler(BaseHTTPRequestHandler):
# Otherwise: a patron signing in with their card number (or userid) and password. # Otherwise: a patron signing in with their card number (or userid) and password.
addr = self.client_address[0] addr = self.client_address[0]
if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()): if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()):
# Behind Caddy the list reads "<visitor>, <Caddy>" (Caddy adds the visitor, the staff site's web server addr = self.headers.get("X-Forwarded-For", "").split(",")[-1].strip() or addr
# adds Caddy), so the visitor is the one before the last. Used only to count failed sign-ins per
# visitor, never to vouch for anyone.
hops = [h.strip() for h in self.headers.get("X-Forwarded-For", "").split(",") if h.strip()]
addr = (hops[-2] if len(hops) >= 2 else hops[-1]) if hops else addr
self.addr = addr self.addr = addr
pid = patron_login(u, p, addr) pid = patron_login(u, p, addr)
if pid: if pid:
+11 -28
View File
@@ -201,7 +201,6 @@
<button class="primary" type="submit">Look up</button> <button class="primary" type="submit">Look up</button>
</form> </form>
<div id="homeresults" aria-live="polite"></div> <div id="homeresults" aria-live="polite"></div>
<div id="homemine" hidden></div>
<div class="bigacts" id="homeacts"> <div class="bigacts" id="homeacts">
<a class="bigact" href="#lend"><strong>Check out</strong><span>Scan a card, then the books</span></a> <a class="bigact" href="#lend"><strong>Check out</strong><span>Scan a card, then the books</span></a>
<a class="bigact" href="#return"><strong>Return</strong><span>Scan the books coming back</span></a> <a class="bigact" href="#return"><strong>Return</strong><span>Scan the books coming back</span></a>
@@ -1244,37 +1243,21 @@
}; };
$('printcard').onclick = () => window.print(); $('printcard').onclick = () => window.print();
// A borrower's loans: title, who wrote it, when it was borrowed, when it is due (days left / overdue), renewals, Renew. async function loadMine() {
function renderLoans(target, r, reload) { let r; try { r = await api('/api/me'); } catch (e) { r = {}; }
if (!r.loans) { $('meloans').textContent = r.message || 'Could not load your books.'; return; }
$('mename').textContent = r.name; $('mecard').textContent = r.card;
const today = new Date(); today.setHours(12, 0, 0, 0); const today = new Date(); today.setHours(12, 0, 0, 0);
const rows = r.loans.map(l => { const rows = r.loans.map(l => {
const days = Math.round((new Date(l.due + 'T12:00:00') - today) / 86400000); const days = Math.round((new Date(l.due + 'T12:00:00') - today) / 86400000);
const when = l.overdue ? 'Overdue by ' + (-days) + (days === -1 ? ' day' : ' days') : days === 0 ? 'Due today' : 'Due ' + fmtDue(l.due) + ' (in ' + days + (days === 1 ? ' day)' : ' days)'); const when = l.overdue ? 'Overdue by ' + (-days) + (days === -1 ? ' day' : ' days') : days === 0 ? 'Due today' : 'Due ' + fmtDue(l.due) + ' (in ' + days + (days === 1 ? ' day)' : ' days)');
const ren = el('button', { type: 'button' }, 'Renew'); const ren = el('button', { type: 'button' }, 'Renew');
ren.onclick = async () => { ren.disabled = true; const x = await api('/api/me/renew', { checkout_id: l.checkout_id }); ren.onclick = async () => { ren.disabled = true; const x = await api('/api/me/renew', { checkout_id: l.checkout_id });
if (x.status === 'renewed') reload(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed (the limit is reached, or someone is waiting for it).'); } }; if (x.status === 'renewed') loadMine(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed.'); } };
const facts = ['Borrowed ' + fmtDue(l.checkout_date), l.renewals ? 'renewed ' + (l.renewals === 1 ? 'once' : l.renewals + ' times') : ''].filter(Boolean).join(' · '); return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title), el('div', { className: 'small' }, l.author)),
return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title), el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, when), ren);
el('div', { className: 'small' }, [l.author, facts].filter(Boolean).join(' · '))),
el('span', { className: 'chip ' + (l.overdue ? 'late' : days <= 3 ? 'out' : 'in') }, when), ren);
}); });
const late = r.loans.filter(l => l.overdue).length; $('meloans').replaceChildren(...(rows.length ? rows : [el('div', { className: 'card ok' }, el('div', { className: 'body' }, 'You have nothing checked out right now.'))]));
const head = r.loans.length ? 'You have ' + r.loans.length + (r.loans.length === 1 ? ' book' : ' books') + ' out' + (late ? ', ' + late + ' overdue' : '') + '.' : 'You have nothing checked out right now.';
target.replaceChildren(el('div', { className: 'card ' + (late ? 'warn' : 'ok') }, el('div', { className: 'body' }, el('strong', {}, head))), ...rows);
}
async function loadMine() {
let r; try { r = await api('/api/me'); } catch (e) { r = {}; }
if (!r.loans) { $('meloans').textContent = r.message || 'Could not load your books.'; return; }
$('mename').textContent = r.name; $('mecard').textContent = r.card;
renderLoans($('meloans'), r, loadMine);
}
async function loadHomeMine() {
let r; try { r = await api('/api/me'); } catch (e) { return; }
if (!r.loans) return;
const box = $('homemine'); box.hidden = false;
const inner = el('div', {});
box.replaceChildren(el('h2', {}, 'Hello, ' + r.name), inner);
renderLoans(inner, r, loadHomeMine);
} }
$('mepwform').onsubmit = async ev => { $('mepwform').onsubmit = async ev => {
ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f))); ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f)));
@@ -1341,7 +1324,7 @@
const names = { home: 'Home', library: 'Browse', book: 'Book', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' }; const names = { home: 'Home', library: 'Browse', book: 'Book', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
document.title = (view === 'home' ? '' : names[view] + ' - ') + 'Room for Books'; document.title = (view === 'home' ? '' : names[view] + ' - ') + 'Room for Books';
window.scrollTo(0, 0); window.scrollTo(0, 0);
if (view === 'home') { $('homeacts').hidden = viewer; $('patronacts').hidden = !isPatron; $('homemine').hidden = !isPatron; if (isPatron) loadHomeMine(); loadRecent(); if (h === 'find') $('hq').focus(); } if (view === 'home') { $('homeacts').hidden = viewer; $('patronacts').hidden = !isPatron; loadRecent(); if (h === 'find') $('hq').focus(); }
else if (view === 'book') loadBook(m[1]); else if (view === 'book') loadBook(m[1]);
else if (view === 'mine') loadMine(); else if (view === 'mine') loadMine();
else if (view === 'add') { if (h === 'settings') { $('settings').open = true; $('settings').scrollIntoView(); } else $('code').focus(); } else if (view === 'add') { if (h === 'settings') { $('settings').open = true; $('settings').scrollIntoView(); } else $('code').focus(); }
@@ -1364,8 +1347,8 @@
// The public catalogue is off by default: only link to it when a public address was configured. // The public catalogue is off by default: only link to it when a public address was configured.
$('lnk-opac').hidden = !s.opac_url; $('lnk-opac').hidden = !s.opac_url;
if (s.opac_url) $('lnk-opac').href = s.opac_url + '/'; if (s.opac_url) $('lnk-opac').href = s.opac_url + '/';
$('lnk-staff').href = staffRoot() + '/cgi-bin/koha/mainpage.pl'; $('lnk-staff').href = staffRoot() + '/';
$('lnk-staff').hidden = viewer; $('footkoha').href = staffRoot() + '/cgi-bin/koha/mainpage.pl'; $('lnk-staff').hidden = viewer; $('footkoha').href = staffRoot() + '/';
$('footname').textContent = 'Room for Books' + (s.library_name ? ' · ' + s.library_name : ''); $('footname').textContent = 'Room for Books' + (s.library_name ? ' · ' + s.library_name : '');
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl'; $('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
if (s.locations && s.locations.length) { if (s.locations && s.locations.length) {
+18 -12
View File
@@ -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}"
} }
+12
View File
@@ -121,6 +121,18 @@ action_upgrade() {
provision_configure_display provision_configure_display
provision_configure_firewall provision_configure_firewall
provision_configure_power_management provision_configure_power_management
# The Web UI runs from its own copy (/opt/kiosk-webui), not from
# this checkout - refresh it too, or new settings never show up
# there. Port, service and sudo rules are left as they are.
if [[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]]; then
log_info "Refreshing the Web UI..."
if webui_install_app_files; then
sudo systemctl restart kiosk-webui || log_warning "Web UI copied but did not restart - run: sudo systemctl restart kiosk-webui"
else
log_warning "Couldn't refresh the Web UI - the Web UI keeps its old version"
fi
fi
log_success "Setup refreshed" log_success "Setup refreshed"
echo echo
+2
View File
@@ -112,6 +112,8 @@ action_complete_uninstall() {
sudo rm -rf "$KIOSK_HOME" sudo rm -rf "$KIOSK_HOME"
echo "[6/12] Removing remaining systemd units..." echo "[6/12] Removing remaining systemd units..."
sudo systemctl disable --now kiosk-wifi-helper.socket 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.socket
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
sudo systemctl daemon-reload 2>/dev/null || true sudo systemctl daemon-reload 2>/dev/null || true
+33
View File
@@ -20,6 +20,7 @@ display_status() {
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")" echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")" echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")" echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
echo "Back/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
}
+2
View File
@@ -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
+104 -8
View File
@@ -4,10 +4,12 @@
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk # (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off. # itself when the console (Ctrl+Alt+F1..F6) is switched off.
# #
# The kiosk user may run this one script via passwordless sudo # The kiosk app reaches it through a root-owned socket
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a # (kiosk-wifi-helper.socket, group kiosk; see kiosk-wifi-helper-socket) -
# or, as a fallback, passwordless sudo (/etc/sudoers.d/kiosk-wifi) - it does a
# fixed set of things and nothing else: # fixed set of things and nothing else:
# #
# kiosk-wifi-helper ifaces {"adapters":[{"name","kind","connected","ssid","ip"}],"selected"}
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"} # kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]} # kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"} # kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
@@ -17,8 +19,10 @@
# WiFi country (regulatory domain), system # WiFi country (regulatory domain), system
# locale and keyboard layout; every field optional # locale and keyboard layout; every field optional
# #
# Every reply is one JSON line on stdout, exit 0 on success. connect # Every reply is one JSON line on stdout, exit 0 on success. status, scan
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh, # and connect accept an optional {"iface": "<adapter>"} in the stdin JSON
# to act on that adapter; without it the connected adapter is used (see
# pick_iface). connect writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface # 60-kiosk-wifi.yaml) and puts the previous one back if the interface
# gets no address - a typo in the password doesn't lose a WiFi network # gets no address - a typo in the password doesn't lose a WiFi network
# that was working. The password comes in on stdin, never argv (argv is # that was working. The password comes in on stdin, never argv (argv is
@@ -168,8 +172,76 @@ default_country() {
echo "${c^^}" echo "${c^^}"
} }
# Every wireless interface, by what it is (see detect_iface).
list_wifi_ifaces() {
local d
for d in $SYS_CLASS/net/*; do
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
basename "$d"
fi
done
}
# The wireless adapter that is connected right now (associated and has an
# address), if any. With a built-in card plus a USB dongle, the connected
# one is often not the first in name order - status/scan/connect must act
# on the adapter that is actually carrying the connection.
connected_iface() {
local i
for i in $(list_wifi_ifaces); do
if [[ -n "$(current_ssid "$i")" && -n "$(iface_ip "$i")" ]]; then
echo "$i"
return 0
fi
done
return 1
}
# Which adapter to use: the connected one, else the one our netplan file
# configures, else the first found.
pick_iface() {
connected_iface || prefer_configured_iface || detect_iface
}
# The request body (JSON on stdin), read once in main. REQ_IFACE is the
# adapter the caller asked for, or empty.
REQ_INPUT=""
REQ_IFACE=""
# Only a real wireless interface name is accepted - never pass caller text
# to a command or a YAML file unchecked.
valid_iface() {
local i
[[ "$1" =~ ^[A-Za-z0-9._-]{1,15}$ ]] || return 1
for i in $(list_wifi_ifaces); do
[[ "$i" == "$1" ]] && return 0
done
return 1
}
# "usb" or "built-in", from where the device sits in sysfs.
iface_kind() {
if readlink -f "$SYS_CLASS/net/$1/device" 2>/dev/null | grep -q '/usb'; then
echo usb
else
echo built-in
fi
}
pick_requested_or_default_iface() {
if [[ -n "$REQ_IFACE" ]]; then
valid_iface "$REQ_IFACE" || return 1
echo "$REQ_IFACE"
else
pick_iface
fi
}
need_iface() { need_iface() {
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in." if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
IFACE=$(pick_requested_or_default_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu" command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
} }
@@ -216,7 +288,7 @@ set_country() {
do_region_set() { do_region_set() {
local input country locale keyboard local input country locale keyboard
input=$(head -c 4096) input="$REQ_INPUT"
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -251,10 +323,27 @@ do_region_set() {
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}' '{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
} }
do_ifaces() {
local i rows="" sel
sel=$(pick_iface) || sel=""
for i in $(list_wifi_ifaces); do
local ssid ip
ssid=$(current_ssid "$i")
ip=$(iface_ip "$i")
rows+=$(jq -cn --arg n "$i" --arg k "$(iface_kind "$i")" --arg s "$ssid" --arg a "$ip" \
'{name:$n,kind:$k,connected:($s != "" and $a != ""),ssid:$s,ip:$a}')$'\n'
done
jq -cn --arg sel "$sel" --argjson a "$(printf '%s' "$rows" | jq -cs .)" \
'{ok:true,selected:$sel,adapters:$a}'
}
do_status() { do_status() {
local iface ssid="" ip="" configured local iface ssid="" ip="" configured
configured=$(configured_ssid) configured=$(configured_ssid)
if ! iface=$(detect_iface); then if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
if ! iface=$(pick_requested_or_default_iface); then
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}' jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
return 0 return 0
fi fi
@@ -351,7 +440,7 @@ PYEOF
do_connect() { do_connect() {
need_iface need_iface
local input ssid password local input ssid password
input=$(head -c 4096) input="$REQ_INPUT"
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given" ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -454,7 +543,14 @@ trap 'rm -f "$TMP_SCAN"' EXIT
exec 9> "$LOCK_FILE" exec 9> "$LOCK_FILE"
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment" flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
# One JSON request body on stdin (not when run by hand from a terminal).
if [[ ! -t 0 ]]; then
REQ_INPUT=$(head -c 4096)
REQ_IFACE=$(jq -r '.iface // "" | strings' <<< "$REQ_INPUT" 2>/dev/null) || REQ_IFACE=""
fi
case "${1:-}" in case "${1:-}" in
ifaces) do_ifaces ;;
status) do_status ;; status) do_status ;;
scan) do_scan ;; scan) do_scan ;;
connect) do_connect ;; connect) do_connect ;;
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
################################################################################
# kiosk-wifi-helper-socket - socket front end for kiosk-wifi-helper.
#
# Run by systemd (kiosk-wifi-helper@.service, one instance per connection to
# /run/kiosk-wifi-helper.sock) as root, with the connection as stdin AND
# stdout. The kiosk app connects, sends
#
# <action>\n<JSON request body>
#
# and half-closes; the reply is the helper's one JSON line.
#
# Why a socket and not `sudo`: the kiosk app can end up running with the
# Linux "no new privileges" flag, and sudo then refuses to run as root
# ("The 'no new privileges' flag is set"). A root service reached through a
# socket isn't affected by that flag. Who may connect is decided by the
# socket's owner/group/mode (root:kiosk 0660) - the same people who could
# run the helper through the old sudoers rule, no more.
#
# Only the fixed actions below are passed on; anything else is refused.
################################################################################
set -uo pipefail
HELPER="${KIOSK_WIFI_HELPER:-/usr/local/bin/kiosk-wifi-helper}"
# First line only; bash's read doesn't consume past the newline on a
# socket, so the JSON body stays on stdin for the helper.
IFS= read -r -t 10 -n 32 action || action=""
case "$action" in
ifaces|status|scan|connect|region-status|region-set)
exec "$HELPER" "$action"
;;
*)
echo '{"ok":false,"error":"unknown action"}'
exit 1
;;
esac
+141
View File
@@ -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
View File
@@ -40,6 +40,8 @@ const SCALAR_DEFAULTS = {
enablePauseButton: true, enablePauseButton: true,
enableKeyboardButton: true, enableKeyboardButton: true,
enableNavButton: true, enableNavButton: true,
enableHistoryButtons: false,
historyButtonsOpacity: 50,
enablePasswordProtection: false, enablePasswordProtection: false,
lockoutTimeout: 0, lockoutTimeout: 0,
lockoutAtTime: '', lockoutAtTime: '',
@@ -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 };
+30
View File
@@ -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
View File
@@ -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 &amp; Page Timing</button> <button type="button" id="save-sites">Save Sites &amp; 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 &rarr; "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">
+60
View File
@@ -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>
+11
View File
@@ -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
View File
@@ -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) {
+14
View File
@@ -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 {
+153
View File
@@ -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();
+11
View File
@@ -75,6 +75,17 @@ check('saveConfig merge preserves fields this app never tracks (the previously-f
assert.strictEqual(onDisk.enableNavButton, false); assert.strictEqual(onDisk.enableNavButton, false);
}); });
check('enableHistoryButtons defaults off and round-trips', () => {
fs.rmSync(process.env.CONFIG_PATH, { force: true });
assert.strictEqual(loadConfig().enableHistoryButtons, false);
saveConfig({ enableHistoryButtons: true });
assert.strictEqual(JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')).enableHistoryButtons, true);
assert.strictEqual(loadConfig().enableHistoryButtons, true);
assert.strictEqual(loadConfig().historyButtonsOpacity, 50);
saveConfig({ historyButtonsOpacity: 80 });
assert.strictEqual(loadConfig().historyButtonsOpacity, 80);
});
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => { check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
const result = saveConfig({ const result = saveConfig({
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }], tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],