Compare commits
275
Commits
0.9.7
..
1775220312
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1775220312 | ||
|
|
7ed2d4744c | ||
|
|
9b646b99f8 | ||
|
|
a2811cced6 | ||
|
|
07e7d2866f | ||
|
|
0fe631b4ed | ||
|
|
5fc7450af7 | ||
|
|
023a64b62c | ||
|
|
49c35f765b | ||
|
|
76866e8bbc | ||
|
|
aa7950aa9e | ||
|
|
e08133dfc0 | ||
|
|
24c95c91f2 | ||
|
|
2ef8f68b9b | ||
|
|
f4da2608cd | ||
|
|
f0a0c4ad40 | ||
|
|
b4c844b124 | ||
|
|
a15478b382 | ||
|
|
7244cfd918 | ||
|
|
5b79608695 | ||
|
|
e828805b9c | ||
|
|
7cb4a661f9 | ||
|
|
eb7bc14a9c | ||
|
|
ead4500406 | ||
|
|
4085866134 | ||
|
|
763162fc57 | ||
|
|
d30f7c7260 | ||
|
|
d673d04259 | ||
|
|
6cc5296cd6 | ||
|
|
5a4e8a8734 | ||
|
|
3e665688c5 | ||
|
|
7f8947ade2 | ||
|
|
d0d39e48bb | ||
|
|
9221fc85aa | ||
|
|
b88d648e14 | ||
|
|
46903b93a4 | ||
|
|
c196a5f7e7 | ||
|
|
f91dbe4d5f | ||
|
|
5e7ffb2eb4 | ||
|
|
903a1e45e4 | ||
|
|
1987b0ee71 | ||
|
|
ff962a41c4 | ||
|
|
d6769d5207 | ||
|
|
f215c64085 | ||
|
|
36f09b2253 | ||
|
|
600f5174d8 | ||
|
|
d4f9233655 | ||
|
|
a66b1e6079 | ||
|
|
5cd2117a94 | ||
|
|
f7901a3684 | ||
|
|
b0c0008d44 | ||
|
|
50cf0bef1d | ||
|
|
1ba9e0a8aa | ||
|
|
c15ff2f098 | ||
|
|
b479b68219 | ||
|
|
b660ba89db | ||
|
|
fcbb890dbf | ||
|
|
fb4ee6fa62 | ||
|
|
4b85ae59fc | ||
|
|
c54ae352a1 | ||
|
|
ab76325c9b | ||
|
|
d4b75ca1b9 | ||
|
|
5792e201b6 | ||
|
|
0b5d958fcc | ||
|
|
96adf6a5e2 | ||
|
|
a170dc6228 | ||
|
|
125622cd74 | ||
|
|
7f171cf52b | ||
|
|
0dfc2dd3ef | ||
|
|
aee6b779eb | ||
|
|
08b33ef823 | ||
|
|
d6d6177dd9 | ||
|
|
ff67e422a4 | ||
|
|
e607e8cea0 | ||
|
|
b6ed4aad9c | ||
|
|
1fad3393ac | ||
|
|
d0b76dc6cf | ||
|
|
d411b05cae | ||
|
|
037a62e008 | ||
|
|
1c9447bc5d | ||
|
|
5d56c667da | ||
|
|
b0b558f015 | ||
|
|
a3313aa9b8 | ||
|
|
3eadcdb584 | ||
|
|
2bf4efe2b1 | ||
|
|
0454a259fa | ||
|
|
cdd6f5adcf | ||
|
|
6c68897935 | ||
|
|
1b16bcf3ee | ||
|
|
459da53182 | ||
|
|
2375bf5eab | ||
|
|
8672c15469 | ||
|
|
074b2ec2e3 | ||
|
|
c1370edc3e | ||
|
|
21a0768c8c | ||
|
|
ccf73d41bd | ||
|
|
10c4c22f44 | ||
|
|
6f6d34512e | ||
|
|
f01781de41 | ||
|
|
68a7345f5b | ||
|
|
b5d37fa646 | ||
|
|
de9c97aa06 | ||
|
|
ca00674e4c | ||
|
|
0c809d7e53 | ||
|
|
29b5783052 | ||
|
|
91d905f45a | ||
|
|
de197f77d3 | ||
|
|
879e2ecd14 | ||
|
|
8116fdf258 | ||
|
|
56e3d17459 | ||
|
|
35350d9908 | ||
|
|
43266a4cf0 | ||
|
|
6621f9221b | ||
|
|
670b32f53c | ||
|
|
c3ea52dda0 | ||
|
|
174bbe46ae | ||
|
|
572f0ed01b | ||
|
|
b7e35c84c9 | ||
|
|
9a9352a9c2 | ||
|
|
440b074245 | ||
|
|
46d42b46c6 | ||
|
|
27aaf5a15c | ||
|
|
981aaf2b6b | ||
|
|
66a633b22c | ||
|
|
4e7fbe497c | ||
|
|
f7f1624e02 | ||
|
|
3315ccc44a | ||
|
|
c58addcecb | ||
|
|
14172782c9 | ||
|
|
279dcd2b66 | ||
|
|
5cbade03d7 | ||
|
|
73eed4a123 | ||
|
|
95027fe6fe | ||
|
|
8b9d688993 | ||
|
|
55039c510b | ||
|
|
7f54944ad8 | ||
|
|
00dd806e9c | ||
|
|
6f18a2f897 | ||
|
|
93b944fdb8 | ||
|
|
c18615a78e | ||
|
|
dcf3ba11c7 | ||
|
|
37663be91b | ||
|
|
690f2daf3c | ||
|
|
8f886854f2 | ||
|
|
22fb77581c | ||
|
|
f40d8e337c | ||
|
|
b92180c4b2 | ||
|
|
d595464400 | ||
|
|
69be816dfe | ||
|
|
543a442775 | ||
|
|
3f1984f9fa | ||
|
|
3d5a5839b3 | ||
|
|
31eb750514 | ||
|
|
effb726979 | ||
|
|
0eef318e78 | ||
|
|
09dfcd40af | ||
|
|
0b52ec27fb | ||
|
|
b003daba85 | ||
|
|
bd4d4cadc8 | ||
|
|
66ec66362f | ||
|
|
2556863129 | ||
|
|
4c3f5576b9 | ||
|
|
7634ddbaaf | ||
|
|
f1febbd914 | ||
|
|
05538611cf | ||
|
|
2a5fb6188b | ||
|
|
b956af05e2 | ||
|
|
42aafff219 | ||
|
|
44b04067ce | ||
|
|
c2d9b34d53 | ||
|
|
91515e5dff | ||
|
|
8b13426215 | ||
|
|
dc8f2b3a9c | ||
|
|
11173bbcda | ||
|
|
a7894ede51 | ||
|
|
589a65bc55 | ||
|
|
5085a19805 | ||
|
|
ae65853f5a | ||
|
|
502baaed44 | ||
|
|
145320998e | ||
|
|
4b7ab7c0be | ||
|
|
07823423e5 | ||
|
|
06c00a5944 | ||
|
|
44991d307b | ||
|
|
2700f27d1c | ||
|
|
96747fa411 | ||
|
|
d42bd363be | ||
|
|
eba2da9de8 | ||
|
|
8098d8d831 | ||
|
|
e8ffe6393f | ||
|
|
eb2f31f818 | ||
|
|
f02b8341a3 | ||
|
|
79d112794b | ||
|
|
d13137278d | ||
|
|
4ac9676ef5 | ||
|
|
c4f93571a9 | ||
|
|
f47d7d54c9 | ||
|
|
27de108579 | ||
|
|
b926d00cf4 | ||
|
|
7ed5faa2dc | ||
|
|
e04fc42a85 | ||
|
|
4332ad1b26 | ||
|
|
966709547f | ||
|
|
8e98f66215 | ||
|
|
bb82108809 | ||
|
|
37fbe07eab | ||
|
|
b17adbde2b | ||
|
|
6742d8dec6 | ||
|
|
ec0d3ee633 | ||
|
|
3896614ffe | ||
|
|
703339c5e9 | ||
|
|
3b35d381f7 | ||
|
|
2ba980ce94 | ||
|
|
da99774c44 | ||
|
|
113adeb119 | ||
|
|
0081394796 | ||
|
|
f2f645b7b8 | ||
|
|
2acc8aaa1d | ||
|
|
1a0b5d9f68 | ||
|
|
5ec37d8457 | ||
|
|
88831e3491 | ||
|
|
20cb5d0a5e | ||
|
|
238018c0ba | ||
|
|
9ecfd0f0d5 | ||
|
|
2048ac7bc8 | ||
|
|
234dd900f6 | ||
|
|
38e732b976 | ||
|
|
505e90551f | ||
|
|
62e4a906b9 | ||
|
|
77eaf19636 | ||
|
|
144ff1271a | ||
|
|
d12307b5d2 | ||
|
|
0c5e070ed0 | ||
|
|
c8c8e71acf | ||
|
|
85d8396aed | ||
|
|
cf612cc7ac | ||
|
|
160c39a47d | ||
|
|
c549d0f1e0 | ||
|
|
08638045d5 | ||
|
|
27c8adf040 | ||
|
|
31f8d2f973 | ||
|
|
7c14a5b407 | ||
|
|
5db15802a0 | ||
|
|
08594601ec | ||
|
|
40052ea3f0 | ||
|
|
89ec3e7871 | ||
|
|
a380f8e705 | ||
|
|
a6994601c4 | ||
|
|
06f0208c0b | ||
|
|
1988d62f87 | ||
|
|
0a4b2bfb70 | ||
|
|
390f286b55 | ||
|
|
2f31a730f6 | ||
|
|
6950b6e2bc | ||
|
|
95c7af9ac6 | ||
|
|
737b336d47 | ||
|
|
c144b3f647 | ||
|
|
5d09a7d27b | ||
|
|
5d63740c05 | ||
|
|
5ceccbb420 | ||
|
|
dbe881ce44 | ||
|
|
1eba5bdf47 | ||
|
|
3387bee3a2 | ||
|
|
5ff748b875 | ||
|
|
ed87ff257c | ||
|
|
f5a8c320de | ||
|
|
a1bfd8aabf | ||
|
|
5e840bafd6 | ||
|
|
1cd1565239 | ||
|
|
7ebf4e6820 | ||
|
|
6d1c0f9bb5 | ||
|
|
a31f1817fc | ||
|
|
3cfdc1e8a0 | ||
|
|
abb8d72771 | ||
|
|
3c33c14f24 |
@@ -0,0 +1,133 @@
|
|||||||
|
# Builds the all-in-one Ubuntu Server 26.04 + kiosk install ISO
|
||||||
|
# (iso/build-iso.sh) on demand: Actions tab -> "Build kiosk ISO" ->
|
||||||
|
# Run workflow. The ISO (~2.8 GB) is attached to the run as a downloadable
|
||||||
|
# artifact for a few days - see the run's Summary page.
|
||||||
|
#
|
||||||
|
# Pre-setting the admin account (needed for "fully-automatic") uses the
|
||||||
|
# repository secret KIOSK_ADMIN_PASSWORD (Settings -> Secrets and
|
||||||
|
# variables -> Actions) - never a workflow input, since inputs are shown
|
||||||
|
# in the run log. Only a SHA-512 hash of it goes on the ISO, but anyone
|
||||||
|
# who downloads the ISO can read that hash, so treat the artifact as
|
||||||
|
# private and use a password you'll change after install.
|
||||||
|
name: Build kiosk ISO
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
mode:
|
||||||
|
description: >-
|
||||||
|
interactive: installer asks network/disk/user, kiosk install.sh
|
||||||
|
runs with prompts on first boot. unattended-kiosk: same installer,
|
||||||
|
kiosk installs with defaults and reboots into the kiosk.
|
||||||
|
fully-automatic: no questions at all - WIPES THE LARGEST DISK
|
||||||
|
(needs username + KIOSK_ADMIN_PASSWORD secret).
|
||||||
|
type: choice
|
||||||
|
default: interactive
|
||||||
|
options:
|
||||||
|
- interactive
|
||||||
|
- unattended-kiosk
|
||||||
|
- fully-automatic
|
||||||
|
username:
|
||||||
|
description: Admin username to pre-set (blank = asked during install). Needs the KIOSK_ADMIN_PASSWORD secret. Not "kiosk"/"admin".
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
hostname:
|
||||||
|
description: Hostname (only used when username is set)
|
||||||
|
type: string
|
||||||
|
default: kiosk
|
||||||
|
ssh_public_key:
|
||||||
|
description: Optional SSH public key for the admin user (ssh-ed25519 AAAA...)
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
offline:
|
||||||
|
description: >-
|
||||||
|
Bundle everything install.sh downloads (~520 MB: all packages incl.
|
||||||
|
the CUPS, LMS/Squeezelite, Intercom, VNC, WireGuard and hotspot
|
||||||
|
addons, Node.js, Electron) so the kiosk and those addons install
|
||||||
|
with no internet.
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
retention_days:
|
||||||
|
description: Days to keep the ISO artifact
|
||||||
|
type: number
|
||||||
|
default: 3
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install build tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q
|
||||||
|
sudo apt-get install -y -q xorriso apt-utils ubuntu-keyring
|
||||||
|
|
||||||
|
- name: Build ISO
|
||||||
|
env:
|
||||||
|
MODE: ${{ inputs.mode }}
|
||||||
|
OFFLINE: ${{ inputs.offline }}
|
||||||
|
ADMIN_USERNAME: ${{ inputs.username }}
|
||||||
|
ADMIN_HOSTNAME: ${{ inputs.hostname }}
|
||||||
|
SSH_PUBLIC_KEY: ${{ inputs.ssh_public_key }}
|
||||||
|
KIOSK_ADMIN_PASSWORD: ${{ secrets.KIOSK_ADMIN_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/iso"
|
||||||
|
args=(--output "$RUNNER_TEMP/iso/ubuntu-kiosk-amd64.iso")
|
||||||
|
case "$MODE" in
|
||||||
|
unattended-kiosk) args+=(--unattended) ;;
|
||||||
|
fully-automatic) args+=(--fully-automatic) ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$ADMIN_USERNAME" ]]; then
|
||||||
|
if [[ -z "$KIOSK_ADMIN_PASSWORD" ]]; then
|
||||||
|
echo "::error::username given but the KIOSK_ADMIN_PASSWORD repository secret isn't set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
args+=(--username "$ADMIN_USERNAME" --hostname "$ADMIN_HOSTNAME")
|
||||||
|
fi
|
||||||
|
if [[ -n "$SSH_PUBLIC_KEY" ]]; then
|
||||||
|
printf '%s\n' "$SSH_PUBLIC_KEY" > "$RUNNER_TEMP/ssh_key.pub"
|
||||||
|
args+=(--ssh-key "$RUNNER_TEMP/ssh_key.pub")
|
||||||
|
fi
|
||||||
|
[[ "$OFFLINE" == "true" ]] && args+=(--offline)
|
||||||
|
iso/build-iso.sh "${args[@]}"
|
||||||
|
# Name the file after the Ubuntu point release the script picked.
|
||||||
|
src="$(find iso/build/cache -name "*.iso" -print -quit)"
|
||||||
|
kind="kiosk"; [[ "$OFFLINE" == "true" ]] && kind="kiosk-offline"
|
||||||
|
name="$(basename "$src" .iso | sed "s/-live-server/-$kind/")-${MODE}-${GITHUB_SHA::7}.iso"
|
||||||
|
mv "$RUNNER_TEMP/iso/ubuntu-kiosk-amd64.iso" "$RUNNER_TEMP/iso/$name"
|
||||||
|
# Free the ~3 GB source ISO before the upload step needs scratch space.
|
||||||
|
rm -rf iso/build/cache iso/build/offline-cache
|
||||||
|
(cd "$RUNNER_TEMP/iso" && sha256sum "$name" > "$name.sha256")
|
||||||
|
echo "ISO_NAME=$name" >> "$GITHUB_ENV"
|
||||||
|
{
|
||||||
|
echo "### $name"
|
||||||
|
echo
|
||||||
|
echo "- Mode: \`$MODE\`"
|
||||||
|
echo "- Offline bundle: $([[ "$OFFLINE" == "true" ]] && echo "yes - installs with no internet" || echo "no - kiosk needs internet on first boot")"
|
||||||
|
echo "- Admin user: ${ADMIN_USERNAME:-chosen during install}"
|
||||||
|
echo "- Built from: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA::7}\`"
|
||||||
|
echo "- SHA-256: \`$(cut -d' ' -f1 "$RUNNER_TEMP/iso/$name.sha256")\`"
|
||||||
|
echo
|
||||||
|
echo "Download it from **Artifacts** below (GitHub zips it; unzip, then write the .iso to a USB stick)."
|
||||||
|
if [[ "$MODE" == "fully-automatic" ]]; then
|
||||||
|
echo
|
||||||
|
echo "> **Warning:** this ISO wipes the target machine's largest disk without asking."
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Upload ISO
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.ISO_NAME }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/iso/${{ env.ISO_NAME }}
|
||||||
|
${{ runner.temp }}/iso/${{ env.ISO_NAME }}.sha256
|
||||||
|
# An ISO is already compressed - zipping it again only costs time.
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: ${{ inputs.retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
webui/test/fixtures/.fake-state/
|
||||||
|
iso/build/
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Asterisk-Easy Addon Installation Functions
|
||||||
|
# Description: Addon module for installing and configuring Asterisk-Easy
|
||||||
|
# Version: 1.0
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Color definitions for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: install_asterisk_easy
|
||||||
|
# Description: Main function to install Asterisk-Easy
|
||||||
|
# Parameters: None
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
################################################################################
|
||||||
|
install_asterisk_easy() {
|
||||||
|
echo -e "${BLUE}================================${NC}"
|
||||||
|
echo -e "${BLUE}Asterisk-Easy Installation${NC}"
|
||||||
|
echo -e "${BLUE}================================${NC}"
|
||||||
|
|
||||||
|
# Update system packages
|
||||||
|
echo -e "${YELLOW}Updating system packages...${NC}"
|
||||||
|
apt-get update -qq
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to update system packages${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Asterisk dependencies
|
||||||
|
echo -e "${YELLOW}Installing Asterisk dependencies...${NC}"
|
||||||
|
apt-get install -y \
|
||||||
|
asterisk \
|
||||||
|
asterisk-config \
|
||||||
|
asterisk-dev \
|
||||||
|
asterisk-doc \
|
||||||
|
asterisk-modules \
|
||||||
|
asterisk-voicemail \
|
||||||
|
dahdi \
|
||||||
|
libpri \
|
||||||
|
asterisk-dahdi &>/dev/null
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to install Asterisk packages${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install additional dependencies
|
||||||
|
echo -e "${YELLOW}Installing additional dependencies...${NC}"
|
||||||
|
apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
libssl-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
uuid-dev &>/dev/null
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to install additional dependencies${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure Asterisk
|
||||||
|
echo -e "${YELLOW}Configuring Asterisk...${NC}"
|
||||||
|
configure_asterisk_easy
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to configure Asterisk-Easy${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Asterisk service
|
||||||
|
echo -e "${YELLOW}Starting Asterisk service...${NC}"
|
||||||
|
systemctl enable asterisk
|
||||||
|
systemctl start asterisk
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to start Asterisk service${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Asterisk-Easy installation completed successfully${NC}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: configure_asterisk_easy
|
||||||
|
# Description: Configure Asterisk-Easy with basic settings
|
||||||
|
# Parameters: None
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
################################################################################
|
||||||
|
configure_asterisk_easy() {
|
||||||
|
local asterisk_dir="/etc/asterisk"
|
||||||
|
|
||||||
|
# Check if Asterisk directory exists
|
||||||
|
if [ ! -d "$asterisk_dir" ]; then
|
||||||
|
echo -e "${RED}Asterisk configuration directory not found: $asterisk_dir${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup original configuration files
|
||||||
|
echo -e "${YELLOW}Backing up original Asterisk configuration...${NC}"
|
||||||
|
if [ -f "$asterisk_dir/extensions.conf" ]; then
|
||||||
|
cp "$asterisk_dir/extensions.conf" "$asterisk_dir/extensions.conf.bak.$(date +%s)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$asterisk_dir/sip.conf" ]; then
|
||||||
|
cp "$asterisk_dir/sip.conf" "$asterisk_dir/sip.conf.bak.$(date +%s)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create basic extensions configuration
|
||||||
|
echo -e "${YELLOW}Creating basic extensions configuration...${NC}"
|
||||||
|
create_extensions_conf "$asterisk_dir"
|
||||||
|
|
||||||
|
# Create basic SIP configuration
|
||||||
|
echo -e "${YELLOW}Creating basic SIP configuration...${NC}"
|
||||||
|
create_sip_conf "$asterisk_dir"
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: create_extensions_conf
|
||||||
|
# Description: Create basic extensions configuration file
|
||||||
|
# Parameters: $1 - Asterisk configuration directory
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
################################################################################
|
||||||
|
create_extensions_conf() {
|
||||||
|
local asterisk_dir="$1"
|
||||||
|
local extensions_file="$asterisk_dir/extensions.conf"
|
||||||
|
|
||||||
|
cat > "$extensions_file" << 'EOF'
|
||||||
|
[general]
|
||||||
|
static=yes
|
||||||
|
writeprotect=no
|
||||||
|
|
||||||
|
[default]
|
||||||
|
exten => 100,1,Dial(SIP/100)
|
||||||
|
exten => 100,n,Voicemail(100@default)
|
||||||
|
exten => 100,n,Hangup()
|
||||||
|
|
||||||
|
exten => 101,1,Dial(SIP/101)
|
||||||
|
exten => 101,n,Voicemail(101@default)
|
||||||
|
exten => 101,n,Hangup()
|
||||||
|
|
||||||
|
exten => 102,1,Dial(SIP/102)
|
||||||
|
exten => 102,n,Voicemail(102@default)
|
||||||
|
exten => 102,n,Hangup()
|
||||||
|
|
||||||
|
exten => 200,1,VoicemailMain(${CALLERID(num)}@default)
|
||||||
|
exten => 200,n,Hangup()
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to create extensions configuration${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: create_sip_conf
|
||||||
|
# Description: Create basic SIP configuration file
|
||||||
|
# Parameters: $1 - Asterisk configuration directory
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
################################################################################
|
||||||
|
create_sip_conf() {
|
||||||
|
local asterisk_dir="$1"
|
||||||
|
local sip_file="$asterisk_dir/sip.conf"
|
||||||
|
|
||||||
|
cat > "$sip_file" << 'EOF'
|
||||||
|
[general]
|
||||||
|
context=default
|
||||||
|
allowoverlap=no
|
||||||
|
bindport=5060
|
||||||
|
bindaddr=0.0.0.0
|
||||||
|
srvlookup=yes
|
||||||
|
pedantic=no
|
||||||
|
maxexpiry=3600
|
||||||
|
minexpiry=60
|
||||||
|
defaultexpiry=120
|
||||||
|
defaultexpirey=120
|
||||||
|
rtptimeout=30
|
||||||
|
rtpholdtimeout=300
|
||||||
|
videosupport=yes
|
||||||
|
allowtransfer=yes
|
||||||
|
nat=force_rport,comedia
|
||||||
|
|
||||||
|
[100]
|
||||||
|
type=friend
|
||||||
|
secret=123456
|
||||||
|
host=dynamic
|
||||||
|
context=default
|
||||||
|
|
||||||
|
[101]
|
||||||
|
type=friend
|
||||||
|
secret=123456
|
||||||
|
host=dynamic
|
||||||
|
context=default
|
||||||
|
|
||||||
|
[102]
|
||||||
|
type=friend
|
||||||
|
secret=123456
|
||||||
|
host=dynamic
|
||||||
|
context=default
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to create SIP configuration${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: verify_asterisk_easy
|
||||||
|
# Description: Verify Asterisk-Easy installation
|
||||||
|
# Parameters: None
|
||||||
|
# Returns: 0 if installed and running, 1 otherwise
|
||||||
|
################################################################################
|
||||||
|
verify_asterisk_easy() {
|
||||||
|
# Check if Asterisk is installed
|
||||||
|
if ! command -v asterisk &> /dev/null; then
|
||||||
|
echo -e "${RED}Asterisk is not installed${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Asterisk service is running
|
||||||
|
if ! systemctl is-active --quiet asterisk; then
|
||||||
|
echo -e "${YELLOW}Asterisk service is not running${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Asterisk-Easy is properly installed and running${NC}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: show_asterisk_easy_status
|
||||||
|
# Description: Display Asterisk-Easy status information
|
||||||
|
# Parameters: None
|
||||||
|
# Returns: 0 on success
|
||||||
|
################################################################################
|
||||||
|
show_asterisk_easy_status() {
|
||||||
|
echo -e "${BLUE}================================${NC}"
|
||||||
|
echo -e "${BLUE}Asterisk-Easy Status${NC}"
|
||||||
|
echo -e "${BLUE}================================${NC}"
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
if systemctl is-active --quiet asterisk; then
|
||||||
|
echo -e "${GREEN}Service Status: Running${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Service Status: Not Running${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Asterisk version
|
||||||
|
if command -v asterisk &> /dev/null; then
|
||||||
|
local version=$(asterisk -v | grep -oP 'Asterisk \K[0-9.]+')
|
||||||
|
echo -e "${BLUE}Asterisk Version: $version${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check configuration files
|
||||||
|
if [ -f "/etc/asterisk/extensions.conf" ]; then
|
||||||
|
echo -e "${GREEN}✓ Extensions Configuration: Present${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Extensions Configuration: Missing${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "/etc/asterisk/sip.conf" ]; then
|
||||||
|
echo -e "${GREEN}✓ SIP Configuration: Present${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ SIP Configuration: Missing${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Function: remove_asterisk_easy
|
||||||
|
# Description: Remove Asterisk-Easy installation
|
||||||
|
# Parameters: None
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
################################################################################
|
||||||
|
remove_asterisk_easy() {
|
||||||
|
echo -e "${YELLOW}Removing Asterisk-Easy...${NC}"
|
||||||
|
|
||||||
|
# Stop Asterisk service
|
||||||
|
systemctl stop asterisk
|
||||||
|
systemctl disable asterisk
|
||||||
|
|
||||||
|
# Remove Asterisk packages
|
||||||
|
apt-get remove -y asterisk asterisk-* &>/dev/null
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to remove Asterisk packages${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Asterisk-Easy has been removed${NC}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export functions for use in main script
|
||||||
|
export -f install_asterisk_easy
|
||||||
|
export -f configure_asterisk_easy
|
||||||
|
export -f verify_asterisk_easy
|
||||||
|
export -f show_asterisk_easy_status
|
||||||
|
export -f remove_asterisk_easy
|
||||||
Executable
+241
@@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# install.sh - Ubuntu Based Kiosk: install and manage, one entry point.
|
||||||
|
#
|
||||||
|
# On a bare Ubuntu Server box with no kiosk installed, this provisions
|
||||||
|
# one (lib/provision.sh) - packages, kiosk user, LightDM/Openbox, the
|
||||||
|
# Electron app, audio/video/power hardware setup - then hands off to the
|
||||||
|
# same Core Settings/Addons/Advanced menus below for initial
|
||||||
|
# configuration. On a machine that already has a kiosk, it skips
|
||||||
|
# straight to those menus. Same entry point either way.
|
||||||
|
#
|
||||||
|
# ubuntu-based-kiosk.sh, the original single-file installer, still
|
||||||
|
# exists and still works, but is no longer the only way to provision a
|
||||||
|
# new kiosk. Upgrade (Advanced -> Upgrade) is now here too, but not a
|
||||||
|
# port of the legacy version - that one re-extracted heredocs on every
|
||||||
|
# run; kiosk-app/ and provision/files/ are real files in this git
|
||||||
|
# checkout, so the modular Upgrade is `git pull` + re-running the same
|
||||||
|
# provisioning steps, reused rather than reimplemented (see
|
||||||
|
# menus/advanced_upgrade.sh). Full Reinstall is deliberately not
|
||||||
|
# carried forward - it never worked reliably in the legacy script, and
|
||||||
|
# the same outcome is already available here, more reliably, as two
|
||||||
|
# already-tested pieces run back to back: Complete Uninstall (Core
|
||||||
|
# Settings), then ./install.sh again to provision fresh.
|
||||||
|
#
|
||||||
|
# Migrated so far, grouped the same way the legacy menu groups them:
|
||||||
|
# Core Settings: Sites & Page Timing, Display & Interaction, Timezone,
|
||||||
|
# Time Server (NTP - menus/time_sync.sh, a LAN time server or manual
|
||||||
|
# clock for networks without internet), Hidden Site PIN, Password Protection & Lockout, WiFi,
|
||||||
|
# Power/Display/Quiet Hours, Complete Uninstall
|
||||||
|
# (menus/complete_uninstall.sh - composed from every addon's own
|
||||||
|
# uninstall helper rather than re-implementing removal a second time).
|
||||||
|
# Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login
|
||||||
|
# (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/
|
||||||
|
# Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server /
|
||||||
|
# Squeezelite Player (menus/addon_lms_squeezelite.sh), Asterisk
|
||||||
|
# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh),
|
||||||
|
# Web UI (menus/addon_webui.sh - browser-based editor for Sites/
|
||||||
|
# Display/Lockout, the webui/ Node app installed as a systemd
|
||||||
|
# service; no login of its own, put it behind your own reverse proxy
|
||||||
|
# with Authelia forward-auth if it needs to be reachable beyond a
|
||||||
|
# trusted LAN - see the file header), Library Catalog (menus/
|
||||||
|
# addon_library.sh - a self-hosted Koha library catalogue in Docker with
|
||||||
|
# Z39.50 lookup, label printing and a scan-to-add page, shown on the
|
||||||
|
# kiosk as a public search terminal; installs Docker on demand via
|
||||||
|
# lib/docker.sh).
|
||||||
|
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
|
||||||
|
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
||||||
|
# manual update, fix blank screen), Factory Reset
|
||||||
|
# (menus/advanced_factory_reset.sh), Virtual Consoles
|
||||||
|
# (menus/advanced_virtual_consoles.sh), Emergency Hotspot
|
||||||
|
# (menus/advanced_emergency_hotspot.sh), Clone Settings
|
||||||
|
# (menus/clone_settings.sh - export/apply portable settings across
|
||||||
|
# several kiosks; deliberately excludes machine-bound credentials
|
||||||
|
# like Authelia/WireGuard/Asterisk Intercom - see the file header),
|
||||||
|
# Upgrade (menus/advanced_upgrade.sh - git pull + re-provision, plus
|
||||||
|
# an on-demand Electron version check).
|
||||||
|
#
|
||||||
|
# Usage (works whether or not a kiosk is already installed):
|
||||||
|
# git clone <repo>
|
||||||
|
# cd ubuntu-based-kiosk
|
||||||
|
# ./install.sh
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# shellcheck source=lib/menu.sh
|
||||||
|
source "$SCRIPT_DIR/lib/menu.sh"
|
||||||
|
# shellcheck source=lib/config.sh
|
||||||
|
source "$SCRIPT_DIR/lib/config.sh"
|
||||||
|
# shellcheck source=lib/electron.sh
|
||||||
|
source "$SCRIPT_DIR/lib/electron.sh"
|
||||||
|
# shellcheck source=lib/docker.sh
|
||||||
|
source "$SCRIPT_DIR/lib/docker.sh"
|
||||||
|
# shellcheck source=menus/sites.sh
|
||||||
|
source "$SCRIPT_DIR/menus/sites.sh"
|
||||||
|
# shellcheck source=menus/display.sh
|
||||||
|
source "$SCRIPT_DIR/menus/display.sh"
|
||||||
|
# shellcheck source=menus/timezone.sh
|
||||||
|
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||||
|
# shellcheck source=menus/time_sync.sh
|
||||||
|
source "$SCRIPT_DIR/menus/time_sync.sh"
|
||||||
|
# shellcheck source=menus/hidden_pin.sh
|
||||||
|
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||||
|
# shellcheck source=menus/lockout.sh
|
||||||
|
source "$SCRIPT_DIR/menus/lockout.sh"
|
||||||
|
# shellcheck source=menus/wifi.sh
|
||||||
|
source "$SCRIPT_DIR/menus/wifi.sh"
|
||||||
|
# shellcheck source=menus/power_schedule.sh
|
||||||
|
source "$SCRIPT_DIR/menus/power_schedule.sh"
|
||||||
|
# shellcheck source=menus/diagnostics.sh
|
||||||
|
source "$SCRIPT_DIR/menus/diagnostics.sh"
|
||||||
|
# shellcheck source=menus/addon_cups.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_cups.sh"
|
||||||
|
# shellcheck source=menus/addon_authelia.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_authelia.sh"
|
||||||
|
# shellcheck source=menus/addon_remote_access.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
|
||||||
|
# shellcheck source=menus/addon_lms_squeezelite.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
||||||
|
# shellcheck source=menus/addon_asterisk_intercom.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||||
|
# shellcheck source=menus/addon_webui.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||||
|
# shellcheck source=menus/addon_library.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_library.sh"
|
||||||
|
# shellcheck source=menus/advanced_electron.sh
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||||
|
# shellcheck source=menus/advanced_upgrade.sh
|
||||||
|
# Depends on action_update_electron above and the provision_* functions
|
||||||
|
# sourced later (lib/provision.sh) - safe either way, bash resolves
|
||||||
|
# function calls at run time, not source time.
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
|
||||||
|
# shellcheck source=menus/advanced_factory_reset.sh
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
|
||||||
|
# shellcheck source=menus/advanced_virtual_consoles.sh
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
|
||||||
|
# shellcheck source=menus/advanced_emergency_hotspot.sh
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
|
||||||
|
# shellcheck source=menus/complete_uninstall.sh
|
||||||
|
# Sourced last among menus/*.sh: composes the *_do_uninstall/
|
||||||
|
# *_do_remove_all/*_do_disable helpers defined in every file above it.
|
||||||
|
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
|
||||||
|
# shellcheck source=menus/clone_settings.sh
|
||||||
|
# Also composes detection helpers (*_is_installed) from every addon above.
|
||||||
|
source "$SCRIPT_DIR/menus/clone_settings.sh"
|
||||||
|
# shellcheck source=lib/provision.sh
|
||||||
|
# Sourced last of all: calls into core_settings_menu and the Advanced
|
||||||
|
# actions below during first-time setup, so everything they depend on
|
||||||
|
# must already be defined by the time it actually runs (not merely
|
||||||
|
# sourced - bash resolves function calls at run time either way).
|
||||||
|
source "$SCRIPT_DIR/lib/provision.sh"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Preflight
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
if [[ "$(whoami)" == "kiosk" ]]; then
|
||||||
|
log_error "Cannot run as user 'kiosk'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
log_error "Run as a regular user with sudo privileges, not as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v jq &>/dev/null; then
|
||||||
|
log_error "jq is required but not installed. Run: sudo apt-get install -y jq"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Top-level menu - grouped the same way the legacy menu groups them
|
||||||
|
# (Core Settings / Addons / Advanced), so the structure stays familiar
|
||||||
|
# and the flat list doesn't grow unwieldy as more menus migrate in.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
core_settings_menu_builder() {
|
||||||
|
MENU_LABELS=(
|
||||||
|
"Sites & Page Timing"
|
||||||
|
"Display & Interaction"
|
||||||
|
"Timezone"
|
||||||
|
"Time Server (NTP)"
|
||||||
|
"Hidden Site PIN"
|
||||||
|
"Password Protection & Lockout"
|
||||||
|
"WiFi"
|
||||||
|
"Power/Display/Quiet Hours"
|
||||||
|
"Complete Uninstall"
|
||||||
|
)
|
||||||
|
MENU_HANDLERS=(
|
||||||
|
sites_menu
|
||||||
|
display_menu
|
||||||
|
timezone_menu
|
||||||
|
time_sync_menu
|
||||||
|
hidden_pin_menu
|
||||||
|
lockout_menu
|
||||||
|
wifi_menu
|
||||||
|
power_schedule_menu
|
||||||
|
complete_uninstall_menu
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
core_settings_menu() {
|
||||||
|
run_menu "CORE SETTINGS" core_settings_menu_builder
|
||||||
|
}
|
||||||
|
|
||||||
|
addons_menu_builder() {
|
||||||
|
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)")
|
||||||
|
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
addons_menu() {
|
||||||
|
run_menu "ADDONS" addons_menu_builder
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_menu_builder() {
|
||||||
|
MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Clone Settings" "Upgrade")
|
||||||
|
MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu clone_settings_menu advanced_upgrade_menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_menu() {
|
||||||
|
run_menu "ADVANCED" advanced_menu_builder
|
||||||
|
}
|
||||||
|
|
||||||
|
main_menu_builder() {
|
||||||
|
MENU_LABELS=("Core Settings" "Addons" "Advanced")
|
||||||
|
MENU_HANDLERS=(core_settings_menu addons_menu advanced_menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
main_menu_status() {
|
||||||
|
echo "Managing kiosk at: ${KIOSK_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Provision if there's nothing here yet, otherwise go straight to management.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
if ! is_kiosk_installed; then
|
||||||
|
echo
|
||||||
|
echo "No installed kiosk found at ${KIOSK_DIR}."
|
||||||
|
echo "This will provision a new one on this machine."
|
||||||
|
echo
|
||||||
|
# Bare call, not `if run_first_time_install; then ...`: this is a
|
||||||
|
# large multi-step function, and testing it as an if-condition would
|
||||||
|
# exempt every step inside it from set -e for the duration - see the
|
||||||
|
# comment at its own call to provision_install_app for why that
|
||||||
|
# matters. Called bare, a real failure anywhere inside it halts the
|
||||||
|
# whole script immediately (set -e's normal behavior); reaching the
|
||||||
|
# lines below is itself proof every step succeeded. A declined
|
||||||
|
# install prints "Cancelled" from inside the function and returns
|
||||||
|
# non-zero, which the same bare-statement rule turns into a normal
|
||||||
|
# exit here - nothing further to print either way.
|
||||||
|
run_first_time_install
|
||||||
|
echo
|
||||||
|
echo "Run ./install.sh again to manage this kiosk."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_menu "UBUNTU BASED KIOSK - MANAGEMENT" main_menu_builder main_menu_status "Exit"
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
################################################################################
|
################################################################################
|
||||||
### Ubuntu Based Kiosk (UBK) v0.9.7 ###
|
### Ubuntu Based Kiosk v0.9.7-3 ###
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# RELEASE v0.9.7 - phase one complete
|
# RELEASE v0.9.7-3 - Install improvements & management features
|
||||||
|
# - Fixed missing complete_uninstall function
|
||||||
|
# - Added virtual console configuration (Ctrl+Alt+F1-F8)
|
||||||
|
# - Added emergency hotspot to initial installation
|
||||||
|
# - Enhanced security options and documentation
|
||||||
#
|
#
|
||||||
# Built with Claude Sonnet 4/.5 AI assistance
|
# Built with Claude Sonnet 4/.5 AI assistance
|
||||||
# License: GPL v3 - Keep derivatives open sour
|
# License: GPL v3 - Keep derivatives open source
|
||||||
# Repository: https://github.com/outis1one/ubk/
|
# Repository: https://github.com/outis1one/ubuntu-based-kiosk/
|
||||||
#
|
#
|
||||||
# TARGET SYSTEMS:
|
# TARGET SYSTEMS:
|
||||||
# - Ubuntu 24.04+ Server (minimal install recommended)
|
# - Ubuntu 24.04+ Server (minimal install recommended)
|
||||||
@@ -35,7 +39,7 @@ set -euo pipefail
|
|||||||
### SECTION 1: CONSTANTS & GLOBALS
|
### SECTION 1: CONSTANTS & GLOBALS
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
SCRIPT_VERSION="0.9.7"
|
SCRIPT_VERSION="0.9.7-3"
|
||||||
KIOSK_USER="kiosk"
|
KIOSK_USER="kiosk"
|
||||||
BUILD_USER="${SUDO_USER:-$(whoami)}"
|
BUILD_USER="${SUDO_USER:-$(whoami)}"
|
||||||
KIOSK_HOME="/home/${KIOSK_USER}"
|
KIOSK_HOME="/home/${KIOSK_USER}"
|
||||||
@@ -2146,7 +2150,75 @@ disable_emergency_hotspot() {
|
|||||||
pause
|
pause
|
||||||
}
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
### VIRTUAL CONSOLE CONFIGURATION
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
configure_virtual_consoles() {
|
||||||
|
clear
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo " VIRTUAL CONSOLE CONFIGURATION "
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
echo "Virtual consoles (Ctrl+Alt+F1 through F8) allow manual login"
|
||||||
|
echo "to a terminal for troubleshooting and system maintenance."
|
||||||
|
echo
|
||||||
|
echo "Current status:"
|
||||||
|
|
||||||
|
# Check if virtual consoles are enabled
|
||||||
|
local consoles_disabled=false
|
||||||
|
if systemctl is-masked getty@tty1.service >/dev/null 2>&1; then
|
||||||
|
consoles_disabled=true
|
||||||
|
echo " Virtual consoles: ✗ DISABLED"
|
||||||
|
else
|
||||||
|
consoles_disabled=false
|
||||||
|
echo " Virtual consoles: ✓ ENABLED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Options:"
|
||||||
|
echo " 1. Enable virtual consoles (Ctrl+Alt+F1-F8 for manual login)"
|
||||||
|
echo " 2. Disable virtual consoles (more secure, kiosk only)"
|
||||||
|
echo " 0. Cancel"
|
||||||
|
echo
|
||||||
|
read -r -p "Choose [0-2]: " choice
|
||||||
|
|
||||||
|
case "$choice" in
|
||||||
|
1)
|
||||||
|
echo
|
||||||
|
echo "Enabling virtual consoles..."
|
||||||
|
for i in {1..8}; do
|
||||||
|
sudo systemctl unmask getty@tty$i.service 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
log_success "Virtual consoles enabled"
|
||||||
|
echo
|
||||||
|
echo "You can now access virtual consoles with:"
|
||||||
|
echo " Ctrl+Alt+F1 through Ctrl+Alt+F8"
|
||||||
|
echo " (Ctrl+Alt+F7 typically returns to the kiosk)"
|
||||||
|
pause
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo
|
||||||
|
read -r -p "Disable all virtual consoles? (y/n): " confirm
|
||||||
|
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Disabling virtual consoles..."
|
||||||
|
for i in {1..8}; do
|
||||||
|
sudo systemctl mask getty@tty$i.service 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
log_success "Virtual consoles disabled"
|
||||||
|
echo
|
||||||
|
echo "Virtual console access has been disabled for security."
|
||||||
|
echo "You can re-enable them from the Tools menu if needed."
|
||||||
|
pause
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
0)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
### SECTION 5: POWER/DISPLAY/QUIET SCHEDULES
|
### SECTION 5: POWER/DISPLAY/QUIET SCHEDULES
|
||||||
@@ -3475,6 +3547,124 @@ full_reinstall() {
|
|||||||
echo ""
|
echo ""
|
||||||
pause
|
pause
|
||||||
}
|
}
|
||||||
|
|
||||||
|
complete_uninstall() {
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo " COMPLETE UNINSTALL"
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ This will COMPLETELY REMOVE:"
|
||||||
|
echo " • Kiosk user and all data"
|
||||||
|
echo " • All kiosk configuration and sites"
|
||||||
|
echo " • All Electron/Node.js installations"
|
||||||
|
echo " • All browser caches and data"
|
||||||
|
echo " • CUPS printer system"
|
||||||
|
echo " • LightDM and Openbox"
|
||||||
|
echo " • All kiosk schedules and services"
|
||||||
|
echo " • Emergency hotspot configuration"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ This CANNOT be undone!"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you ABSOLUTELY SURE? (type UNINSTALL): " CONFIRM
|
||||||
|
|
||||||
|
if [ "$CONFIRM" != "UNINSTALL" ]; then
|
||||||
|
echo "Cancelled."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Beginning complete uninstall..."
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
echo "[1/12] Stopping all kiosk services..."
|
||||||
|
sudo systemctl stop lightdm 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-display-on.timer 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-display-off.timer 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-quiet-start.timer 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-quiet-end.timer 2>/dev/null || true
|
||||||
|
sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true
|
||||||
|
sudo systemctl stop x11vnc 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove kiosk user
|
||||||
|
echo "[2/12] Removing kiosk user..."
|
||||||
|
if id "$KIOSK_USER" &>/dev/null; then
|
||||||
|
sudo pkill -u "$KIOSK_USER" 2>/dev/null || true
|
||||||
|
sudo userdel -r "$KIOSK_USER" 2>/dev/null || true
|
||||||
|
log_success "Kiosk user removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove kiosk files
|
||||||
|
echo "[3/12] Removing kiosk files..."
|
||||||
|
sudo rm -rf "$KIOSK_DIR"
|
||||||
|
sudo rm -rf /home/$KIOSK_USER
|
||||||
|
|
||||||
|
# Remove systemd services and timers
|
||||||
|
echo "[4/12] Removing systemd services..."
|
||||||
|
sudo rm -f /etc/systemd/system/kiosk-*.service
|
||||||
|
sudo rm -f /etc/systemd/system/kiosk-*.timer
|
||||||
|
sudo rm -f /etc/systemd/system/x11vnc.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# Remove scripts
|
||||||
|
echo "[5/12] Removing scripts..."
|
||||||
|
sudo rm -f /usr/local/bin/kiosk-*
|
||||||
|
sudo rm -f /usr/local/bin/rtc-wake.sh
|
||||||
|
|
||||||
|
# Remove CUPS
|
||||||
|
echo "[6/12] Removing CUPS..."
|
||||||
|
sudo systemctl stop cups 2>/dev/null || true
|
||||||
|
sudo systemctl disable cups 2>/dev/null || true
|
||||||
|
sudo apt-get purge -y cups cups-client cups-common 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove Node.js and Electron
|
||||||
|
echo "[7/12] Removing Node.js..."
|
||||||
|
sudo apt-get purge -y nodejs npm 2>/dev/null || true
|
||||||
|
sudo rm -rf /usr/local/lib/node_modules
|
||||||
|
sudo rm -rf /usr/local/bin/node
|
||||||
|
sudo rm -rf /usr/local/bin/npm
|
||||||
|
|
||||||
|
# Remove LightDM and Openbox
|
||||||
|
echo "[8/12] Removing LightDM and Openbox..."
|
||||||
|
sudo systemctl disable lightdm 2>/dev/null || true
|
||||||
|
sudo apt-get purge -y lightdm openbox 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove VNC
|
||||||
|
echo "[9/12] Removing VNC..."
|
||||||
|
sudo systemctl stop x11vnc 2>/dev/null || true
|
||||||
|
sudo systemctl disable x11vnc 2>/dev/null || true
|
||||||
|
sudo apt-get purge -y x11vnc 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove polkit rules
|
||||||
|
echo "[10/12] Removing polkit rules..."
|
||||||
|
sudo rm -f /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
|
||||||
|
|
||||||
|
# System cleanup
|
||||||
|
echo "[11/12] Cleaning up packages..."
|
||||||
|
sudo apt-get autoremove -y 2>/dev/null || true
|
||||||
|
sudo apt-get autoclean 2>/dev/null || true
|
||||||
|
|
||||||
|
# Re-enable virtual consoles if they were disabled
|
||||||
|
echo "[12/12] Re-enabling virtual consoles..."
|
||||||
|
for i in {1..6}; do
|
||||||
|
sudo systemctl unmask getty@tty$i.service 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓✓✓ KIOSK COMPLETELY UNINSTALLED ✓✓✓"
|
||||||
|
echo ""
|
||||||
|
echo "The system has been returned to its pre-kiosk state."
|
||||||
|
echo "You may want to reboot to ensure all changes take effect."
|
||||||
|
echo ""
|
||||||
|
read -r -p "Reboot now? (y/n): " do_reboot
|
||||||
|
if [[ "$do_reboot" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Rebooting..."
|
||||||
|
sleep 3
|
||||||
|
sudo reboot
|
||||||
|
fi
|
||||||
|
}
|
||||||
################################################################################
|
################################################################################
|
||||||
### SECTION 8: FIRST TIME INSTALLATION
|
### SECTION 8: FIRST TIME INSTALLATION
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -6575,6 +6765,52 @@ fi
|
|||||||
echo "[26/27] Finalizing installation..."
|
echo "[26/27] Finalizing installation..."
|
||||||
log_success "Core installation complete!"
|
log_success "Core installation complete!"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
# Optional: Configure emergency hotspot
|
||||||
|
echo
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo " OPTIONAL: Emergency Hotspot "
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
echo "The emergency hotspot automatically activates when there's no"
|
||||||
|
echo "internet connection, allowing you to connect and troubleshoot."
|
||||||
|
echo
|
||||||
|
read -r -p "Configure emergency hotspot now? (y/n): " setup_hotspot
|
||||||
|
if [[ "$setup_hotspot" =~ ^[Yy]$ ]]; then
|
||||||
|
install_emergency_hotspot
|
||||||
|
else
|
||||||
|
log_info "Emergency hotspot can be configured later from Advanced menu"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional: Configure virtual consoles
|
||||||
|
echo
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo " OPTIONAL: Virtual Console Access "
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
echo "Virtual consoles (Ctrl+Alt+F1-F8) allow manual terminal login"
|
||||||
|
echo "for troubleshooting. This is useful but less secure."
|
||||||
|
echo
|
||||||
|
echo "Current status: ENABLED (default)"
|
||||||
|
echo
|
||||||
|
read -r -p "Keep virtual consoles enabled? (y/n): " keep_consoles
|
||||||
|
if [[ ! "$keep_consoles" =~ ^[Yy]$ ]]; then
|
||||||
|
echo
|
||||||
|
echo "Disabling virtual consoles for security..."
|
||||||
|
for i in {1..8}; do
|
||||||
|
sudo systemctl mask getty@tty$i.service 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
log_success "Virtual consoles disabled"
|
||||||
|
echo
|
||||||
|
echo "You can re-enable them later from Advanced menu (option 7)"
|
||||||
|
else
|
||||||
|
log_info "Virtual consoles remain enabled (Ctrl+Alt+F1-F8)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[27/27] Installation complete!"
|
||||||
|
echo
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " • Rerun this script to configure addons"
|
echo " • Rerun this script to configure addons"
|
||||||
echo " • Reboot to start the kiosk"
|
echo " • Reboot to start the kiosk"
|
||||||
@@ -8957,12 +9193,13 @@ advanced_menu() {
|
|||||||
echo " 4. Audio Diagnostics"
|
echo " 4. Audio Diagnostics"
|
||||||
echo " 5. Fix Squeezelite Audio"
|
echo " 5. Fix Squeezelite Audio"
|
||||||
echo " 6. Factory Reset Config"
|
echo " 6. Factory Reset Config"
|
||||||
|
echo " 7. Virtual Consoles (Ctrl+Alt+F1-F8)"
|
||||||
echo " 9. Emergency Hotspot"
|
echo " 9. Emergency Hotspot"
|
||||||
echo " 10. Network Test"
|
echo " 10. Network Test"
|
||||||
echo " 0. Return"
|
echo " 0. Return"
|
||||||
echo
|
echo
|
||||||
read -r -p "Choose [0-10]: " choice
|
read -r -p "Choose [0-10]: " choice
|
||||||
|
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
1) manual_electron_update ;;
|
1) manual_electron_update ;;
|
||||||
2) system_diagnostics ;;
|
2) system_diagnostics ;;
|
||||||
@@ -8970,6 +9207,7 @@ advanced_menu() {
|
|||||||
4) audio_diagnostics ;;
|
4) audio_diagnostics ;;
|
||||||
5) fix_squeezelite_audio ;;
|
5) fix_squeezelite_audio ;;
|
||||||
6) factory_reset ;;
|
6) factory_reset ;;
|
||||||
|
7) configure_virtual_consoles ;;
|
||||||
9) configure_emergency_hotspot ;;
|
9) configure_emergency_hotspot ;;
|
||||||
10) network_test ;;
|
10) network_test ;;
|
||||||
0) return ;;
|
0) return ;;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+411
@@ -0,0 +1,411 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# iso/build-iso.sh - Build an Ubuntu Server 26.04 install ISO with the
|
||||||
|
# kiosk built in, so there's no separate "install Ubuntu, then clone and
|
||||||
|
# run ./install.sh" step.
|
||||||
|
#
|
||||||
|
# What the ISO does:
|
||||||
|
# 1. Boots the stock Ubuntu Server installer (Subiquity), driven by an
|
||||||
|
# autoinstall.yaml at the root of the ISO. By default it still asks
|
||||||
|
# for network, disk and your admin username/password - everything
|
||||||
|
# else (language, keyboard, SSH server, snaps...) is answered for
|
||||||
|
# you. Flags below make any of those automatic too.
|
||||||
|
# 2. At the end of the install (autoinstall late-commands), copies this
|
||||||
|
# repo to /opt/ubuntu-based-kiosk on the new system and enables a
|
||||||
|
# one-shot first-boot service (firstboot/kiosk-firstboot.service).
|
||||||
|
# 3. On the first boot of the installed system, that service takes over
|
||||||
|
# tty1 and runs ./install.sh as your admin user from
|
||||||
|
# ~/ubuntu-based-kiosk - interactively by default (same prompts as a
|
||||||
|
# manual install), or with --unattended taking every default and
|
||||||
|
# rebooting straight into the kiosk. Configure sites afterwards via
|
||||||
|
# the Web UI (http://<ip>:8090) or ./install.sh.
|
||||||
|
#
|
||||||
|
# Nothing in the original ISO is changed except /boot/grub/grub.cfg
|
||||||
|
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
|
||||||
|
# boot records (BIOS + UEFI) are replayed from the original as-is.
|
||||||
|
#
|
||||||
|
# Requirements (on the build machine, any recent Ubuntu/Debian): the
|
||||||
|
# script checks for its build tools and offers to install whatever's
|
||||||
|
# missing with apt (or pass --install-deps to just do it). By hand:
|
||||||
|
# sudo apt install xorriso curl git openssl
|
||||||
|
# plus, for --offline (amd64 build machine only):
|
||||||
|
# sudo apt install apt-utils python3 gnupg ubuntu-keyring
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# iso/build-iso.sh [options]
|
||||||
|
#
|
||||||
|
# --iso PATH Use this Ubuntu Server ISO instead of downloading.
|
||||||
|
# --install-deps Install missing build tools with apt without asking.
|
||||||
|
# --release VER Ubuntu release to download (default: 26.04 - picks
|
||||||
|
# the newest 26.04.x live-server-amd64 point release).
|
||||||
|
# --output PATH Output ISO (default: iso/build/ubuntu-<ver>-kiosk-amd64.iso)
|
||||||
|
#
|
||||||
|
# Installed system:
|
||||||
|
# --username NAME Admin username (the account you SSH in with and run
|
||||||
|
# ./install.sh as - must not be "kiosk"). Giving this
|
||||||
|
# makes the installer's identity screen automatic.
|
||||||
|
# --password PASS Admin password (prompted for if --username is given
|
||||||
|
# without it, or read from $KIOSK_ADMIN_PASSWORD -
|
||||||
|
# better than this flag on shared machines/CI, where
|
||||||
|
# command lines are visible). Stored on the ISO only
|
||||||
|
# as a SHA-512 crypt hash.
|
||||||
|
# --hostname NAME Hostname (default: kiosk).
|
||||||
|
# --realname NAME Full name for the admin account (default: Kiosk Admin).
|
||||||
|
# --ssh-key FILE Add this public key file to the admin user's
|
||||||
|
# authorized_keys.
|
||||||
|
# --locale LOCALE Default: en_US.UTF-8
|
||||||
|
# --keyboard LAYOUT Default: us
|
||||||
|
# --auto-storage Don't ask about disks: wipe the largest disk and use
|
||||||
|
# all of it (LVM). DESTROYS DATA on that disk.
|
||||||
|
# --auto-network Don't show the network screen (DHCP on wired
|
||||||
|
# interfaces). Leave this off if you need WiFi.
|
||||||
|
# --no-confirm Don't ask "Continue with autoinstall?" at boot.
|
||||||
|
# --fully-automatic Shorthand for --auto-storage --auto-network
|
||||||
|
# --no-confirm --unattended; needs --username.
|
||||||
|
# Booting this ISO will wipe a disk with NO questions.
|
||||||
|
#
|
||||||
|
# Kiosk install on first boot:
|
||||||
|
# --unattended Run install.sh with all defaults, no prompts
|
||||||
|
# (skips the Core Settings menu), then reboot.
|
||||||
|
# --repo-url URL git remote recorded in the copied repo, used by
|
||||||
|
# Advanced -> Upgrade (default: this checkout's
|
||||||
|
# origin, else the upstream GitHub repo).
|
||||||
|
# --offline Also bundle everything install.sh downloads (~520 MB:
|
||||||
|
# every apt package incl. the CUPS, LMS/Squeezelite,
|
||||||
|
# Asterisk Intercom, VNC, WireGuard and hotspot addons,
|
||||||
|
# Node.js, prebuilt Electron/Web UI node_modules), so
|
||||||
|
# first boot can install the kiosk with NO internet.
|
||||||
|
# With internet it still installs online as usual;
|
||||||
|
# offline it waits 90s, then uses the bundle. The
|
||||||
|
# bundle stays on the kiosk at /opt/kiosk-offline
|
||||||
|
# - Addons -> CUPS Printing then installs from it too
|
||||||
|
# whenever the kiosk is offline.
|
||||||
|
#
|
||||||
|
# The repo copied onto the ISO is a clone of this checkout's current HEAD
|
||||||
|
# (committed changes only - uncommitted edits are NOT included).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
|
DEFAULT_REPO_URL="https://github.com/outis1one/ubuntu-based-kiosk.git"
|
||||||
|
|
||||||
|
SRC_ISO=""
|
||||||
|
RELEASE="26.04"
|
||||||
|
OUTPUT=""
|
||||||
|
ADMIN_USER=""
|
||||||
|
ADMIN_PASS="${KIOSK_ADMIN_PASSWORD:-}"
|
||||||
|
HOSTNAME_="kiosk"
|
||||||
|
REALNAME="Kiosk Admin"
|
||||||
|
SSH_KEY_FILE=""
|
||||||
|
LOCALE="en_US.UTF-8"
|
||||||
|
KEYBOARD="us"
|
||||||
|
AUTO_STORAGE=0
|
||||||
|
AUTO_NETWORK=0
|
||||||
|
NO_CONFIRM=0
|
||||||
|
UNATTENDED=0
|
||||||
|
OFFLINE=0
|
||||||
|
INSTALL_DEPS=0
|
||||||
|
REPO_URL=""
|
||||||
|
|
||||||
|
# Usernames the Ubuntu installer rejects (system users/groups) - copied
|
||||||
|
# from subiquity's reserved-usernames so a bad --username fails here
|
||||||
|
# instead of at boot with "Username is reserved by the system".
|
||||||
|
RESERVED_USERNAMES="
|
||||||
|
root daemon bin sys sync games man lp mail news uucp proxy www-data
|
||||||
|
backup list irc gnats nobody adm tty disk kmem dialout fax voice cdrom
|
||||||
|
floppy tape sudo audio dip operator src shadow utmp video sasl plugdev
|
||||||
|
staff users nogroup netplan ftn mysql tac-plus alias qmail qmaild
|
||||||
|
qmails qmailr qmailq qmaill qmailp asterisk vpopmail vchkpw slurm
|
||||||
|
hacluster haclient grsec-tpe grsec-sock-all grsec-sock-clt
|
||||||
|
grsec-sock-srv grsec-proc ceph opensrf libvirt-qemu admin Debian-exim
|
||||||
|
bind crontab cupsys dcc dhcp dictd dnsmasq dovecot fetchmail firebird
|
||||||
|
ftp fuse gdm haldaemon hplilp identd input jwhois klog kvm lpadmin maas
|
||||||
|
messagebus mythtv netdev powerdev radvd render saned sbuild scanner sgx
|
||||||
|
slocate ssh sshd ssl-cert sslwrap statd syslog telnetd tftpd
|
||||||
|
"
|
||||||
|
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
info() { echo "==> $*"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'
|
||||||
|
exit "${1:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--iso) SRC_ISO="$2"; shift 2 ;;
|
||||||
|
--release) RELEASE="$2"; shift 2 ;;
|
||||||
|
--output) OUTPUT="$2"; shift 2 ;;
|
||||||
|
--username) ADMIN_USER="$2"; shift 2 ;;
|
||||||
|
--password) ADMIN_PASS="$2"; shift 2 ;;
|
||||||
|
--hostname) HOSTNAME_="$2"; shift 2 ;;
|
||||||
|
--realname) REALNAME="$2"; shift 2 ;;
|
||||||
|
--ssh-key) SSH_KEY_FILE="$2"; shift 2 ;;
|
||||||
|
--locale) LOCALE="$2"; shift 2 ;;
|
||||||
|
--keyboard) KEYBOARD="$2"; shift 2 ;;
|
||||||
|
--auto-storage) AUTO_STORAGE=1; shift ;;
|
||||||
|
--auto-network) AUTO_NETWORK=1; shift ;;
|
||||||
|
--no-confirm) NO_CONFIRM=1; shift ;;
|
||||||
|
--unattended) UNATTENDED=1; shift ;;
|
||||||
|
--fully-automatic) AUTO_STORAGE=1; AUTO_NETWORK=1; NO_CONFIRM=1; UNATTENDED=1; shift ;;
|
||||||
|
--repo-url) REPO_URL="$2"; shift 2 ;;
|
||||||
|
--offline) OFFLINE=1; shift ;;
|
||||||
|
--install-deps) INSTALL_DEPS=1; shift ;;
|
||||||
|
-h|--help) usage 0 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; usage 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Validate
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Build tools: command -> the apt package that provides it.
|
||||||
|
declare -A DEP_PKG=([xorriso]=xorriso [curl]=curl [git]=git [openssl]=openssl [sha256sum]=coreutils)
|
||||||
|
if (( OFFLINE )); then
|
||||||
|
DEP_PKG+=([apt-ftparchive]=apt-utils [python3]=python3 [gpg]=gnupg [dpkg-deb]=dpkg)
|
||||||
|
fi
|
||||||
|
missing_pkgs=()
|
||||||
|
for cmd in "${!DEP_PKG[@]}"; do
|
||||||
|
command -v "$cmd" &>/dev/null || missing_pkgs+=("${DEP_PKG[$cmd]}")
|
||||||
|
done
|
||||||
|
if (( OFFLINE )) && [[ ! -r /usr/share/keyrings/ubuntu-archive-keyring.gpg ]]; then
|
||||||
|
missing_pkgs+=(ubuntu-keyring)
|
||||||
|
fi
|
||||||
|
if (( ${#missing_pkgs[@]} )); then
|
||||||
|
mapfile -t missing_pkgs < <(printf '%s\n' "${missing_pkgs[@]}" | sort -u)
|
||||||
|
echo "Missing build tools: ${missing_pkgs[*]}"
|
||||||
|
command -v apt-get &>/dev/null \
|
||||||
|
|| die "install them with your package manager and re-run (this script expects Ubuntu/Debian)"
|
||||||
|
if (( ! INSTALL_DEPS )); then
|
||||||
|
[[ -t 0 ]] || die "install them first: sudo apt install ${missing_pkgs[*]} (or re-run with --install-deps)"
|
||||||
|
read -r -p "Install them now with 'sudo apt install'? [Y/n] " reply
|
||||||
|
[[ "${reply,,}" =~ ^(|y|yes)$ ]] || die "install them first: sudo apt install ${missing_pkgs[*]}"
|
||||||
|
fi
|
||||||
|
sudo apt-get update -q
|
||||||
|
sudo apt-get install -y "${missing_pkgs[@]}" || die "couldn't install: ${missing_pkgs[*]}"
|
||||||
|
fi
|
||||||
|
if (( OFFLINE )) && [[ "$(dpkg --print-architecture 2>/dev/null)" != "amd64" ]]; then
|
||||||
|
die "--offline must be built on an amd64 (64-bit Intel/AMD) machine - it runs the bundled Node.js to build the kiosk's modules"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ADMIN_USER" ]]; then
|
||||||
|
[[ "$ADMIN_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid username: $ADMIN_USER"
|
||||||
|
[[ "$ADMIN_USER" != "kiosk" && "$ADMIN_USER" != "root" ]] \
|
||||||
|
|| die "username '$ADMIN_USER' is reserved (install.sh creates its own 'kiosk' user)"
|
||||||
|
[[ " $(echo $RESERVED_USERNAMES) " != *" $ADMIN_USER "* ]] \
|
||||||
|
|| die "username '$ADMIN_USER' is reserved by Ubuntu (a system user/group) - pick another"
|
||||||
|
if [[ -z "$ADMIN_PASS" ]]; then
|
||||||
|
[[ -t 0 ]] || die "--username needs a password: pass --password or set KIOSK_ADMIN_PASSWORD"
|
||||||
|
read -r -s -p "Password for $ADMIN_USER: " ADMIN_PASS; echo
|
||||||
|
read -r -s -p "Confirm password: " pass2; echo
|
||||||
|
[[ "$ADMIN_PASS" == "$pass2" ]] || die "passwords don't match"
|
||||||
|
[[ -n "$ADMIN_PASS" ]] || die "empty password"
|
||||||
|
fi
|
||||||
|
elif [[ -n "$ADMIN_PASS" ]]; then
|
||||||
|
die "--password needs --username"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( AUTO_STORAGE && AUTO_NETWORK && NO_CONFIRM )) && [[ -z "$ADMIN_USER" ]]; then
|
||||||
|
die "--fully-automatic (or all of --auto-storage --auto-network --no-confirm) needs --username"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$HOSTNAME_" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$ ]] || die "invalid hostname: $HOSTNAME_"
|
||||||
|
[[ -z "$SSH_KEY_FILE" || -r "$SSH_KEY_FILE" ]] || die "can't read --ssh-key file: $SSH_KEY_FILE"
|
||||||
|
|
||||||
|
if [[ -z "$REPO_URL" ]]; then
|
||||||
|
REPO_URL="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
|
||||||
|
# Local proxies/mirrors and ssh remotes aren't reachable from a kiosk.
|
||||||
|
[[ "$REPO_URL" == https://github.com/* ]] || REPO_URL="$DEFAULT_REPO_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
WORK="$(mktemp -d "$BUILD_DIR/work.XXXXXX")"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Source ISO - download and verify unless one was given
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
if [[ -z "$SRC_ISO" ]]; then
|
||||||
|
base="https://releases.ubuntu.com/$RELEASE"
|
||||||
|
info "Looking up the latest Ubuntu $RELEASE live-server ISO..."
|
||||||
|
sums="$(curl -fsSL "$base/SHA256SUMS")" || die "couldn't fetch $base/SHA256SUMS"
|
||||||
|
iso_name="$(grep -oE "ubuntu-${RELEASE//./\\.}(\.[0-9]+)?-live-server-amd64\.iso" <<<"$sums" | sort -V | tail -1)"
|
||||||
|
[[ -n "$iso_name" ]] || die "no live-server-amd64 ISO listed in $base/SHA256SUMS"
|
||||||
|
expected="$(grep -F "*$iso_name" <<<"$sums" | awk '{print $1}')"
|
||||||
|
|
||||||
|
mkdir -p "$BUILD_DIR/cache"
|
||||||
|
SRC_ISO="$BUILD_DIR/cache/$iso_name"
|
||||||
|
if [[ -f "$SRC_ISO" ]] && echo "$expected $SRC_ISO" | sha256sum -c --status; then
|
||||||
|
info "Using cached $iso_name"
|
||||||
|
else
|
||||||
|
info "Downloading $iso_name (~3 GB)..."
|
||||||
|
curl -fL --retry 5 -C - -o "$SRC_ISO" "$base/$iso_name" \
|
||||||
|
|| curl -fL --retry 5 -o "$SRC_ISO" "$base/$iso_name"
|
||||||
|
info "Verifying checksum..."
|
||||||
|
echo "$expected $SRC_ISO" | sha256sum -c --status \
|
||||||
|
|| { rm -f "$SRC_ISO"; die "checksum mismatch for $iso_name - deleted, re-run to retry"; }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -f "$SRC_ISO" ]] || die "ISO not found: $SRC_ISO"
|
||||||
|
|
||||||
|
if [[ -z "$OUTPUT" ]]; then
|
||||||
|
src_base="$(basename "$SRC_ISO" .iso)"
|
||||||
|
kind="kiosk"; (( OFFLINE )) && kind="kiosk-offline"
|
||||||
|
OUTPUT="$BUILD_DIR/${src_base/-live-server/-$kind}.iso"
|
||||||
|
[[ "$OUTPUT" != "$BUILD_DIR/$src_base.iso" ]] || OUTPUT="$BUILD_DIR/${src_base}-$kind.iso"
|
||||||
|
fi
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Stage the repo + first-boot files that go on the ISO under /kiosk
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Branch the copied repo is on - what Advanced -> Upgrade pulls. CI
|
||||||
|
# checkouts (actions/checkout) are a detached HEAD, so fall back to the
|
||||||
|
# branch CI says it built, then main.
|
||||||
|
BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||||
|
[[ "$BRANCH" != "HEAD" ]] || BRANCH="${GITHUB_REF_NAME:-main}"
|
||||||
|
COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||||
|
|
||||||
|
info "Staging repo ($BRANCH @ ${COMMIT:0:7})..."
|
||||||
|
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
|
||||||
|
echo " WARNING: uncommitted changes in $REPO_ROOT are NOT included - commit them first if you need them."
|
||||||
|
fi
|
||||||
|
mkdir -p "$WORK/kiosk/firstboot"
|
||||||
|
# init + fetch by commit rather than `git clone --branch`, which can't
|
||||||
|
# clone a detached HEAD.
|
||||||
|
stage_repo="$WORK/kiosk/ubuntu-based-kiosk"
|
||||||
|
git init --quiet "$stage_repo"
|
||||||
|
git -C "$stage_repo" fetch --quiet --depth 1 "file://$REPO_ROOT" "$COMMIT"
|
||||||
|
git -C "$stage_repo" checkout --quiet -B "$BRANCH" FETCH_HEAD
|
||||||
|
git -C "$stage_repo" remote add origin "$REPO_URL"
|
||||||
|
git -C "$stage_repo" config "branch.$BRANCH.remote" origin
|
||||||
|
git -C "$stage_repo" config "branch.$BRANCH.merge" "refs/heads/$BRANCH"
|
||||||
|
|
||||||
|
install -m 755 "$SCRIPT_DIR/firstboot/kiosk-firstboot" "$WORK/kiosk/firstboot/"
|
||||||
|
install -m 644 "$SCRIPT_DIR/firstboot/kiosk-firstboot.service" "$WORK/kiosk/firstboot/"
|
||||||
|
install -m 755 "$SCRIPT_DIR/firstboot/kiosk-offline-apt" "$WORK/kiosk/firstboot/"
|
||||||
|
cat > "$WORK/kiosk/firstboot/kiosk-firstboot.conf" <<EOF
|
||||||
|
# Read by /usr/local/sbin/kiosk-firstboot. Written by iso/build-iso.sh.
|
||||||
|
# 1 = take every install.sh default and reboot into the kiosk; 0 = interactive.
|
||||||
|
KIOSK_UNATTENDED=$UNATTENDED
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if (( OFFLINE )); then
|
||||||
|
info "Building offline bundle (packages, Node.js, Electron)..."
|
||||||
|
"$SCRIPT_DIR/offline/build-bundle.sh" "$WORK/kiosk/offline" "$SRC_ISO" "$stage_repo" "$BUILD_DIR/offline-cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# autoinstall.yaml
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Single-quoted YAML scalar.
|
||||||
|
yq() { printf "'%s'" "${1//\'/\'\'}"; }
|
||||||
|
|
||||||
|
interactive=()
|
||||||
|
(( AUTO_NETWORK )) || interactive+=(network)
|
||||||
|
(( AUTO_STORAGE )) || interactive+=(storage)
|
||||||
|
[[ -n "$ADMIN_USER" ]] || interactive+=(identity)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "#cloud-config"
|
||||||
|
echo "# Generated by iso/build-iso.sh - see that file for what this does."
|
||||||
|
echo "autoinstall:"
|
||||||
|
echo " version: 1"
|
||||||
|
if (( ${#interactive[@]} )); then
|
||||||
|
echo " interactive-sections:"
|
||||||
|
for s in "${interactive[@]}"; do echo " - $s"; done
|
||||||
|
fi
|
||||||
|
echo " locale: $(yq "$LOCALE")"
|
||||||
|
echo " keyboard:"
|
||||||
|
echo " layout: $(yq "$KEYBOARD")"
|
||||||
|
# Pre-selected answer for the storage screen when it's interactive;
|
||||||
|
# the whole answer when --auto-storage.
|
||||||
|
echo " storage:"
|
||||||
|
echo " layout:"
|
||||||
|
echo " name: lvm"
|
||||||
|
echo " sizing-policy: all"
|
||||||
|
if [[ -n "$ADMIN_USER" ]]; then
|
||||||
|
echo " identity:"
|
||||||
|
echo " hostname: $(yq "$HOSTNAME_")"
|
||||||
|
echo " realname: $(yq "$REALNAME")"
|
||||||
|
echo " username: $(yq "$ADMIN_USER")"
|
||||||
|
echo " password: $(yq "$(openssl passwd -6 -stdin <<<"$ADMIN_PASS")")"
|
||||||
|
fi
|
||||||
|
echo " ssh:"
|
||||||
|
echo " install-server: true"
|
||||||
|
echo " allow-pw: true"
|
||||||
|
if [[ -n "$SSH_KEY_FILE" ]]; then
|
||||||
|
echo " authorized-keys:"
|
||||||
|
while IFS= read -r key; do
|
||||||
|
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||||
|
echo " - $(yq "$key")"
|
||||||
|
done < "$SSH_KEY_FILE"
|
||||||
|
fi
|
||||||
|
cat <<'EOF'
|
||||||
|
late-commands:
|
||||||
|
- mkdir -p /target/opt /target/usr/local/sbin /target/var/lib/kiosk-firstboot
|
||||||
|
- cp -a /cdrom/kiosk/ubuntu-based-kiosk /target/opt/ubuntu-based-kiosk
|
||||||
|
- chmod -R u+w /target/opt/ubuntu-based-kiosk
|
||||||
|
- install -m 755 /cdrom/kiosk/firstboot/kiosk-firstboot /target/usr/local/sbin/kiosk-firstboot
|
||||||
|
- install -m 644 /cdrom/kiosk/firstboot/kiosk-firstboot.service /target/etc/systemd/system/kiosk-firstboot.service
|
||||||
|
- install -m 644 /cdrom/kiosk/firstboot/kiosk-firstboot.conf /target/etc/kiosk-firstboot.conf
|
||||||
|
- install -m 755 /cdrom/kiosk/firstboot/kiosk-offline-apt /target/usr/local/sbin/kiosk-offline-apt
|
||||||
|
- touch /target/var/lib/kiosk-firstboot/pending
|
||||||
|
- curtin in-target --target=/target -- systemctl enable kiosk-firstboot.service
|
||||||
|
EOF
|
||||||
|
if (( OFFLINE )); then
|
||||||
|
echo " - cp -a /cdrom/kiosk/offline /target/opt/kiosk-offline"
|
||||||
|
echo " - chmod -R u+w,a+rX /target/opt/kiosk-offline"
|
||||||
|
fi
|
||||||
|
} > "$WORK/autoinstall.yaml"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# grub.cfg - same file serves BIOS and UEFI boot on the server ISO
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
xorriso -osirrox on -indev "$SRC_ISO" -extract /boot/grub/grub.cfg "$WORK/grub.cfg" 2>/dev/null \
|
||||||
|
|| die "couldn't read /boot/grub/grub.cfg from $SRC_ISO - is it an Ubuntu Server ISO?"
|
||||||
|
chmod u+w "$WORK/grub.cfg"
|
||||||
|
grep -q '/casper/vmlinuz' "$WORK/grub.cfg" || die "unexpected grub.cfg layout in $SRC_ISO"
|
||||||
|
|
||||||
|
sed -i 's/Try or Install Ubuntu Server/Install Ubuntu Based Kiosk/' "$WORK/grub.cfg"
|
||||||
|
if (( NO_CONFIRM )); then
|
||||||
|
sed -i -E '/linux[[:space:]]+\/casper\/vmlinuz/ s/[[:space:]]+---/ autoinstall ---/' "$WORK/grub.cfg"
|
||||||
|
fi
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Write the new ISO
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
info "Writing $OUTPUT..."
|
||||||
|
rm -f "$OUTPUT"
|
||||||
|
xorriso -indev "$SRC_ISO" -outdev "$OUTPUT" \
|
||||||
|
-map "$WORK/autoinstall.yaml" /autoinstall.yaml \
|
||||||
|
-map "$WORK/kiosk" /kiosk \
|
||||||
|
-map "$WORK/grub.cfg" /boot/grub/grub.cfg \
|
||||||
|
-boot_image any replay \
|
||||||
|
-padding included \
|
||||||
|
> "$WORK/xorriso.log" 2>&1 \
|
||||||
|
|| { cat "$WORK/xorriso.log" >&2; rm -f "$OUTPUT"; die "xorriso failed"; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
info "Done: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
|
||||||
|
echo
|
||||||
|
echo "Installer asks for : ${interactive[*]:-nothing}$( (( NO_CONFIRM )) || echo " (plus a 'Continue with autoinstall?' confirmation)")"
|
||||||
|
echo "Admin user : ${ADMIN_USER:-chosen during install}"
|
||||||
|
echo "First boot : $( (( UNATTENDED )) && echo "unattended install.sh, then reboots into the kiosk" || echo "interactive install.sh on the console")"
|
||||||
|
echo "Offline bundle : $( (( OFFLINE )) && echo "yes - installs with no internet (used after 90s offline)" || echo "no - first boot needs internet (add --offline to bundle everything)")"
|
||||||
|
echo
|
||||||
|
echo "Write it to a USB stick (replace /dev/sdX - this erases it):"
|
||||||
|
echo " sudo dd if='$OUTPUT' of=/dev/sdX bs=4M status=progress oflag=sync"
|
||||||
|
if (( AUTO_STORAGE && NO_CONFIRM )); then
|
||||||
|
echo
|
||||||
|
echo "WARNING: this ISO wipes the target's largest disk without asking. Label the stick."
|
||||||
|
fi
|
||||||
Executable
+303
@@ -0,0 +1,303 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# iso/e2b-contig.sh - Make a file on a USB drive contiguous, from Linux.
|
||||||
|
#
|
||||||
|
# Easy2Boot (and other multiboot loaders) can only boot some ISO files when
|
||||||
|
# they sit on the drive in one unbroken piece. A big ISO copied onto a
|
||||||
|
# well-used drive usually lands in several pieces, and Linux has no
|
||||||
|
# defragmenter for NTFS/FAT32/exFAT. This rewrites the one file so it is
|
||||||
|
# contiguous, using only ordinary file operations - it never writes
|
||||||
|
# filesystem structures directly, so the worst case is "still not
|
||||||
|
# contiguous", not a damaged drive.
|
||||||
|
#
|
||||||
|
# How: a plain copy just fills the drive's scattered free gaps again. So
|
||||||
|
# 1. fill the free space with temporary filler files (plugging every gap),
|
||||||
|
# 2. find the longest physically unbroken run among them and free exactly
|
||||||
|
# that run,
|
||||||
|
# 3. write the copy - the only free space left is that run,
|
||||||
|
# 4. delete the fillers, verify the copy's SHA-256, swap it in.
|
||||||
|
# This writes about as much data as the drive has free (progress shown) -
|
||||||
|
# minutes on USB 3, longer on USB 2 or a nearly empty big drive.
|
||||||
|
#
|
||||||
|
# If there's no unbroken run as big as the file while the original is still
|
||||||
|
# on the drive, --move-out first parks the original on this computer's disk
|
||||||
|
# (checksummed), deletes it from the drive to free its pieces, and retries.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo iso/e2b-contig.sh [--check] [--move-out] [--temp-dir DIR] [--yes] FILE...
|
||||||
|
#
|
||||||
|
# --check Only report whether each file is contiguous.
|
||||||
|
# --move-out Allowed to park the original on this computer if needed
|
||||||
|
# (default temp dir: /var/tmp).
|
||||||
|
# --temp-dir D Where to park it.
|
||||||
|
# --yes Don't ask for confirmation.
|
||||||
|
#
|
||||||
|
# Works with NTFS (ntfs-3g or the ntfs3 kernel driver), FAT32 and exFAT -
|
||||||
|
# anything filefrag can map. Run as root (filefrag needs it on most of
|
||||||
|
# these filesystems).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
CHECK_ONLY=0
|
||||||
|
MOVE_OUT=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
TEMP_DIR=/var/tmp
|
||||||
|
# Filler size. Small enough that fillers fit inside the drive's free gaps
|
||||||
|
# (a filler spanning two gaps can't be part of an unbroken run); kept in
|
||||||
|
# subfolders of 1000 because FAT32 caps the number of files per folder.
|
||||||
|
CHUNK_MB=4
|
||||||
|
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
info() { echo "==> $*"; }
|
||||||
|
|
||||||
|
FILES=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--check) CHECK_ONLY=1; shift ;;
|
||||||
|
--move-out) MOVE_OUT=1; shift ;;
|
||||||
|
--temp-dir) TEMP_DIR="$2"; shift 2 ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
-h|--help) sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
-*) die "unknown option: $1" ;;
|
||||||
|
*) FILES+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
(( ${#FILES[@]} )) || die "no file given - usage: sudo $0 [--check] FILE..."
|
||||||
|
[[ $EUID -eq 0 ]] || die "run with sudo (filefrag needs root on NTFS/FAT/exFAT)"
|
||||||
|
for cmd in filefrag findmnt blkid sha256sum dd awk df stat; do
|
||||||
|
command -v "$cmd" &>/dev/null || die "'$cmd' not found (packages: e2fsprogs, util-linux, coreutils)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# extents FILE -> "physical_start physical_end" per extent (filesystem
|
||||||
|
# blocks), in file order. Empty output = couldn't map (unsupported).
|
||||||
|
extents() {
|
||||||
|
filefrag -e "$1" 2>/dev/null | awk '
|
||||||
|
/^ *[0-9]+:/ {
|
||||||
|
gsub(/\.\./, " "); gsub(/:/, " ")
|
||||||
|
# fields: idx logical_start logical_end physical_start physical_end length ...
|
||||||
|
print $4, $5
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block size filefrag reports in.
|
||||||
|
fs_block_size() {
|
||||||
|
filefrag -e "$1" 2>/dev/null | sed -n 's/.*(\([0-9]*\) blocks of \([0-9]*\) bytes).*/\2/p' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# pieces FILE -> number of physically separate pieces (adjacent extents
|
||||||
|
# count as one; filefrag splits long runs into several extents).
|
||||||
|
pieces() {
|
||||||
|
extents "$1" | awk 'NR==1 {n=1; prev=$2; next} { if ($1 != prev + 1) n++; prev=$2 } END { print n+0 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
(( ASSUME_YES )) && return 0
|
||||||
|
local reply
|
||||||
|
read -r -p "$1 [y/N] " reply
|
||||||
|
[[ "${reply,,}" == y || "${reply,,}" == yes ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
FILL_DIR=""
|
||||||
|
TMP_COPY=""
|
||||||
|
cleanup() {
|
||||||
|
[[ -n "$FILL_DIR" && -d "$FILL_DIR" ]] && rm -rf -- "$FILL_DIR"
|
||||||
|
[[ -n "$TMP_COPY" && -f "$TMP_COPY" ]] && rm -f -- "$TMP_COPY"
|
||||||
|
sync
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'echo; echo "Interrupted - removing temporary files..."; exit 130' INT TERM
|
||||||
|
|
||||||
|
# fill_and_carve MOUNT NEED_BYTES -> 0 if it left exactly one free run of at
|
||||||
|
# least NEED_BYTES (fillers stay in $FILL_DIR until cleanup), 1 if no such run.
|
||||||
|
fill_and_carve() {
|
||||||
|
local mnt="$1" need="$2"
|
||||||
|
FILL_DIR="$mnt/.e2b-contig-fill.$$"
|
||||||
|
mkdir -p "$FILL_DIR" || return 1
|
||||||
|
local free_mb
|
||||||
|
free_mb=$(( $(df -B1M --output=avail "$mnt" | tail -1) ))
|
||||||
|
info "Filling $free_mb MB of free space with temporary files to plug the gaps..."
|
||||||
|
local n=0 written=0 fname
|
||||||
|
while :; do
|
||||||
|
n=$((n + 1))
|
||||||
|
fname="$FILL_DIR/d$(printf %04d $((n / 1000)))/f$(printf %07d $n)"
|
||||||
|
mkdir -p "${fname%/*}" 2>/dev/null || break
|
||||||
|
# dd stops (keeping what it wrote) when the drive is full.
|
||||||
|
dd if=/dev/zero of="$fname" bs=1M count="$CHUNK_MB" status=none 2>/dev/null
|
||||||
|
local rc=$?
|
||||||
|
local sz
|
||||||
|
sz=$(stat -c %s "$fname" 2>/dev/null || echo 0)
|
||||||
|
written=$((written + sz))
|
||||||
|
(( n % 25 == 0 )) && printf '\r %d / %d MB' "$((written / 1048576))" "$free_mb"
|
||||||
|
(( rc != 0 || sz < CHUNK_MB * 1048576 )) && break
|
||||||
|
done
|
||||||
|
printf '\r %d / %d MB\n' "$((written / 1048576))" "$free_mb"
|
||||||
|
sync
|
||||||
|
|
||||||
|
# Single-piece fillers sorted by physical position; the longest chain
|
||||||
|
# where each starts right after the previous ends is the best run.
|
||||||
|
local bs
|
||||||
|
bs=$(fs_block_size "$FILL_DIR/d0000/f0000001")
|
||||||
|
[[ -n "$bs" ]] || { echo " Couldn't map the filler files (filefrag unsupported here)."; return 1; }
|
||||||
|
# The map lives on this computer, not on the (now full) drive.
|
||||||
|
local f list
|
||||||
|
list=$(mktemp) || return 1
|
||||||
|
info "Mapping where the filler files landed..."
|
||||||
|
for f in "$FILL_DIR"/d*/f*; do
|
||||||
|
[[ -s "$f" ]] || continue
|
||||||
|
# One filefrag call per filler (there can be thousands): keep it only
|
||||||
|
# if it's a single piece, as "start end path".
|
||||||
|
extents "$f" | awk -v f="$f" '
|
||||||
|
NR == 1 { s = $1; e = $2; n = 1; next }
|
||||||
|
{ if ($1 == e + 1) e = $2; else n++ }
|
||||||
|
END { if (NR > 0 && n == 1) print s, e, f }' >> "$list"
|
||||||
|
done
|
||||||
|
local best
|
||||||
|
best=$(sort -n "$list" | awk -v bs="$bs" '
|
||||||
|
{ if (NR == 1 || $1 != prev_end + 1) { start_line = NR; run_bytes = 0; files = "" }
|
||||||
|
run_bytes += ($2 - $1 + 1) * bs; files = files " " $3; prev_end = $2
|
||||||
|
if (run_bytes > best_bytes) { best_bytes = run_bytes; best_files = files } }
|
||||||
|
END { print best_bytes + 0, best_files }')
|
||||||
|
rm -f -- "$list"
|
||||||
|
local best_bytes=${best%% *}
|
||||||
|
info "Longest unbroken free run found: $((best_bytes / 1048576)) MB (need $((need / 1048576)) MB)"
|
||||||
|
if (( best_bytes < need )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Free just enough of that run (from its start) for the file - keeping the
|
||||||
|
# rest plugged so the copy can't wander elsewhere.
|
||||||
|
local freed=0 g
|
||||||
|
for g in ${best#* }; do
|
||||||
|
(( freed >= need )) && break
|
||||||
|
freed=$((freed + $(stat -c %s "$g")))
|
||||||
|
rm -f -- "$g"
|
||||||
|
done
|
||||||
|
sync
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# restore_parked PARKED FILE SUM - copy a parked original back onto the
|
||||||
|
# drive (not contiguous - just safe) and only delete the parked copy once
|
||||||
|
# the restored one verifies.
|
||||||
|
restore_parked() {
|
||||||
|
local parked="$1" file="$2" sum="$3"
|
||||||
|
info "Putting the original back..."
|
||||||
|
if cp -- "$parked" "$file" && [[ "$(sha256sum "$file" | cut -d' ' -f1)" == "$sum" ]]; then
|
||||||
|
rm -f -- "$parked"
|
||||||
|
echo " Original restored (unchanged, still in pieces)."
|
||||||
|
else
|
||||||
|
echo " !! Couldn't verify the restored file - the original is kept safe at $parked"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# make_contiguous FILE
|
||||||
|
make_contiguous() {
|
||||||
|
local file="$1"
|
||||||
|
[[ -f "$file" ]] || { echo "$file: not a file"; return 1; }
|
||||||
|
file=$(realpath "$file")
|
||||||
|
local mnt fstype dev
|
||||||
|
read -r dev fstype mnt < <(findmnt -n -o SOURCE,FSTYPE,TARGET --target "$file")
|
||||||
|
local size
|
||||||
|
size=$(stat -c %s "$file")
|
||||||
|
local p
|
||||||
|
p=$(pieces "$file")
|
||||||
|
echo
|
||||||
|
echo "$file"
|
||||||
|
echo " drive: $dev ($fstype) mounted at $mnt, size $((size / 1048576)) MB"
|
||||||
|
if [[ -z "$(extents "$file")" ]]; then
|
||||||
|
echo " Can't read this file's layout through this driver ($fstype)."
|
||||||
|
# FUSE drivers (fusefat, exfat-fuse) can't map files; the kernel
|
||||||
|
# drivers can - and ntfs-3g usually can, but ntfs3 always can.
|
||||||
|
local real kdrv=""
|
||||||
|
real=$(blkid -o value -s TYPE "$dev" 2>/dev/null)
|
||||||
|
case "$real" in
|
||||||
|
ntfs) kdrv=ntfs3 ;;
|
||||||
|
exfat) kdrv=exfat ;;
|
||||||
|
vfat|msdos) kdrv=vfat ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$kdrv" ]]; then
|
||||||
|
echo " Remount it with the kernel's $kdrv driver and retry:"
|
||||||
|
echo " sudo umount $dev && sudo mount -t $kdrv $dev $mnt"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$p" == 1 ]]; then
|
||||||
|
echo " Contiguous (1 piece) - nothing to do."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo " NOT contiguous: $p pieces."
|
||||||
|
(( CHECK_ONLY )) && return 2
|
||||||
|
|
||||||
|
confirm " Rewrite it in one piece? (temporarily fills the drive's free space)" || { echo " Skipped."; return 2; }
|
||||||
|
|
||||||
|
info "Checksumming the original..."
|
||||||
|
local sum
|
||||||
|
sum=$(sha256sum "$file" | cut -d' ' -f1)
|
||||||
|
|
||||||
|
local source="$file" parked=""
|
||||||
|
if ! fill_and_carve "$mnt" "$size"; then
|
||||||
|
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
|
||||||
|
if (( ! MOVE_OUT )); then
|
||||||
|
echo " Not enough unbroken free space with the original still on the drive."
|
||||||
|
echo " Re-run with --move-out to park the original on this computer meanwhile,"
|
||||||
|
echo " or free up space on the drive (move a few other large files off)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local avail
|
||||||
|
avail=$(( $(df -B1 --output=avail "$TEMP_DIR" | tail -1) ))
|
||||||
|
(( avail > size )) || { echo " Not enough room in $TEMP_DIR to park it ($((size / 1048576)) MB needed)."; return 1; }
|
||||||
|
parked="$TEMP_DIR/$(basename "$file").e2b-contig-parked"
|
||||||
|
info "Parking the original in $parked..."
|
||||||
|
cp -- "$file" "$parked" && [[ "$(sha256sum "$parked" | cut -d' ' -f1)" == "$sum" ]] \
|
||||||
|
|| { rm -f -- "$parked"; echo " Parking copy failed its checksum - nothing changed."; return 1; }
|
||||||
|
rm -f -- "$file"; sync
|
||||||
|
source="$parked"
|
||||||
|
if ! fill_and_carve "$mnt" "$size"; then
|
||||||
|
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
|
||||||
|
echo " Still no unbroken run big enough."
|
||||||
|
restore_parked "$parked" "$file" "$sum"
|
||||||
|
echo " Free up space on the drive (it needs one unbroken $((size / 1048576)) MB area) and retry."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_COPY="$(dirname "$file")/.e2b-contig.$$.tmp"
|
||||||
|
info "Writing the copy into the freed run..."
|
||||||
|
if ! dd if="$source" of="$TMP_COPY" bs=4M conv=fsync status=progress; then
|
||||||
|
echo " Copy failed."
|
||||||
|
rm -f -- "$TMP_COPY"; TMP_COPY=""; rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
|
||||||
|
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -rf -- "$FILL_DIR"; FILL_DIR=""
|
||||||
|
sync
|
||||||
|
|
||||||
|
local newp
|
||||||
|
newp=$(pieces "$TMP_COPY")
|
||||||
|
info "Verifying..."
|
||||||
|
if [[ "$(sha256sum "$TMP_COPY" | cut -d' ' -f1)" != "$sum" ]]; then
|
||||||
|
echo " !! The copy doesn't match the original's checksum - discarding it."
|
||||||
|
rm -f -- "$TMP_COPY"; TMP_COPY=""; sync
|
||||||
|
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$newp" != 1 ]]; then
|
||||||
|
echo " The copy still came out in $newp pieces (was $p)."
|
||||||
|
fi
|
||||||
|
# Same directory, so a rename: the data stays where it was written.
|
||||||
|
mv -f -- "$TMP_COPY" "$file" && TMP_COPY=""
|
||||||
|
[[ -n "$parked" ]] && rm -f -- "$parked"
|
||||||
|
sync
|
||||||
|
if [[ "$(pieces "$file")" == 1 ]]; then
|
||||||
|
echo " ✓ Contiguous now (1 piece), checksum verified."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo " Checksum verified, but still $(pieces "$file") pieces - see the notes above."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
make_contiguous "$f" || rc=1
|
||||||
|
done
|
||||||
|
exit "$rc"
|
||||||
Executable
+327
@@ -0,0 +1,327 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# kiosk-firstboot - runs ubuntu-based-kiosk's ./install.sh on the first
|
||||||
|
# boot of a system installed from the custom ISO (iso/build-iso.sh).
|
||||||
|
#
|
||||||
|
# install.sh refuses to run as root and expects a regular sudo user, so
|
||||||
|
# this runs it as the admin account the Ubuntu installer created (the
|
||||||
|
# first regular user, uid 1000), from a copy of the repo in that user's
|
||||||
|
# home - the same layout as a manual `git clone && ./install.sh`, so
|
||||||
|
# re-running ./install.sh later and Advanced -> Upgrade both work as
|
||||||
|
# usual afterwards.
|
||||||
|
#
|
||||||
|
# For the duration of the install only, that user gets passwordless sudo
|
||||||
|
# (nobody has typed a password yet, and in unattended mode nobody will);
|
||||||
|
# the drop-in is removed on every exit path, including the reboot at the
|
||||||
|
# end of install.sh (see ExecStopPost in kiosk-firstboot.service).
|
||||||
|
#
|
||||||
|
# Settings come from /etc/kiosk-firstboot.conf, written at ISO build time:
|
||||||
|
# KIOSK_UNATTENDED=1 take every prompt's default, skip the Core
|
||||||
|
# Settings menu, reboot into the kiosk when done.
|
||||||
|
# KIOSK_UNATTENDED=0 (default) normal interactive install.sh on tty1.
|
||||||
|
# KIOSK_OFFLINE_WAIT=N on an ISO built with --offline: seconds to wait
|
||||||
|
# for internet before installing from the bundled
|
||||||
|
# packages instead (default 90).
|
||||||
|
# KIOSK_STALL_TIMEOUT=N on an --offline ISO, unattended: if the install
|
||||||
|
# prints nothing for N seconds (a stalled download on
|
||||||
|
# a flaky link), stop it and finish from the bundled
|
||||||
|
# packages instead (default 600).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
CONF=/etc/kiosk-firstboot.conf
|
||||||
|
STATE_DIR=/var/lib/kiosk-firstboot
|
||||||
|
PENDING="$STATE_DIR/pending"
|
||||||
|
DONE_MARKER="$STATE_DIR/done"
|
||||||
|
SUDOERS_DROPIN=/etc/sudoers.d/90-kiosk-firstboot
|
||||||
|
APT_DROPIN=/etc/apt/apt.conf.d/90-kiosk-firstboot
|
||||||
|
REPO_SRC=/opt/ubuntu-based-kiosk
|
||||||
|
LOG="$STATE_DIR/install.log"
|
||||||
|
OFFLINE_BUNDLE=/opt/kiosk-offline
|
||||||
|
OFFLINE_APT=/usr/local/sbin/kiosk-offline-apt
|
||||||
|
OFFLINE_FLAG="$STATE_DIR/offline-mode"
|
||||||
|
|
||||||
|
KIOSK_UNATTENDED=0
|
||||||
|
KIOSK_OFFLINE_WAIT=90
|
||||||
|
KIOSK_STALL_TIMEOUT=600
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[[ -f "$CONF" ]] && source "$CONF"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$SUDOERS_DROPIN" "$APT_DROPIN"
|
||||||
|
# Offline mode was only for this install - hand apt back to the real
|
||||||
|
# archive so normal updates work once the kiosk is online.
|
||||||
|
if [[ -f "$OFFLINE_FLAG" ]]; then
|
||||||
|
"$OFFLINE_APT" off >/dev/null 2>&1 || true
|
||||||
|
rm -f "$OFFLINE_FLAG"
|
||||||
|
fi
|
||||||
|
if [[ -f "$DONE_MARKER" ]]; then
|
||||||
|
rm -f "$PENDING"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--cleanup" ]]; then
|
||||||
|
cleanup
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
have_bundle() { [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; }
|
||||||
|
|
||||||
|
# Finish this install from the bundled packages instead of the internet
|
||||||
|
# (cleanup() turns it back off). install.sh notices via lib/config.sh's
|
||||||
|
# offline_mode_active.
|
||||||
|
switch_to_offline() {
|
||||||
|
touch "$OFFLINE_FLAG"
|
||||||
|
if ! "$OFFLINE_APT" on; then
|
||||||
|
echo "ERROR: couldn't switch apt to the offline bundle."
|
||||||
|
rm -f "$OFFLINE_FLAG"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make sure tty1 gets its login prompt back. getty@tty1 is ordered after
|
||||||
|
# this unit and normally starts by itself once we exit; this just covers
|
||||||
|
# a getty that was stopped or never queued (e.g. started by hand later).
|
||||||
|
release_tty() {
|
||||||
|
systemctl --no-block start getty@tty1.service 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT TERM HUP
|
||||||
|
|
||||||
|
banner() {
|
||||||
|
clear
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo " Ubuntu Based Kiosk - First Boot Setup"
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive-mode escape hatch when something needs a human: retry,
|
||||||
|
# drop to a shell as the admin user to fix it, or give up until next boot.
|
||||||
|
# Returns 0 to retry, 1 to give up.
|
||||||
|
offer_retry() {
|
||||||
|
local choice offline_opt=""
|
||||||
|
have_bundle && [[ ! -f "$OFFLINE_FLAG" ]] && offline_opt=" [O] Install from the offline bundle on this disk"
|
||||||
|
while true; do
|
||||||
|
echo
|
||||||
|
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt$offline_opt"
|
||||||
|
read -r -p "Choice [R]: " choice
|
||||||
|
case "${choice,,}" in
|
||||||
|
""|r) return 0 ;;
|
||||||
|
o) [[ -n "$offline_opt" ]] && switch_to_offline && return 0 ;;
|
||||||
|
s) runuser -l "$ADMIN_USER" ;;
|
||||||
|
q)
|
||||||
|
echo "Setup will run again on next boot, or run it now with:"
|
||||||
|
echo " sudo systemctl start kiosk-firstboot"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns 0 once online, 2 to install from the offline bundle instead
|
||||||
|
# (only when there is one), 1 to give up.
|
||||||
|
wait_for_internet() {
|
||||||
|
local waited=0
|
||||||
|
until curl -fsS --max-time 10 -o /dev/null https://deb.nodesource.com/ 2>/dev/null \
|
||||||
|
|| curl -fsS --max-time 10 -o /dev/null http://archive.ubuntu.com/ubuntu/ 2>/dev/null; do
|
||||||
|
if (( waited == 0 )); then
|
||||||
|
echo "Waiting for an internet connection (needed for packages,"
|
||||||
|
echo "Node.js and Electron)..."
|
||||||
|
if [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; then
|
||||||
|
echo "This disk has the offline bundle: installing from it instead"
|
||||||
|
echo "if there's still no internet after ${KIOSK_OFFLINE_WAIT}s."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
waited=$((waited + 10))
|
||||||
|
if [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]] && (( waited >= KIOSK_OFFLINE_WAIT )); then
|
||||||
|
echo "No internet after ${waited}s - installing from the offline bundle on this disk."
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
if (( waited % 120 == 0 )); then
|
||||||
|
echo "Still no internet after ${waited}s. Network status:"
|
||||||
|
networkctl list --no-pager 2>/dev/null || ip -brief addr
|
||||||
|
if [[ "$KIOSK_UNATTENDED" != "1" ]]; then
|
||||||
|
echo "WiFi or static IP? Use the Shell option and edit /etc/netplan/*.yaml, then 'sudo netplan apply'."
|
||||||
|
offer_retry || return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Internet connection OK."
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Keep kernel/systemd chatter off the screen the installer is drawing on.
|
||||||
|
dmesg -n 1 2>/dev/null || true
|
||||||
|
chvt 1 2>/dev/null || true
|
||||||
|
export TERM=linux
|
||||||
|
|
||||||
|
ADMIN_USER="$(getent passwd 1000 | cut -d: -f1)"
|
||||||
|
banner
|
||||||
|
if [[ -z "$ADMIN_USER" ]]; then
|
||||||
|
echo "ERROR: no regular user (uid 1000) found - can't run install.sh."
|
||||||
|
echo "Create one, then: sudo systemctl start kiosk-firstboot"
|
||||||
|
release_tty
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$ADMIN_USER" == "kiosk" ]]; then
|
||||||
|
echo "ERROR: the admin account is named 'kiosk', which install.sh reserves"
|
||||||
|
echo "for the kiosk's own restricted user. Reinstall with a different username."
|
||||||
|
release_tty
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ADMIN_HOME="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
|
||||||
|
REPO_DEST="$ADMIN_HOME/ubuntu-based-kiosk"
|
||||||
|
|
||||||
|
if [[ ! -d "$REPO_DEST" ]]; then
|
||||||
|
cp -a "$REPO_SRC" "$REPO_DEST"
|
||||||
|
chmod -R u+w "$REPO_DEST"
|
||||||
|
chown -R "$ADMIN_USER:$ADMIN_USER" "$REPO_DEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Admin user : $ADMIN_USER"
|
||||||
|
echo "Installer : $REPO_DEST/install.sh"
|
||||||
|
echo "Mode : $([[ "$KIOSK_UNATTENDED" == "1" ]] && echo "unattended (defaults, reboots when done)" || echo "interactive")"
|
||||||
|
echo "Log : $LOG"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Anything else on a fresh system that wants the dpkg lock (e.g. the
|
||||||
|
# first unattended-upgrades run): wait for it rather than failing. Not
|
||||||
|
# `cloud-init status --wait` - cloud-init's final stage runs after
|
||||||
|
# multi-user.target, which waits for this service: a deadlock.
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
printf 'DPkg::Lock::Timeout "900";\n' > "$APT_DROPIN"
|
||||||
|
|
||||||
|
wait_for_internet
|
||||||
|
case $? in
|
||||||
|
0) ;;
|
||||||
|
2)
|
||||||
|
# install.sh notices this by itself (lib/config.sh's
|
||||||
|
# offline_mode_active) and unpacks prebuilt node_modules instead
|
||||||
|
# of running npm; cleanup() switches it back off.
|
||||||
|
touch "$OFFLINE_FLAG"
|
||||||
|
if ! "$OFFLINE_APT" on; then
|
||||||
|
echo "ERROR: couldn't switch apt to the offline bundle."
|
||||||
|
release_tty
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) release_tty; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# install.sh's own preflight needs jq; Advanced -> Upgrade needs git.
|
||||||
|
echo "Installing installer prerequisites (jq, git)..."
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get update -q >/dev/null
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -q jq git curl >/dev/null
|
||||||
|
|
||||||
|
cat > "$SUDOERS_DROPIN".tmp <<SUDOERS
|
||||||
|
# Temporary - removed by /usr/local/sbin/kiosk-firstboot when it exits.
|
||||||
|
$ADMIN_USER ALL=(ALL) NOPASSWD: ALL
|
||||||
|
Defaults:$ADMIN_USER env_keep += "DEBIAN_FRONTEND"
|
||||||
|
SUDOERS
|
||||||
|
chmod 440 "$SUDOERS_DROPIN".tmp
|
||||||
|
if ! visudo -cf "$SUDOERS_DROPIN".tmp >/dev/null; then
|
||||||
|
rm -f "$SUDOERS_DROPIN".tmp
|
||||||
|
echo "ERROR: generated sudoers drop-in failed validation."
|
||||||
|
release_tty
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv "$SUDOERS_DROPIN".tmp "$SUDOERS_DROPIN"
|
||||||
|
|
||||||
|
run_install() {
|
||||||
|
local -a envs=(
|
||||||
|
HOME="$ADMIN_HOME" USER="$ADMIN_USER" LOGNAME="$ADMIN_USER"
|
||||||
|
SHELL=/bin/bash TERM=linux LANG="${LANG:-C.UTF-8}"
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
# config.sh would otherwise derive this from SUDO_USER/whoami.
|
||||||
|
BUILD_USER="$ADMIN_USER"
|
||||||
|
KIOSK_UNATTENDED="$KIOSK_UNATTENDED"
|
||||||
|
KIOSK_PROVISION_DONE_MARKER="$DONE_MARKER"
|
||||||
|
)
|
||||||
|
# Even interactively: stdout goes through tee, which debconf dialogs
|
||||||
|
# cannot draw on, and nothing install.sh installs needs a debconf answer.
|
||||||
|
envs+=(DEBIAN_FRONTEND=noninteractive)
|
||||||
|
|
||||||
|
# runuser rather than sudo -u: sudo would set SUDO_USER=root, which
|
||||||
|
# config.sh reads as "the admin running this tool".
|
||||||
|
# A stalled download on a flaky connection would otherwise hang an
|
||||||
|
# unattended install forever. With the offline bundle as a safety net,
|
||||||
|
# watch the log: no new output for KIOSK_STALL_TIMEOUT seconds means
|
||||||
|
# stuck, so the whole process group is stopped (the caller then
|
||||||
|
# finishes from the bundle).
|
||||||
|
if [[ "$KIOSK_UNATTENDED" == "1" ]] && have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
||||||
|
touch "$LOG"
|
||||||
|
setsid bash -c '
|
||||||
|
runuser -u "$1" -- env -i "${@:4}" bash -c "cd \"\$1\" && exec ./install.sh" _ "$2" 2>&1 | tee -a "$3"
|
||||||
|
exit "${PIPESTATUS[0]}"
|
||||||
|
' _ "$ADMIN_USER" "$REPO_DEST" "$LOG" "${envs[@]}" &
|
||||||
|
local pid=$!
|
||||||
|
(
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
sleep 20
|
||||||
|
age=$(( $(date +%s) - $(stat -c %Y "$LOG") ))
|
||||||
|
if (( age > KIOSK_STALL_TIMEOUT )); then
|
||||||
|
echo
|
||||||
|
echo "No output for ${age}s - the online install looks stuck; stopping it."
|
||||||
|
kill -TERM -- "-$pid" 2>/dev/null
|
||||||
|
sleep 30
|
||||||
|
kill -KILL -- "-$pid" 2>/dev/null
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
local watcher=$!
|
||||||
|
wait "$pid"
|
||||||
|
local rc=$?
|
||||||
|
kill "$watcher" 2>/dev/null
|
||||||
|
wait "$watcher" 2>/dev/null
|
||||||
|
return "$rc"
|
||||||
|
fi
|
||||||
|
runuser -u "$ADMIN_USER" -- env -i "${envs[@]}" \
|
||||||
|
bash -c 'cd "$1" && exec ./install.sh' _ "$REPO_DEST" \
|
||||||
|
2>&1 | tee -a "$LOG"
|
||||||
|
return "${PIPESTATUS[0]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while true; do
|
||||||
|
run_install
|
||||||
|
rc=$?
|
||||||
|
if [[ -f "$DONE_MARKER" ]]; then
|
||||||
|
# Reached only when the final "Reboot now?" was declined.
|
||||||
|
echo
|
||||||
|
echo "Kiosk install complete. Reboot to start the kiosk: sudo reboot"
|
||||||
|
cleanup
|
||||||
|
release_tty
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "install.sh did not finish (exit code $rc). Full output: $LOG"
|
||||||
|
if [[ "$KIOSK_UNATTENDED" == "1" ]]; then
|
||||||
|
# Online attempt failed or stalled: the bundle on this disk is
|
||||||
|
# always going to work, so use it rather than retry the same thing.
|
||||||
|
if have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
||||||
|
echo "Online install didn't finish - continuing from the offline bundle on this disk."
|
||||||
|
if switch_to_offline; then
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if (( attempt >= 3 )); then
|
||||||
|
echo "Giving up after $attempt attempts; will try again next boot."
|
||||||
|
release_tty
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
echo "Retrying in 60 seconds (attempt $attempt of 3)..."
|
||||||
|
sleep 60
|
||||||
|
else
|
||||||
|
offer_retry || { release_tty; exit 1; }
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Installed by the custom ISO (iso/build-iso.sh) and enabled from its
|
||||||
|
# autoinstall late-commands. Runs install.sh once, on tty1, the first
|
||||||
|
# time the freshly installed system boots - ordered before getty@tty1,
|
||||||
|
# so the login prompt only appears on tty1 once this has finished and
|
||||||
|
# an interactive install has the keyboard and screen to itself. /var/lib/kiosk-firstboot/pending is removed once the install
|
||||||
|
# finishes, so this is a no-op on every boot after that; an interrupted
|
||||||
|
# or failed install leaves it in place and simply runs again next boot.
|
||||||
|
#
|
||||||
|
# Ordering, learned the hard way on 26.04:
|
||||||
|
# - NOT After=cloud-final.service: cloud-final is After=multi-user.target,
|
||||||
|
# and multi-user.target (which wants this unit) is After= it - an
|
||||||
|
# ordering cycle, which systemd "fixes" at boot by dropping a job: this
|
||||||
|
# one, so the install silently never ran. (The script doesn't wait for
|
||||||
|
# cloud-init either, for the same reason - it would deadlock.)
|
||||||
|
# - NOT Conflicts=getty@tty1: getty.target pulls getty@tty1 into the same
|
||||||
|
# boot transaction, and systemd resolves a conflict between two wanted
|
||||||
|
# jobs by dropping one. Before= alone is enough - getty@tty1 waits for
|
||||||
|
# this oneshot to finish.
|
||||||
|
[Unit]
|
||||||
|
Description=Ubuntu Based Kiosk first-boot install
|
||||||
|
After=network-online.target systemd-user-sessions.service plymouth-quit-wait.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Before=getty@tty1.service
|
||||||
|
ConditionPathExists=/var/lib/kiosk-firstboot/pending
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/kiosk-firstboot
|
||||||
|
# Belt and braces for the temporary sudoers/apt drop-ins when install.sh
|
||||||
|
# reboots out from under the script (its own EXIT trap normally does this).
|
||||||
|
ExecStopPost=/usr/local/sbin/kiosk-firstboot --cleanup
|
||||||
|
StandardInput=tty-force
|
||||||
|
StandardOutput=tty
|
||||||
|
StandardError=tty
|
||||||
|
TTYPath=/dev/tty1
|
||||||
|
TTYReset=yes
|
||||||
|
TTYVHangup=yes
|
||||||
|
TimeoutStartSec=infinity
|
||||||
|
TimeoutStopSec=30
|
||||||
|
KillMode=mixed
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# kiosk-offline-apt - point apt at the offline install bundle, or back.
|
||||||
|
#
|
||||||
|
# sudo kiosk-offline-apt on apt uses ONLY /opt/kiosk-offline/apt
|
||||||
|
# sudo kiosk-offline-apt off back to the normal Ubuntu archive
|
||||||
|
# kiosk-offline-apt status
|
||||||
|
#
|
||||||
|
# Installed by the custom ISO (iso/build-iso.sh); the bundle itself is
|
||||||
|
# only there on ISOs built with --offline. kiosk-firstboot turns this on
|
||||||
|
# by itself when first boot finds no internet, and back off afterwards;
|
||||||
|
# so does the CUPS addon install (lib/config.sh's
|
||||||
|
# run_with_offline_fallback). By hand it's for installing anything else
|
||||||
|
# from the bundle with no internet. While it's on, install.sh also
|
||||||
|
# unpacks the bundle's prebuilt node_modules instead of running npm
|
||||||
|
# (lib/config.sh's offline_mode_active checks for the file this writes).
|
||||||
|
#
|
||||||
|
# "Only" matters: with the normal archive still listed, apt would pick
|
||||||
|
# newer versions from it that it then can't download.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUNDLE_DIR=/opt/kiosk-offline
|
||||||
|
APT_CONF=/etc/apt/apt.conf.d/90-kiosk-offline
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
on)
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "Run with sudo" >&2; exit 1; }
|
||||||
|
if [[ ! -f "$BUNDLE_DIR/apt/Packages" ]]; then
|
||||||
|
echo "No offline bundle at $BUNDLE_DIR (ISO not built with --offline)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$BUNDLE_DIR/sources.d"
|
||||||
|
echo "deb [trusted=yes] file:$BUNDLE_DIR/apt ./" > "$BUNDLE_DIR/offline.list"
|
||||||
|
cat > "$APT_CONF" <<EOF
|
||||||
|
// Written by kiosk-offline-apt - remove with: sudo kiosk-offline-apt off
|
||||||
|
Dir::Etc::SourceList "$BUNDLE_DIR/offline.list";
|
||||||
|
Dir::Etc::SourceParts "$BUNDLE_DIR/sources.d";
|
||||||
|
Acquire::Languages "none";
|
||||||
|
EOF
|
||||||
|
apt-get update -q >/dev/null
|
||||||
|
echo "apt now installs from the offline bundle only ($BUNDLE_DIR)."
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "Run with sudo" >&2; exit 1; }
|
||||||
|
rm -f "$APT_CONF"
|
||||||
|
echo "apt is back on the normal Ubuntu sources."
|
||||||
|
echo "Run 'sudo apt update' once there's internet."
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
if [[ -f "$APT_CONF" ]]; then
|
||||||
|
echo "on (apt uses $BUNDLE_DIR/apt only)"
|
||||||
|
else
|
||||||
|
echo "off"
|
||||||
|
fi
|
||||||
|
[[ -f "$BUNDLE_DIR/bundle-info.txt" ]] && sed -n '1,3p;5,6p' "$BUNDLE_DIR/bundle-info.txt"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: kiosk-offline-apt on|off|status" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# iso/offline/build-bundle.sh - Build the offline install bundle that
|
||||||
|
# `iso/build-iso.sh --offline` puts on the ISO, so a kiosk can be installed
|
||||||
|
# with no internet at all. Called by build-iso.sh; not normally run by hand.
|
||||||
|
#
|
||||||
|
# Usage: build-bundle.sh OUT_DIR SOURCE_ISO REPO_DIR CACHE_DIR
|
||||||
|
#
|
||||||
|
# OUT_DIR ends up as /opt/kiosk-offline on the installed system:
|
||||||
|
# apt/ Local apt repository (flat, [trusted=yes]) with every
|
||||||
|
# package install.sh's provisioning and the addons ask
|
||||||
|
# for (CUPS, LMS + Squeezelite, Asterisk Intercom, VNC,
|
||||||
|
# WireGuard, Emergency Hotspot - not Tailscale/Netbird,
|
||||||
|
# which need their vendors' servers anyway), Lyrion's
|
||||||
|
# own .deb, NodeSource's nodejs, and all their
|
||||||
|
# dependencies that a fresh Ubuntu Server install from
|
||||||
|
# SOURCE_ISO doesn't already have.
|
||||||
|
# npm/kiosk-app-node_modules.tar.gz Prebuilt node_modules (Electron
|
||||||
|
# npm/webui-node_modules.tar.gz included) in place of `npm install`.
|
||||||
|
# bundle-info.txt What was bundled, from where, when.
|
||||||
|
#
|
||||||
|
# Package lists are read from REPO_DIR's own lib/provision.sh and menus/
|
||||||
|
# (PROVISION_APT_PACKAGES, CUPS_APT_PACKAGES, SQUEEZELITE_APT_PACKAGES,
|
||||||
|
# LMS_DEB_URL, ...), so the bundle always matches what install.sh asks for.
|
||||||
|
#
|
||||||
|
# Dependencies are resolved against SOURCE_ISO's own package manifest - a
|
||||||
|
# fake dpkg status built from it (fake-dpkg-status.py) - so apt only
|
||||||
|
# downloads what's actually missing and makes the same choices (virtual
|
||||||
|
# packages, alternatives) it will make on the real target.
|
||||||
|
#
|
||||||
|
# CACHE_DIR keeps apt's downloaded .debs, npm's cache and Electron's
|
||||||
|
# download between builds, so a rebuild only fetches what changed.
|
||||||
|
#
|
||||||
|
# Build host: Ubuntu or Debian, amd64 (Node.js from the bundled .deb is
|
||||||
|
# run here to build node_modules). Needs: apt-get, apt-ftparchive
|
||||||
|
# (apt-utils), dpkg-deb, python3, gpg, curl, xorriso, and
|
||||||
|
# /usr/share/keyrings/ubuntu-archive-keyring.gpg (ubuntu-keyring).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT_DIR="$1"
|
||||||
|
SRC_ISO="$2"
|
||||||
|
REPO_DIR="$3"
|
||||||
|
CACHE_DIR="$4"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
UBUNTU_MIRROR="${UBUNTU_MIRROR:-http://archive.ubuntu.com/ubuntu}"
|
||||||
|
NODESOURCE_REPO="https://deb.nodesource.com/node_22.x"
|
||||||
|
NODESOURCE_KEY="https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key"
|
||||||
|
UBUNTU_KEYRING=/usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||||
|
|
||||||
|
# Needed on the target beyond install.sh's own lists: kiosk-firstboot's
|
||||||
|
# prerequisites, wget (lib/electron.sh's fallback), and openssh-server
|
||||||
|
# (installed by the Ubuntu installer only when it had a network).
|
||||||
|
EXTRA_APT_PACKAGES=(nodejs jq git curl wget unzip openssh-server)
|
||||||
|
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
info() { echo " $*"; }
|
||||||
|
|
||||||
|
for cmd in apt-get apt-ftparchive dpkg-deb dpkg python3 gpg curl xorriso tar gzip; do
|
||||||
|
command -v "$cmd" &>/dev/null \
|
||||||
|
|| die "--offline needs '$cmd' - on Ubuntu/Debian: sudo apt install apt-utils dpkg python3 gnupg curl xorriso"
|
||||||
|
done
|
||||||
|
[[ "$(dpkg --print-architecture)" == "amd64" ]] \
|
||||||
|
|| die "--offline must be built on an amd64 machine (it runs the bundled Node.js to build node_modules)"
|
||||||
|
[[ -r "$UBUNTU_KEYRING" ]] \
|
||||||
|
|| die "missing $UBUNTU_KEYRING - install it: sudo apt install ubuntu-keyring"
|
||||||
|
|
||||||
|
rm -rf "$OUT_DIR/apt" "$OUT_DIR/npm"
|
||||||
|
mkdir -p "$OUT_DIR/apt" "$OUT_DIR/npm" "$CACHE_DIR"
|
||||||
|
# apt's Dir= and Signed-By need absolute paths.
|
||||||
|
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||||
|
CACHE_DIR="$(cd "$CACHE_DIR" && pwd)"
|
||||||
|
REPO_DIR="$(cd "$REPO_DIR" && pwd)"
|
||||||
|
SRC_ISO="$(cd "$(dirname "$SRC_ISO")" && pwd)/$(basename "$SRC_ISO")"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Which Ubuntu release, and what a default server install already has
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
codename="$(xorriso -indev "$SRC_ISO" -ls /dists 2>/dev/null \
|
||||||
|
| tr -d "'" | awk '{print $NF}' | grep -E '^[a-z]+$' | head -1)"
|
||||||
|
[[ -n "$codename" ]] || die "couldn't tell the Ubuntu release of $SRC_ISO (no /dists on it)"
|
||||||
|
|
||||||
|
manifest="$CACHE_DIR/server.manifest"
|
||||||
|
xorriso -osirrox on -indev "$SRC_ISO" \
|
||||||
|
-extract /casper/ubuntu-server-minimal.ubuntu-server.manifest.full "$manifest" 2>/dev/null \
|
||||||
|
|| die "no server package manifest on $SRC_ISO (casper/ubuntu-server-minimal.ubuntu-server.manifest.full)"
|
||||||
|
chmod u+w "$manifest"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Standalone apt environment for $codename (never touches the host's apt)
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
R="$CACHE_DIR/aptroot-$codename"
|
||||||
|
mkdir -p "$R/etc/apt/apt.conf.d" "$R/etc/apt/preferences.d" "$R/etc/apt/sources.list.d" \
|
||||||
|
"$R/var/lib/apt/lists/partial" "$R/var/cache/apt/archives/partial" "$R/var/lib/dpkg" "$R/keys"
|
||||||
|
: > "$R/etc/apt/sources.list"
|
||||||
|
cp "$UBUNTU_KEYRING" "$R/keys/ubuntu.gpg"
|
||||||
|
curl -fsSL "$NODESOURCE_KEY" | gpg --dearmor --yes -o "$R/keys/nodesource.gpg"
|
||||||
|
cat > "$R/etc/apt/sources.list.d/bundle.sources" <<EOF
|
||||||
|
Types: deb
|
||||||
|
URIs: $UBUNTU_MIRROR
|
||||||
|
Suites: $codename $codename-updates $codename-security
|
||||||
|
Components: main restricted universe multiverse
|
||||||
|
Signed-By: $R/keys/ubuntu.gpg
|
||||||
|
|
||||||
|
Types: deb
|
||||||
|
URIs: $NODESOURCE_REPO
|
||||||
|
Suites: nodistro
|
||||||
|
Components: main
|
||||||
|
Signed-By: $R/keys/nodesource.gpg
|
||||||
|
EOF
|
||||||
|
|
||||||
|
APT=(apt-get
|
||||||
|
-o "Dir=$R" -o "Dir::State::status=$R/var/lib/dpkg/status"
|
||||||
|
-o APT::Architecture=amd64 -o APT::Architectures::=amd64
|
||||||
|
-o Acquire::Languages=none -o Acquire::GzipIndexes=false
|
||||||
|
-o Debug::NoLocking=1 -q)
|
||||||
|
|
||||||
|
info "Fetching $codename package indexes..."
|
||||||
|
: > "$R/var/lib/dpkg/status"
|
||||||
|
"${APT[@]}" update >/dev/null
|
||||||
|
python3 "$HERE/fake-dpkg-status.py" "$manifest" "$R/var/lib/apt/lists" > "$R/var/lib/dpkg/status"
|
||||||
|
"${APT[@]}" check >/dev/null || die "baseline from the ISO manifest is inconsistent"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Packages
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091,SC2034
|
||||||
|
# Each list lives next to the code that installs it; lib/config.sh first
|
||||||
|
# for the path defaults those files use at the top level.
|
||||||
|
mapfile -t packages < <(
|
||||||
|
SCRIPT_DIR="$REPO_DIR"
|
||||||
|
source "$REPO_DIR/lib/config.sh"
|
||||||
|
source "$REPO_DIR/lib/provision.sh"
|
||||||
|
source "$REPO_DIR/menus/addon_cups.sh"
|
||||||
|
source "$REPO_DIR/menus/addon_lms_squeezelite.sh"
|
||||||
|
source "$REPO_DIR/menus/addon_asterisk_intercom.sh"
|
||||||
|
source "$REPO_DIR/menus/addon_remote_access.sh"
|
||||||
|
source "$REPO_DIR/menus/advanced_emergency_hotspot.sh"
|
||||||
|
printf '%s\n' "${PROVISION_APT_PACKAGES[@]}" "${PROVISION_INTEL_APT_PACKAGES[@]}" \
|
||||||
|
"${CUPS_APT_PACKAGES[@]}" "${SQUEEZELITE_APT_PACKAGES[@]}" \
|
||||||
|
"${ASTERISK_INTERCOM_APT_PACKAGES[@]}" "${VNC_APT_PACKAGES[@]}" \
|
||||||
|
"${WIREGUARD_APT_PACKAGES[@]}" "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}" \
|
||||||
|
"${EXTRA_APT_PACKAGES[@]}"
|
||||||
|
)
|
||||||
|
(( ${#packages[@]} > 20 )) || die "couldn't read the package lists from $REPO_DIR"
|
||||||
|
|
||||||
|
# Lyrion Music Server isn't in Ubuntu's archive: bundle the exact .deb the
|
||||||
|
# LMS addon downloads online, and let apt resolve its dependencies from it
|
||||||
|
# (a local .deb path in the install list).
|
||||||
|
# shellcheck disable=SC1091,SC2034
|
||||||
|
lms_url="$(SCRIPT_DIR="$REPO_DIR"; source "$REPO_DIR/lib/config.sh"; source "$REPO_DIR/menus/addon_lms_squeezelite.sh"; echo "$LMS_DEB_URL")"
|
||||||
|
lms_deb="$CACHE_DIR/$(basename "$lms_url")"
|
||||||
|
if [[ ! -s "$lms_deb" ]]; then
|
||||||
|
info "Downloading $(basename "$lms_url")..."
|
||||||
|
curl -fL --retry 3 -o "$lms_deb.part" "$lms_url" || die "couldn't download $lms_url"
|
||||||
|
mv "$lms_deb.part" "$lms_deb"
|
||||||
|
fi
|
||||||
|
dpkg-deb -I "$lms_deb" >/dev/null 2>&1 || { rm -f "$lms_deb"; die "$lms_deb isn't a valid .deb - deleted, re-run to retry"; }
|
||||||
|
packages+=("$lms_deb")
|
||||||
|
|
||||||
|
info "Resolving ${#packages[@]} packages (with recommends, as install.sh installs them)..."
|
||||||
|
# --print-uris against an empty archive dir: it leaves out anything already
|
||||||
|
# in the cache, which on a rebuild would be everything.
|
||||||
|
rm -rf "$R/empty-archives"; mkdir -p "$R/empty-archives/partial"
|
||||||
|
mapfile -t debs < <("${APT[@]}" -qq -o "Dir::Cache::archives=$R/empty-archives" \
|
||||||
|
install --print-uris -y "${packages[@]}" | awk '$1 !~ /^.file:/ {print $2}') # the local LMS .deb is copied below
|
||||||
|
(( ${#debs[@]} )) || die "apt resolved nothing to download - check the output above"
|
||||||
|
|
||||||
|
info "Downloading ${#debs[@]} .debs (cached between builds)..."
|
||||||
|
"${APT[@]}" install --download-only -y "${packages[@]}" >/dev/null
|
||||||
|
|
||||||
|
for f in "${debs[@]}"; do
|
||||||
|
cp "$R/var/cache/apt/archives/$f" "$OUT_DIR/apt/"
|
||||||
|
done
|
||||||
|
cp "$lms_deb" "$OUT_DIR/apt/"
|
||||||
|
(
|
||||||
|
cd "$OUT_DIR/apt"
|
||||||
|
apt-ftparchive packages . > Packages 2>/dev/null
|
||||||
|
gzip -9 -k Packages
|
||||||
|
apt-ftparchive release . > Release
|
||||||
|
)
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# node_modules, built with the exact Node.js being bundled
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
node_deb="$(printf '%s\n' "${debs[@]}" | grep -E '^nodejs_' | head -1 || true)"
|
||||||
|
[[ -n "$node_deb" ]] || node_deb="$(cd "$R/var/cache/apt/archives" && ls nodejs_*_amd64.deb 2>/dev/null | sort -V | tail -1)"
|
||||||
|
[[ -n "$node_deb" ]] || die "no nodejs .deb downloaded"
|
||||||
|
node_root="$CACHE_DIR/node-root"
|
||||||
|
rm -rf "$node_root"
|
||||||
|
dpkg-deb -x "$R/var/cache/apt/archives/$node_deb" "$node_root"
|
||||||
|
export PATH="$node_root/usr/bin:$PATH"
|
||||||
|
export npm_config_cache="$CACHE_DIR/npm-cache"
|
||||||
|
export electron_config_cache="$CACHE_DIR/electron-cache"
|
||||||
|
export npm_config_update_notifier=false npm_config_fund=false npm_config_audit=false
|
||||||
|
info "Building node_modules with Node.js $(node -v)..."
|
||||||
|
|
||||||
|
# build_modules NAME SRC_DIR NPM_ARGS...
|
||||||
|
build_modules() {
|
||||||
|
local name="$1" src="$2"; shift 2
|
||||||
|
local tmp="$CACHE_DIR/build-$name"
|
||||||
|
rm -rf "$tmp"; mkdir -p "$tmp"
|
||||||
|
cp "$src/package.json" "$tmp/"
|
||||||
|
[[ -f "$src/package-lock.json" ]] && cp "$src/package-lock.json" "$tmp/"
|
||||||
|
(cd "$tmp" && npm install --no-progress "$@" >/dev/null) || die "npm install failed for $name"
|
||||||
|
# Electron's package no longer fetches its binary at install time (no
|
||||||
|
# postinstall since ~v42) - lib/electron.sh runs this same install.js
|
||||||
|
# on the target when online; offline it has to already be in dist/.
|
||||||
|
if [[ -f "$tmp/node_modules/electron/install.js" ]]; then
|
||||||
|
# Retried: a large download cut off mid-stream crashes it outright.
|
||||||
|
local try
|
||||||
|
for try in 1 2 3; do
|
||||||
|
(cd "$tmp" && node node_modules/electron/install.js) && break
|
||||||
|
(( try < 3 )) || die "Electron binary download failed (3 attempts)"
|
||||||
|
info "Electron download failed - retrying ($((try + 1))/3)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
tar -czf "$OUT_DIR/npm/${name}-node_modules.tar.gz" --owner=0 --group=0 -C "$tmp" node_modules
|
||||||
|
info "$name: $(du -sh "$tmp/node_modules" | cut -f1) -> $(du -h "$OUT_DIR/npm/${name}-node_modules.tar.gz" | cut -f1) compressed"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_modules kiosk-app "$REPO_DIR/kiosk-app"
|
||||||
|
tar -tzf "$OUT_DIR/npm/kiosk-app-node_modules.tar.gz" node_modules/electron/dist/electron >/dev/null 2>&1 \
|
||||||
|
|| die "Electron binary missing from the built kiosk-app modules (download blocked?)"
|
||||||
|
build_modules webui "$REPO_DIR/webui" --omit=dev
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
electron_ver="$(tar -xzOf "$OUT_DIR/npm/kiosk-app-node_modules.tar.gz" node_modules/electron/package.json \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')"
|
||||||
|
{
|
||||||
|
echo "Ubuntu Based Kiosk offline bundle"
|
||||||
|
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
echo "Ubuntu: $codename (resolved against $(basename "$SRC_ISO"))"
|
||||||
|
echo "Packages: ${#debs[@]} .debs for: ${packages[*]}"
|
||||||
|
echo "Node.js: ${node_deb}"
|
||||||
|
echo "Electron: $electron_ver"
|
||||||
|
} > "$OUT_DIR/bundle-info.txt"
|
||||||
|
|
||||||
|
info "Bundle: $(du -sh "$OUT_DIR" | cut -f1) (${#debs[@]} packages, Node.js $(node -v), Electron $electron_ver)"
|
||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Write a dpkg status file claiming a set of packages is installed.
|
||||||
|
|
||||||
|
Usage: fake-dpkg-status.py MANIFEST LISTS_DIR > status
|
||||||
|
|
||||||
|
MANIFEST is an Ubuntu ISO's casper/*.manifest.full ("name[:arch]<TAB>version"
|
||||||
|
per line) - the packages a default Ubuntu Server install ends up with.
|
||||||
|
LISTS_DIR is an apt lists directory holding uncompressed *_Packages indexes.
|
||||||
|
|
||||||
|
iso/offline/build-bundle.sh resolves the kiosk's packages against this, so
|
||||||
|
apt only downloads what a freshly installed server is actually missing
|
||||||
|
(and makes the same alternative/virtual-package choices the real target
|
||||||
|
will), instead of the whole dependency closure down to libc6.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
KEEP = ("Package", "Essential", "Priority", "Section", "Architecture",
|
||||||
|
"Multi-Arch", "Source", "Version", "Replaces", "Provides", "Depends",
|
||||||
|
"Pre-Depends", "Recommends", "Suggests", "Breaks", "Conflicts")
|
||||||
|
|
||||||
|
|
||||||
|
def stanzas(path):
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as f:
|
||||||
|
cur = []
|
||||||
|
for line in f:
|
||||||
|
if line.strip() == "":
|
||||||
|
if cur:
|
||||||
|
yield cur
|
||||||
|
cur = []
|
||||||
|
else:
|
||||||
|
cur.append(line.rstrip("\n"))
|
||||||
|
if cur:
|
||||||
|
yield cur
|
||||||
|
|
||||||
|
|
||||||
|
def parse(lines):
|
||||||
|
fields, order, key = {}, [], None
|
||||||
|
for line in lines:
|
||||||
|
if line[:1] in (" ", "\t") and key:
|
||||||
|
fields[key] += "\n" + line
|
||||||
|
else:
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
fields[key] = val.strip()
|
||||||
|
order.append(key)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
manifest, lists_dir = sys.argv[1], sys.argv[2]
|
||||||
|
wanted = {}
|
||||||
|
with open(manifest) as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.rstrip("\n").split("\t")
|
||||||
|
if len(parts) != 2 or parts[0].startswith("snap:"):
|
||||||
|
continue
|
||||||
|
wanted[parts[0].split(":")[0]] = parts[1]
|
||||||
|
|
||||||
|
exact, any_version = {}, {}
|
||||||
|
for path in glob.glob(os.path.join(lists_dir, "*_Packages")):
|
||||||
|
for st in stanzas(path):
|
||||||
|
p = parse(st)
|
||||||
|
name = p.get("Package")
|
||||||
|
if name not in wanted:
|
||||||
|
continue
|
||||||
|
any_version.setdefault(name, p)
|
||||||
|
if p.get("Version") == wanted[name]:
|
||||||
|
exact[name] = p
|
||||||
|
|
||||||
|
missing = 0
|
||||||
|
for name, version in sorted(wanted.items()):
|
||||||
|
p = exact.get(name) or any_version.get(name)
|
||||||
|
if p is None:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
out = [f"Package: {name}", "Status: install ok installed"]
|
||||||
|
for k in KEEP[1:]:
|
||||||
|
if k == "Version":
|
||||||
|
out.append(f"Version: {version}")
|
||||||
|
elif k in p:
|
||||||
|
val = p[k]
|
||||||
|
# The archive no longer carries this exact version (an
|
||||||
|
# update superseded it), so these fields came from a
|
||||||
|
# different one and its dependencies (version pins on
|
||||||
|
# siblings, a newer kernel ABI...) don't describe what's
|
||||||
|
# installed. Already-installed packages' own dependencies
|
||||||
|
# are assumed satisfied anyway, so just leave them out.
|
||||||
|
if name not in exact and k in ("Depends", "Pre-Depends", "Recommends"):
|
||||||
|
continue
|
||||||
|
# Same for versioned Breaks/Conflicts - drop them outright
|
||||||
|
# (unversioned ones still hold for any version).
|
||||||
|
if name not in exact and k in ("Breaks", "Conflicts"):
|
||||||
|
val = ", ".join(c.strip() for c in val.split(",") if "(" not in c)
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
out.append(f"{k}: {val}")
|
||||||
|
print("\n".join(out) + "\n")
|
||||||
|
print(f"fake-dpkg-status: {len(wanted) - missing} packages marked installed,"
|
||||||
|
f" {missing} not in the archive indexes (skipped)", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Document</title>
|
||||||
|
<!-- Shown for a site that's a local .docx file - see docview.js. The
|
||||||
|
document's bytes arrive from main.js via window.kioskDocView.show(). -->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; background: #666; }
|
||||||
|
#msg {
|
||||||
|
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #eee; font: 22px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||||
|
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||||
|
<script src="docx-render.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="msg">Loading document…</div>
|
||||||
|
<div id="doc"></div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const docEl = document.getElementById('doc');
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
let cls = null;
|
||||||
|
|
||||||
|
function showMessage(text) {
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.style.display = 'flex';
|
||||||
|
docEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth));
|
||||||
|
|
||||||
|
window.kioskDocView = {
|
||||||
|
async show(d) {
|
||||||
|
if (d.error) { showMessage(d.error); return; }
|
||||||
|
if (!window.KioskDocx || !KioskDocx.available()) {
|
||||||
|
showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl);
|
||||||
|
msg.style.display = 'none';
|
||||||
|
KioskDocx.fit(docEl, cls, window.innerWidth);
|
||||||
|
// Only called on first load or when the file changed on disk -
|
||||||
|
// start a changed document from the top.
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`Couldn't show ${d.file}: ${e.message || e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Word document (.docx) support for main.js.
|
||||||
|
//
|
||||||
|
// A site whose URL is a local .docx file (file:///home/kiosk/docs/menu.docx,
|
||||||
|
// entered in the Sites menu or Web UI as /home/kiosk/docs/menu.docx) is
|
||||||
|
// rendered with docx-preview (an npm dependency of this app - pure
|
||||||
|
// JavaScript, works offline) instead of Chromium offering to download it.
|
||||||
|
// main.js swaps the file URL for docview.html via resolveSiteUrl(), then
|
||||||
|
// attach() hands the page the file's bytes - the page itself has no
|
||||||
|
// filesystem access (contextIsolation, no Node). The file is re-checked
|
||||||
|
// every minute and re-rendered if it changed, so replacing the document
|
||||||
|
// on disk updates the kiosk without a restart.
|
||||||
|
//
|
||||||
|
// Only .docx (Word 2007+). Old binary .doc and LibreOffice .odt aren't
|
||||||
|
// supported by docx-preview - save those as .docx or PDF.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { fileURLToPath, pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const VIEWER_PAGE = path.join(__dirname, 'docview.html');
|
||||||
|
const REFRESH_MS = 60000;
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
// The .docx path a site URL points at, or null.
|
||||||
|
function docxPath(url) {
|
||||||
|
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
p = fileURLToPath(url);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.extname(p).toLowerCase() === '.docx' ? p : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a view should actually load for a configured site URL.
|
||||||
|
function resolveSiteUrl(url) {
|
||||||
|
const file = docxPath(url);
|
||||||
|
if (!file) return url;
|
||||||
|
const u = pathToFileURL(VIEWER_PAGE);
|
||||||
|
u.searchParams.set('file', file);
|
||||||
|
return u.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDoc(file) {
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
if (st.size > MAX_BYTES) return { file, error: `Document is too large to show (${Math.round(st.size / 1048576)} MB).` };
|
||||||
|
return { file, mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
|
||||||
|
} catch (e) {
|
||||||
|
return { file, error: e.code === 'ENOENT' ? `File not found: ${file}` : `Can't read ${file}: ${e.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed a view's viewer page the document now and again whenever the file
|
||||||
|
// changes. Call once per view; safe on views that never show a document.
|
||||||
|
function attach(webContents) {
|
||||||
|
let lastKey = '';
|
||||||
|
const send = (force) => {
|
||||||
|
if (webContents.isDestroyed()) return;
|
||||||
|
const current = webContents.getURL();
|
||||||
|
if (!current.startsWith(pathToFileURL(VIEWER_PAGE).href)) return;
|
||||||
|
const file = new URL(current).searchParams.get('file');
|
||||||
|
if (!file) return;
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
key = `${st.mtimeMs}:${st.size}`;
|
||||||
|
} catch (e) {
|
||||||
|
key = 'missing';
|
||||||
|
}
|
||||||
|
if (!force && key === lastKey) return;
|
||||||
|
lastKey = key;
|
||||||
|
webContents.executeJavaScript(`window.kioskDocView&&window.kioskDocView.show(${JSON.stringify(readDoc(file))})`)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
webContents.on('did-finish-load', () => send(true));
|
||||||
|
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||||
|
webContents.once('destroyed', () => clearInterval(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveSiteUrl, docxPath, attach };
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Browser-side .docx rendering shared by docview.html (one document as a
|
||||||
|
// site) and slideshow.html (documents mixed into a folder slideshow).
|
||||||
|
// Needs node_modules/jszip and node_modules/docx-preview loaded first.
|
||||||
|
//
|
||||||
|
// Each render gets its own class prefix and keeps its generated styles
|
||||||
|
// inside its own container, so two documents on screen at once (a
|
||||||
|
// slideshow crossfade) can't restyle each other, and removing the
|
||||||
|
// container removes its styles.
|
||||||
|
(() => {
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
// Word draws most bullets with private-use characters from the Symbol
|
||||||
|
// and Wingdings fonts (U+F0B7 etc.), which a kiosk doesn't have - they
|
||||||
|
// render as blanks. docx-preview passes them through into its generated
|
||||||
|
// list CSS; swap the common ones for real Unicode equivalents.
|
||||||
|
const SYMBOL_BULLETS = {
|
||||||
|
'': '•', // Symbol bullet -> •
|
||||||
|
'': '▪', // Wingdings square -> ▪
|
||||||
|
'': '➢', // Wingdings arrowhead -> ➢
|
||||||
|
'': '❖', // Wingdings diamond -> ❖
|
||||||
|
'': '✓', // Wingdings check -> ✓
|
||||||
|
'': '■', // Wingdings black square -> ■
|
||||||
|
'': '❏', // Wingdings box -> ❏
|
||||||
|
};
|
||||||
|
|
||||||
|
function fixSymbolBullets(container) {
|
||||||
|
container.querySelectorAll('style').forEach((st) => {
|
||||||
|
let css = st.textContent;
|
||||||
|
const before = css;
|
||||||
|
for (const [from, to] of Object.entries(SYMBOL_BULLETS)) {
|
||||||
|
css = css.split(from).join(to);
|
||||||
|
// Also the CSS-escaped form ("\f0b7" / "\00f0b7 ").
|
||||||
|
const hex = from.charCodeAt(0).toString(16);
|
||||||
|
css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to);
|
||||||
|
}
|
||||||
|
if (css !== before) st.textContent = css;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.KioskDocx = {
|
||||||
|
available() {
|
||||||
|
return !!(window.docx && window.JSZip);
|
||||||
|
},
|
||||||
|
|
||||||
|
base64ToBytes(b64) {
|
||||||
|
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Render into `container` (emptied first). Returns the class prefix
|
||||||
|
// used, for fit().
|
||||||
|
async render(bytes, container) {
|
||||||
|
const cls = `kdocx${++counter}`;
|
||||||
|
container.innerHTML = '';
|
||||||
|
await window.docx.renderAsync(bytes, container, container, {
|
||||||
|
className: cls,
|
||||||
|
inWrapper: true,
|
||||||
|
ignoreLastRenderedPageBreak: true,
|
||||||
|
experimental: true,
|
||||||
|
useBase64URL: true,
|
||||||
|
});
|
||||||
|
const wrapper = container.querySelector(`.${cls}-wrapper`);
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.background = 'transparent';
|
||||||
|
wrapper.style.padding = '16px 0';
|
||||||
|
}
|
||||||
|
container.querySelectorAll(`section.${cls}`).forEach((s) => {
|
||||||
|
s.style.marginBottom = '16px';
|
||||||
|
s.style.boxShadow = '0 2px 8px rgba(0,0,0,.4)';
|
||||||
|
});
|
||||||
|
fixSymbolBullets(container);
|
||||||
|
return cls;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scale `zoomEl` so the first page fills `width` (capped at maxZoom),
|
||||||
|
// so a portrait page is readable on a landscape screen without
|
||||||
|
// sideways scrolling. CSS zoom keeps scrolling and touch coordinates
|
||||||
|
// consistent.
|
||||||
|
fit(zoomEl, cls, width, maxZoom = 1.6) {
|
||||||
|
const page = zoomEl.querySelector(`section.${cls}`);
|
||||||
|
if (!page) return;
|
||||||
|
zoomEl.style.zoom = 1;
|
||||||
|
const pageWidth = page.getBoundingClientRect().width + 32;
|
||||||
|
zoomEl.style.zoom = Math.min(width / pageWidth, maxZoom);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scale `zoomEl` so the whole first page fits in width x height
|
||||||
|
// (letterboxed, like an image) - for one-page documents in a slideshow,
|
||||||
|
// where scrolling through the page's empty bottom would be pointless.
|
||||||
|
fitPage(zoomEl, cls, width, height) {
|
||||||
|
const page = zoomEl.querySelector(`section.${cls}`);
|
||||||
|
if (!page) return;
|
||||||
|
zoomEl.style.zoom = 1;
|
||||||
|
const r = page.getBoundingClientRect();
|
||||||
|
zoomEl.style.zoom = Math.min(width / (r.width + 32), height / (r.height + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// True when the whole document fits on one physical page. Not just
|
||||||
|
// "one <section>": docx-preview doesn't paginate by itself - it only
|
||||||
|
// starts a new section at an explicit page break - so a long document
|
||||||
|
// without breaks is one very tall section. Each section's min-height
|
||||||
|
// is the paper height, so content that fits leaves it at that height.
|
||||||
|
isSinglePage(zoomEl, cls) {
|
||||||
|
const pages = zoomEl.querySelectorAll(`section.${cls}`);
|
||||||
|
if (pages.length !== 1) return false;
|
||||||
|
const paper = parseFloat(getComputedStyle(pages[0]).minHeight);
|
||||||
|
if (!paper) return false;
|
||||||
|
return pages[0].getBoundingClientRect().height <= paper * 1.02;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
|
color: #ecf0f1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.container { text-align: center; max-width: 600px; }
|
||||||
|
h2 { font-size: 32px; margin-bottom: 20px; }
|
||||||
|
.message { font-size: 20px; margin-bottom: 20px; line-height: 1.5; }
|
||||||
|
.countdown { font-size: 72px; font-weight: bold; color: #e74c3c; margin: 20px 0; }
|
||||||
|
.options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 20px 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #27ae60;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #229954; }
|
||||||
|
.btn-primary:active { background: #1e8449; }
|
||||||
|
|
||||||
|
.btn-extend { background: #3498db; }
|
||||||
|
.btn-extend:hover { background: #2980b9; }
|
||||||
|
.btn-extend:active { background: #21618c; }
|
||||||
|
|
||||||
|
.btn-home {
|
||||||
|
background: #95a5a6;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.btn-home:hover { background: #7f8c8d; }
|
||||||
|
|
||||||
|
.info {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #95a5a6;
|
||||||
|
margin-top: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>👋 Are you still here?</h2>
|
||||||
|
<div class="message">
|
||||||
|
No activity detected. Choose an option:
|
||||||
|
</div>
|
||||||
|
<div class="countdown" id="countdown">15</div>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<button class="btn btn-primary" onclick="imHere(0)">
|
||||||
|
✓ Yes, I'm here!
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="imHere(15)">
|
||||||
|
🍿 15 more minutes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="imHere(30)">
|
||||||
|
⏱️ 30 more minutes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="imHere(60)">
|
||||||
|
🎬 1 hour
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="imHere(120)">
|
||||||
|
📺 2 hours
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-home" onclick="goHome()">
|
||||||
|
🏠 Return to home now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
ℹ️ Extensions pause the inactivity timer<br>
|
||||||
|
Media playback (video/audio) automatically pauses the timer<br>
|
||||||
|
Maximum extension: 4 hours (safety timeout)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const {ipcRenderer} = require('electron');
|
||||||
|
let count = 15;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
count--;
|
||||||
|
document.getElementById('countdown').textContent = count;
|
||||||
|
if (count <= 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
function imHere(minutes) {
|
||||||
|
clearInterval(interval);
|
||||||
|
console.log('[PROMPT] User selected: '+(minutes===0?'Continue':minutes+' minutes'));
|
||||||
|
ipcRenderer.send('user-still-here', minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
clearInterval(interval);
|
||||||
|
console.log('[PROMPT] User requested immediate home return');
|
||||||
|
ipcRenderer.send('user-still-here', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
imHere(0);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
goHome();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
color: #ecf0f1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.keyboard { width: 100%; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.row { display: flex; justify-content: center; margin-bottom: 8px; gap: 6px; }
|
||||||
|
.key {
|
||||||
|
min-width: 60px; height: 60px;
|
||||||
|
background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%);
|
||||||
|
border: 2px solid #4a5f7f; border-radius: 8px; color: white;
|
||||||
|
font-size: 24px; font-weight: 600; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.1s; user-select: none; box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.key:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
border-color: #5dade2;
|
||||||
|
}
|
||||||
|
.key.space { flex: 3; }
|
||||||
|
.key.wide { min-width: 90px; }
|
||||||
|
.key.extra-wide { min-width: 120px; }
|
||||||
|
.key.special {
|
||||||
|
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.key.enter {
|
||||||
|
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||||
|
border-color: #52be80;
|
||||||
|
}
|
||||||
|
.key.backspace {
|
||||||
|
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||||
|
border-color: #ec7063;
|
||||||
|
}
|
||||||
|
.key.shift, .key.caps {
|
||||||
|
background: linear-gradient(135deg, #f39c12 0%, #d68910 100%);
|
||||||
|
border-color: #f8c471;
|
||||||
|
}
|
||||||
|
.key.shift.active, .key.caps.active {
|
||||||
|
background: linear-gradient(135deg, #16a085 0%, #138d75 100%);
|
||||||
|
border-color: #48c9b0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #bdc3c7;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(231, 76, 60, 0.9);
|
||||||
|
border: 2px solid #e74c3c;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.close-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
background: rgba(192, 57, 43, 0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="close-btn" onclick="closeKeyboard()">×</div>
|
||||||
|
|
||||||
|
<div class="keyboard">
|
||||||
|
<div class="header">⌨️ Keyboard - Click icon or swipe to reopen</div>
|
||||||
|
|
||||||
|
<!-- Number Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="key" data-key="1" data-shift="!" onclick="typeKey(this)">1</div>
|
||||||
|
<div class="key" data-key="2" data-shift="@" onclick="typeKey(this)">2</div>
|
||||||
|
<div class="key" data-key="3" data-shift="#" onclick="typeKey(this)">3</div>
|
||||||
|
<div class="key" data-key="4" data-shift="$" onclick="typeKey(this)">4</div>
|
||||||
|
<div class="key" data-key="5" data-shift="%" onclick="typeKey(this)">5</div>
|
||||||
|
<div class="key" data-key="6" data-shift="^" onclick="typeKey(this)">6</div>
|
||||||
|
<div class="key" data-key="7" data-shift="&" onclick="typeKey(this)">7</div>
|
||||||
|
<div class="key" data-key="8" data-shift="*" onclick="typeKey(this)">8</div>
|
||||||
|
<div class="key" data-key="9" data-shift="(" onclick="typeKey(this)">9</div>
|
||||||
|
<div class="key" data-key="0" data-shift=")" onclick="typeKey(this)">0</div>
|
||||||
|
<div class="key" data-key="-" data-shift="_" onclick="typeKey(this)">-</div>
|
||||||
|
<div class="key" data-key="=" data-shift="+" onclick="typeKey(this)">=</div>
|
||||||
|
<div class="key backspace wide" onclick="typeKey(this)" data-special="Backspace">⌫</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="key special wide" onclick="typeKey(this)" data-special="Tab">Tab</div>
|
||||||
|
<div class="key" data-key="q" onclick="typeKey(this)">q</div>
|
||||||
|
<div class="key" data-key="w" onclick="typeKey(this)">w</div>
|
||||||
|
<div class="key" data-key="e" onclick="typeKey(this)">e</div>
|
||||||
|
<div class="key" data-key="r" onclick="typeKey(this)">r</div>
|
||||||
|
<div class="key" data-key="t" onclick="typeKey(this)">t</div>
|
||||||
|
<div class="key" data-key="y" onclick="typeKey(this)">y</div>
|
||||||
|
<div class="key" data-key="u" onclick="typeKey(this)">u</div>
|
||||||
|
<div class="key" data-key="i" onclick="typeKey(this)">i</div>
|
||||||
|
<div class="key" data-key="o" onclick="typeKey(this)">o</div>
|
||||||
|
<div class="key" data-key="p" onclick="typeKey(this)">p</div>
|
||||||
|
<div class="key" data-key="[" data-shift="{" onclick="typeKey(this)">[</div>
|
||||||
|
<div class="key" data-key="]" data-shift="}" onclick="typeKey(this)">]</div>
|
||||||
|
<div class="key" data-key="\\" data-shift="|" onclick="typeKey(this)">\</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Home Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="key caps special extra-wide" onclick="toggleCaps()" id="caps-key">Caps</div>
|
||||||
|
<div class="key" data-key="a" onclick="typeKey(this)">a</div>
|
||||||
|
<div class="key" data-key="s" onclick="typeKey(this)">s</div>
|
||||||
|
<div class="key" data-key="d" onclick="typeKey(this)">d</div>
|
||||||
|
<div class="key" data-key="f" onclick="typeKey(this)">f</div>
|
||||||
|
<div class="key" data-key="g" onclick="typeKey(this)">g</div>
|
||||||
|
<div class="key" data-key="h" onclick="typeKey(this)">h</div>
|
||||||
|
<div class="key" data-key="j" onclick="typeKey(this)">j</div>
|
||||||
|
<div class="key" data-key="k" onclick="typeKey(this)">k</div>
|
||||||
|
<div class="key" data-key="l" onclick="typeKey(this)">l</div>
|
||||||
|
<div class="key" data-key=";" data-shift=":" onclick="typeKey(this)">;</div>
|
||||||
|
<div class="key" data-key="'" data-shift='"' onclick="typeKey(this)">'</div>
|
||||||
|
<div class="key enter extra-wide" onclick="typeKey(this)" data-special="Enter">↵</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="key shift extra-wide" onclick="toggleShift()" id="shift-left">⇧</div>
|
||||||
|
<div class="key" data-key="z" onclick="typeKey(this)">z</div>
|
||||||
|
<div class="key" data-key="x" onclick="typeKey(this)">x</div>
|
||||||
|
<div class="key" data-key="c" onclick="typeKey(this)">c</div>
|
||||||
|
<div class="key" data-key="v" onclick="typeKey(this)">v</div>
|
||||||
|
<div class="key" data-key="b" onclick="typeKey(this)">b</div>
|
||||||
|
<div class="key" data-key="n" onclick="typeKey(this)">n</div>
|
||||||
|
<div class="key" data-key="m" onclick="typeKey(this)">m</div>
|
||||||
|
<div class="key" data-key="," data-shift="<" onclick="typeKey(this)">,</div>
|
||||||
|
<div class="key" data-key="." data-shift=">" onclick="typeKey(this)">.</div>
|
||||||
|
<div class="key" data-key="/" data-shift="?" onclick="typeKey(this)">/</div>
|
||||||
|
<div class="key shift extra-wide" onclick="toggleShift()" id="shift-right">⇧</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Space Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="key special" onclick="typeKey(this)" data-special="Control">Ctrl</div>
|
||||||
|
<div class="key special" onclick="typeKey(this)" data-special="Alt">Alt</div>
|
||||||
|
<div class="key space" onclick="typeKey(this)" data-special=" ">Space</div>
|
||||||
|
<div class="key special" onclick="typeKey(this)" data-special="Alt">Alt</div>
|
||||||
|
<div class="key special" onclick="typeKey(this)" data-special="Control">Ctrl</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
let shiftPressed = false;
|
||||||
|
let capsLock = false;
|
||||||
|
|
||||||
|
// CRITICAL: Do NOT preventDefault on keyboard events
|
||||||
|
// This allows physical keyboard to work alongside OSK
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
// Don't interfere with physical keyboard
|
||||||
|
// Just update our visual state if needed
|
||||||
|
if (e.key === 'CapsLock') {
|
||||||
|
capsLock = !capsLock;
|
||||||
|
updateCapsDisplay();
|
||||||
|
}
|
||||||
|
}, {passive: true});
|
||||||
|
|
||||||
|
function updateKeyDisplay() {
|
||||||
|
const keys = document.querySelectorAll('.key[data-key]');
|
||||||
|
keys.forEach(key => {
|
||||||
|
const baseKey = key.getAttribute('data-key');
|
||||||
|
const shiftKey = key.getAttribute('data-shift');
|
||||||
|
if (/^[a-z]$/.test(baseKey)) {
|
||||||
|
const shouldBeUpper = (shiftPressed && !capsLock) || (!shiftPressed && capsLock);
|
||||||
|
key.textContent = shouldBeUpper ? baseKey.toUpperCase() : baseKey.toLowerCase();
|
||||||
|
} else if (shiftKey) {
|
||||||
|
key.textContent = shiftPressed ? shiftKey : baseKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeKey(element) {
|
||||||
|
const special = element.getAttribute('data-special');
|
||||||
|
if (special) {
|
||||||
|
ipcRenderer.send('keyboard-type', special);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKey = element.getAttribute('data-key');
|
||||||
|
const shiftKey = element.getAttribute('data-shift');
|
||||||
|
let finalKey = baseKey;
|
||||||
|
|
||||||
|
if (/^[a-z]$/.test(baseKey)) {
|
||||||
|
const shouldBeUpper = (shiftPressed && !capsLock) || (!shiftPressed && capsLock);
|
||||||
|
finalKey = shouldBeUpper ? baseKey.toUpperCase() : baseKey.toLowerCase();
|
||||||
|
} else if (shiftPressed && shiftKey) {
|
||||||
|
finalKey = shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.send('keyboard-type', finalKey);
|
||||||
|
|
||||||
|
if (shiftPressed) {
|
||||||
|
shiftPressed = false;
|
||||||
|
updateShiftDisplay();
|
||||||
|
updateKeyDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShift() {
|
||||||
|
shiftPressed = !shiftPressed;
|
||||||
|
updateShiftDisplay();
|
||||||
|
updateKeyDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCaps() {
|
||||||
|
capsLock = !capsLock;
|
||||||
|
updateCapsDisplay();
|
||||||
|
updateKeyDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShiftDisplay() {
|
||||||
|
document.querySelectorAll('.shift').forEach(key => {
|
||||||
|
if (shiftPressed) key.classList.add('active');
|
||||||
|
else key.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCapsDisplay() {
|
||||||
|
const capsKey = document.getElementById('caps-key');
|
||||||
|
if (capsLock) capsKey.classList.add('active');
|
||||||
|
else capsKey.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeKeyboard() {
|
||||||
|
ipcRenderer.send('close-keyboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateKeyDisplay();
|
||||||
|
|
||||||
|
// Tell main process we're ready
|
||||||
|
ipcRenderer.send('keyboard-ready');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Locked</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.box { text-align: center; width: 100%; max-width: 400px; padding: 20px; }
|
||||||
|
h1 { font-size: 32px; margin-bottom: 30px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
#pin-display {
|
||||||
|
font-size: 44px;
|
||||||
|
letter-spacing: 16px;
|
||||||
|
min-height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
border-bottom: 2px solid #555;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||||
|
.pad button {
|
||||||
|
padding: 22px 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
background: #222;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 50px;
|
||||||
|
}
|
||||||
|
.pad button:active { background: #555; }
|
||||||
|
.pad .ok { background: #fff; color: #000; font-size: 22px; }
|
||||||
|
.pad .back { font-size: 24px; }
|
||||||
|
button.unlock {
|
||||||
|
padding: 15px 30px;
|
||||||
|
font-size: 18px;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.4; }
|
||||||
|
.error { color: #f44; margin-top: 18px; min-height: 22px; font-size: 18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1 id="title">Session Locked</h1>
|
||||||
|
|
||||||
|
<div id="password-entry" class="hidden">
|
||||||
|
<input type="password" id="password" autocomplete="off">
|
||||||
|
<button class="unlock" id="unlock">Unlock</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pin-entry" class="hidden">
|
||||||
|
<div id="pin-display"></div>
|
||||||
|
<div class="pad" id="pad"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const {ipcRenderer} = require('electron');
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const isPin = params.get('type') === 'pin';
|
||||||
|
const isBoot = params.get('mode') === 'boot';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let pin = '';
|
||||||
|
let blockedUntil = 0;
|
||||||
|
let blockTimer = null;
|
||||||
|
|
||||||
|
$('title').textContent = isBoot ? 'Kiosk Locked' : 'Session Locked';
|
||||||
|
|
||||||
|
function submit(code) {
|
||||||
|
if (Date.now() < blockedUntil || !code) return;
|
||||||
|
ipcRenderer.send('check-lockout-password', code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisabled(on) {
|
||||||
|
for (const b of document.querySelectorAll('button')) b.disabled = on;
|
||||||
|
$('password').disabled = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPin) {
|
||||||
|
$('pin-entry').classList.remove('hidden');
|
||||||
|
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', 'OK'];
|
||||||
|
for (const k of keys) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = k;
|
||||||
|
if (k === 'OK') b.className = 'ok';
|
||||||
|
if (k === '⌫') b.className = 'back';
|
||||||
|
b.addEventListener('click', () => press(k));
|
||||||
|
$('pad').appendChild(b);
|
||||||
|
}
|
||||||
|
const show = () => { $('pin-display').textContent = '•'.repeat(pin.length); };
|
||||||
|
window.press = k => {
|
||||||
|
if (Date.now() < blockedUntil) return;
|
||||||
|
if (k === 'OK') { submit(pin); return; }
|
||||||
|
if (k === '⌫') pin = pin.slice(0, -1);
|
||||||
|
else if (pin.length < 8) pin += k;
|
||||||
|
$('error').textContent = '';
|
||||||
|
show();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key >= '0' && e.key <= '9') press(e.key);
|
||||||
|
else if (e.key === 'Backspace') press('⌫');
|
||||||
|
else if (e.key === 'Enter') press('OK');
|
||||||
|
});
|
||||||
|
window.clearEntry = () => { pin = ''; show(); };
|
||||||
|
show();
|
||||||
|
} else {
|
||||||
|
$('password-entry').classList.remove('hidden');
|
||||||
|
$('password').placeholder = 'Enter password to unlock';
|
||||||
|
$('password').focus();
|
||||||
|
$('unlock').addEventListener('click', () => submit($('password').value));
|
||||||
|
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') submit($('password').value); });
|
||||||
|
window.clearEntry = () => { $('password').value = ''; $('password').focus(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.on('password-incorrect', (event, info) => {
|
||||||
|
clearEntry();
|
||||||
|
const wait = (info && info.waitSeconds) || 0;
|
||||||
|
const what = isPin ? 'PIN' : 'password';
|
||||||
|
if (!wait) { $('error').textContent = 'Incorrect ' + what; return; }
|
||||||
|
blockedUntil = Date.now() + wait * 1000;
|
||||||
|
setDisabled(true);
|
||||||
|
clearInterval(blockTimer);
|
||||||
|
const tick = () => {
|
||||||
|
const left = Math.ceil((blockedUntil - Date.now()) / 1000);
|
||||||
|
if (left <= 0) {
|
||||||
|
clearInterval(blockTimer);
|
||||||
|
setDisabled(false);
|
||||||
|
$('error').textContent = '';
|
||||||
|
if (!isPin) $('password').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('error').textContent = 'Too many wrong tries - wait ' + left + 's';
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
blockTimer = setInterval(tick, 500);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1955
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "kiosk-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "main.js",
|
||||||
|
"dependencies": {
|
||||||
|
"electron": "^44.0.0",
|
||||||
|
"docx-preview": "^0.4.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"pdfjs-dist": "^6.3.289"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
|
color: #ecf0f1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.container { text-align: center; max-width: 600px; }
|
||||||
|
h2 { font-size: 32px; margin-bottom: 20px; }
|
||||||
|
.message { font-size: 20px; margin-bottom: 30px; line-height: 1.5; }
|
||||||
|
.options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 20px 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-extend { background: #e67e22; }
|
||||||
|
.btn-extend:hover { background: #d35400; }
|
||||||
|
.btn-extend:active { background: #ba4a00; }
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #95a5a6;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { background: #7f8c8d; }
|
||||||
|
|
||||||
|
.info {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #95a5a6;
|
||||||
|
margin-top: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.countdown {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #e74c3c;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>⏸️ Pause Timers</h2>
|
||||||
|
<div class="message">
|
||||||
|
Select how long to pause rotation and inactivity timers:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<button class="btn btn-extend" onclick="selectTime(15)">
|
||||||
|
🍿 15 minutes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="selectTime(30)">
|
||||||
|
⏱️ 30 minutes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="selectTime(60)">
|
||||||
|
🎬 1 hour
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-extend" onclick="selectTime(120)">
|
||||||
|
📺 2 hours
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-cancel" onclick="selectTime(0)">
|
||||||
|
✗ Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
After the time expires, normal rotation and return-to-home logic will resume.
|
||||||
|
</div>
|
||||||
|
<div class="countdown" id="countdown">Auto-closing in 30 seconds...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const {ipcRenderer} = require('electron');
|
||||||
|
let timeLeft = 30;
|
||||||
|
let countdownInterval;
|
||||||
|
|
||||||
|
function selectTime(minutes) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
ipcRenderer.send('pause-time-selected', minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
timeLeft--;
|
||||||
|
document.getElementById('countdown').textContent = 'Auto-closing in ' + timeLeft + ' seconds...';
|
||||||
|
if (timeLeft <= 0) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
selectTime(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countdownInterval = setInterval(updateCountdown, 1000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Browser-side PDF rendering for slideshow.html, via pdf.js (pdfjs-dist,
|
||||||
|
// an npm dependency of this app). A module because pdf.js only ships as
|
||||||
|
// one; it sets window.KioskPdf for slideshow.html's classic script.
|
||||||
|
//
|
||||||
|
// Pages are drawn to canvases (no viewer toolbar, and the slideshow can
|
||||||
|
// scroll them like a Word document). pdf.js >= 4.2.67 is required - older
|
||||||
|
// versions could run JavaScript embedded in a crafted PDF (CVE-2024-4367);
|
||||||
|
// isEvalSupported:false is set as well, belt and braces.
|
||||||
|
import * as pdfjs from './node_modules/pdfjs-dist/build/pdf.min.mjs';
|
||||||
|
|
||||||
|
const base = new URL('./node_modules/pdfjs-dist/', import.meta.url).href;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = base + 'build/pdf.worker.min.mjs';
|
||||||
|
|
||||||
|
// Rendering every page of a huge PDF would take a long time and a lot of
|
||||||
|
// memory for something shown for seconds - stop after this many.
|
||||||
|
const MAX_PAGES = 30;
|
||||||
|
|
||||||
|
window.KioskPdf = {
|
||||||
|
// Render `bytes` into `container` (emptied first), sized for a screen of
|
||||||
|
// width x height: a one-page PDF fits entirely (like an image); a longer
|
||||||
|
// one fits the width, pages stacked, for the slideshow to scroll.
|
||||||
|
async render(bytes, container, width, height) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const task = pdfjs.getDocument({
|
||||||
|
// A copy: pdf.js transfers (empties) the buffer it's given, and the
|
||||||
|
// slideshow caches these bytes to show the PDF again next round.
|
||||||
|
data: bytes.slice(),
|
||||||
|
isEvalSupported: false,
|
||||||
|
cMapUrl: base + 'cmaps/',
|
||||||
|
cMapPacked: true,
|
||||||
|
standardFontDataUrl: base + 'standard_fonts/',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const doc = await task.promise;
|
||||||
|
const count = Math.min(doc.numPages, MAX_PAGES);
|
||||||
|
const single = doc.numPages === 1;
|
||||||
|
const gap = 16;
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.style.flexDirection = 'column';
|
||||||
|
container.style.alignItems = 'center';
|
||||||
|
container.style.gap = gap + 'px';
|
||||||
|
container.style.padding = (single ? 0 : gap) + 'px 0';
|
||||||
|
container.style.minHeight = single ? '100%' : '';
|
||||||
|
container.style.justifyContent = single ? 'center' : '';
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
for (let n = 1; n <= count; n++) {
|
||||||
|
const page = await doc.getPage(n);
|
||||||
|
const natural = page.getViewport({ scale: 1 });
|
||||||
|
const scale = single
|
||||||
|
? Math.min(width / natural.width, height / natural.height)
|
||||||
|
: Math.min((width - 2 * gap) / natural.width, 1.6 * (96 / 72));
|
||||||
|
const vp = page.getViewport({ scale: scale * dpr });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.floor(vp.width);
|
||||||
|
canvas.height = Math.floor(vp.height);
|
||||||
|
canvas.style.width = Math.floor(vp.width / dpr) + 'px';
|
||||||
|
canvas.style.height = Math.floor(vp.height / dpr) + 'px';
|
||||||
|
canvas.style.background = '#fff';
|
||||||
|
canvas.style.boxShadow = single ? '' : '0 2px 8px rgba(0,0,0,.4)';
|
||||||
|
container.appendChild(canvas);
|
||||||
|
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||||||
|
page.cleanup();
|
||||||
|
}
|
||||||
|
return { pages: doc.numPages, rendered: count };
|
||||||
|
} finally {
|
||||||
|
// Frees the document and its worker-side data (pdf.js 6 has this on
|
||||||
|
// the loading task, not the document).
|
||||||
|
task.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #2c3e50;
|
||||||
|
color: #ecf0f1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container { width: 100%; max-width: 400px; }
|
||||||
|
h2 { text-align: center; margin-bottom: 30px; font-size: 24px; }
|
||||||
|
#pin-display {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 48px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
border: 3px solid #34495e;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #34495e;
|
||||||
|
color: #ecf0f1;
|
||||||
|
letter-spacing: 20px;
|
||||||
|
min-height: 90px;
|
||||||
|
line-height: 50px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
#error { color: #e74c3c; text-align: center; display: none; margin: 10px 0; font-weight: bold; font-size: 18px; }
|
||||||
|
.numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin: 20px 0; }
|
||||||
|
.numpad button {
|
||||||
|
padding: 30px;
|
||||||
|
font-size: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #34495e;
|
||||||
|
color: #ecf0f1;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.numpad button:active { background: #3498db; }
|
||||||
|
.numpad button:hover { background: #475d6d; }
|
||||||
|
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||||
|
.btn { padding: 20px; font-size: 20px; cursor: pointer; border: none; border-radius: 12px; font-weight: bold; }
|
||||||
|
#clear { background: #f39c12; color: white; }
|
||||||
|
#backspace { background: #e67e22; color: white; }
|
||||||
|
#submit { background: #27ae60; color: white; }
|
||||||
|
#cancel { background: #e74c3c; color: white; }
|
||||||
|
.info { text-align: center; font-size: 14px; color: #95a5a6; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>🔒 Enter PIN</h2>
|
||||||
|
<div id="pin-display">••••</div>
|
||||||
|
<div id="error">❌ Incorrect PIN</div>
|
||||||
|
<div class="numpad">
|
||||||
|
<button onclick="addDigit('1')">1</button>
|
||||||
|
<button onclick="addDigit('2')">2</button>
|
||||||
|
<button onclick="addDigit('3')">3</button>
|
||||||
|
<button onclick="addDigit('4')">4</button>
|
||||||
|
<button onclick="addDigit('5')">5</button>
|
||||||
|
<button onclick="addDigit('6')">6</button>
|
||||||
|
<button onclick="addDigit('7')">7</button>
|
||||||
|
<button onclick="addDigit('8')">8</button>
|
||||||
|
<button onclick="addDigit('9')">9</button>
|
||||||
|
<button id="clear" onclick="clearPin()">Clear</button>
|
||||||
|
<button onclick="addDigit('0')">0</button>
|
||||||
|
<button id="backspace" onclick="backspace()">⌫</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" id="submit" onclick="submitPin()">✓ Submit</button>
|
||||||
|
<button class="btn" id="cancel" onclick="cancel()">✗ Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="info">4-8 digits</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const {ipcRenderer} = require('electron');
|
||||||
|
let enteredPin = '';
|
||||||
|
let blockedUntil = 0;
|
||||||
|
let blockTimer = null;
|
||||||
|
function updateDisplay() {
|
||||||
|
const display = document.getElementById('pin-display');
|
||||||
|
if (enteredPin.length === 0) {
|
||||||
|
display.textContent = '••••';
|
||||||
|
display.style.color = '#7f8c8d';
|
||||||
|
} else {
|
||||||
|
display.textContent = '•'.repeat(enteredPin.length);
|
||||||
|
display.style.color = '#ecf0f1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addDigit(digit) {
|
||||||
|
if (Date.now() < blockedUntil) return;
|
||||||
|
if (enteredPin.length < 8) {
|
||||||
|
enteredPin += digit;
|
||||||
|
updateDisplay();
|
||||||
|
document.getElementById('error').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function backspace() { enteredPin = enteredPin.slice(0, -1); updateDisplay(); }
|
||||||
|
function clearPin() { enteredPin = ''; updateDisplay(); document.getElementById('error').style.display = 'none'; }
|
||||||
|
function showError(text) {
|
||||||
|
document.getElementById('error').textContent = '❌ ' + text;
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
}
|
||||||
|
// Checked in the main process (the stored PIN is hashed), which also
|
||||||
|
// refuses input for a while after repeated wrong PINs.
|
||||||
|
async function submitPin() {
|
||||||
|
if (Date.now() < blockedUntil) return;
|
||||||
|
if (enteredPin.length < 4) {
|
||||||
|
showError('PIN must be 4-8 digits');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await ipcRenderer.invoke('check-hidden-pin', enteredPin)
|
||||||
|
.catch(() => ({ok: false, error: 'PIN check failed'}));
|
||||||
|
enteredPin = '';
|
||||||
|
updateDisplay();
|
||||||
|
if (r.ok) {
|
||||||
|
ipcRenderer.send('pin-correct');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.waitSeconds) { showError(r.error || 'Incorrect PIN'); return; }
|
||||||
|
blockedUntil = Date.now() + r.waitSeconds * 1000;
|
||||||
|
clearInterval(blockTimer);
|
||||||
|
const tick = () => {
|
||||||
|
const left = Math.ceil((blockedUntil - Date.now()) / 1000);
|
||||||
|
if (left <= 0) {
|
||||||
|
clearInterval(blockTimer);
|
||||||
|
document.getElementById('error').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError('Too many wrong tries - wait ' + left + 's');
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
blockTimer = setInterval(tick, 500);
|
||||||
|
}
|
||||||
|
function cancel() { ipcRenderer.send('pin-cancelled'); }
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key >= '0' && e.key <= '9') addDigit(e.key);
|
||||||
|
else if (e.key === 'Backspace') backspace();
|
||||||
|
else if (e.key === 'Enter') submitPin();
|
||||||
|
else if (e.key === 'Escape') cancel();
|
||||||
|
});
|
||||||
|
updateDisplay();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Slideshow</title>
|
||||||
|
<!-- Shown for a site that's a local folder - see slideshow.js. The item
|
||||||
|
list arrives from main.js via window.kioskSlideshow.update(); a Word
|
||||||
|
document's bytes are requested by setting the page title (see
|
||||||
|
TITLE_REQUEST in slideshow.js) and arrive via kioskSlideshow.docData(). -->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
|
||||||
|
.slide {
|
||||||
|
position: absolute; inset: 0; overflow: hidden;
|
||||||
|
opacity: 0; transition-property: opacity;
|
||||||
|
}
|
||||||
|
.slide.show { opacity: 1; }
|
||||||
|
.slide img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
.slide.doc { background: #666; }
|
||||||
|
#msg {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #888; font: 24px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||||
|
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||||
|
<script src="docx-render.js"></script>
|
||||||
|
<script type="module" src="pdf-render.mjs"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="slide" id="a"></div>
|
||||||
|
<div class="slide" id="b"></div>
|
||||||
|
<div id="msg">Loading slideshow…</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const TITLE = 'Slideshow';
|
||||||
|
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||||
|
const layers = [document.getElementById('a'), document.getElementById('b')];
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
let front = 0;
|
||||||
|
let order = [];
|
||||||
|
let pos = -1;
|
||||||
|
let timer = null;
|
||||||
|
let scrollAnim = null;
|
||||||
|
let state = null;
|
||||||
|
let generation = 0; // bumps on every (re)start, cancels stale work
|
||||||
|
const docCache = new Map(); // url -> { mtime, bytes }
|
||||||
|
const pendingDocs = new Map(); // url -> { resolve, reject, timeout }
|
||||||
|
|
||||||
|
const keyOf = (it) => it && `${it.type}:${it.url}:${it.mtime || ''}`;
|
||||||
|
|
||||||
|
function shuffled(list) {
|
||||||
|
const a = list.slice();
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimers() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cancelAnimationFrame(scrollAnim);
|
||||||
|
scrollAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text) {
|
||||||
|
stopTimers();
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.style.display = 'flex';
|
||||||
|
layers.forEach((l) => { l.classList.remove('show'); l.innerHTML = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask main.js for a document's bytes (see slideshow.js); cached by mtime
|
||||||
|
// so a document is only re-read when it changed on disk.
|
||||||
|
function getDocBytes(item) {
|
||||||
|
const hit = docCache.get(item.url);
|
||||||
|
if (hit && hit.mtime === item.mtime) return Promise.resolve(hit.bytes);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingDocs.delete(item.url);
|
||||||
|
document.title = TITLE;
|
||||||
|
reject(new Error('timed out reading the file'));
|
||||||
|
}, 20000);
|
||||||
|
pendingDocs.set(item.url, { resolve, reject, timeout, mtime: item.mtime });
|
||||||
|
// main.js notices title *changes*; every answer (or timeout) puts it
|
||||||
|
// back to TITLE, so each request is a change.
|
||||||
|
document.title = TITLE_REQUEST + item.url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill `layer` with `item`, resolving once it's ready to fade in.
|
||||||
|
async function prepare(layer, item) {
|
||||||
|
layer.innerHTML = '';
|
||||||
|
layer.scrollTop = 0;
|
||||||
|
layer.classList.toggle('doc', item.type !== 'image');
|
||||||
|
if (item.type === 'image') {
|
||||||
|
const img = new Image();
|
||||||
|
img.style.objectFit = state.options.fit;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = () => reject(new Error('unreadable image'));
|
||||||
|
img.src = item.url;
|
||||||
|
});
|
||||||
|
layer.appendChild(img);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.type === 'pdf') {
|
||||||
|
if (!window.KioskPdf) throw new Error('PDF renderer missing');
|
||||||
|
const bytes = await getDocBytes(item);
|
||||||
|
const box = document.createElement('div');
|
||||||
|
layer.appendChild(box);
|
||||||
|
await KioskPdf.render(bytes, box, window.innerWidth, window.innerHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!KioskDocx.available()) throw new Error('Word document viewer missing');
|
||||||
|
const bytes = await getDocBytes(item);
|
||||||
|
const zoomEl = document.createElement('div');
|
||||||
|
layer.appendChild(zoomEl);
|
||||||
|
const cls = await KioskDocx.render(bytes, zoomEl);
|
||||||
|
// One page: show all of it at once. Longer: fit the width and let
|
||||||
|
// autoScroll() move through it.
|
||||||
|
if (KioskDocx.isSinglePage(zoomEl, cls)) {
|
||||||
|
KioskDocx.fitPage(zoomEl, cls, window.innerWidth, window.innerHeight);
|
||||||
|
zoomEl.style.display = 'flex';
|
||||||
|
zoomEl.style.justifyContent = 'center';
|
||||||
|
} else {
|
||||||
|
KioskDocx.fit(zoomEl, cls, window.innerWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A document taller than the screen scrolls smoothly top to bottom
|
||||||
|
// during its time on screen: holds at the top for the first 15%,
|
||||||
|
// reaches the bottom at 85%, then holds.
|
||||||
|
function autoScroll(layer, seconds) {
|
||||||
|
const distance = layer.scrollHeight - layer.clientHeight;
|
||||||
|
if (distance <= 4) return;
|
||||||
|
const start = performance.now();
|
||||||
|
const total = seconds * 1000;
|
||||||
|
const step = (now) => {
|
||||||
|
const t = Math.min(Math.max(((now - start) / total - 0.15) / 0.7, 0), 1);
|
||||||
|
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||||
|
layer.scrollTop = distance * eased;
|
||||||
|
if (t < 1) scrollAnim = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
scrollAnim = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next(attempts = 0) {
|
||||||
|
stopTimers();
|
||||||
|
if (!order.length) return;
|
||||||
|
if (attempts >= order.length) { showMessage('None of the files in this folder could be shown.'); return; }
|
||||||
|
const gen = generation;
|
||||||
|
pos = (pos + 1) % order.length;
|
||||||
|
if (pos === 0 && state.options.shuffle && order.length > 1) order = shuffled(order);
|
||||||
|
const item = order[pos];
|
||||||
|
const back = layers[1 - front];
|
||||||
|
try {
|
||||||
|
await prepare(back, item);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SLIDESHOW] Skipping', item.url, '-', e.message || e);
|
||||||
|
if (gen === generation) next(attempts + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gen !== generation) return; // folder changed while loading
|
||||||
|
msg.style.display = 'none';
|
||||||
|
const old = layers[front];
|
||||||
|
back.classList.add('show');
|
||||||
|
old.classList.remove('show');
|
||||||
|
front = 1 - front;
|
||||||
|
// Free the old slide (a rendered document can be large) once faded out.
|
||||||
|
setTimeout(() => { if (!old.classList.contains('show')) old.innerHTML = ''; },
|
||||||
|
state.options.transition * 1000 + 100);
|
||||||
|
const isDoc = item.type !== 'image';
|
||||||
|
const seconds = isDoc ? state.options.docInterval : state.options.interval;
|
||||||
|
if (isDoc) autoScroll(back, seconds);
|
||||||
|
// A lone document still cycles, so its scroll starts over from the top.
|
||||||
|
if (order.length > 1 || isDoc) timer = setTimeout(next, seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.kioskSlideshow = {
|
||||||
|
update(s) {
|
||||||
|
const first = !state;
|
||||||
|
const sameItems = state && JSON.stringify(state.items) === JSON.stringify(s.items);
|
||||||
|
const currentKey = keyOf(order[pos]);
|
||||||
|
state = s;
|
||||||
|
layers.forEach((l) => { l.style.transitionDuration = s.options.transition + 's'; });
|
||||||
|
layers.forEach((l) => l.querySelectorAll('img').forEach((img) => { img.style.objectFit = s.options.fit; }));
|
||||||
|
// Drop cached documents that left the folder.
|
||||||
|
for (const url of docCache.keys()) {
|
||||||
|
if (!s.items.some((it) => it.url === url)) docCache.delete(url);
|
||||||
|
}
|
||||||
|
if (!s.items.length) {
|
||||||
|
order = [];
|
||||||
|
generation++;
|
||||||
|
showMessage(s.exists ? `No images, documents or PDFs in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameItems && !first) return;
|
||||||
|
// Folder changed: keep showing the current item if it's still there
|
||||||
|
// unchanged and carry on from it, otherwise restart from the top.
|
||||||
|
generation++;
|
||||||
|
order = s.options.shuffle ? shuffled(s.items) : s.items.slice();
|
||||||
|
const keep = currentKey ? order.findIndex((it) => keyOf(it) === currentKey) : -1;
|
||||||
|
if (keep >= 0 && !first) {
|
||||||
|
pos = keep;
|
||||||
|
clearTimeout(timer);
|
||||||
|
const it = order[pos];
|
||||||
|
const seconds = it.type !== 'image' ? s.options.docInterval : s.options.interval;
|
||||||
|
if (order.length > 1 || it.type !== 'image') timer = setTimeout(next, seconds * 1000);
|
||||||
|
} else {
|
||||||
|
pos = -1;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
docData(url, d) {
|
||||||
|
const p = pendingDocs.get(url);
|
||||||
|
if (!p) return;
|
||||||
|
pendingDocs.delete(url);
|
||||||
|
clearTimeout(p.timeout);
|
||||||
|
document.title = TITLE;
|
||||||
|
if (d.error) { p.reject(new Error(d.error)); return; }
|
||||||
|
const bytes = KioskDocx.base64ToBytes(d.data);
|
||||||
|
docCache.set(url, { mtime: p.mtime, bytes });
|
||||||
|
// Keep at most 10 documents' bytes around.
|
||||||
|
while (docCache.size > 10) docCache.delete(docCache.keys().next().value);
|
||||||
|
p.resolve(bytes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Folder slideshow support for main.js.
|
||||||
|
//
|
||||||
|
// A site whose URL is a local folder (file:///home/kiosk/photos, entered
|
||||||
|
// in the Sites menu or Web UI as /home/kiosk/photos/) is shown as a
|
||||||
|
// full-screen slideshow of the images, Word documents (.docx) and PDFs in it,
|
||||||
|
// instead of Chromium's directory listing. main.js swaps the folder URL
|
||||||
|
// for slideshow.html via resolveSiteUrl(), then attach() hands the page
|
||||||
|
// its item list - the page itself has no filesystem access
|
||||||
|
// (contextIsolation, no Node). The folder is re-read every minute, so
|
||||||
|
// files added, removed or changed show up without restarting the kiosk.
|
||||||
|
//
|
||||||
|
// Images load directly by file:// URL. A document's bytes are fetched on
|
||||||
|
// demand: the page sets its title to TITLE_REQUEST + the document's URL,
|
||||||
|
// and attach() answers via kioskSlideshow.docData() - but only for a
|
||||||
|
// document (.docx/.pdf) in that slideshow's own current item list, so a page can't use
|
||||||
|
// this to read anything else on disk.
|
||||||
|
//
|
||||||
|
// Optional slideshow.json in the folder (all keys optional):
|
||||||
|
// { "interval": 10, seconds per image (default 10, minimum 2)
|
||||||
|
// "docInterval": 20, seconds per document - .docx or PDF (default 2x interval,
|
||||||
|
// minimum 5); a document longer than the
|
||||||
|
// screen scrolls slowly top to bottom in it
|
||||||
|
// "shuffle": false, random order instead of by file name
|
||||||
|
// "fit": "contain", images: "contain" (whole image, letterboxed)
|
||||||
|
// or "cover" (fill the screen, cropped)
|
||||||
|
// "transition": 1, crossfade seconds (0 = instant cut)
|
||||||
|
// "recursive": false } include files in subfolders
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { fileURLToPath, pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
|
||||||
|
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
|
||||||
|
const DOC_TYPES = { '.docx': 'docx', '.pdf': 'pdf' };
|
||||||
|
const REFRESH_MS = 60000;
|
||||||
|
const MAX_DOC_BYTES = 50 * 1024 * 1024;
|
||||||
|
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||||
|
const DEFAULTS = { interval: 10, docInterval: null, shuffle: false, fit: 'contain', transition: 1, recursive: false };
|
||||||
|
|
||||||
|
// The folder path a site URL points at, or null if it isn't a slideshow.
|
||||||
|
// A file:// URL counts when it's an existing directory, or ends in "/"
|
||||||
|
// (so a folder that isn't there yet - e.g. a USB stick not plugged in -
|
||||||
|
// still shows the slideshow's "nothing to show" message, not an error).
|
||||||
|
function slideshowDir(url) {
|
||||||
|
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
p = fileURLToPath(url);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.endsWith('/')) return p;
|
||||||
|
try {
|
||||||
|
return fs.statSync(p).isDirectory() ? p : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a view should actually load for a configured site URL.
|
||||||
|
function resolveSiteUrl(url) {
|
||||||
|
const dir = slideshowDir(url);
|
||||||
|
if (!dir) return url;
|
||||||
|
const u = pathToFileURL(SLIDESHOW_PAGE);
|
||||||
|
u.searchParams.set('dir', dir);
|
||||||
|
return u.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptions(dir) {
|
||||||
|
const opts = { ...DEFAULTS };
|
||||||
|
let user = {};
|
||||||
|
try {
|
||||||
|
user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
// No (or unreadable) slideshow.json - defaults.
|
||||||
|
}
|
||||||
|
if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval));
|
||||||
|
opts.docInterval = Number(user.docInterval) > 0 ? Math.max(5, Number(user.docInterval)) : opts.interval * 2;
|
||||||
|
if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle;
|
||||||
|
if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit;
|
||||||
|
if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2);
|
||||||
|
if (typeof user.recursive === 'boolean') opts.recursive = user.recursive;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listItems(dir, recursive, depth = 0) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
// Skip hidden files and Word's "~$name.docx" lock files.
|
||||||
|
if (e.name.startsWith('.') || e.name.startsWith('~$')) continue;
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
const ext = path.extname(e.name).toLowerCase();
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (recursive && depth < 8) out.push(...listItems(full, recursive, depth + 1));
|
||||||
|
} else if (IMAGE_EXT.has(ext)) {
|
||||||
|
out.push({ path: full, type: 'image' });
|
||||||
|
} else if (DOC_TYPES[ext]) {
|
||||||
|
let mtime = 0;
|
||||||
|
try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ }
|
||||||
|
out.push({ path: full, type: DOC_TYPES[ext], mtime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(dir) {
|
||||||
|
const options = readOptions(dir);
|
||||||
|
const items = listItems(dir, options.recursive)
|
||||||
|
.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))
|
||||||
|
.map((it) => ({ url: pathToFileURL(it.path).href, type: it.type, ...(it.mtime ? { mtime: it.mtime } : {}) }));
|
||||||
|
return { dir, exists: fs.existsSync(dir), options, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDoc(fileUrl) {
|
||||||
|
try {
|
||||||
|
const file = fileURLToPath(fileUrl);
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
if (st.size > MAX_DOC_BYTES) return { error: `too large (${Math.round(st.size / 1048576)} MB)` };
|
||||||
|
return { mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.code === 'ENOENT' ? 'file not found' : e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed a view's slideshow page its item list now and every REFRESH_MS
|
||||||
|
// while it stays on a slideshow, and answer its document requests. Call
|
||||||
|
// once per view (main.js does, right after creating it); safe on views
|
||||||
|
// that never show a slideshow.
|
||||||
|
function attach(webContents) {
|
||||||
|
const pagePrefix = pathToFileURL(SLIDESHOW_PAGE).href;
|
||||||
|
let lastSent = '';
|
||||||
|
let current = null; // latest snapshot sent to the page
|
||||||
|
|
||||||
|
const onSlideshow = () => !webContents.isDestroyed() && webContents.getURL().startsWith(pagePrefix);
|
||||||
|
|
||||||
|
const send = (force) => {
|
||||||
|
if (!onSlideshow()) return;
|
||||||
|
const dir = new URL(webContents.getURL()).searchParams.get('dir');
|
||||||
|
if (!dir) return;
|
||||||
|
const snap = snapshot(dir);
|
||||||
|
const payload = JSON.stringify(snap);
|
||||||
|
if (!force && payload === lastSent) return;
|
||||||
|
lastSent = payload;
|
||||||
|
current = snap;
|
||||||
|
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
webContents.on('did-finish-load', () => send(true));
|
||||||
|
webContents.on('page-title-updated', (event, title) => {
|
||||||
|
if (!title.startsWith(TITLE_REQUEST) || !onSlideshow() || !current) return;
|
||||||
|
const url = title.slice(TITLE_REQUEST.length);
|
||||||
|
if (!current.items.some((it) => it.type !== 'image' && it.url === url)) return;
|
||||||
|
const doc = readDoc(url);
|
||||||
|
webContents.executeJavaScript(
|
||||||
|
`window.kioskSlideshow&&window.kioskSlideshow.docData(${JSON.stringify(url)},${JSON.stringify(doc)})`,
|
||||||
|
).catch(() => {});
|
||||||
|
});
|
||||||
|
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||||
|
webContents.once('destroyed', () => clearInterval(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot, TITLE_REQUEST };
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/kiosk/kiosk-app
|
||||||
|
|
||||||
|
# Wait for network
|
||||||
|
for i in {1..30}; do
|
||||||
|
ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
export DISPLAY=:0
|
||||||
|
export XAUTHORITY=/home/kiosk/.Xauthority
|
||||||
|
export ELECTRON_ENABLE_LOGGING=1
|
||||||
|
|
||||||
|
# Ensure PipeWire is running
|
||||||
|
systemctl --user is-active --quiet pipewire || systemctl --user start pipewire
|
||||||
|
systemctl --user is-active --quiet pipewire-pulse || systemctl --user start pipewire-pulse
|
||||||
|
systemctl --user is-active --quiet wireplumber || systemctl --user start wireplumber
|
||||||
|
|
||||||
|
# Wait for PipeWire
|
||||||
|
for i in {1..10}; do
|
||||||
|
pactl info >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
exec node_modules/electron/dist/electron . \
|
||||||
|
--no-sandbox --disable-gpu-sandbox --disable-dev-shm-usage \
|
||||||
|
--enable-features=UseOzonePlatform --ozone-platform=x11 \
|
||||||
|
--enable-audio-service-sandbox=false --autoplay-policy=no-user-gesture-required \
|
||||||
|
--password-store=basic \
|
||||||
|
2>&1 | tee -a /home/kiosk/electron.log
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>WiFi</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #2c3e50;
|
||||||
|
color: #ecf0f1;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h2 { text-align: center; margin-bottom: 16px; font-size: 26px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
label { display: block; margin: 14px 0 6px; font-size: 16px; color: #bdc3c7; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 20px;
|
||||||
|
border: 2px solid #34495e;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #34495e;
|
||||||
|
color: #ecf0f1;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
width: 100%; padding: 14px; font-size: 18px; border: 2px solid #34495e;
|
||||||
|
border-radius: 10px; background: #34495e; color: #ecf0f1;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #3498db; }
|
||||||
|
.row { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.row input { flex: 1; }
|
||||||
|
button {
|
||||||
|
padding: 14px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
background: #34495e;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.primary { background: #27ae60; }
|
||||||
|
.scan { background: #2980b9; }
|
||||||
|
.close { background: #e74c3c; }
|
||||||
|
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 20px; }
|
||||||
|
#status {
|
||||||
|
background: #34495e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#message { margin-top: 16px; font-size: 17px; font-weight: bold; text-align: center; min-height: 24px; }
|
||||||
|
#message.error { color: #e74c3c; }
|
||||||
|
#message.ok { color: #2ecc71; }
|
||||||
|
#message.busy { color: #f1c40f; }
|
||||||
|
#networks {
|
||||||
|
list-style: none;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #34495e;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
#networks li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
border-bottom: 1px solid #2c3e50;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#networks li:last-child { border-bottom: none; }
|
||||||
|
#networks li.selected { background: #2980b9; }
|
||||||
|
#networks li .meta { color: #bdc3c7; font-size: 15px; white-space: nowrap; margin-left: 12px; }
|
||||||
|
#networks li.empty { color: #95a5a6; cursor: default; }
|
||||||
|
.hint { text-align: center; font-size: 13px; color: #95a5a6; margin-top: 18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>📶 WiFi</h2>
|
||||||
|
|
||||||
|
<div id="pin-step" class="hidden">
|
||||||
|
<label for="pin" id="pin-label">Enter the PIN</label>
|
||||||
|
<input id="pin" type="password" inputmode="numeric" autocomplete="off">
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" id="pin-ok">✓ OK</button>
|
||||||
|
<button class="close" data-close>✗ Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wifi-step" class="hidden">
|
||||||
|
<div id="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">
|
||||||
|
<label style="margin: 0; flex: 1">Networks nearby</label>
|
||||||
|
<button class="scan" id="scan">⟳ Scan</button>
|
||||||
|
</div>
|
||||||
|
<ul id="networks"><li class="empty">Scanning…</li></ul>
|
||||||
|
|
||||||
|
<label for="ssid">Network name</label>
|
||||||
|
<input id="ssid" type="text" autocomplete="off" spellcheck="false">
|
||||||
|
|
||||||
|
<label for="password">Password (empty for an open network)</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="password" type="password" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="show">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" id="connect">Connect</button>
|
||||||
|
<button class="close" data-close>✗ Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details id="region-panel" class="hidden" style="margin-top: 18px">
|
||||||
|
<summary style="font-size: 17px; cursor: pointer; color: #bdc3c7">🌍 Region (country, locale, keyboard)</summary>
|
||||||
|
<label for="country">WiFi country (2 letters, e.g. US) - needed to join many 5 GHz networks</label>
|
||||||
|
<input id="country" type="text" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
|
||||||
|
<label for="locale">Language / locale</label>
|
||||||
|
<select id="locale"></select>
|
||||||
|
<label for="keyboard">Keyboard layout</label>
|
||||||
|
<select id="keyboard"></select>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" id="region-save">Save region</button>
|
||||||
|
<button id="region-from-locale">Country from locale</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const {ipcRenderer} = require('electron');
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let busy = false;
|
||||||
|
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
|
||||||
|
|
||||||
|
function request(action, extra) {
|
||||||
|
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
|
||||||
|
.catch(e => ({ok: false, error: String(e && e.message || e)}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(text, kind) {
|
||||||
|
const m = $('message');
|
||||||
|
m.textContent = text || '';
|
||||||
|
m.className = kind || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(on) {
|
||||||
|
busy = on;
|
||||||
|
for (const id of ['scan', 'connect', 'pin-ok', 'region-save']) $(id).disabled = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalBars(dbm) {
|
||||||
|
if (dbm >= -55) return '▂▄▆█';
|
||||||
|
if (dbm >= -67) return '▂▄▆';
|
||||||
|
if (dbm >= -78) 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() {
|
||||||
|
const ad = fillAdapters(await request('ifaces'));
|
||||||
|
const s = await request('status', adapter ? {iface: adapter} : {});
|
||||||
|
const box = $('status');
|
||||||
|
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
|
||||||
|
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
|
||||||
|
box.textContent = '';
|
||||||
|
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
|
||||||
|
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
|
||||||
|
// Every adapter at a glance, the selected one marked.
|
||||||
|
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
|
||||||
|
} else {
|
||||||
|
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
|
||||||
|
line(`Adapter: ${s.iface}`);
|
||||||
|
}
|
||||||
|
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
|
||||||
|
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
message('Scanning…', 'busy');
|
||||||
|
const list = $('networks');
|
||||||
|
const r = await request('scan', adapter ? {iface: adapter} : {});
|
||||||
|
list.textContent = '';
|
||||||
|
if (!r.ok) {
|
||||||
|
message(r.error, 'error');
|
||||||
|
list.innerHTML = '<li class="empty">No scan results</li>';
|
||||||
|
} else {
|
||||||
|
message('');
|
||||||
|
if (!r.networks.length) list.innerHTML = '<li class="empty">No networks found - type the name below</li>';
|
||||||
|
for (const n of r.networks) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.textContent = n.ssid;
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'meta';
|
||||||
|
meta.textContent = (n.secure ? '🔒 ' : '') + signalBars(n.signal);
|
||||||
|
li.append(name, meta);
|
||||||
|
li.addEventListener('click', () => {
|
||||||
|
for (const other of list.children) other.classList.remove('selected');
|
||||||
|
li.classList.add('selected');
|
||||||
|
$('ssid').value = n.ssid;
|
||||||
|
$('password').value = '';
|
||||||
|
$('password').focus();
|
||||||
|
});
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (busy) return;
|
||||||
|
const ssid = $('ssid').value;
|
||||||
|
const password = $('password').value;
|
||||||
|
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
|
||||||
|
setBusy(true);
|
||||||
|
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
|
||||||
|
const r = await request('connect', {ssid, password, iface: adapter});
|
||||||
|
if (r.ok) {
|
||||||
|
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
|
||||||
|
$('password').value = '';
|
||||||
|
} else {
|
||||||
|
message(r.error || 'Connection failed', 'error');
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(sel, items, current) {
|
||||||
|
sel.textContent = '';
|
||||||
|
const list = items.slice();
|
||||||
|
if (current && !list.includes(current)) list.unshift(current);
|
||||||
|
for (const v of list) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = v; o.textContent = v;
|
||||||
|
if (v === current) o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegion() {
|
||||||
|
const r = await request('region-status');
|
||||||
|
if (!r.ok) return;
|
||||||
|
$('country').value = r.country || '';
|
||||||
|
fillSelect($('locale'), r.locales || [], r.locale);
|
||||||
|
fillSelect($('keyboard'), r.keyboards || [], r.keyboard);
|
||||||
|
$('region-panel').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRegion() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
message('Saving region…', 'busy');
|
||||||
|
const r = await request('region-set', {
|
||||||
|
country: $('country').value.trim(),
|
||||||
|
locale: $('locale').value,
|
||||||
|
keyboard: $('keyboard').value,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
message(r.restart ? '✓ Saved. Locale and keyboard apply after a restart; the country applies now.' : '✓ Saved', 'ok');
|
||||||
|
loadRegion();
|
||||||
|
} else {
|
||||||
|
message(r.error || 'Could not save', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// en_US.UTF-8 -> US
|
||||||
|
function countryFromLocale() {
|
||||||
|
const m = /_([A-Z]{2})/.exec($('locale').value);
|
||||||
|
if (m) $('country').value = m[1];
|
||||||
|
else message("That locale doesn't name a country", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
const r = await request('unlock', {pin: $('pin').value});
|
||||||
|
setBusy(false);
|
||||||
|
$('pin').value = '';
|
||||||
|
if (r.ok) { showWifi(); } else { message(r.error, 'error'); $('pin').focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWifi() {
|
||||||
|
message('');
|
||||||
|
$('pin-step').classList.add('hidden');
|
||||||
|
$('wifi-step').classList.remove('hidden');
|
||||||
|
refreshStatus().then(() => scan());
|
||||||
|
loadRegion();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('pin-ok').addEventListener('click', unlock);
|
||||||
|
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
|
||||||
|
$('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-from-locale').addEventListener('click', countryFromLocale);
|
||||||
|
$('connect').addEventListener('click', connect);
|
||||||
|
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
|
||||||
|
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
|
||||||
|
$('show').addEventListener('click', () => {
|
||||||
|
const p = $('password');
|
||||||
|
p.type = p.type === 'password' ? 'text' : 'password';
|
||||||
|
$('show').textContent = p.type === 'password' ? 'Show' : 'Hide';
|
||||||
|
});
|
||||||
|
for (const b of document.querySelectorAll('[data-close]')) b.addEventListener('click', () => request('close'));
|
||||||
|
|
||||||
|
request('needs-pin').then(r => {
|
||||||
|
if (r.ok && r.needsPin) {
|
||||||
|
if (r.codeType === 'password') {
|
||||||
|
$('pin-label').textContent = 'Enter the kiosk password';
|
||||||
|
$('pin').removeAttribute('inputmode');
|
||||||
|
}
|
||||||
|
$('pin-step').classList.remove('hidden');
|
||||||
|
$('pin').focus();
|
||||||
|
} else {
|
||||||
|
showWifi();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+363
@@ -0,0 +1,363 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# lib/config.sh - config.json read/write for the kiosk Electron app.
|
||||||
|
#
|
||||||
|
# Every menu that touches sites/timing/settings works against the same
|
||||||
|
# in-memory bash arrays (URLS, DURS, USERS, PASSES, NAMES, ...) and the same
|
||||||
|
# two functions below. Load once when a menu opens, save after each change.
|
||||||
|
#
|
||||||
|
# IMPORTANT: save_config() rewrites config.json from these globals in full.
|
||||||
|
# Any menu that calls save_config() MUST call load_existing_config() first
|
||||||
|
# (even if it only touches sites), otherwise settings it doesn't know about
|
||||||
|
# (swipe mode, navigation security, lockout, etc) get silently reset to
|
||||||
|
# script defaults. This bit the old single-file Sites menu, which read
|
||||||
|
# tabs directly via jq but never loaded the rest of the settings - fixed
|
||||||
|
# here by making load_existing_config() the one canonical loader.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
: "${KIOSK_USER:=kiosk}"
|
||||||
|
: "${KIOSK_HOME:=/home/${KIOSK_USER}}"
|
||||||
|
: "${KIOSK_DIR:=${KIOSK_HOME}/kiosk-app}"
|
||||||
|
: "${CONFIG_PATH:=${KIOSK_DIR}/config.json}"
|
||||||
|
|
||||||
|
# System paths that menus (e.g. power/display/quiet-hours scheduling)
|
||||||
|
# write units, scripts, and cron entries into. Overridable so tests can
|
||||||
|
# point them at a scratch directory instead of the real system - nothing
|
||||||
|
# under menus/ should ever hardcode /etc/systemd/system, /etc/cron.d, or
|
||||||
|
# /usr/local/bin directly.
|
||||||
|
: "${SYSTEMD_DIR:=/etc/systemd/system}"
|
||||||
|
: "${CRON_D_DIR:=/etc/cron.d}"
|
||||||
|
: "${BIN_DIR:=/usr/local/bin}"
|
||||||
|
: "${NETPLAN_DIR:=/etc/netplan}"
|
||||||
|
: "${TIMESYNCD_CONF_DIR:=/etc/systemd/timesyncd.conf.d}"
|
||||||
|
: "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}"
|
||||||
|
: "${WIREGUARD_DIR:=/etc/wireguard}"
|
||||||
|
: "${WEBUI_DIR:=/opt/kiosk-webui}"
|
||||||
|
: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}"
|
||||||
|
: "${SUDOERS_D_DIR:=/etc/sudoers.d}"
|
||||||
|
# The one root-owned script the Web UI's addon-install/Update actions are
|
||||||
|
# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it
|
||||||
|
# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's
|
||||||
|
# header). Under $BIN_DIR since that's already the convention for every
|
||||||
|
# other addon's own scripts.
|
||||||
|
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
||||||
|
|
||||||
|
# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker
|
||||||
|
# stack is deployed here from this checkout's library/ directory. Root-owned
|
||||||
|
# on purpose - it holds the generated database/admin passwords in .env.
|
||||||
|
: "${LIBRARY_DIR:=/opt/kiosk-library}"
|
||||||
|
|
||||||
|
# Offline install bundle - only present on systems installed from an ISO
|
||||||
|
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
||||||
|
# package provisioning and the CUPS addon install) plus prebuilt
|
||||||
|
# node_modules for the kiosk app and Web UI. Offline mode is switched on
|
||||||
|
# by /usr/local/sbin/kiosk-offline-apt (which writes $KIOSK_OFFLINE_APT_CONF
|
||||||
|
# pointing apt at that repository only) - the first-boot service does that
|
||||||
|
# automatically when there's no internet; by hand: `sudo kiosk-offline-apt on`.
|
||||||
|
: "${KIOSK_OFFLINE_DIR:=/opt/kiosk-offline}"
|
||||||
|
: "${KIOSK_OFFLINE_APT_CONF:=/etc/apt/apt.conf.d/90-kiosk-offline}"
|
||||||
|
|
||||||
|
# The admin account actually running this tool (as opposed to $KIOSK_USER,
|
||||||
|
# the kiosk's own restricted account) - used where an addon needs to grant
|
||||||
|
# *this* user a group membership (e.g. lpadmin for CUPS).
|
||||||
|
: "${BUILD_USER:=${SUDO_USER:-$(whoami)}}"
|
||||||
|
|
||||||
|
# Site/tab arrays
|
||||||
|
declare -a URLS=()
|
||||||
|
declare -a DURS=()
|
||||||
|
declare -a USERS=()
|
||||||
|
declare -a PASSES=()
|
||||||
|
declare -a NAMES=()
|
||||||
|
|
||||||
|
# Other top-level config.json settings we must round-trip even though the
|
||||||
|
# Sites menu doesn't edit most of them.
|
||||||
|
AUTOSWITCH="true"
|
||||||
|
SWIPE_MODE="dual"
|
||||||
|
ALLOW_NAVIGATION="same-origin"
|
||||||
|
HOME_TAB_INDEX=-1
|
||||||
|
INACTIVITY_TIMEOUT=120
|
||||||
|
ENABLE_PAUSE_BUTTON="true"
|
||||||
|
ENABLE_KEYBOARD_BUTTON="true"
|
||||||
|
ENABLE_NAV_BUTTON="true"
|
||||||
|
ENABLE_HISTORY_BUTTONS="false"
|
||||||
|
ENABLE_PASSWORD_PROTECTION="false"
|
||||||
|
LOCKOUT_PASSWORD=""
|
||||||
|
LOCKOUT_TIMEOUT=0
|
||||||
|
LOCKOUT_AT_TIME=""
|
||||||
|
LOCKOUT_ACTIVE_START=""
|
||||||
|
LOCKOUT_ACTIVE_END=""
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||||
|
# "password" or "pin" (4-8 digits, unlocked with an on-screen keypad).
|
||||||
|
LOCKOUT_CODE_TYPE="password"
|
||||||
|
# A separate code for the boot lock screen - empty means the unlock code
|
||||||
|
# above is used at boot too. Hashed like LOCKOUT_PASSWORD.
|
||||||
|
BOOT_PASSWORD=""
|
||||||
|
BOOT_CODE_TYPE="password"
|
||||||
|
AUTHELIA_URL=""
|
||||||
|
AUTHELIA_USERNAME=""
|
||||||
|
AUTHELIA_ENCRYPTED_PASSWORD=""
|
||||||
|
|
||||||
|
offline_mode_active() {
|
||||||
|
[[ -f "$KIOSK_OFFLINE_APT_CONF" && -d "$KIOSK_OFFLINE_DIR/apt" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# offline_install_node_modules NAME DEST_DIR
|
||||||
|
# Unpacks the bundle's prebuilt node_modules for NAME (kiosk-app, webui)
|
||||||
|
# into DEST_DIR as $KIOSK_USER, in place of `npm install` - which can't
|
||||||
|
# reach the npm registry (or Electron's GitHub download) offline.
|
||||||
|
offline_install_node_modules() {
|
||||||
|
local name="$1" dest="$2"
|
||||||
|
local tarball="$KIOSK_OFFLINE_DIR/npm/${name}-node_modules.tar.gz"
|
||||||
|
if [[ ! -f "$tarball" ]]; then
|
||||||
|
log_error "Offline bundle has no prebuilt modules for $name ($tarball)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Offline mode: unpacking prebuilt $name modules from $KIOSK_OFFLINE_DIR..."
|
||||||
|
sudo -u "$KIOSK_USER" rm -rf "$dest/node_modules"
|
||||||
|
sudo -u "$KIOSK_USER" tar -xzf "$tarball" -C "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quick reachability check for the package mirrors install.sh needs.
|
||||||
|
have_internet() {
|
||||||
|
curl -fsS --max-time 8 -o /dev/null http://archive.ubuntu.com/ubuntu/ 2>/dev/null \
|
||||||
|
|| curl -fsS --max-time 8 -o /dev/null https://deb.nodesource.com/ 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_with_offline_fallback CMD [ARGS...]
|
||||||
|
# Runs CMD normally - but on a kiosk installed from an --offline ISO with
|
||||||
|
# no internet right now, first points apt at the bundle on this disk
|
||||||
|
# (kiosk-offline-apt on) and switches it back afterwards, whatever CMD
|
||||||
|
# returns. Lets apt-based installs (e.g. the CUPS addon) work from the
|
||||||
|
# normal menus and the Web UI with no extra steps. Everywhere else
|
||||||
|
# (manual installs, or online) it just runs CMD.
|
||||||
|
run_with_offline_fallback() {
|
||||||
|
local restore=0 rc=0
|
||||||
|
if ! offline_mode_active && [[ -f "$KIOSK_OFFLINE_DIR/apt/Packages" ]] && ! have_internet; then
|
||||||
|
log_info "No internet - installing from the offline bundle on this disk ($(sed -n 's/^Built: *//p' "$KIOSK_OFFLINE_DIR/bundle-info.txt" 2>/dev/null))"
|
||||||
|
if sudo kiosk-offline-apt on >/dev/null; then
|
||||||
|
restore=1
|
||||||
|
else
|
||||||
|
log_warning "Couldn't switch apt to the offline bundle - trying normally"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
"$@" || rc=$?
|
||||||
|
if (( restore )); then
|
||||||
|
sudo kiosk-offline-apt off >/dev/null || true
|
||||||
|
fi
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
kiosk_user_exists() {
|
||||||
|
id "$KIOSK_USER" &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
is_kiosk_installed() {
|
||||||
|
kiosk_user_exists && sudo -u "$KIOSK_USER" test -f "$KIOSK_DIR/main.js" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
is_service_active() {
|
||||||
|
local service="$1"
|
||||||
|
systemctl is-active --quiet "$service" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether a service is enabled (would start on boot), regardless of
|
||||||
|
# whether it's currently running. The legacy script's version of this
|
||||||
|
# pre-checked `systemctl list-unit-files | grep -q "^${service}\s"`
|
||||||
|
# before calling is-enabled - but every call site passes a bare service
|
||||||
|
# name (e.g. "squeezelite"), while list-unit-files lines start with
|
||||||
|
# "squeezelite.service", so that regex never matched and the legacy
|
||||||
|
# function always fell through to `return 1` no matter the real state.
|
||||||
|
# `systemctl is-enabled` already reports "not found" as a failure on its
|
||||||
|
# own, so the pre-check was both broken and unnecessary - dropped here.
|
||||||
|
is_service_enabled() {
|
||||||
|
local service="$1"
|
||||||
|
systemctl is-enabled --quiet "$service" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load every setting config.json has into the bash globals above.
|
||||||
|
# Safe to call with no existing config file - leaves script defaults in place.
|
||||||
|
load_existing_config() {
|
||||||
|
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
URLS=()
|
||||||
|
DURS=()
|
||||||
|
USERS=()
|
||||||
|
PASSES=()
|
||||||
|
NAMES=()
|
||||||
|
|
||||||
|
local tab_count
|
||||||
|
tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0")
|
||||||
|
if [[ "$tab_count" -gt 0 ]]; then
|
||||||
|
for ((i = 0; i < tab_count; i++)); do
|
||||||
|
URLS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].url" "$CONFIG_PATH")")
|
||||||
|
DURS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].duration" "$CONFIG_PATH")")
|
||||||
|
USERS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].username // empty" "$CONFIG_PATH")")
|
||||||
|
PASSES+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].password // empty" "$CONFIG_PATH")")
|
||||||
|
NAMES+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].name // empty" "$CONFIG_PATH")")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null || echo "-1")
|
||||||
|
INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null || echo "120")
|
||||||
|
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")
|
||||||
|
|
||||||
|
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" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
|
||||||
|
|
||||||
|
keyboard_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableKeyboardButton // true' "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
[[ "$keyboard_btn" == "true" ]] && ENABLE_KEYBOARD_BUTTON="true" || ENABLE_KEYBOARD_BUTTON="false"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
LOCKOUT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.lockoutPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
LOCKOUT_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 0' "$CONFIG_PATH" 2>/dev/null || echo "0")
|
||||||
|
LOCKOUT_AT_TIME=$(sudo -u "$KIOSK_USER" jq -r '.lockoutAtTime // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
LOCKOUT_ACTIVE_START=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveStart // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
LOCKOUT_ACTIVE_END=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveEnd // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
[[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false"
|
||||||
|
|
||||||
|
local code_type
|
||||||
|
code_type=$(sudo -u "$KIOSK_USER" jq -r '.lockoutCodeType // "password"' "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
[[ "$code_type" == "pin" ]] && LOCKOUT_CODE_TYPE="pin" || LOCKOUT_CODE_TYPE="password"
|
||||||
|
BOOT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.bootPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
code_type=$(sudo -u "$KIOSK_USER" jq -r '.bootCodeType // "password"' "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
[[ "$code_type" == "pin" ]] && BOOT_CODE_TYPE="pin" || BOOT_CODE_TYPE="password"
|
||||||
|
|
||||||
|
AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write every bash global back out to config.json, then offer to reload the
|
||||||
|
# kiosk display so the change takes effect immediately.
|
||||||
|
save_config() {
|
||||||
|
if ! kiosk_user_exists; then
|
||||||
|
log_error "Kiosk user doesn't exist - run the full installer first"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$KIOSK_DIR"
|
||||||
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
|
||||||
|
local dual_json="false"
|
||||||
|
[[ "$SWIPE_MODE" == "dual" ]] && dual_json="true"
|
||||||
|
|
||||||
|
local pause_btn_json="true"
|
||||||
|
[[ "$ENABLE_PAUSE_BUTTON" == "false" ]] && pause_btn_json="false"
|
||||||
|
|
||||||
|
local keyboard_btn_json="true"
|
||||||
|
[[ "$ENABLE_KEYBOARD_BUTTON" == "false" ]] && keyboard_btn_json="false"
|
||||||
|
|
||||||
|
local nav_btn_json="true"
|
||||||
|
[[ "$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"
|
||||||
|
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
|
||||||
|
|
||||||
|
local boot_password_json="false"
|
||||||
|
[[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]] && boot_password_json="true"
|
||||||
|
|
||||||
|
# Merge onto whatever's already in config.json rather than rebuilding
|
||||||
|
# the file from nothing. The legacy save_config did a full `jq -n`
|
||||||
|
# rebuild listing every known field - any field it doesn't know about
|
||||||
|
# (e.g. Authelia's autheliaURL/autheliaUsername/
|
||||||
|
# autheliaEncryptedPassword, written by its own careful `. + {...}`
|
||||||
|
# merge) gets silently DELETED the next time any other menu that
|
||||||
|
# calls save_config runs. Real, currently-shipping bug in the legacy
|
||||||
|
# script, not unique to this migration - ported faithfully into this
|
||||||
|
# file's first version because no test happened to set an untracked
|
||||||
|
# field first. `. + {known fields...}` below preserves anything this
|
||||||
|
# tool doesn't track while still fully replacing every field it does
|
||||||
|
# (including tabs, via the same array-rebuild loop as before) - jq's
|
||||||
|
# `+` on objects takes the right-hand value for any key present on
|
||||||
|
# both sides, so a fully-specified `tabs` here still discards a
|
||||||
|
# deleted tab rather than merging old and new.
|
||||||
|
local existing="{}"
|
||||||
|
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
echo "$existing" | jq empty 2>/dev/null || existing="{}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$existing" | jq \
|
||||||
|
--argjson autoswitch true \
|
||||||
|
--argjson enableTouch true \
|
||||||
|
--argjson dualSwipe "$dual_json" \
|
||||||
|
--arg swipeMode "$SWIPE_MODE" \
|
||||||
|
--arg allowNavigation "$ALLOW_NAVIGATION" \
|
||||||
|
--argjson homeTabIndex "${HOME_TAB_INDEX:--1}" \
|
||||||
|
--argjson inactivityTimeout "${INACTIVITY_TIMEOUT:-120}" \
|
||||||
|
--argjson enablePauseButton "$pause_btn_json" \
|
||||||
|
--argjson enableKeyboardButton "$keyboard_btn_json" \
|
||||||
|
--argjson enableNavButton "$nav_btn_json" \
|
||||||
|
--argjson enableHistoryButtons "$history_btn_json" \
|
||||||
|
--argjson enablePasswordProtection "$password_json" \
|
||||||
|
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
|
||||||
|
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
|
||||||
|
--arg lockoutAtTime "${LOCKOUT_AT_TIME:-}" \
|
||||||
|
--arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \
|
||||||
|
--arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \
|
||||||
|
--argjson requirePasswordOnBoot "$boot_password_json" \
|
||||||
|
--arg lockoutCodeType "${LOCKOUT_CODE_TYPE:-password}" \
|
||||||
|
--arg bootPassword "${BOOT_PASSWORD:-}" \
|
||||||
|
--arg bootCodeType "${BOOT_CODE_TYPE:-password}" \
|
||||||
|
--arg autheliaURL "${AUTHELIA_URL:-}" \
|
||||||
|
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
|
||||||
|
--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,enableHistoryButtons:$enableHistoryButtons,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
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
local url="${URLS[$idx]:-}"
|
||||||
|
local dur="${DURS[$idx]:-0}"
|
||||||
|
local user="${USERS[$idx]:-}"
|
||||||
|
local pass="${PASSES[$idx]:-}"
|
||||||
|
local name="${NAMES[$idx]:-}"
|
||||||
|
|
||||||
|
jq --arg u "$url" \
|
||||||
|
--argjson d "$dur" \
|
||||||
|
--arg user "$user" \
|
||||||
|
--arg pass "$pass" \
|
||||||
|
--arg name "$name" \
|
||||||
|
'.tabs += [{"url":$u,"duration":$d,"username":$user,"password":$pass,"name":$name}]' \
|
||||||
|
"$tmp" > "${tmp}.new"
|
||||||
|
mv -f "${tmp}.new" "$tmp"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp"
|
||||||
|
sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH"
|
||||||
|
rm -f "$tmp"
|
||||||
|
|
||||||
|
log_success "Configuration saved"
|
||||||
|
|
||||||
|
if is_service_active lightdm; then
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Reload kiosk now to apply changes?" "y"; then
|
||||||
|
echo "Reloading kiosk..."
|
||||||
|
sudo systemctl restart lightdm
|
||||||
|
sleep 2
|
||||||
|
log_success "Kiosk reloaded"
|
||||||
|
else
|
||||||
|
log_warning "Remember to reload: sudo systemctl restart lightdm"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a
|
||||||
|
# containerised service (the Library Catalog addon is the first).
|
||||||
|
#
|
||||||
|
# Everything here goes through `sudo docker ...`: the kiosk admin runs
|
||||||
|
# install.sh as a regular user and is deliberately NOT put in the `docker`
|
||||||
|
# group (docker-group membership is root-equivalent).
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh (log_* helpers) being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Overridable so tests can substitute stubs for the real binaries.
|
||||||
|
: "${DOCKER_BIN:=docker}"
|
||||||
|
|
||||||
|
docker_is_installed() {
|
||||||
|
command -v "$DOCKER_BIN" >/dev/null 2>&1 \
|
||||||
|
&& sudo "$DOCKER_BIN" compose version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
docker_daemon_running() {
|
||||||
|
sudo "$DOCKER_BIN" info >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install Docker Engine + the Compose plugin if they are missing.
|
||||||
|
#
|
||||||
|
# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2):
|
||||||
|
# preferred because it also works through the offline apt bundle's mirror and
|
||||||
|
# gets security updates with everything else. On releases without
|
||||||
|
# docker-compose-v2 (22.04), fall back to Docker's official install script.
|
||||||
|
docker_ensure_installed() {
|
||||||
|
if docker_is_installed; then
|
||||||
|
docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
|
log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Installing Docker..."
|
||||||
|
if ! sudo apt-get update -qq; then
|
||||||
|
log_error "apt update failed - check the network/package sources and try again"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \
|
||||||
|
|| { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; }
|
||||||
|
else
|
||||||
|
log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script"
|
||||||
|
have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; }
|
||||||
|
local script
|
||||||
|
script=$(mktemp)
|
||||||
|
if ! curl -fsSL https://get.docker.com -o "$script"; then
|
||||||
|
rm -f "$script"
|
||||||
|
log_error "Could not download Docker's install script"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; }
|
||||||
|
rm -f "$script"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
|
if ! docker_is_installed || ! docker_daemon_running; then
|
||||||
|
log_error "Docker was installed but is not working - check: sudo systemctl status docker"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Docker installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_compose DIR args... -> sudo docker compose --project-directory DIR args...
|
||||||
|
docker_compose() {
|
||||||
|
local dir="$1"; shift
|
||||||
|
sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_container_state NAME -> running | exited | restarting | ... | missing
|
||||||
|
docker_container_state() {
|
||||||
|
sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_container_health NAME -> healthy | unhealthy | starting | none | missing
|
||||||
|
docker_container_health() {
|
||||||
|
sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \
|
||||||
|
|| echo "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_wait_http URL [TIMEOUT_SECONDS]
|
||||||
|
# Polls until URL answers 2xx/3xx; returns 1 on timeout.
|
||||||
|
docker_wait_http() {
|
||||||
|
local url="$1" timeout="${2:-300}" waited=0
|
||||||
|
while (( waited < timeout )); do
|
||||||
|
if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
waited=$((waited + 5))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_free_port START -> prints the first TCP port >= START nothing listens on.
|
||||||
|
docker_free_port() {
|
||||||
|
local port="$1"
|
||||||
|
while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do
|
||||||
|
port=$((port + 1))
|
||||||
|
done
|
||||||
|
echo "$port"
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# lib/electron.sh - Electron binary install/repair, shared between fresh
|
||||||
|
# provisioning (lib/provision.sh) and ongoing maintenance
|
||||||
|
# (menus/advanced_electron.sh's "Fix blank screen" action) - the exact
|
||||||
|
# same repair sequence applies whether the binary never downloaded during
|
||||||
|
# `npm install` or went missing later.
|
||||||
|
#
|
||||||
|
# Depends on: lib/config.sh being sourced first (for $KIOSK_DIR/$KIOSK_USER).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Re-verify/download the Electron binary and fix chrome-sandbox
|
||||||
|
# permissions, without touching package.json or reinstalling anything else.
|
||||||
|
electron_install_binary() {
|
||||||
|
local electron_bin="$KIOSK_DIR/node_modules/electron/dist/electron"
|
||||||
|
|
||||||
|
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||||
|
log_warning "Electron binary missing - retrying via install.js..."
|
||||||
|
sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && ELECTRON_FORCE_DOWNLOAD=true node node_modules/electron/install.js" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||||
|
log_warning "Attempting direct download of Electron binary (~120MB)..."
|
||||||
|
local electron_ver
|
||||||
|
electron_ver=$(sudo -u "$KIOSK_USER" node -e \
|
||||||
|
"try{console.log(require('$KIOSK_DIR/node_modules/electron/package.json').version)}catch(e){}" 2>/dev/null || true)
|
||||||
|
if [[ -n "$electron_ver" ]]; then
|
||||||
|
local electron_url="https://github.com/electron/electron/releases/download/v${electron_ver}/electron-v${electron_ver}-linux-x64.zip"
|
||||||
|
log_info "Downloading Electron v${electron_ver} directly..."
|
||||||
|
local tmp_zip
|
||||||
|
tmp_zip=$(mktemp --suffix=.zip)
|
||||||
|
if wget --timeout=300 --tries=3 -O "$tmp_zip" "$electron_url"; then
|
||||||
|
command -v unzip &>/dev/null || sudo apt install -y unzip
|
||||||
|
chmod 644 "$tmp_zip"
|
||||||
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/node_modules/electron/" 2>/dev/null || true
|
||||||
|
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_DIR/node_modules/electron/dist"
|
||||||
|
sudo -u "$KIOSK_USER" unzip -o "$tmp_zip" -d "$KIOSK_DIR/node_modules/electron/dist/" || true
|
||||||
|
sudo -u "$KIOSK_USER" chmod +x "$electron_bin" || true
|
||||||
|
fi
|
||||||
|
rm -f "$tmp_zip"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||||
|
log_error "Electron binary download failed after all attempts."
|
||||||
|
log_error "Check your internet connection and try again."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Electron binary verified"
|
||||||
|
|
||||||
|
# chrome-sandbox MUST be owned by root and setuid, or Electron shows a blank screen.
|
||||||
|
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||||
|
if sudo -u "$KIOSK_USER" test -f "$sandbox"; then
|
||||||
|
sudo chown root:root "$sandbox"
|
||||||
|
sudo chmod 4755 "$sandbox"
|
||||||
|
log_success "Chrome sandbox permissions set (required for display)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# lib/menu.sh - Reusable numbered-menu framework + validated input helpers.
|
||||||
|
#
|
||||||
|
# Goal: menu *behavior* (numbering, "0 to exit/return", input validation)
|
||||||
|
# lives here once. Individual menus/*.sh files only supply their content
|
||||||
|
# (labels + handler functions) and never re-implement the loop/echo/case
|
||||||
|
# boilerplate that made the old single-file installer hard to change safely.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# my_menu_builder() {
|
||||||
|
# MENU_LABELS=("Do thing A" "Do thing B")
|
||||||
|
# MENU_HANDLERS=(action_a action_b)
|
||||||
|
# }
|
||||||
|
# run_menu "MY MENU TITLE" my_menu_builder [my_status_func]
|
||||||
|
#
|
||||||
|
# The builder runs fresh on every redraw, so labels/handlers can change
|
||||||
|
# based on current state (e.g. "no sites yet" vs "5 sites configured").
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Logging
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo "[INFO] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[ERROR] $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo "✓ $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo "⚠ $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared "true"/"false" -> "ON"/"OFF" label for status lines and menu
|
||||||
|
# entries showing a boolean setting's current value.
|
||||||
|
onoff() {
|
||||||
|
[[ "$1" == "true" ]] && echo "ON" || echo "OFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Current primary IP, or the literal "No IP" if there isn't one (e.g. no
|
||||||
|
# network yet). Callers that only care whether there's an address should
|
||||||
|
# still check for -n on top of this, since "No IP" is itself non-empty.
|
||||||
|
get_ip_address() {
|
||||||
|
local ip
|
||||||
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
if [[ -n "$ip" ]]; then
|
||||||
|
echo "$ip"
|
||||||
|
else
|
||||||
|
echo "No IP"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# "WireGuard: 10.x.x.x | Tailscale: 100.x.x.x" for whichever VPN clients
|
||||||
|
# are installed and connected, or "None" if none are.
|
||||||
|
get_vpn_ips() {
|
||||||
|
local vpn_info=""
|
||||||
|
|
||||||
|
if command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface; then
|
||||||
|
local wg_ip
|
||||||
|
wg_ip=$(sudo wg show all | grep "allowed ips" | head -1 | awk '{print $3}' | cut -d'/' -f1)
|
||||||
|
[[ -n "$wg_ip" ]] && vpn_info="${vpn_info}WireGuard: $wg_ip | "
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v tailscale &>/dev/null; then
|
||||||
|
local ts_ip
|
||||||
|
ts_ip=$(tailscale ip -4 2>/dev/null)
|
||||||
|
[[ -n "$ts_ip" ]] && vpn_info="${vpn_info}Tailscale: $ts_ip | "
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v netbird &>/dev/null; then
|
||||||
|
local nb_ip
|
||||||
|
nb_ip=$(netbird status 2>/dev/null | grep "NetBird IP:" | awk '{print $3}')
|
||||||
|
[[ -n "$nb_ip" ]] && vpn_info="${vpn_info}Netbird: $nb_ip | "
|
||||||
|
fi
|
||||||
|
|
||||||
|
vpn_info="${vpn_info% | }"
|
||||||
|
[[ -n "$vpn_info" ]] && echo "$vpn_info" || echo "None"
|
||||||
|
}
|
||||||
|
|
||||||
|
# enable_and_start_units UNIT [UNIT...]
|
||||||
|
# Reloads systemd and enables+starts the given unit(s) - services or
|
||||||
|
# timers - returning non-zero if enable or start fails (e.g. systemd/
|
||||||
|
# D-Bus unreachable, or a real failure on real hardware). Always call
|
||||||
|
# this from an `if`/`&&`/`||` context: this whole tool runs under
|
||||||
|
# set -e, so a bare, unguarded call whose last command fails would take
|
||||||
|
# down the entire session instead of just this one action.
|
||||||
|
enable_and_start_units() {
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
sudo systemctl enable "$@" 2>/dev/null && sudo systemctl start "$@" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
read -r -p "Press Enter to continue..."
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Validated input helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
validate_yes_no() {
|
||||||
|
local answer="$1"
|
||||||
|
case "${answer,,}" in
|
||||||
|
y|yes|yeah|yep|yup|sure|ok|okay) return 0 ;;
|
||||||
|
n|no|nope|nah) return 1 ;;
|
||||||
|
*) return 2 ;; # invalid
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_yes_no() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default="${2:-n}"
|
||||||
|
local answer
|
||||||
|
|
||||||
|
# Unattended first-boot install (iso/ - KIOSK_UNATTENDED=1 in
|
||||||
|
# /etc/kiosk-firstboot.conf): nobody is at the keyboard, so take the
|
||||||
|
# default every prompt already offers and echo it so the log still
|
||||||
|
# shows what was decided.
|
||||||
|
if [[ "${KIOSK_UNATTENDED:-0}" == "1" ]]; then
|
||||||
|
echo "$prompt (y/n) [$default]: $default (unattended)"
|
||||||
|
validate_yes_no "$default"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
read -r -p "$prompt (y/n) [$default]: " answer
|
||||||
|
answer="${answer:-$default}"
|
||||||
|
|
||||||
|
validate_yes_no "$answer"
|
||||||
|
local result=$?
|
||||||
|
|
||||||
|
if [[ $result -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
elif [[ $result -eq 1 ]]; then
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo "❌ Invalid input. Please enter 'y' for yes or 'n' for no"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_integer() {
|
||||||
|
local value="$1"
|
||||||
|
local min="${2:--2147483648}"
|
||||||
|
local max="${3:-2147483647}"
|
||||||
|
|
||||||
|
if [[ $value =~ ^-?[0-9]+$ ]]; then
|
||||||
|
if [[ $value -ge $min && $value -le $max ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_integer() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default="$2"
|
||||||
|
local min="${3:--2147483648}"
|
||||||
|
local max="${4:-2147483647}"
|
||||||
|
local value
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
read -r -p "$prompt [$default]: " value
|
||||||
|
value="${value:-$default}"
|
||||||
|
|
||||||
|
if validate_integer "$value" "$min" "$max"; then
|
||||||
|
echo "$value"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "❌ Invalid number. Please enter an integer between $min and $max" >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_time() {
|
||||||
|
local time="$1"
|
||||||
|
[[ $time =~ ^([0-1][0-9]|2[0-3]):([0-5][0-9])$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_time() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default="$2"
|
||||||
|
local time
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
read -r -p "$prompt [$default]: " time
|
||||||
|
time="${time:-$default}"
|
||||||
|
|
||||||
|
if validate_time "$time"; then
|
||||||
|
echo "$time"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "❌ Invalid time format. Please use HH:MM (00:00 to 23:59)" >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_url() {
|
||||||
|
local url="$1"
|
||||||
|
if [[ $url =~ ^(https?|file|data)://.*$ ]] || [[ $url =~ ^about: ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_url() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default="$2"
|
||||||
|
local url
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
read -r -p "$prompt [$default]: " url
|
||||||
|
url="${url:-$default}"
|
||||||
|
|
||||||
|
if validate_url "$url"; then
|
||||||
|
echo "$url"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "❌ Invalid URL. Must start with http://, https://, file://, data:, or about:" >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_text() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default="${2:-}"
|
||||||
|
local value
|
||||||
|
|
||||||
|
read -r -p "$prompt [$default]: " value
|
||||||
|
echo "${value:-$default}"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_menu_choice() {
|
||||||
|
local choice="$1"
|
||||||
|
local max="$2"
|
||||||
|
validate_integer "$choice" 0 "$max"
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_menu_choice() {
|
||||||
|
local max="$1"
|
||||||
|
local choice
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
read -r -p "Choose [0-$max]: " choice
|
||||||
|
|
||||||
|
if validate_menu_choice "$choice" "$max"; then
|
||||||
|
echo "$choice"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "❌ Invalid choice. Please enter a number between 0 and $max" >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Menu framework
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
print_menu_header() {
|
||||||
|
local title="$1"
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
printf " %s\n" "$title"
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_menu TITLE BUILDER_FUNC [STATUS_FUNC] [EXIT_LABEL]
|
||||||
|
#
|
||||||
|
# BUILDER_FUNC must set the globals MENU_LABELS and MENU_HANDLERS (parallel
|
||||||
|
# indexed arrays). It is called once per redraw, so it can reflect current
|
||||||
|
# state. STATUS_FUNC, if given, is called right after the header to print
|
||||||
|
# read-only context (current settings, current list, etc).
|
||||||
|
#
|
||||||
|
# Entries are auto-numbered 1..N. "0" always returns from run_menu - no
|
||||||
|
# menu file needs to hand-roll its own exit case.
|
||||||
|
#
|
||||||
|
# The handler is called as `handler "$choice"` (the 1-based number picked),
|
||||||
|
# so a data-driven list (e.g. a set of timezones) can share one handler
|
||||||
|
# instead of needing a distinct wrapper function per entry. Handlers that
|
||||||
|
# don't care can just ignore the argument.
|
||||||
|
run_menu() {
|
||||||
|
local title="$1"
|
||||||
|
local builder="$2"
|
||||||
|
local status_func="${3:-}"
|
||||||
|
local exit_label="${4:-Return}"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
clear
|
||||||
|
print_menu_header "$title"
|
||||||
|
|
||||||
|
if [[ -n "$status_func" ]]; then
|
||||||
|
# `|| true`: same reasoning as the handler call below - a
|
||||||
|
# status function's job is read-only display, and a
|
||||||
|
# legitimately failing command inside it (e.g. a pipeline
|
||||||
|
# whose grep matches nothing, which pipefail turns into a
|
||||||
|
# pipeline failure even though the actual last command
|
||||||
|
# succeeded) must not be allowed to kill the whole session
|
||||||
|
# over what should be, at worst, incomplete status text.
|
||||||
|
"$status_func" || true
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a MENU_LABELS=()
|
||||||
|
local -a MENU_HANDLERS=()
|
||||||
|
"$builder"
|
||||||
|
|
||||||
|
if [[ "${#MENU_LABELS[@]}" -eq 0 ]]; then
|
||||||
|
log_warning "Nothing to do here yet."
|
||||||
|
echo " 0. $exit_label"
|
||||||
|
echo
|
||||||
|
ask_menu_choice 0 >/dev/null
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local i=1
|
||||||
|
for label in "${MENU_LABELS[@]}"; do
|
||||||
|
printf " %2d. %s\n" "$i" "$label"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
echo " 0. $exit_label"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local choice
|
||||||
|
choice=$(ask_menu_choice "${#MENU_LABELS[@]}")
|
||||||
|
|
||||||
|
if [[ "$choice" == "0" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# `|| true`: this whole tool runs under `set -e`. A handler that
|
||||||
|
# legitimately fails (invalid input, a guard clause, etc) and
|
||||||
|
# returns non-zero as its last statement must not be allowed to
|
||||||
|
# take the entire session down - it should just redraw the menu.
|
||||||
|
# Absorbing that here means no menus/*.sh file has to think about
|
||||||
|
# set -e at all.
|
||||||
|
"${MENU_HANDLERS[$((choice - 1))]}" "$choice" || true
|
||||||
|
done
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# lib/provision.sh - First-time kiosk provisioning: turns a bare Ubuntu
|
||||||
|
# Server box into a working kiosk. This is the piece the modular tool
|
||||||
|
# never had - everything else in menus/*.sh only manages a kiosk that
|
||||||
|
# already exists.
|
||||||
|
#
|
||||||
|
# The legacy ubuntu-based-kiosk.sh did this in one ~4,000-line function
|
||||||
|
# (first_time_install) that mixed three different things together:
|
||||||
|
# 1. ~2,900 lines of embedded app source (main.js, preload.js, 4 HTML
|
||||||
|
# dialogs, package.json, start.sh), written via `sudo tee ... <<'EOF'`.
|
||||||
|
# 2. A few hundred more lines of embedded system scripts/units/configs
|
||||||
|
# (HDMI mirroring, audio routing, hotplug udev rules, power button
|
||||||
|
# handling, etc), written the same way.
|
||||||
|
# 3. The actual provisioning logic - roughly 1,000 lines once (1) and
|
||||||
|
# (2) are out of the way.
|
||||||
|
#
|
||||||
|
# Every one of those embedded files used a quoted heredoc delimiter
|
||||||
|
# (<<'EOF', not <<EOF) - meaning none of them did variable substitution
|
||||||
|
# at write time - so they've been extracted byte-for-byte into real
|
||||||
|
# files: the app source under kiosk-app/, everything else under
|
||||||
|
# provision/files/ (mirroring its real destination path, e.g.
|
||||||
|
# provision/files/etc/X11/xorg.conf.d/foo.conf -> /etc/X11/xorg.conf.d/foo.conf).
|
||||||
|
# This function copies them into place instead of re-embedding them, and
|
||||||
|
# calls straight into the Core Settings / Addons / Advanced menus this
|
||||||
|
# tool already has for configuration - sites, timezone, touch/nav
|
||||||
|
# settings, password protection, WiFi, schedules, emergency hotspot, and
|
||||||
|
# virtual consoles are NOT reimplemented a third time here.
|
||||||
|
#
|
||||||
|
# One known, deliberate limitation carried over unchanged: several of
|
||||||
|
# the extracted system scripts (start.sh, kiosk-hotplug.sh, the power
|
||||||
|
# button handler) hardcode the username "kiosk" rather than using
|
||||||
|
# $KIOSK_USER, exactly as the legacy heredocs did (quoted heredocs can't
|
||||||
|
# substitute at write time either way). Fine for the common case since
|
||||||
|
# $KIOSK_USER is virtually never overridden outside this project's own
|
||||||
|
# tests, but a real gap if someone ever does. Not fixed here - fixing it
|
||||||
|
# means moving those scripts off static templates onto generated ones,
|
||||||
|
# which is more risk than this pass should take on.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced
|
||||||
|
# first, and every menus/*.sh this calls into for configuration
|
||||||
|
# (including menus/addon_webui.sh, for provision_configure_webui below).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
PROVISION_FILES="$SCRIPT_DIR/provision/files"
|
||||||
|
KIOSK_APP_SRC="$SCRIPT_DIR/kiosk-app"
|
||||||
|
|
||||||
|
# provision_install_file SRC_REL DEST [MODE]
|
||||||
|
# Copies a template from provision/files/SRC_REL to the real system path
|
||||||
|
# DEST (creating parent directories as needed) as root, mode 644 unless
|
||||||
|
# overridden - pass 755 for scripts, 750 for the openbox autostart.
|
||||||
|
provision_install_file() {
|
||||||
|
local src_rel="$1" dest="$2" mode="${3:-644}"
|
||||||
|
sudo install -D -m "$mode" "$PROVISION_FILES/$src_rel" "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Package lists live in variables, not inline, so iso/offline/build-bundle.sh
|
||||||
|
# (the offline install ISO) can read the exact same lists to bundle - add a
|
||||||
|
# package here and the offline bundle picks it up on its next build.
|
||||||
|
PROVISION_APT_PACKAGES=(
|
||||||
|
xorg openbox lightdm unclutter screen curl git build-essential
|
||||||
|
ca-certificates gnupg lsb-release jq ufw x11-xserver-utils xinput
|
||||||
|
vainfo mesa-utils libgl1-mesa-dri libglx-mesa0 mesa-vulkan-drivers
|
||||||
|
libva2 libva-drm2 libva-x11-2 mesa-va-drivers
|
||||||
|
libegl-mesa0 libegl1-mesa-dev libgles2-mesa-dev
|
||||||
|
pipewire pipewire-pulse pipewire-alsa wireplumber pipewire-audio-client-libraries alsa-utils libnotify-bin
|
||||||
|
gstreamer1.0-pipewire libspa-0.2-bluetooth
|
||||||
|
systemd-timesyncd acpid xbindkeys xdotool python3-evdev unzip
|
||||||
|
net-tools ncdu evtest
|
||||||
|
# WiFi: a default Ubuntu Server install only has these if WiFi was set
|
||||||
|
# up in the installer - without them netplan can't join a WiFi network
|
||||||
|
# and Core Settings -> WiFi has nothing to scan with. In the base list
|
||||||
|
# so an offline install gets them (from the bundle) before that menu.
|
||||||
|
wpasupplicant iw
|
||||||
|
)
|
||||||
|
PROVISION_INTEL_APT_PACKAGES=(
|
||||||
|
intel-gpu-tools xserver-xorg-video-intel i965-va-driver intel-media-va-driver
|
||||||
|
)
|
||||||
|
|
||||||
|
provision_install_packages() {
|
||||||
|
echo "[1/10] Installing packages..."
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y "${PROVISION_APT_PACKAGES[@]}"
|
||||||
|
|
||||||
|
if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then
|
||||||
|
sudo apt install -y "${PROVISION_INTEL_APT_PACKAGES[@]}"
|
||||||
|
provision_install_file "etc/X11/xorg.conf.d/20-intel.conf" /etc/X11/xorg.conf.d/20-intel.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo systemctl enable systemd-timesyncd
|
||||||
|
sudo systemctl start systemd-timesyncd
|
||||||
|
log_success "Packages installed, NTP time sync enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_create_kiosk_user() {
|
||||||
|
echo "[2/10] Creating kiosk user..."
|
||||||
|
local kiosk_groups=(audio video input plugdev netdev)
|
||||||
|
# Not every Ubuntu release ships all of these - 26.04 Server has no
|
||||||
|
# netdev group - and useradd refuses to create the user at all if any
|
||||||
|
# -G group is missing. Create missing ones as plain system groups
|
||||||
|
# (no-op when they exist).
|
||||||
|
local g
|
||||||
|
for g in "${kiosk_groups[@]}"; do
|
||||||
|
sudo groupadd -f -r "$g"
|
||||||
|
done
|
||||||
|
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||||
|
sudo useradd -m -s /bin/bash -G "$(IFS=,; echo "${kiosk_groups[*]}")" "$KIOSK_USER"
|
||||||
|
echo "$KIOSK_USER:kiosk" | sudo chpasswd
|
||||||
|
log_success "Kiosk user created (default password: kiosk - change it)"
|
||||||
|
else
|
||||||
|
log_success "Kiosk user already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$KIOSK_DIR"
|
||||||
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME"
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/pipewire/pipewire.conf.d"
|
||||||
|
provision_install_file "pipewire/99-noise-cancellation.conf" \
|
||||||
|
"$KIOSK_HOME/.config/pipewire/pipewire.conf.d/99-noise-cancellation.conf"
|
||||||
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config"
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_install_nodejs() {
|
||||||
|
echo "[3/10] Installing Node.js..."
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
if offline_mode_active; then
|
||||||
|
# The bundle carries NodeSource's own nodejs package.
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
else
|
||||||
|
curl -fsSL --connect-timeout 20 --max-time 300 https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "Node.js: $(node -v)"
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_install_app() {
|
||||||
|
echo "[4/10] Installing kiosk app..."
|
||||||
|
# *.mjs: pdf-render.mjs (pdf.js only ships as a JavaScript module).
|
||||||
|
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.mjs "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||||
|
# Not `chown "$KIOSK_DIR"/*.js ...`: that glob is expanded by *this*
|
||||||
|
# (admin) shell before sudo runs, and the kiosk user's home is private
|
||||||
|
# (mode 750 on 26.04), so it can't be listed - the pattern stays literal
|
||||||
|
# and chown fails. find runs as root. Top level only: node_modules is
|
||||||
|
# already the kiosk user's, and chrome-sandbox inside it must stay root's.
|
||||||
|
sudo find "$KIOSK_DIR" -maxdepth 1 -type f -exec chown "$KIOSK_USER:$KIOSK_USER" {} +
|
||||||
|
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||||
|
|
||||||
|
if offline_mode_active; then
|
||||||
|
offline_install_node_modules kiosk-app "$KIOSK_DIR"
|
||||||
|
else
|
||||||
|
echo "Installing npm dependencies (Electron ~120MB - may take several minutes)..."
|
||||||
|
sudo -u "$KIOSK_USER" bash -lc "
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 30000
|
||||||
|
npm config set fetch-retry-maxtimeout 300000
|
||||||
|
"
|
||||||
|
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm"; then
|
||||||
|
log_error "npm install failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
electron_install_binary
|
||||||
|
}
|
||||||
|
|
||||||
|
# Not moved to lib/electron.sh or anywhere else - this is the one piece
|
||||||
|
# of first-time setup with no modular equivalent to call into, and
|
||||||
|
# nothing else needs it.
|
||||||
|
provision_configure_lightdm_autologin() {
|
||||||
|
sudo mkdir -p /etc/lightdm/lightdm.conf.d
|
||||||
|
# nopasswdlogin is checked by PAM on Ubuntu 24.04; autologin group for older versions
|
||||||
|
sudo groupadd -f nopasswdlogin
|
||||||
|
sudo groupadd -f autologin
|
||||||
|
sudo usermod -aG nopasswdlogin,autologin "$KIOSK_USER"
|
||||||
|
# [Seat:*] works on all LightDM versions; [SeatDefaults] is ignored on newer Ubuntu
|
||||||
|
sudo tee /etc/lightdm/lightdm.conf.d/10-kiosk.conf > /dev/null <<EOF
|
||||||
|
[Seat:*]
|
||||||
|
autologin-user=$KIOSK_USER
|
||||||
|
autologin-user-timeout=0
|
||||||
|
user-session=openbox
|
||||||
|
autologin-session=openbox
|
||||||
|
greeter-hide-users=true
|
||||||
|
greeter-show-manual-login=false
|
||||||
|
allow-guest=false
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_configure_display() {
|
||||||
|
echo "[5/10] Configuring display (LightDM/Openbox/touch/video)..."
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse"
|
||||||
|
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||||
|
|
||||||
|
# Touch screens always use libinput (proper multitouch for Chromium
|
||||||
|
# gestures) and virtual consoles start enabled - matches the choice
|
||||||
|
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
|
||||||
|
# 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
|
||||||
|
# Only on first install: this file is also where Advanced -> Virtual
|
||||||
|
# Consoles records the admin's choice, and Upgrade re-runs this step -
|
||||||
|
# overwriting it would silently turn consoles back off (or on).
|
||||||
|
if [[ ! -f /etc/X11/xorg.conf.d/10-serverflags.conf ]]; then
|
||||||
|
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
|
||||||
|
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
|
||||||
|
|
||||||
|
provision_install_file "openbox/autostart" "$KIOSK_HOME/.config/openbox/autostart" 750
|
||||||
|
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config/openbox/autostart"
|
||||||
|
sudo -u "$KIOSK_USER" touch "$KIOSK_HOME/.xbindkeysrc"
|
||||||
|
|
||||||
|
# HDMI hotplug: re-mirror any newly connected external display without
|
||||||
|
# waiting for the next login, triggered by udev on DRM "change" events.
|
||||||
|
provision_install_file "usr/local/bin/kiosk-hotplug.sh" /usr/local/bin/kiosk-hotplug.sh 755
|
||||||
|
provision_install_file "etc/systemd/system/kiosk-hotplug.service" /etc/systemd/system/kiosk-hotplug.service
|
||||||
|
provision_install_file "etc/udev/rules.d/99-kiosk-hotplug.rules" /etc/udev/rules.d/99-kiosk-hotplug.rules
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
|
||||||
|
provision_configure_lightdm_autologin
|
||||||
|
provision_install_wifi_helper
|
||||||
|
|
||||||
|
log_success "Display configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The kiosk app's WiFi screen (Ctrl+Alt+Super+W) - joining WiFi from
|
||||||
|
# the kiosk itself when the console is switched off. The app runs as the
|
||||||
|
# kiosk user; the netplan write is done by this one root helper, which
|
||||||
|
# the kiosk user may run through sudo and nothing else (it only does
|
||||||
|
# status/scan/connect - see its header). Here rather than its own
|
||||||
|
# provisioning step so Advanced -> Upgrade, which re-runs this function,
|
||||||
|
# installs it on an existing kiosk too. The sudoers rule is validated
|
||||||
|
# before it goes in place, like the Web UI's: a broken sudoers.d file
|
||||||
|
# breaks sudo for everyone.
|
||||||
|
provision_install_wifi_helper() {
|
||||||
|
provision_install_file "usr/local/bin/kiosk-wifi-helper" "$BIN_DIR/kiosk-wifi-helper" 755
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${BIN_DIR}/kiosk-wifi-helper" > "$tmp"
|
||||||
|
if sudo visudo -c -f "$tmp" &>/dev/null; then
|
||||||
|
sudo mkdir -p "$SUDOERS_D_DIR"
|
||||||
|
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi"
|
||||||
|
else
|
||||||
|
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
|
||||||
|
fi
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_configure_firewall() {
|
||||||
|
echo "[6/10] Configuring firewall..."
|
||||||
|
sudo ufw --force enable
|
||||||
|
sudo ufw default deny incoming
|
||||||
|
sudo ufw default allow outgoing
|
||||||
|
sudo ufw allow ssh
|
||||||
|
log_success "Firewall configured (SSH allowed, incoming denied by default)"
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_configure_power_management() {
|
||||||
|
echo "[7/10] Configuring power management..."
|
||||||
|
sudo mkdir -p /etc/polkit-1/localauthority/50-local.d
|
||||||
|
provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
|
||||||
|
|
||||||
|
provision_install_file "usr/local/bin/kiosk-volume-up" /usr/local/bin/kiosk-volume-up 755
|
||||||
|
provision_install_file "usr/local/bin/kiosk-volume-down" /usr/local/bin/kiosk-volume-down 755
|
||||||
|
|
||||||
|
provision_install_file "usr/local/bin/kiosk-power-button.sh" /usr/local/bin/kiosk-power-button.sh 755
|
||||||
|
# Backwards-compatible second location some older docs/scripts reference.
|
||||||
|
sudo cp /usr/local/bin/kiosk-power-button.sh "$KIOSK_HOME/trigger-power-menu.sh"
|
||||||
|
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/trigger-power-menu.sh"
|
||||||
|
provision_install_file "usr/local/bin/test-power-button" /usr/local/bin/test-power-button 755
|
||||||
|
|
||||||
|
sudo rm -f /etc/acpi/events/powerbtn* /etc/acpi/events/power* 2>/dev/null || true
|
||||||
|
provision_install_file "etc/acpi/events/kiosk-power-button" /etc/acpi/events/kiosk-power-button
|
||||||
|
provision_install_file "etc/acpi/events/kiosk-power-pbtn" /etc/acpi/events/kiosk-power-pbtn
|
||||||
|
provision_install_file "etc/acpi/events/kiosk-power-pwr" /etc/acpi/events/kiosk-power-pwr
|
||||||
|
|
||||||
|
sudo mkdir -p /etc/systemd/logind.conf.d
|
||||||
|
provision_install_file "etc/systemd/logind.conf.d/power-button.conf" /etc/systemd/logind.conf.d/power-button.conf
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart systemd-logind
|
||||||
|
sudo systemctl enable acpid
|
||||||
|
sudo systemctl restart acpid
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet acpid; then
|
||||||
|
log_success "Power button configured"
|
||||||
|
else
|
||||||
|
log_warning "acpid may not be running properly - check: sudo systemctl status acpid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installed by default now, not opt-in - reuses menus/addon_webui.sh's
|
||||||
|
# own install helpers directly rather than duplicating them, with a
|
||||||
|
# fixed default port and no prompt (first-time install already has
|
||||||
|
# plenty). See that file's header for the privilege model (a narrow
|
||||||
|
# allow-listed root helper, not the service itself running as root) and
|
||||||
|
# why this exists at all: browser-based config editing, and - via that
|
||||||
|
# helper - install/reconfigure for CUPS/LMS/Squeezelite/Asterisk
|
||||||
|
# Intercom and Update, none of which are reimplemented here.
|
||||||
|
provision_configure_webui() {
|
||||||
|
echo "[8/10] Installing Web UI..."
|
||||||
|
if ! webui_install_app_files; then
|
||||||
|
log_warning "Web UI install failed - configure later: Addons -> Web UI"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local port="8090"
|
||||||
|
webui_write_env_file "$port"
|
||||||
|
webui_write_unit_file
|
||||||
|
webui_write_helper_script
|
||||||
|
if ! webui_write_sudoers_file; then
|
||||||
|
log_warning "Web UI installed but its addon-install/Update helper could not be granted permission - configure later: Addons -> Web UI"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-webui; then
|
||||||
|
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||||
|
log_success "Web UI installed: http://$(get_ip_address):${port}"
|
||||||
|
else
|
||||||
|
log_warning "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuration from here on is NOT reimplemented - it's the exact same
|
||||||
|
# Core Settings / Advanced menus this tool already uses to manage a
|
||||||
|
# kiosk after install, called directly instead of duplicated.
|
||||||
|
provision_configure_kiosk_settings() {
|
||||||
|
echo "[9/10] Configuring kiosk settings..."
|
||||||
|
echo "Core Settings is next - sites, timezone, touch/navigation,"
|
||||||
|
echo "password protection, WiFi, and schedules. Skip and configure"
|
||||||
|
echo "later via ./install.sh if you'd rather do this after reboot."
|
||||||
|
echo
|
||||||
|
if [[ "${KIOSK_UNATTENDED:-0}" == "1" ]]; then
|
||||||
|
# The menu itself is interactive - no default to take. Sites etc
|
||||||
|
# are set afterwards via the Web UI or ./install.sh instead.
|
||||||
|
log_info "Unattended install - skipping Core Settings (configure later via the Web UI or ./install.sh)"
|
||||||
|
elif ask_yes_no "Configure Core Settings now?" "y"; then
|
||||||
|
core_settings_menu
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Emergency Hotspot auto-starts a WiFi hotspot if no internet is"
|
||||||
|
echo "detected after boot, so you can connect and reconfigure remotely."
|
||||||
|
if ask_yes_no "Configure emergency hotspot now?" "n"; then
|
||||||
|
action_configure_emergency_hotspot
|
||||||
|
else
|
||||||
|
log_info "Configure later: Advanced -> Emergency Hotspot"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${KIOSK_UNATTENDED:-0}" != "1" ]]; then
|
||||||
|
ssh_first_time_setup
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default."
|
||||||
|
if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then
|
||||||
|
action_disable_virtual_consoles
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_finish() {
|
||||||
|
echo "[10/10] Done."
|
||||||
|
echo
|
||||||
|
log_success "Core installation complete!"
|
||||||
|
echo
|
||||||
|
echo "Run ./install.sh again anytime to configure Core Settings, Addons"
|
||||||
|
echo "(CUPS, LMS/Squeezelite, Remote Access, Authelia, Asterisk Intercom),"
|
||||||
|
echo "or Advanced options."
|
||||||
|
echo
|
||||||
|
# Lets a caller (iso/firstboot/kiosk-firstboot) tell a finished
|
||||||
|
# install apart from an interrupted one - is_kiosk_installed is
|
||||||
|
# already true from step 4 on, and the reboot below can kill the
|
||||||
|
# caller before it sees this function return.
|
||||||
|
if [[ -n "${KIOSK_PROVISION_DONE_MARKER:-}" ]]; then
|
||||||
|
sudo install -D -m 644 /dev/null "$KIOSK_PROVISION_DONE_MARKER"
|
||||||
|
fi
|
||||||
|
if ask_yes_no "Reboot now to start the kiosk?" "y"; then
|
||||||
|
echo "Rebooting in 3 seconds..."
|
||||||
|
sleep 3
|
||||||
|
sudo reboot
|
||||||
|
else
|
||||||
|
log_warning "Remember to reboot before the kiosk will start: sudo reboot"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_first_time_install() {
|
||||||
|
clear
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo " Ubuntu Based Kiosk - First-Time Installation"
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
echo "This will install a HEADLESS KIOSK (no desktop environment):"
|
||||||
|
echo
|
||||||
|
echo "CORE:"
|
||||||
|
echo " - Kiosk user with auto-login"
|
||||||
|
echo " - LightDM + Openbox (minimal window manager)"
|
||||||
|
echo " - Electron browser"
|
||||||
|
echo " - Multi-site rotation with touch controls"
|
||||||
|
echo " - Hardware video acceleration"
|
||||||
|
echo " - Audio support (PipeWire)"
|
||||||
|
echo " - Time synchronization (NTP)"
|
||||||
|
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
|
||||||
|
echo " addon install/reconfigure and Update - no login of its own,"
|
||||||
|
echo " see Addons -> Web UI)"
|
||||||
|
echo
|
||||||
|
echo "OPTIONAL (configure after install, via Addons):"
|
||||||
|
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
||||||
|
echo " - CUPS printing"
|
||||||
|
echo " - Remote desktop (VNC), VPN (WireGuard/Tailscale/Netbird)"
|
||||||
|
echo " - Authelia auto-login, Asterisk Intercom"
|
||||||
|
echo
|
||||||
|
ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; return 1; }
|
||||||
|
|
||||||
|
# Cache sudo credentials upfront so they don't expire mid-install.
|
||||||
|
sudo -v
|
||||||
|
|
||||||
|
provision_install_packages
|
||||||
|
provision_create_kiosk_user
|
||||||
|
provision_install_nodejs
|
||||||
|
# Bare call, not `if ! provision_install_app; then ...`: testing a
|
||||||
|
# multi-statement function's result as an if-condition exempts
|
||||||
|
# everything *inside* that function from set -e for the duration -
|
||||||
|
# an early step failing (e.g. the `cp` before npm install even
|
||||||
|
# runs) would silently not stop the later steps. provision_install_app
|
||||||
|
# already reports its own npm-install failure via a guarded
|
||||||
|
# single-command `if`, which doesn't have this problem; letting its
|
||||||
|
# overall exit status propagate here as a bare statement preserves
|
||||||
|
# real fail-fast for every step in between.
|
||||||
|
provision_install_app
|
||||||
|
provision_configure_display
|
||||||
|
provision_configure_firewall
|
||||||
|
provision_configure_power_management
|
||||||
|
provision_configure_webui
|
||||||
|
provision_configure_kiosk_settings
|
||||||
|
provision_finish
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Generated by install.sh - contains passwords
|
||||||
|
.env
|
||||||
|
# Extra CA certificates dropped in for building behind a proxy
|
||||||
|
koha/certs/*.crt
|
||||||
|
quickadd/certs/*.crt
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Home library: Koha + Quick Add (Docker)
|
||||||
|
|
||||||
|
A self-hosted library catalogue you can set up in one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It installs Docker if it is missing, generates the passwords, builds the images,
|
||||||
|
starts everything and prints the addresses and your login. Running it again is safe;
|
||||||
|
it never overwrites your `.env` (passwords) or your data.
|
||||||
|
|
||||||
|
| What | Address | Login |
|
||||||
|
|---|---|---|
|
||||||
|
| **Catalogue** (public search, no login) | `http://<this machine>:8080` | none |
|
||||||
|
| **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password |
|
||||||
|
| **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login |
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## What it is made of
|
||||||
|
|
||||||
|
* **Koha**: the open-source library system, built from the Koha project's own Debian
|
||||||
|
packages (no third-party Koha images). Installed and configured automatically; there is
|
||||||
|
no web installer to click through.
|
||||||
|
* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added".
|
||||||
|
* **MariaDB**: the database.
|
||||||
|
|
||||||
|
Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes.
|
||||||
|
|
||||||
|
## Adding books
|
||||||
|
|
||||||
|
Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode
|
||||||
|
scanner works as a keyboard, so just scan.
|
||||||
|
|
||||||
|
1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot
|
||||||
|
be reached (many networks block port 210), **Open Library** over HTTPS is used instead.
|
||||||
|
2. If you already have the title, you are asked whether to add **another copy**.
|
||||||
|
3. Otherwise the record is added with a barcode (see **Barcodes** below), the Dewey (or LC) call
|
||||||
|
number from the record, the item type chosen at the top (Book, Movie, Music, Game) and the shelf
|
||||||
|
you picked.
|
||||||
|
4. Nothing found? A short form lets you type the title and it is added anyway.
|
||||||
|
5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers
|
||||||
|
are never reused, because a label may already be printed.
|
||||||
|
|
||||||
|
DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you
|
||||||
|
own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title
|
||||||
|
for many of them.
|
||||||
|
|
||||||
|
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
|
||||||
|
stack of books without looking at the screen.
|
||||||
|
|
||||||
|
### Scanning with a phone camera
|
||||||
|
|
||||||
|
Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
|
||||||
|
opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
|
||||||
|
typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
|
||||||
|
|
||||||
|
* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
|
||||||
|
the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
|
||||||
|
* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
|
||||||
|
* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
|
||||||
|
can't read the photo you are told, and nothing is added.
|
||||||
|
* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
|
||||||
|
Bluetooth scanner paired to the phone also works, since it types into the page.
|
||||||
|
|
||||||
|
## Barcodes: your choice
|
||||||
|
|
||||||
|
Barcodes are optional. Open **Barcodes and settings** at the bottom of the Add books page:
|
||||||
|
|
||||||
|
| Choice | What happens |
|
||||||
|
|---|---|
|
||||||
|
| **Number them automatically** (default) | Each new item gets the next number, zero-padded to 7 digits (`0000042`). |
|
||||||
|
| **I stick a pre-printed barcode on each one** | After the ISBN is recognised you scan the sticker on the book. A sticker that is already in use is refused. |
|
||||||
|
| **No barcodes for now** | Items are added without one; the catalogue still shows them. |
|
||||||
|
|
||||||
|
Later, in the same panel:
|
||||||
|
|
||||||
|
* **Give them barcodes** numbers every item that has none, in one go, and shows the range to print.
|
||||||
|
* **Print a sheet of barcodes ahead of time** reserves a block (say 80 numbers), and the link opens
|
||||||
|
Koha's label printer with that range filled in. Print the sheet, and stick the labels on whenever you like.
|
||||||
|
Reserved numbers are never handed out again.
|
||||||
|
|
||||||
|
Good to know:
|
||||||
|
|
||||||
|
* Koha's range printer needs both ends to be the same length, so barcodes are fixed-width digits. They are
|
||||||
|
**at least 7 digits** on purpose: 6-digit all-number Code 39 barcodes can be misread as the pharmacy
|
||||||
|
"Code 32" format by some scanners (checked with a decoder: 7 digits read back perfectly, 6 did not).
|
||||||
|
* Prefixes (such as `HOME-0042`) are not offered: Koha's range printer cannot print them.
|
||||||
|
* Barcode types Koha can print: Code 39 (default, read by every scanner), Code 39 with a Modulo-43 or
|
||||||
|
Modulo-10 check digit (needs a scanner set up for check digits), EAN-13 (exactly 12-13 digits),
|
||||||
|
COOP 2 of 5 and Industrial 2 of 5. Change it under *Cataloging → Label creator → Label layouts*.
|
||||||
|
* Koha's own automatic barcode is switched off so there is one numbering scheme. Items added in the staff
|
||||||
|
interface have no barcode until you press **Give them barcodes**.
|
||||||
|
|
||||||
|
## Printing labels
|
||||||
|
|
||||||
|
From the Add books page, **Print labels** opens Koha's label printer with the barcode range of what you
|
||||||
|
just added filled in (you may be asked to log in to the staff interface). Pick a template and layout
|
||||||
|
and print at 100% scale (not "fit to page") so the labels line up.
|
||||||
|
|
||||||
|
| Template | Sheet |
|
||||||
|
|---|---|
|
||||||
|
| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode |
|
||||||
|
| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only |
|
||||||
|
| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines |
|
||||||
|
|
||||||
|
| Layout | Prints |
|
||||||
|
|---|---|
|
||||||
|
| Spine + barcode | call number (split over lines) with the barcode below |
|
||||||
|
| Spine - call number | call number only |
|
||||||
|
| Barcode only | barcode |
|
||||||
|
|
||||||
|
## People and lending
|
||||||
|
|
||||||
|
Lending is set up for a home library: 3-week loans, up to 25 items, 3 renewals, **no fines**.
|
||||||
|
Change it under *Administration → Circulation and fines rules*.
|
||||||
|
|
||||||
|
1. **Add a person**: staff interface → *Patrons → New patron → Friend / borrower*. Only the name is
|
||||||
|
required; the **library card number is assigned automatically** (`1000001`, `1000002`, ...). Numbers
|
||||||
|
start well away from item barcodes so a scan can never mean both.
|
||||||
|
2. **Print the card**: *Tools → Patron card creator → New batch*, add the patron's number, *Export card
|
||||||
|
batch*, template **Avery 5371**, layout **Library card**. The card carries the name and a scannable barcode.
|
||||||
|
3. **Check out**: *Circulation → Check out*. Scan the person's card, then scan each book. The due date appears
|
||||||
|
on screen.
|
||||||
|
4. **Check in**: *Circulation → Check in*, scan the book.
|
||||||
|
|
||||||
|
A USB or Bluetooth scanner types the number and presses Enter, so it works in all of these boxes.
|
||||||
|
|
||||||
|
## Finding the books
|
||||||
|
|
||||||
|
The public catalogue searches title, author, subject and ISBN, and shows each copy's
|
||||||
|
availability, call number and **shelving location**.
|
||||||
|
|
||||||
|
On the Add books page, pick the **Shelf / room** before you scan; every book you add
|
||||||
|
gets it. The choice is saved on the server, so every device shares it. If all your books are in one
|
||||||
|
place, leave it on *(not set / all in one place)*; nothing is shown and nothing needs changing. Change it
|
||||||
|
whenever you move to another shelf.
|
||||||
|
The list comes from the staff interface (*Administration → Authorized values → LOC*), which
|
||||||
|
starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
|
||||||
|
for example "Living room - bookcase 2". (Koha has no way to add a room from the Add books page.)
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./install.sh --status # addresses, login, container health
|
||||||
|
sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept)
|
||||||
|
sudo ./install.sh --stop # stop everything
|
||||||
|
sudo ./install.sh --start # start again
|
||||||
|
sudo docker logs -f library-koha
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings live in `.env` (created on first run, mode 600). Useful ones:
|
||||||
|
|
||||||
|
| Setting | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access |
|
||||||
|
| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports |
|
||||||
|
| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries |
|
||||||
|
| `LIBRARY_NAME` | name shown on the catalogue (first install only) |
|
||||||
|
|
||||||
|
After editing `.env` run `sudo docker compose up -d`.
|
||||||
|
|
||||||
|
## Behind a proxy or on a locked-down network
|
||||||
|
|
||||||
|
* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`.
|
||||||
|
* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause.
|
||||||
|
* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Your books live in two Docker volumes: `library_db-data` (the catalogue) and
|
||||||
|
`library_koha-data` (search index, uploads). Back up the database with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
The search index is rebuilt automatically if it is lost.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
* The staff interface and Quick Add are protected by the generated password, over plain HTTP.
|
||||||
|
Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with
|
||||||
|
HTTPS in front.
|
||||||
|
* Quick Add uses the staff `admin` login to talk to Koha's API.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
install.sh one-command setup and --status/--update/--stop/--start
|
||||||
|
docker-compose.yml db + koha + quickadd
|
||||||
|
koha/ Dockerfile (Koha from the project's Debian repo), entrypoint,
|
||||||
|
bootstrap.pl (headless install), seed/ (item types, Z39.50
|
||||||
|
servers, label templates/layouts)
|
||||||
|
quickadd/ the scan-to-add service (Python standard library + yaz)
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: library
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
container_name: library-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
|
MARIADB_DATABASE: koha_library
|
||||||
|
MARIADB_USER: koha_library
|
||||||
|
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"]
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
koha:
|
||||||
|
build:
|
||||||
|
context: ./koha
|
||||||
|
args:
|
||||||
|
KOHA_SUITE: ${KOHA_SUITE:-stable}
|
||||||
|
image: library-koha:local
|
||||||
|
container_name: library-koha
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
KOHA_DB_HOST: db
|
||||||
|
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||||
|
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
KOHA_ADMIN_PASS: ${ADMIN_PASSWORD}
|
||||||
|
KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library}
|
||||||
|
KOHA_LIBRARY_CODE: ${LIBRARY_CODE:-HOME}
|
||||||
|
KOHA_SHELVES: ${SHELVES:-}
|
||||||
|
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
||||||
|
ports:
|
||||||
|
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
|
||||||
|
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
|
||||||
|
volumes:
|
||||||
|
- koha-data:/var/lib/koha
|
||||||
|
|
||||||
|
# Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API).
|
||||||
|
quickadd:
|
||||||
|
build:
|
||||||
|
context: ./quickadd
|
||||||
|
image: library-quickadd:local
|
||||||
|
container_name: library-quickadd
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
koha:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
KOHA_API: http://koha:8081/api/v1
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
||||||
|
OPAC_PORT: ${OPAC_PORT:-8080}
|
||||||
|
STAFF_PORT: ${STAFF_PORT:-8081}
|
||||||
|
OPAC_URL: ${OPAC_URL:-}
|
||||||
|
STAFF_URL: ${STAFF_URL:-}
|
||||||
|
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
|
||||||
|
ports:
|
||||||
|
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082"
|
||||||
|
volumes:
|
||||||
|
- quickadd-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
koha-data:
|
||||||
|
quickadd-data:
|
||||||
Executable
+168
@@ -0,0 +1,168 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian.
|
||||||
|
#
|
||||||
|
# sudo ./install.sh # install Docker if needed, build, start
|
||||||
|
# sudo ./install.sh --status # show URLs and container health
|
||||||
|
# sudo ./install.sh --update # rebuild with the latest Koha and restart
|
||||||
|
# sudo ./install.sh --stop | --start
|
||||||
|
#
|
||||||
|
# Optional environment variables:
|
||||||
|
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
|
||||||
|
# LIBRARY_CODE=HOME short code for the library (letters/digits)
|
||||||
|
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
|
||||||
|
# ITEM_TYPES="CD=Compact disc" extra item types (first install only)
|
||||||
|
# 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
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$HERE"
|
||||||
|
|
||||||
|
c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
|
||||||
|
c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; }
|
||||||
|
c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
|
||||||
|
c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; }
|
||||||
|
RUN_USER="${SUDO_USER:-root}"
|
||||||
|
|
||||||
|
# --- Docker -------------------------------------------------------------------
|
||||||
|
ensure_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||||
|
c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed"
|
||||||
|
else
|
||||||
|
c_info "Installing Docker (official get.docker.com script)..."
|
||||||
|
command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; }
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin
|
||||||
|
c_ok "Docker installed"
|
||||||
|
fi
|
||||||
|
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
|
if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then
|
||||||
|
usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)"
|
||||||
|
fi
|
||||||
|
docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
port_in_use() {
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
|
||||||
|
else # minimal systems without iproute2
|
||||||
|
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; }
|
||||||
|
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; }
|
||||||
|
lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||||
|
|
||||||
|
dc() { docker compose --project-directory "$HERE" "$@"; }
|
||||||
|
|
||||||
|
# Docker Hub rate-limits anonymous pulls (HTTP 429), which is common on shared
|
||||||
|
# IPs; retry with a growing pause instead of failing the whole install.
|
||||||
|
with_retry() {
|
||||||
|
local n=1 max=5 wait=15
|
||||||
|
until "$@"; do
|
||||||
|
[ "$n" -ge "$max" ] && { c_err "Failed after $max attempts: $*"; return 1; }
|
||||||
|
c_warn "Attempt $n failed (Docker Hub rate limit or network?) - retrying in ${wait}s..."
|
||||||
|
sleep "$wait"; n=$((n + 1)); wait=$((wait * 2))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
[ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; }
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./.env
|
||||||
|
local ip; ip="$(lan_ip)"; ip="${ip:-localhost}"
|
||||||
|
if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then
|
||||||
|
ip="localhost"
|
||||||
|
echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)"
|
||||||
|
fi
|
||||||
|
dc ps
|
||||||
|
echo
|
||||||
|
echo " Catalogue (public): http://$ip:$OPAC_PORT"
|
||||||
|
echo " Staff interface: http://$ip:$STAFF_PORT"
|
||||||
|
echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)"
|
||||||
|
echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--status) show_status; exit 0 ;;
|
||||||
|
--stop) dc stop; exit 0 ;;
|
||||||
|
--start) dc start; exit 0 ;;
|
||||||
|
--update)
|
||||||
|
ensure_docker
|
||||||
|
with_retry dc build --pull --no-cache koha
|
||||||
|
dc up -d
|
||||||
|
show_status; exit 0 ;;
|
||||||
|
"" ) ;;
|
||||||
|
*) c_err "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ensure_docker
|
||||||
|
|
||||||
|
# --- Settings (.env is created once and never overwritten) ------------------------
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
c_info "Generating settings and passwords..."
|
||||||
|
TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
|
||||||
|
umask 077
|
||||||
|
cat > .env << ENVEOF
|
||||||
|
# Generated by install.sh - keep this file private.
|
||||||
|
LIBRARY_NAME="${LIBRARY_NAME:-My Library}"
|
||||||
|
LIBRARY_CODE=${LIBRARY_CODE:-HOME}
|
||||||
|
SHELVES="${SHELVES:-}"
|
||||||
|
ITEM_TYPES="${ITEM_TYPES:-}"
|
||||||
|
TZ=${TZ_NOW}
|
||||||
|
OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
|
||||||
|
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
|
||||||
|
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
|
||||||
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=$(rand 16)
|
||||||
|
DB_ROOT_PASSWORD=$(rand 32)
|
||||||
|
DB_PASSWORD=$(rand 32)
|
||||||
|
KOHA_SUITE=stable
|
||||||
|
ENVEOF
|
||||||
|
umask 022
|
||||||
|
[ "$RUN_USER" = root ] || chown "$RUN_USER" .env
|
||||||
|
else
|
||||||
|
c_info "Re-using existing .env"
|
||||||
|
# Settings added in later versions: append them to an older .env.
|
||||||
|
if ! grep -q '^QUICKADD_PORT=' .env; then
|
||||||
|
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
|
||||||
|
fi
|
||||||
|
if ! grep -q '^BIND_ADDR=' .env; then
|
||||||
|
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${EXTRA_CA:-}" ]; then
|
||||||
|
cp "$EXTRA_CA" koha/certs/extra-ca.crt
|
||||||
|
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
|
||||||
|
c_info "Trusting extra CA during the build: $EXTRA_CA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Build and start ---------------------------------------------------------------
|
||||||
|
c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..."
|
||||||
|
with_retry dc build
|
||||||
|
c_info "Starting..."
|
||||||
|
with_retry dc up -d
|
||||||
|
|
||||||
|
# --- Wait until the site answers ---------------------------------------------------
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./.env
|
||||||
|
c_info "First start installs the database - waiting until Koha reports healthy..."
|
||||||
|
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)"
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
|
||||||
|
|
||||||
|
c_ok "Your library is running."
|
||||||
|
echo
|
||||||
|
show_status
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Koha (open-source library system) built from the Koha project's own Debian
|
||||||
|
# packages - no third-party Koha images involved.
|
||||||
|
#
|
||||||
|
# KOHA_SUITE stable (default) | oldstable | unstable
|
||||||
|
# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files;
|
||||||
|
# set "yes" if you want to install other languages later
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG KOHA_SUITE=stable
|
||||||
|
ARG KEEP_TRANSLATIONS=no
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
LANG=C.UTF-8
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata
|
||||||
|
|
||||||
|
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||||
|
# you are behind a TLS-inspecting proxy. Empty by default.
|
||||||
|
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
|
# Each step stands alone and fails the build on error: a swallowed failure here once produced a
|
||||||
|
# "successful" image with no Koha in it. memcached and cron are named explicitly rather than
|
||||||
|
# trusted to arrive as dependencies of koha-common.
|
||||||
|
RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \
|
||||||
|
> /etc/apt/sources.list.d/koha.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends koha-common mariadb-client memcached cron
|
||||||
|
|
||||||
|
RUN a2enmod rewrite headers proxy_http cgi deflate >/dev/null \
|
||||||
|
&& (a2dissite 000-default >/dev/null 2>&1 || true)
|
||||||
|
|
||||||
|
RUN if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /usr/share/doc/*
|
||||||
|
|
||||||
|
# Refuse to produce an image that cannot run.
|
||||||
|
RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-schema apache2ctl memcached cron mariadb zebrasrv; do \
|
||||||
|
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||||
|
done
|
||||||
|
|
||||||
|
COPY bootstrap.pl entrypoint.sh /opt/koha-init/
|
||||||
|
COPY seed/ /opt/koha-init/seed/
|
||||||
|
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||||
|
|
||||||
|
EXPOSE 80 8081
|
||||||
|
VOLUME ["/var/lib/koha"]
|
||||||
|
HEALTHCHECK --interval=10s --timeout=10s --start-period=300s --retries=6 \
|
||||||
|
CMD test -f /run/koha-ready && curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1
|
||||||
|
ENTRYPOINT ["/opt/koha-init/entrypoint.sh"]
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
# Headless replacement for Koha's web installer + onboarding wizard.
|
||||||
|
# Run as the instance user: koha-shell -c "perl bootstrap.pl" <instance>
|
||||||
|
#
|
||||||
|
# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a
|
||||||
|
# future Koha upgrade keeps working), then seeds a home-library setup.
|
||||||
|
use Modern::Perl;
|
||||||
|
use C4::Context;
|
||||||
|
use C4::Installer;
|
||||||
|
use Koha;
|
||||||
|
use Koha::Libraries;
|
||||||
|
use Koha::Patrons;
|
||||||
|
use Koha::Patron::Categories;
|
||||||
|
use Koha::CirculationRules;
|
||||||
|
use Koha::ItemTypes;
|
||||||
|
use Koha::AuthorisedValues;
|
||||||
|
|
||||||
|
$| = 1;
|
||||||
|
my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed';
|
||||||
|
my $admin_pass = $ENV{KOHA_ADMIN_PASS};
|
||||||
|
die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass;
|
||||||
|
my %env = (
|
||||||
|
admin_user => $ENV{KOHA_ADMIN_USER} || 'admin',
|
||||||
|
admin_pass => $admin_pass,
|
||||||
|
library => $ENV{KOHA_LIBRARY_NAME} || 'My Library',
|
||||||
|
code => uc( ( $ENV{KOHA_LIBRARY_CODE} || 'HOME' ) =~ s/[^A-Za-z0-9]//gr ) || 'HOME',
|
||||||
|
);
|
||||||
|
|
||||||
|
my $installer = C4::Installer->new();
|
||||||
|
my $dbh = C4::Context->dbh;
|
||||||
|
my $intranet = C4::Context->config('intranetdir');
|
||||||
|
my $data = "$intranet/installer/data/mysql";
|
||||||
|
|
||||||
|
# Already installed? (the entrypoint checks too; this is a safety net)
|
||||||
|
my ($tables) = $dbh->selectrow_array(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()");
|
||||||
|
if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; }
|
||||||
|
|
||||||
|
say "bootstrap: loading database schema...";
|
||||||
|
if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; }
|
||||||
|
|
||||||
|
$installer->set_marcflavour_syspref('marc21');
|
||||||
|
|
||||||
|
# Same ordering rules the web installer applies (sysprefs first, userflags last).
|
||||||
|
my @files = (
|
||||||
|
sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ),
|
||||||
|
sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ),
|
||||||
|
);
|
||||||
|
# Optional data worth having: matching rules, "Fast add" framework (a short
|
||||||
|
# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values.
|
||||||
|
for my $f (qw(
|
||||||
|
marcflavour/marc21/optional/marc21_default_matching_rules.yml
|
||||||
|
marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml
|
||||||
|
optional/marc21_holdings_coded_values.yml
|
||||||
|
optional/marc21_relatorterms.yml
|
||||||
|
optional/auth_val.yml
|
||||||
|
optional/csv_profiles.yml
|
||||||
|
)) {
|
||||||
|
push @files, "$data/en/$f" if -f "$data/en/$f";
|
||||||
|
}
|
||||||
|
|
||||||
|
say "bootstrap: loading ", scalar(@files), " framework / data files...";
|
||||||
|
my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files );
|
||||||
|
for my $lvl (@$list) {
|
||||||
|
for my $f ( @{ $lvl->{fwklist} } ) {
|
||||||
|
say " ! $f->{fwkname}: $f->{error}" if $f->{error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$installer->set_version_syspref();
|
||||||
|
$installer->set_languages_syspref('en');
|
||||||
|
|
||||||
|
# ---- Home-library seed data (YAML in the same format Koha ships) ----------
|
||||||
|
for my $y (sort glob("$seed_dir/*.yml")) {
|
||||||
|
my $err = $installer->load_sql($y);
|
||||||
|
say " seed $y: ", ( $err ? "ERROR $err" : 'ok' );
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Library, patron categories, owner account ----------------------------
|
||||||
|
Koha::Library->new({
|
||||||
|
branchcode => $env{code}, branchname => $env{library},
|
||||||
|
})->store unless Koha::Libraries->find( $env{code} );
|
||||||
|
|
||||||
|
for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) {
|
||||||
|
Koha::Patron::Category->new({
|
||||||
|
categorycode => $c->[0], description => $c->[1], category_type => $c->[2],
|
||||||
|
enrolmentperiod => 999, overduenoticerequired => 0,
|
||||||
|
})->store unless Koha::Patron::Categories->find( $c->[0] );
|
||||||
|
}
|
||||||
|
|
||||||
|
my $admin = Koha::Patron->new({
|
||||||
|
cardnumber => '1000000', surname => 'Librarian', userid => $env{admin_user},
|
||||||
|
branchcode => $env{code}, categorycode => 'OWNER', flags => 1, # 1 = superlibrarian
|
||||||
|
})->store;
|
||||||
|
$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 });
|
||||||
|
|
||||||
|
# ---- Make "add a book" need as few decisions as possible -------------------
|
||||||
|
# The item type (942$c) is mandatory in Koha's frameworks; default it to Book so
|
||||||
|
# a Z39.50 import can be saved without touching that field.
|
||||||
|
$dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK'
|
||||||
|
WHERE tagfield = '942' AND tagsubfield = 'c'});
|
||||||
|
|
||||||
|
# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework
|
||||||
|
# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it
|
||||||
|
# to the catalogue's `ean` column (used by the quick-add page's duplicate check).
|
||||||
|
$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean'
|
||||||
|
WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')});
|
||||||
|
|
||||||
|
# ---- Extra item types and shelves chosen at install time -------------------
|
||||||
|
# KOHA_ITEM_TYPES / KOHA_SHELVES: "CODE=Description;CODE=Description". Koha has no
|
||||||
|
# API for adding shelves later, so they are declared here (more can be added in
|
||||||
|
# Administration -> Authorized values -> LOC).
|
||||||
|
sub pairs {
|
||||||
|
my ($raw) = @_;
|
||||||
|
return map { my ( $c, $d ) = split /=/, $_, 2; $c =~ s/[^A-Za-z0-9_]//g; ( uc $c, ( $d // $c ) =~ s/^\s+|\s+$//gr ) }
|
||||||
|
grep { /\S/ } split /;/, ( $raw // '' );
|
||||||
|
}
|
||||||
|
{
|
||||||
|
my @t = pairs( $ENV{KOHA_ITEM_TYPES} );
|
||||||
|
while ( my ( $c, $d ) = splice @t, 0, 2 ) {
|
||||||
|
next unless length $c;
|
||||||
|
Koha::ItemType->new( { itemtype => substr( $c, 0, 10 ), description => $d } )->store
|
||||||
|
unless Koha::ItemTypes->find( substr( $c, 0, 10 ) );
|
||||||
|
}
|
||||||
|
my @s = pairs( $ENV{KOHA_SHELVES} );
|
||||||
|
while ( my ( $c, $d ) = splice @s, 0, 2 ) {
|
||||||
|
next unless length $c;
|
||||||
|
Koha::AuthorisedValue->new( { category => 'LOC', authorised_value => substr( $c, 0, 80 ), lib => $d, lib_opac => $d } )->store
|
||||||
|
unless Koha::AuthorisedValues->search( { category => 'LOC', authorised_value => substr( $c, 0, 80 ) } )->count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Lending rules --------------------------------------------------------
|
||||||
|
# A fresh Koha refuses every checkout ("No circulation rule is defined for this
|
||||||
|
# patron and itemtype combination") until a default rule exists. One catch-all
|
||||||
|
# rule for a home library: 3 weeks, 25 items, 3 renewals, no fines.
|
||||||
|
Koha::CirculationRules->set_rules({
|
||||||
|
branchcode => undef, categorycode => undef, itemtype => undef,
|
||||||
|
rules => {
|
||||||
|
issuelength => 21, lengthunit => 'days', maxissueqty => 25, maxonsiteissueqty => '',
|
||||||
|
renewalsallowed => 3, renewalperiod => 21, norenewalbefore => '', noautorenewalbefore => '',
|
||||||
|
auto_renew => 0, no_auto_renewal_after => '', no_auto_renewal_after_hard_limit => '',
|
||||||
|
unseen_renewals_allowed => '',
|
||||||
|
fine => 0, finedays => 0, firstremind => 0, chargeperiod => 0, chargeperiod_charge_at => 0,
|
||||||
|
overduefinescap => '', cap_fine_to_replacement_price => 0, rentaldiscount => 0,
|
||||||
|
maxsuspensiondays => '', suspension_chargeperiod => 1,
|
||||||
|
hardduedate => '', hardduedatecompare => -1,
|
||||||
|
reservesallowed => 5, holds_per_record => 1, holds_per_day => '', onshelfholds => 1,
|
||||||
|
opacitemholds => 'N', decreaseloanholds => '', article_requests => 'no',
|
||||||
|
recalls_allowed => undef, recalls_per_record => undef, on_shelf_recalls => undef,
|
||||||
|
recall_due_date_interval => undef, recall_overdue_fine => undef,
|
||||||
|
recall_shelf_time => undef, holds_pickup_period => undef,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
# ---- Preferences ----------------------------------------------------------
|
||||||
|
my %prefs = (
|
||||||
|
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
|
||||||
|
autoMemberNum => 1, # new patrons get the next library-card number automatically
|
||||||
|
finesMode => 'off', # a home library does not charge overdue fines
|
||||||
|
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
||||||
|
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
||||||
|
OpenLibraryCovers => 1, # cover images in the catalogue
|
||||||
|
OPACSearchForTitleIn => '',
|
||||||
|
BiblioDefaultView => 'normal',
|
||||||
|
OpacPublic => 1,
|
||||||
|
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
|
||||||
|
OpacMaintenance => 0,
|
||||||
|
ExpireReservesMaxPickUpDelay => 0,
|
||||||
|
AllowOfflineCirculation => 0,
|
||||||
|
marcflavour => 'MARC21',
|
||||||
|
LibraryName => $env{library},
|
||||||
|
timezone => $ENV{TZ} || 'UTC',
|
||||||
|
);
|
||||||
|
C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs;
|
||||||
|
|
||||||
|
say "bootstrap: done. Staff login: $env{admin_user}";
|
||||||
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Container entrypoint: (re)creates the Koha instance config from environment
|
||||||
|
# variables on every start, installs the database on the first start, then runs
|
||||||
|
# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INST="${KOHA_INSTANCE:-library}"
|
||||||
|
DB_HOST="${KOHA_DB_HOST:-db}"
|
||||||
|
DB_NAME="${KOHA_DB_NAME:-koha_library}"
|
||||||
|
DB_USER="${KOHA_DB_USER:-koha_library}"
|
||||||
|
: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}"
|
||||||
|
: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}"
|
||||||
|
STAFF_PORT=8081
|
||||||
|
export TZ="${TZ:-UTC}"
|
||||||
|
|
||||||
|
log() { echo "[koha-init] $*"; }
|
||||||
|
|
||||||
|
# Marker the container healthcheck waits for. Before the database exists Koha
|
||||||
|
# serves its web-installer page with HTTP 200, so "port answers" is not "ready".
|
||||||
|
rm -f /run/koha-ready
|
||||||
|
|
||||||
|
# --- 1. memcached (Koha caches sysprefs and sessions in it) -----------------
|
||||||
|
memcached -u memcache -m 64 -d
|
||||||
|
cron
|
||||||
|
|
||||||
|
# --- 2. wait for MariaDB ----------------------------------------------------
|
||||||
|
log "waiting for database at $DB_HOST..."
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break
|
||||||
|
[ "$i" = 120 ] && { log "database never became ready"; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 3. instance config (regenerated each time a container is created) ------
|
||||||
|
if [ ! -d "/etc/koha/sites/$INST" ]; then
|
||||||
|
log "creating Koha instance '$INST'..."
|
||||||
|
sed -i \
|
||||||
|
-e 's|^DOMAIN=.*|DOMAIN=""|' \
|
||||||
|
-e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \
|
||||||
|
-e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \
|
||||||
|
/etc/koha/koha-sites.conf
|
||||||
|
echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd
|
||||||
|
koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \
|
||||||
|
--memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true
|
||||||
|
|
||||||
|
# No RabbitMQ in this stack: workers fall back to polling the database.
|
||||||
|
CONF="/etc/koha/sites/$INST/koha-conf.xml"
|
||||||
|
perl -0pi -e 's|\s*<message_broker>.*?</message_broker>||s' "$CONF"
|
||||||
|
|
||||||
|
# Serve both interfaces through Plack (much faster than plain CGI).
|
||||||
|
SITE="/etc/apache2/sites-available/$INST.conf"
|
||||||
|
# The *-plack.conf files add the proxy rules on top of the plain ones (which
|
||||||
|
# still provide static files and rewrites), so enable them in addition.
|
||||||
|
sed -i \
|
||||||
|
-e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf| Include /etc/koha/apache-shared-opac-plack.conf|' \
|
||||||
|
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
||||||
|
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
||||||
|
|
||||||
|
# Run Apache itself as the instance user instead of mpm_itk's per-vhost
|
||||||
|
# uid switching (which needs extra capabilities and is pointless here:
|
||||||
|
# Plack does the real work as that user already).
|
||||||
|
# koha-create insists on mpm_itk being enabled, but inside a container it can
|
||||||
|
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
|
||||||
|
# page). Now that the instance exists, swap to plain prefork.
|
||||||
|
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
||||||
|
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
||||||
|
sed -i '/AssignUserID/d' "$SITE"
|
||||||
|
sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \
|
||||||
|
-e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars
|
||||||
|
grep -q "^Listen $STAFF_PORT\b" /etc/apache2/ports.conf || echo "Listen $STAFF_PORT" >> /etc/apache2/ports.conf
|
||||||
|
grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf
|
||||||
|
fi
|
||||||
|
chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- 4. install or upgrade the database -------------------------------------
|
||||||
|
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
|
||||||
|
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
|
||||||
|
if [ "$TABLES" = "0" ]; then
|
||||||
|
log "first start: installing Koha (about a minute)..."
|
||||||
|
# (not koha-shell -c: it wraps the command in single quotes of its own)
|
||||||
|
runuser -u "$INST-koha" -- env \
|
||||||
|
KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||||
|
KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \
|
||||||
|
KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" KOHA_LIBRARY_CODE="${KOHA_LIBRARY_CODE:-HOME}" \
|
||||||
|
KOHA_SHELVES="${KOHA_SHELVES:-}" KOHA_ITEM_TYPES="${KOHA_ITEM_TYPES:-}" TZ="$TZ" \
|
||||||
|
perl /opt/koha-init/bootstrap.pl
|
||||||
|
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
|
||||||
|
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
|
||||||
|
[ "$TABLES" -gt 0 ] || { log "ERROR: installation did not create the database"; exit 1; }
|
||||||
|
FIRST_RUN=1
|
||||||
|
else
|
||||||
|
log "existing database found - applying any pending upgrades..."
|
||||||
|
koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)"
|
||||||
|
FIRST_RUN=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. background services (their `daemon` processes die with the container,
|
||||||
|
# so they are started explicitly on every start) ------------------------
|
||||||
|
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
||||||
|
log "building search index..."
|
||||||
|
koha-rebuild-zebra -f -v "$INST" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
for svc in koha-zebra koha-indexer koha-worker koha-plack; do
|
||||||
|
"$svc" --restart "$INST" >/dev/null 2>&1 || "$svc" --start "$INST" >/dev/null 2>&1 \
|
||||||
|
|| log "WARNING: could not start $svc"
|
||||||
|
done
|
||||||
|
|
||||||
|
# koha-create / koha-plack may have started Apache already; run it in the foreground instead.
|
||||||
|
apache2ctl -k stop >/dev/null 2>&1 || true
|
||||||
|
for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done
|
||||||
|
rm -f /var/run/apache2/apache2.pid
|
||||||
|
|
||||||
|
touch /run/koha-ready
|
||||||
|
log "ready - library catalogue on :80, staff interface on :$STAFF_PORT"
|
||||||
|
exec apache2ctl -D FOREGROUND
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Item types for a home library: books, movies, music, games"
|
||||||
|
tables:
|
||||||
|
- itemtypes:
|
||||||
|
translatable: [ description ]
|
||||||
|
multiline: []
|
||||||
|
rows:
|
||||||
|
- { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" }
|
||||||
|
- { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" }
|
||||||
|
- { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" }
|
||||||
|
- { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" }
|
||||||
|
- { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Public Z39.50 servers used by Cataloging > Z39.50/SRU search"
|
||||||
|
tables:
|
||||||
|
- z3950servers:
|
||||||
|
translatable: [ servername ]
|
||||||
|
multiline: []
|
||||||
|
rows:
|
||||||
|
- { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Label sheets and layouts for Tools > Labels (spine labels and barcodes)"
|
||||||
|
tables:
|
||||||
|
- creator_templates:
|
||||||
|
translatable: [ template_desc ]
|
||||||
|
multiline: [ ]
|
||||||
|
rows:
|
||||||
|
# Avery 5160 / 8160 - the common 30-up address sheet; fits a spine label
|
||||||
|
# plus barcode on one label.
|
||||||
|
- template_code: "Avery 5160 | 1 x 2-5/8 (30 per sheet)"
|
||||||
|
template_desc: "3 columns, 10 rows. Spine label with barcode."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 2.63
|
||||||
|
label_height: 1
|
||||||
|
top_text_margin: 0.139
|
||||||
|
left_text_margin: 0.1
|
||||||
|
top_margin: 0.35
|
||||||
|
left_margin: 0.23
|
||||||
|
cols: 3
|
||||||
|
rows: 10
|
||||||
|
col_gap: 0.13
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
# Avery 5167 / 8167 - 80-up small labels, good for barcode-only stickers.
|
||||||
|
- template_code: "Avery 5167 | 1/2 x 1-3/4 (80 per sheet)"
|
||||||
|
template_desc: "4 columns, 20 rows. Barcode-only stickers."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 1.75
|
||||||
|
label_height: 0.5
|
||||||
|
top_text_margin: 0.1
|
||||||
|
left_text_margin: 0.05
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.3
|
||||||
|
cols: 4
|
||||||
|
rows: 20
|
||||||
|
col_gap: 0.3
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
# Demco 1" x 1.5" spine labels (Koha's own sample measurements).
|
||||||
|
- template_code: "Demco spine | 1 x 1.5 (50 per sheet)"
|
||||||
|
template_desc: "5 columns, 10 rows. Dewey / call number on up to four lines."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 1.5
|
||||||
|
label_height: 1
|
||||||
|
top_text_margin: 0.236
|
||||||
|
left_text_margin: 0
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.25
|
||||||
|
cols: 5
|
||||||
|
rows: 10
|
||||||
|
col_gap: 0.0625
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
- creator_layouts:
|
||||||
|
translatable: [ layout_name, layout_xml ]
|
||||||
|
multiline: [ layout_xml ]
|
||||||
|
rows:
|
||||||
|
# callnum_split splits "613.2 SMI" onto separate lines the way spine
|
||||||
|
# labels are normally read (Dewey/LC aware).
|
||||||
|
- layout_name: "Spine - call number"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BIB
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TB
|
||||||
|
font_size: 11
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 1
|
||||||
|
text_justify: C
|
||||||
|
format_string: "itemcallnumber"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt></opt>"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
- layout_name: "Spine + barcode"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BIBBAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TB
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 1
|
||||||
|
text_justify: C
|
||||||
|
format_string: "itemcallnumber"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt></opt>"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
- layout_name: "Barcode only"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TR
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 0
|
||||||
|
text_justify: L
|
||||||
|
format_string: "barcode"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt></opt>"
|
||||||
|
creator: "Labels"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Library card sheet and layout for Tools > Patron card creator"
|
||||||
|
tables:
|
||||||
|
- creator_templates:
|
||||||
|
translatable: [ template_desc ]
|
||||||
|
multiline: [ ]
|
||||||
|
rows:
|
||||||
|
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
|
||||||
|
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
|
||||||
|
template_desc: "Library cards: 2 columns, 5 rows."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 3.5
|
||||||
|
label_height: 2
|
||||||
|
top_text_margin: 0
|
||||||
|
left_text_margin: 0
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.75
|
||||||
|
cols: 2
|
||||||
|
rows: 5
|
||||||
|
col_gap: 0
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Patroncards"
|
||||||
|
|
||||||
|
- creator_layouts:
|
||||||
|
translatable: [ layout_name, layout_xml ]
|
||||||
|
multiline: [ layout_xml ]
|
||||||
|
rows:
|
||||||
|
# Name and library at the top, card number as a scannable barcode.
|
||||||
|
- layout_name: "Library card"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TR
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 0
|
||||||
|
text_justify: L
|
||||||
|
format_string: "barcode"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt page_side=\"F\" units=\"POINT\">"
|
||||||
|
- " <text><firstname> <surname></text>"
|
||||||
|
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
|
||||||
|
- " <text>Library card</text>"
|
||||||
|
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
|
||||||
|
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
|
||||||
|
- "</opt>"
|
||||||
|
creator: "Patroncards"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python
|
||||||
|
# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds
|
||||||
|
# one small layer instead of another base-image pull.
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||||
|
# you are behind a TLS-inspecting proxy. Empty by default.
|
||||||
|
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
|
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
|
||||||
|
|
||||||
|
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
||||||
|
&& mkdir -p /app /data && chown quickadd /data
|
||||||
|
COPY app.py index.html /app/
|
||||||
|
USER quickadd
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 8082
|
||||||
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)"
|
||||||
|
CMD ["python3", "/app/app.py"]
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Quick Add - scan an ISBN, the book is in the catalogue.
|
||||||
|
|
||||||
|
A tiny web app that sits next to Koha. For each ISBN/UPC it:
|
||||||
|
1. looks the item up (Z39.50 servers first, then Open Library over HTTPS),
|
||||||
|
2. checks Koha for an existing copy,
|
||||||
|
3. adds the record + an item (next free barcode) through Koha's REST API.
|
||||||
|
|
||||||
|
Standard library only; Z39.50 is done by shelling out to yaz-client.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
|
||||||
|
USER = os.environ.get("ADMIN_USER", "admin")
|
||||||
|
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||||
|
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
|
||||||
|
STAFF_PORT = os.environ.get("STAFF_PORT", "8081")
|
||||||
|
OPAC_PORT = os.environ.get("OPAC_PORT", "8080")
|
||||||
|
# Set these when the site is behind a reverse proxy (different hostnames than host:port).
|
||||||
|
OPAC_URL = os.environ.get("OPAC_URL", "").rstrip("/")
|
||||||
|
STAFF_URL = os.environ.get("STAFF_URL", "").rstrip("/")
|
||||||
|
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||||
|
PORT = int(os.environ.get("PORT", "8082"))
|
||||||
|
SOURCES = [s.strip() for s in os.environ.get(
|
||||||
|
"QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()]
|
||||||
|
Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12"))
|
||||||
|
DEFAULT_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"}
|
||||||
|
|
||||||
|
NS = {"m": "http://www.loc.gov/MARC21/slim"}
|
||||||
|
ET.register_namespace("", NS["m"])
|
||||||
|
RECENT_FILE = os.path.join(DATA_DIR, "recent.json")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- identifiers
|
||||||
|
def clean_code(raw):
|
||||||
|
code = re.sub(r"[\s-]", "", raw or "").upper()
|
||||||
|
return code if re.fullmatch(r"[0-9]{8,14}|[0-9]{9}[0-9X]", code) else None
|
||||||
|
|
||||||
|
|
||||||
|
def isbn10_to_13(i10):
|
||||||
|
body = "978" + i10[:9]
|
||||||
|
s = sum(int(c) * (1 if n % 2 == 0 else 3) for n, c in enumerate(body))
|
||||||
|
return body + str((10 - s % 10) % 10)
|
||||||
|
|
||||||
|
|
||||||
|
def isbn13_to_10(i13):
|
||||||
|
if not i13.startswith("978"):
|
||||||
|
return None
|
||||||
|
body = i13[3:12]
|
||||||
|
s = sum(int(c) * (10 - n) for n, c in enumerate(body))
|
||||||
|
chk = (11 - s % 11) % 11
|
||||||
|
return body + ("X" if chk == 10 else str(chk))
|
||||||
|
|
||||||
|
|
||||||
|
def is_isbn(code):
|
||||||
|
return len(code) == 10 or (len(code) == 13 and code[:3] in ("978", "979"))
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- Z39.50
|
||||||
|
def z3950_search(spec, code):
|
||||||
|
"""spec = host:port/db. Returns MARCXML string or None."""
|
||||||
|
host_port_db = spec
|
||||||
|
attrs = ["@attr 1=7"] if is_isbn(code) else ["@attr 1=1007"]
|
||||||
|
queries = [code]
|
||||||
|
if len(code) == 13 and is_isbn(code):
|
||||||
|
alt = isbn13_to_10(code)
|
||||||
|
if alt:
|
||||||
|
queries.append(alt)
|
||||||
|
for q in queries:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
dump = os.path.join(tmp, "rec.mrc")
|
||||||
|
script = "format usmarc\nfind %s %s\nshow 1\nquit\n" % (attrs[0], q)
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["yaz-client", "-m", dump, host_port_db], input=script,
|
||||||
|
capture_output=True, text=True, timeout=Z3950_TIMEOUT).stdout
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return None
|
||||||
|
m = re.search(r"Number of hits: (\d+)", out)
|
||||||
|
if not m or int(m.group(1)) == 0 or not os.path.exists(dump):
|
||||||
|
if "Connection refused" in out or "Connecting...error" in out or "Unable to" in out:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
raw = open(dump, "rb").read()
|
||||||
|
args = ["yaz-marcdump", "-o", "marcxml"]
|
||||||
|
if len(raw) > 9 and raw[9:10] == b" ": # MARC-8 -> UTF-8
|
||||||
|
args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"]
|
||||||
|
res = subprocess.run(args + [dump], capture_output=True, text=True, timeout=10)
|
||||||
|
if res.returncode == 0 and "<record" in res.stdout:
|
||||||
|
return res.stdout
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Open Library
|
||||||
|
def openlibrary_search(code):
|
||||||
|
if not is_isbn(code):
|
||||||
|
return None
|
||||||
|
url = ("https://openlibrary.org/api/books?bibkeys=ISBN:%s&format=json&jscmd=data"
|
||||||
|
% urllib.parse.quote(code))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "library-quickadd/1.0"}), timeout=10) as r:
|
||||||
|
data = json.load(r).get("ISBN:" + code)
|
||||||
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not data or not data.get("title"):
|
||||||
|
return None
|
||||||
|
rec = ET.Element("{%s}record" % NS["m"])
|
||||||
|
ET.SubElement(rec, "{%s}leader" % NS["m"]).text = "00000nam a2200000 a 4500"
|
||||||
|
|
||||||
|
def field(tag, subs, i1=" ", i2=" "):
|
||||||
|
subs = [(c, str(v)) for c, v in subs if v]
|
||||||
|
if not subs: # an empty datafield is invalid MARC
|
||||||
|
return
|
||||||
|
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=i2)
|
||||||
|
for c, v in subs:
|
||||||
|
ET.SubElement(df, "{%s}subfield" % NS["m"], code=c).text = v
|
||||||
|
|
||||||
|
field("020", [("a", code)])
|
||||||
|
dewey = (data.get("classifications") or {}).get("dewey_decimal_class") or []
|
||||||
|
if dewey:
|
||||||
|
field("082", [("a", dewey[0])], "0", "4")
|
||||||
|
lcc = (data.get("classifications") or {}).get("lc_classifications") or []
|
||||||
|
if lcc:
|
||||||
|
field("050", [("a", lcc[0])], " ", "4")
|
||||||
|
authors = data.get("authors") or []
|
||||||
|
if authors:
|
||||||
|
field("100", [("a", authors[0].get("name"))], "1")
|
||||||
|
title = data["title"] + (": " + data["subtitle"] if data.get("subtitle") else "")
|
||||||
|
field("245", [("a", title)], "1" if authors else "0", "0")
|
||||||
|
pubs = [p.get("name") for p in data.get("publishers") or []]
|
||||||
|
field("264", [("b", pubs[0] if pubs else None), ("c", data.get("publish_date"))], " ", "1")
|
||||||
|
if data.get("number_of_pages"):
|
||||||
|
field("300", [("a", "%s pages" % data["number_of_pages"])])
|
||||||
|
for subj in (data.get("subjects") or [])[:6]:
|
||||||
|
field("650", [("a", subj.get("name"))], " ", "0")
|
||||||
|
return ET.tostring(rec, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- MARC XML helpers
|
||||||
|
def parse_marc(xml_text):
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
rec = root if root.tag.endswith("record") else root.find(".//m:record", NS)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def sub(rec, tag, code):
|
||||||
|
for df in rec.findall("m:datafield[@tag='%s']" % tag, NS):
|
||||||
|
for sf in df.findall("m:subfield[@code='%s']" % code, NS):
|
||||||
|
if sf.text:
|
||||||
|
return sf.text.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def describe(rec):
|
||||||
|
title = " ".join(x for x in (sub(rec, "245", "a"), sub(rec, "245", "b")) if x).rstrip(" /:;,")
|
||||||
|
author = sub(rec, "100", "a").rstrip(",.") or sub(rec, "110", "a").rstrip(",.")
|
||||||
|
year = re.search(r"\d{4}", sub(rec, "264", "c") or sub(rec, "260", "c") or "")
|
||||||
|
return {"title": title or "(untitled)", "author": author, "year": year.group(0) if year else "",
|
||||||
|
"publisher": (sub(rec, "264", "b") or sub(rec, "260", "b")).rstrip(" ,:;")}
|
||||||
|
|
||||||
|
|
||||||
|
def call_number(rec, author):
|
||||||
|
dewey = sub(rec, "082", "a").replace("/", "")
|
||||||
|
if dewey:
|
||||||
|
cutter = sub(rec, "082", "b")
|
||||||
|
if not cutter and author:
|
||||||
|
cutter = re.sub(r"[^A-Za-z]", "", author.split(",")[0])[:3].upper()
|
||||||
|
return (dewey + " " + cutter).strip()
|
||||||
|
lc = sub(rec, "050", "a")
|
||||||
|
if lc:
|
||||||
|
return (lc + " " + sub(rec, "050", "b")).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def set_item_type(rec, itype):
|
||||||
|
for df in rec.findall("m:datafield[@tag='942']", NS):
|
||||||
|
rec.remove(df)
|
||||||
|
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag="942", ind1=" ", ind2=" ")
|
||||||
|
ET.SubElement(df, "{%s}subfield" % NS["m"], code="c").text = itype
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_identifier(rec, code):
|
||||||
|
"""Make sure the scanned code is on the record, so scanning it again is
|
||||||
|
recognised as a duplicate (a Z39.50 hit for a UPC often lacks the UPC)."""
|
||||||
|
wanted = {code}
|
||||||
|
if len(code) == 13 and code[:3] == "978":
|
||||||
|
wanted.add(isbn13_to_10(code) or code)
|
||||||
|
elif len(code) == 10:
|
||||||
|
wanted.add(isbn10_to_13(code))
|
||||||
|
for tag in ("020", "024"):
|
||||||
|
for df in rec.findall("m:datafield[@tag='%s']" % tag, NS):
|
||||||
|
for sf in df.findall("m:subfield[@code='a']", NS):
|
||||||
|
if re.sub(r"[^0-9X]", "", (sf.text or "").upper())[:13] in wanted:
|
||||||
|
return
|
||||||
|
tag, i1 = ("020", " ") if is_isbn(code) else ("024", "1")
|
||||||
|
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=" ")
|
||||||
|
ET.SubElement(df, "{%s}subfield" % NS["m"], code="a").text = code
|
||||||
|
|
||||||
|
|
||||||
|
def manual_record(code, title, author, year, publisher):
|
||||||
|
rec = ET.Element("{%s}record" % NS["m"])
|
||||||
|
ET.SubElement(rec, "{%s}leader" % NS["m"]).text = "00000nam a2200000 a 4500"
|
||||||
|
|
||||||
|
def field(tag, subs, i1=" ", i2=" "):
|
||||||
|
subs = [(c, str(v)) for c, v in subs if v]
|
||||||
|
if not subs: # an empty datafield is invalid MARC
|
||||||
|
return
|
||||||
|
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=i2)
|
||||||
|
for c, v in subs:
|
||||||
|
ET.SubElement(df, "{%s}subfield" % NS["m"], code=c).text = v
|
||||||
|
|
||||||
|
if code:
|
||||||
|
field("020" if is_isbn(code) else "024", [("a", code)], " " if is_isbn(code) else "1")
|
||||||
|
if author:
|
||||||
|
field("100", [("a", author)], "1")
|
||||||
|
field("245", [("a", title)], "1" if author else "0", "0")
|
||||||
|
field("264", [("b", publisher), ("c", year)], " ", "1")
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Koha API
|
||||||
|
class KohaError(Exception):
|
||||||
|
def __init__(self, status, body):
|
||||||
|
super().__init__("Koha API %s: %s" % (status, body[:200]))
|
||||||
|
self.status, self.body = status, body
|
||||||
|
|
||||||
|
|
||||||
|
def koha(method, path, body=None, ctype="application/json", headers=None, query=None):
|
||||||
|
url = KOHA_API + path + ("?" + urllib.parse.urlencode(query) if query else "")
|
||||||
|
data = body if isinstance(body, bytes) else (json.dumps(body).encode() if body is not None else None)
|
||||||
|
h = {"Authorization": "Basic " + base64.b64encode(("%s:%s" % (USER, PASSWORD)).encode()).decode(),
|
||||||
|
"Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
h["Content-Type"] = ctype
|
||||||
|
h.update(headers or {})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, data=data, method=method, headers=h), timeout=30) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise KohaError(e.code, e.read().decode("utf8", "replace"))
|
||||||
|
except (urllib.error.URLError, OSError) as e:
|
||||||
|
raise KohaError(503, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
_TYPE_CACHE = {"at": 0.0, "value": dict(DEFAULT_TYPES)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_item_types():
|
||||||
|
"""Item types as Koha has them (so types added at install or later in Koha
|
||||||
|
appear here), cached for 60 s; falls back to the built-in five."""
|
||||||
|
if time.time() - _TYPE_CACHE["at"] < 60:
|
||||||
|
return _TYPE_CACHE["value"]
|
||||||
|
try:
|
||||||
|
vals = koha("GET", "/item_types", query={"_per_page": "100"}) or []
|
||||||
|
found = {v["item_type_id"]: v.get("description") or v["item_type_id"] for v in vals}
|
||||||
|
if found:
|
||||||
|
_TYPE_CACHE["value"] = found
|
||||||
|
_TYPE_CACHE["at"] = time.time()
|
||||||
|
except KohaError:
|
||||||
|
pass
|
||||||
|
return _TYPE_CACHE["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def valid_type(code):
|
||||||
|
types = get_item_types()
|
||||||
|
return code if code in types else ("BOOK" if "BOOK" in types else next(iter(types)))
|
||||||
|
|
||||||
|
|
||||||
|
_LOC_CACHE = {"at": 0.0, "value": []}
|
||||||
|
|
||||||
|
|
||||||
|
def get_locations():
|
||||||
|
"""Shelving locations (Koha authorised-value category LOC), cached for 30 s."""
|
||||||
|
if time.time() - _LOC_CACHE["at"] < 30:
|
||||||
|
return _LOC_CACHE["value"]
|
||||||
|
try:
|
||||||
|
vals = koha("GET", "/authorised_value_categories/LOC/authorised_values") or []
|
||||||
|
_LOC_CACHE["value"] = [{"value": v["value"], "label": v.get("description") or v["value"]} for v in vals]
|
||||||
|
_LOC_CACHE["at"] = time.time()
|
||||||
|
except KohaError:
|
||||||
|
pass
|
||||||
|
return _LOC_CACHE["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def valid_location(code):
|
||||||
|
return code if code and any(l["value"] == code for l in get_locations()) else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing(code):
|
||||||
|
variants = {code}
|
||||||
|
if len(code) == 13:
|
||||||
|
alt = isbn13_to_10(code)
|
||||||
|
if alt:
|
||||||
|
variants.add(alt)
|
||||||
|
elif len(code) == 10:
|
||||||
|
variants.add(isbn10_to_13(code))
|
||||||
|
for v in variants:
|
||||||
|
q = {"-or": [{"isbn": {"-like": "%" + v + "%"}}, {"ean": {"-like": "%" + v + "%"}}]}
|
||||||
|
res = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "1"})
|
||||||
|
if res:
|
||||||
|
b = res[0]
|
||||||
|
items = koha("GET", "/biblios/%s/items" % b["biblio_id"]) or []
|
||||||
|
return {"biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"),
|
||||||
|
"author": b.get("author") or "", "copies": len(items)}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- settings
|
||||||
|
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||||
|
COUNTER_FILE = os.path.join(DATA_DIR, "counter.json")
|
||||||
|
MIN_WIDTH, MAX_WIDTH = 7, 10 # 6 all-digit Code 39 symbols can be misread as Code 32 by some scanners
|
||||||
|
DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": ""}
|
||||||
|
BARCODE_MODES = ("auto", "scan", "none")
|
||||||
|
LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings():
|
||||||
|
s = dict(DEFAULT_SETTINGS)
|
||||||
|
try:
|
||||||
|
s.update(json.load(open(SETTINGS_FILE)))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
s["width"] = min(max(int(s["width"]), MIN_WIDTH), MAX_WIDTH)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
s["width"] = MIN_WIDTH
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path, data):
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_settings(payload, current):
|
||||||
|
s = dict(current)
|
||||||
|
if payload.get("barcode_mode") in BARCODE_MODES:
|
||||||
|
s["barcode_mode"] = payload["barcode_mode"]
|
||||||
|
if "width" in payload:
|
||||||
|
try:
|
||||||
|
s["width"] = min(max(int(payload["width"]), MIN_WIDTH), MAX_WIDTH)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if "location" in payload:
|
||||||
|
loc = payload["location"] or ""
|
||||||
|
if loc == "" or valid_location(loc):
|
||||||
|
s["location"] = loc
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ barcodes
|
||||||
|
# Fixed-width digits ("000042"): Koha's "Print barcode range" needs both ends
|
||||||
|
# the same length, and it cannot take a prefix, so neither is offered.
|
||||||
|
def fmt_barcode(n, s):
|
||||||
|
return "%0*d" % (s["width"], n)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_max():
|
||||||
|
"""Highest all-digit barcode already in Koha (paged; only when needed)."""
|
||||||
|
mx, page = 0, 1
|
||||||
|
while True:
|
||||||
|
items = koha("GET", "/items", query={"_per_page": "100", "_page": str(page), "_order_by": "item_id"}) or []
|
||||||
|
for it in items:
|
||||||
|
bc = it.get("external_id") or ""
|
||||||
|
if bc.isdigit():
|
||||||
|
mx = max(mx, int(bc))
|
||||||
|
if len(items) < 100:
|
||||||
|
return mx
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
|
||||||
|
def read_counter():
|
||||||
|
try:
|
||||||
|
return int(json.load(open(COUNTER_FILE))["n"])
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
n = scan_max() # first use (or lost state): start after what exists
|
||||||
|
save_json(COUNTER_FILE, {"n": n})
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def peek_next():
|
||||||
|
with LOCK:
|
||||||
|
return read_counter() + 1
|
||||||
|
|
||||||
|
|
||||||
|
def reserve(count):
|
||||||
|
"""Reserve `count` consecutive numbers and return the first. Reserved
|
||||||
|
numbers are never handed out again - a label may already be printed."""
|
||||||
|
with LOCK:
|
||||||
|
n = read_counter()
|
||||||
|
save_json(COUNTER_FILE, {"n": n + count})
|
||||||
|
return n + 1
|
||||||
|
|
||||||
|
|
||||||
|
def add_item(biblio_id, itype, callnum, location, mode, scanned=None):
|
||||||
|
s = load_settings()
|
||||||
|
base = {"item_type_id": itype, "home_library_id": LIBRARY, "holding_library_id": LIBRARY,
|
||||||
|
"callnumber": callnum or None, "location": location or None}
|
||||||
|
path = "/biblios/%s/items" % biblio_id
|
||||||
|
if mode == "none":
|
||||||
|
return koha("POST", path, base)
|
||||||
|
if mode == "scan":
|
||||||
|
return koha("POST", path, dict(base, external_id=scanned))
|
||||||
|
for _ in range(25):
|
||||||
|
bc = fmt_barcode(reserve(1), s)
|
||||||
|
try:
|
||||||
|
return koha("POST", path, dict(base, external_id=bc))
|
||||||
|
except KohaError as e:
|
||||||
|
if e.status == 409 or "Duplicate" in e.body:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise KohaError(409, "could not find a free barcode")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- state
|
||||||
|
def load_recent():
|
||||||
|
try:
|
||||||
|
return json.load(open(RECENT_FILE))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def remember(entry):
|
||||||
|
with LOCK:
|
||||||
|
rec = load_recent()
|
||||||
|
rec.insert(0, entry)
|
||||||
|
try:
|
||||||
|
save_json(RECENT_FILE, rec[:300])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def forget(item_id):
|
||||||
|
with LOCK:
|
||||||
|
rec = [r for r in load_recent() if r.get("item_id") != item_id]
|
||||||
|
try:
|
||||||
|
save_json(RECENT_FILE, rec)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- operations
|
||||||
|
def lookup(code):
|
||||||
|
"""Try each configured source; returns (xml, source_name) or (None, None)."""
|
||||||
|
for src in SOURCES:
|
||||||
|
xml = None
|
||||||
|
if src.startswith("z3950:"):
|
||||||
|
xml = z3950_search(src[len("z3950:"):], code)
|
||||||
|
name = "Z39.50 " + src.split(":", 1)[1].split("/")[0].split(":")[0]
|
||||||
|
elif src == "openlibrary":
|
||||||
|
xml = openlibrary_search(code)
|
||||||
|
name = "Open Library"
|
||||||
|
if xml:
|
||||||
|
return xml, name
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
PENDING = {} # token -> (expires, plan): waiting for a scanned sticker
|
||||||
|
PENDING_TTL = 1800
|
||||||
|
|
||||||
|
|
||||||
|
def prune_pending():
|
||||||
|
now = time.time()
|
||||||
|
for k in [k for k, (exp, _) in PENDING.items() if exp < now]:
|
||||||
|
del PENDING[k]
|
||||||
|
|
||||||
|
|
||||||
|
def chosen_location(payload):
|
||||||
|
"""The shelf for this add: an explicit choice wins, else the saved default."""
|
||||||
|
if "location" in payload:
|
||||||
|
return valid_location(payload.get("location"))
|
||||||
|
return valid_location(load_settings().get("location"))
|
||||||
|
|
||||||
|
|
||||||
|
def make_plan(payload):
|
||||||
|
"""Look the code up. Returns ("done", http_status, body) or ("plan", plan)."""
|
||||||
|
code = clean_code(payload.get("code"))
|
||||||
|
itype = valid_type(payload.get("type"))
|
||||||
|
if not code:
|
||||||
|
return "done", 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."}
|
||||||
|
location = chosen_location(payload)
|
||||||
|
with LOCK:
|
||||||
|
existing = find_existing(code)
|
||||||
|
if existing and not payload.get("copy"):
|
||||||
|
return "done", 200, dict(existing, status="duplicate", code=code)
|
||||||
|
if existing:
|
||||||
|
return "plan", {"code": code, "itype": itype, "location": location, "existing": existing,
|
||||||
|
"xml": None, "title": existing["title"], "author": existing["author"],
|
||||||
|
"callnum": None, "source": "extra copy"}
|
||||||
|
xml, source = lookup(code)
|
||||||
|
if not xml:
|
||||||
|
return "done", 200, {"status": "notfound", "code": code}
|
||||||
|
rec = parse_marc(xml)
|
||||||
|
info = describe(rec)
|
||||||
|
ensure_identifier(rec, code)
|
||||||
|
set_item_type(rec, itype)
|
||||||
|
return "plan", {"code": code, "itype": itype, "location": location, "existing": None,
|
||||||
|
"xml": ET.tostring(rec, encoding="utf-8"), "title": info["title"], "author": info["author"],
|
||||||
|
"callnum": call_number(rec, info["author"]), "source": source}
|
||||||
|
|
||||||
|
|
||||||
|
def commit(plan, scanned=None):
|
||||||
|
mode = load_settings()["barcode_mode"]
|
||||||
|
with LOCK:
|
||||||
|
created = None
|
||||||
|
if plan["existing"]:
|
||||||
|
biblio_id = plan["existing"]["biblio_id"]
|
||||||
|
else:
|
||||||
|
bib = koha("POST", "/biblios", plan["xml"], "application/marcxml+xml", {"x-confirm-not-duplicate": "1"})
|
||||||
|
biblio_id = created = bib["id"]
|
||||||
|
try:
|
||||||
|
item = add_item(biblio_id, plan["itype"], plan["callnum"], plan["location"], mode, scanned)
|
||||||
|
except KohaError:
|
||||||
|
if created: # don't leave an item-less record behind
|
||||||
|
try:
|
||||||
|
koha("DELETE", "/biblios/%s" % created)
|
||||||
|
except KohaError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
entry = entry_for(plan["code"], plan["title"], plan["author"], item, biblio_id, plan["source"])
|
||||||
|
remember(entry)
|
||||||
|
return dict(entry, status="added")
|
||||||
|
|
||||||
|
|
||||||
|
def need_barcode(plan):
|
||||||
|
token = secrets.token_urlsafe(12)
|
||||||
|
prune_pending()
|
||||||
|
PENDING[token] = (time.time() + PENDING_TTL, plan)
|
||||||
|
return 200, {"status": "need_barcode", "token": token, "code": plan["code"],
|
||||||
|
"title": plan["title"], "author": plan["author"]}
|
||||||
|
|
||||||
|
|
||||||
|
def finish_or_ask(plan):
|
||||||
|
if load_settings()["barcode_mode"] == "scan":
|
||||||
|
return need_barcode(plan)
|
||||||
|
return 200, commit(plan)
|
||||||
|
|
||||||
|
|
||||||
|
def op_add(payload):
|
||||||
|
kind, *rest = make_plan(payload)
|
||||||
|
if kind == "done":
|
||||||
|
return rest[0], rest[1]
|
||||||
|
return finish_or_ask(rest[0])
|
||||||
|
|
||||||
|
|
||||||
|
def op_manual(payload):
|
||||||
|
title = (payload.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return 400, {"status": "invalid", "message": "A title is required."}
|
||||||
|
code = clean_code(payload.get("code"))
|
||||||
|
itype = valid_type(payload.get("type"))
|
||||||
|
rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120],
|
||||||
|
(payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120])
|
||||||
|
set_item_type(rec, itype)
|
||||||
|
plan = {"code": code or "", "itype": itype, "location": chosen_location(payload), "existing": None,
|
||||||
|
"xml": ET.tostring(rec, encoding="utf-8"), "title": title, "author": (payload.get("author") or "").strip(),
|
||||||
|
"callnum": None, "source": "typed in"}
|
||||||
|
return finish_or_ask(plan)
|
||||||
|
|
||||||
|
|
||||||
|
STICKER_RE = re.compile(r"[A-Za-z0-9.$/+% -]{1,32}")
|
||||||
|
|
||||||
|
|
||||||
|
def op_finish(payload):
|
||||||
|
"""Second step of 'scan a pre-printed sticker' mode."""
|
||||||
|
token = payload.get("token")
|
||||||
|
barcode = (payload.get("barcode") or "").strip()
|
||||||
|
prune_pending()
|
||||||
|
entry = PENDING.get(token)
|
||||||
|
if not entry:
|
||||||
|
return 200, {"status": "error", "message": "That one timed out - scan the ISBN again."}
|
||||||
|
if not STICKER_RE.fullmatch(barcode):
|
||||||
|
return 200, {"status": "barcode_invalid", "token": token, "message": "That barcode has characters Koha can't use."}
|
||||||
|
try:
|
||||||
|
result = commit(entry[1], barcode)
|
||||||
|
except KohaError as e:
|
||||||
|
if e.status == 409 or "Duplicate" in e.body:
|
||||||
|
return 200, {"status": "barcode_taken", "token": token, "barcode": barcode}
|
||||||
|
raise
|
||||||
|
PENDING.pop(token, None)
|
||||||
|
return 200, result
|
||||||
|
|
||||||
|
|
||||||
|
def op_cancel(payload):
|
||||||
|
PENDING.pop(payload.get("token"), None)
|
||||||
|
return 200, {"status": "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
def entry_for(code, title, author, item, biblio_id, source):
|
||||||
|
return {"time": int(time.time()), "code": code, "title": title, "author": author,
|
||||||
|
"barcode": item.get("external_id"), "callnumber": item.get("callnumber") or "",
|
||||||
|
"item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source,
|
||||||
|
"location": next((l["label"] for l in get_locations() if l["value"] == item.get("location")), "")}
|
||||||
|
|
||||||
|
|
||||||
|
def op_undo(payload):
|
||||||
|
item_id = payload.get("item_id")
|
||||||
|
biblio_id = payload.get("biblio_id")
|
||||||
|
if not isinstance(item_id, int) or not isinstance(biblio_id, int):
|
||||||
|
return 400, {"status": "invalid"}
|
||||||
|
with LOCK:
|
||||||
|
koha("DELETE", "/items/%d" % item_id)
|
||||||
|
remaining = koha("GET", "/biblios/%d/items" % biblio_id) or []
|
||||||
|
if not remaining: # drop the now-empty record too
|
||||||
|
try:
|
||||||
|
koha("DELETE", "/biblios/%d" % biblio_id)
|
||||||
|
except KohaError:
|
||||||
|
pass
|
||||||
|
forget(item_id)
|
||||||
|
return 200, {"status": "undone"}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- settings / tools
|
||||||
|
def items_without_barcode():
|
||||||
|
found, page = [], 1
|
||||||
|
while len(found) < 5000:
|
||||||
|
chunk = koha("GET", "/items", query={"q": json.dumps({"external_id": None}), "_per_page": "100",
|
||||||
|
"_page": str(page), "_order_by": "item_id"}) or []
|
||||||
|
found += chunk
|
||||||
|
if len(chunk) < 100:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def settings_view():
|
||||||
|
s = load_settings()
|
||||||
|
try:
|
||||||
|
missing = len(items_without_barcode())
|
||||||
|
except KohaError:
|
||||||
|
missing = None
|
||||||
|
return {"settings": s, "next": fmt_barcode(peek_next(), s) if s["barcode_mode"] == "auto" else None,
|
||||||
|
"without_barcode": missing, "locations": get_locations()}
|
||||||
|
|
||||||
|
|
||||||
|
def op_settings(payload):
|
||||||
|
with LOCK:
|
||||||
|
s = clean_settings(payload, load_settings())
|
||||||
|
save_json(SETTINGS_FILE, s)
|
||||||
|
return 200, settings_view()
|
||||||
|
|
||||||
|
|
||||||
|
def op_reserve(payload):
|
||||||
|
try:
|
||||||
|
count = int(payload.get("count"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 0
|
||||||
|
if not 1 <= count <= 2000:
|
||||||
|
return 400, {"status": "invalid", "message": "Choose between 1 and 2000."}
|
||||||
|
s = load_settings()
|
||||||
|
first = reserve(count)
|
||||||
|
return 200, {"status": "reserved", "count": count, "first": fmt_barcode(first, s),
|
||||||
|
"last": fmt_barcode(first + count - 1, s)}
|
||||||
|
|
||||||
|
|
||||||
|
def op_assign(payload):
|
||||||
|
"""Give a barcode to every item that has none."""
|
||||||
|
s = load_settings()
|
||||||
|
with LOCK:
|
||||||
|
items = items_without_barcode()
|
||||||
|
if not items:
|
||||||
|
return 200, {"status": "assigned", "count": 0}
|
||||||
|
first = reserve(len(items))
|
||||||
|
for i, it in enumerate(items):
|
||||||
|
koha("PUT", "/biblios/%s/items/%s" % (it["biblio_id"], it["item_id"]), {
|
||||||
|
"external_id": fmt_barcode(first + i, s), "item_type_id": it["item_type_id"],
|
||||||
|
"home_library_id": it["home_library_id"], "holding_library_id": it["holding_library_id"]})
|
||||||
|
return 200, {"status": "assigned", "count": len(items), "first": fmt_barcode(first, s),
|
||||||
|
"last": fmt_barcode(first + len(items) - 1, s)}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ photo scanning
|
||||||
|
MAX_IMAGE = 12 * 1024 * 1024
|
||||||
|
ZBAR_TIMEOUT = 20
|
||||||
|
|
||||||
|
|
||||||
|
def image_kind(data):
|
||||||
|
"""Only JPEG and PNG are accepted: the decoder goes through ImageMagick, which
|
||||||
|
has a long history of bugs in rarely used formats, so everything else is refused
|
||||||
|
before it gets anywhere near it."""
|
||||||
|
if data[:3] == b"\xff\xd8\xff":
|
||||||
|
return "jpg"
|
||||||
|
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
return "png"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_photo(data, kind):
|
||||||
|
"""Read barcodes from a photo with zbarimg. kind 'isbn' returns codes usable as an
|
||||||
|
ISBN/UPC (ISBNs first); kind 'sticker' returns anything Koha could store as a barcode."""
|
||||||
|
ext = image_kind(data)
|
||||||
|
if not ext:
|
||||||
|
raise ValueError("not a JPEG or PNG image")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "photo." + ext)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["zbarimg", "--raw", "-q", path], capture_output=True, text=True,
|
||||||
|
timeout=ZBAR_TIMEOUT).stdout
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return []
|
||||||
|
codes = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if kind == "sticker":
|
||||||
|
ok = line if STICKER_RE.fullmatch(line) else None
|
||||||
|
else:
|
||||||
|
ok = clean_code(line)
|
||||||
|
# zbar reports a UPC-A (12 digits) as a 13-digit EAN with a leading zero.
|
||||||
|
# Use the 12 digits printed on the item, as a keyboard scanner would type
|
||||||
|
# them, so lookups and duplicate checks match.
|
||||||
|
if ok and len(ok) == 13 and ok[0] == "0":
|
||||||
|
ok = ok[1:]
|
||||||
|
if ok and ok not in codes:
|
||||||
|
codes.append(ok)
|
||||||
|
if kind != "sticker": # book ISBNs (978/979) before UPCs and short codes
|
||||||
|
codes.sort(key=lambda c: 0 if (len(c) == 13 and c[:3] in ("978", "979")) else 1 if len(c) == 10 else 2)
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- HTTP
|
||||||
|
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "QuickAdd/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, code, body, ctype="application/json", extra=None):
|
||||||
|
if not isinstance(body, bytes):
|
||||||
|
body = json.dumps(body).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Content-Security-Policy",
|
||||||
|
"default-src 'self'; img-src 'self' https: data:; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||||
|
for k, v in (extra or {}).items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _authed(self):
|
||||||
|
if not PASSWORD:
|
||||||
|
return False
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
if header.startswith("Basic "):
|
||||||
|
try:
|
||||||
|
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
||||||
|
ok_u = hmac.compare_digest(u.encode(), USER.encode())
|
||||||
|
ok_p = hmac.compare_digest(p.encode(), PASSWORD.encode())
|
||||||
|
return ok_u and ok_p
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _guard(self):
|
||||||
|
if not self._authed():
|
||||||
|
self._send(401, b"Login required", "text/plain",
|
||||||
|
{"WWW-Authenticate": 'Basic realm="Library quick add"'})
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/healthz":
|
||||||
|
return self._send(200, b"ok", "text/plain")
|
||||||
|
if not self._guard():
|
||||||
|
return
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
if path == "/":
|
||||||
|
return self._send(200, INDEX, "text/html; charset=utf-8")
|
||||||
|
if path == "/api/state":
|
||||||
|
return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(),
|
||||||
|
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
||||||
|
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
||||||
|
if path == "/api/settings":
|
||||||
|
return self._send(200, settings_view())
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if not self._guard():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.path.split("?")[0] == "/api/scan":
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if not 0 < length <= MAX_IMAGE:
|
||||||
|
return self._send(413, {"status": "error", "message": "That photo is too large."})
|
||||||
|
kind = "sticker" if "kind=sticker" in self.path else "isbn"
|
||||||
|
try:
|
||||||
|
codes = decode_photo(self.rfile.read(length), kind)
|
||||||
|
except ValueError:
|
||||||
|
return self._send(415, {"status": "error", "message": "Please send a JPEG or PNG photo."})
|
||||||
|
return self._send(200, {"status": "ok", "codes": codes})
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length > 20000:
|
||||||
|
return self._send(413, {"error": "too large"})
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
ops = {"/api/add": op_add, "/api/manual": op_manual, "/api/undo": op_undo,
|
||||||
|
"/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings,
|
||||||
|
"/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign}
|
||||||
|
fn = ops.get(self.path)
|
||||||
|
if not fn:
|
||||||
|
return self._send(404, {"error": "not found"})
|
||||||
|
code, body = fn(payload)
|
||||||
|
self._send(code, body)
|
||||||
|
except KohaError as e:
|
||||||
|
msg = ("Koha is still starting up - try again in a minute." if e.status == 503
|
||||||
|
else "Koha refused that: %s" % e.body[:160])
|
||||||
|
self._send(502, {"status": "error", "message": msg})
|
||||||
|
except (ValueError, ET.ParseError):
|
||||||
|
self._send(400, {"status": "invalid", "message": "Bad request."})
|
||||||
|
except Exception as e: # keep the scanner loop alive whatever happens
|
||||||
|
self._send(500, {"status": "error", "message": "Unexpected error: %s" % e})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not PASSWORD:
|
||||||
|
raise SystemExit("ADMIN_PASSWORD is required")
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
print("quick add listening on :%d sources=%s" % (PORT, SOURCES), flush=True)
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Add to library</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
|
||||||
|
--accent: #2f6b4f; --accent-ink: #ffffff; --ok: #1f7a4a; --warn: #8a5a00; --bad: #a3321f;
|
||||||
|
--ok-bg: #e6f3ec; --warn-bg: #fbf1dc; --bad-bg: #f9e5e1; --focus: #1a5fb4;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #151a17; --card: #1e2521; --ink: #e8ece9; --muted: #a2ada6; --line: #333c36;
|
||||||
|
--accent: #5bb58a; --accent-ink: #0d1a13; --ok: #6fd3a0; --warn: #e8bd6a; --bad: #f08a78;
|
||||||
|
--ok-bg: #1c3226; --warn-bg: #352c15; --bad-bg: #3a2220; --focus: #7ab0ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
main { max-width: 760px; margin: 0 auto; padding: 16px; }
|
||||||
|
header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; justify-content: space-between; }
|
||||||
|
h1 { font-size: 1.35rem; margin: 8px 0; }
|
||||||
|
nav a { color: var(--accent); margin-right: 14px; text-decoration: none; font-weight: 600; }
|
||||||
|
nav a:hover { text-decoration: underline; }
|
||||||
|
.types { display: flex; gap: 6px; margin: 10px 0 14px; flex-wrap: wrap; }
|
||||||
|
.types label { cursor: pointer; }
|
||||||
|
.types input { position: absolute; opacity: 0; }
|
||||||
|
.types span { display: inline-block; padding: 8px 16px; border: 1px solid var(--line);
|
||||||
|
border-radius: 999px; background: var(--card); font-weight: 600; }
|
||||||
|
.types input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
||||||
|
.types input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.shelf { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
|
||||||
|
.shelf label { font-weight: 600; }
|
||||||
|
.shelf select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
background: var(--card); color: var(--ink); max-width: 100%; }
|
||||||
|
.scan { display: flex; gap: 8px; }
|
||||||
|
.scan input { flex: 1; font-size: 1.5rem; padding: 14px 16px; border: 2px solid var(--line);
|
||||||
|
border-radius: 12px; background: var(--card); color: var(--ink); min-width: 0; }
|
||||||
|
.scan input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||||
|
button { font: inherit; cursor: pointer; border-radius: 10px; border: 1px solid var(--line);
|
||||||
|
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
|
||||||
|
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
||||||
|
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
|
||||||
|
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
|
||||||
|
.card.ok { border-color: var(--ok); background: var(--ok-bg); }
|
||||||
|
.card.warn { border-color: var(--warn); background: var(--warn-bg); }
|
||||||
|
.card.bad { border-color: var(--bad); background: var(--bad-bg); }
|
||||||
|
.cover { width: 64px; height: 92px; border-radius: 4px; background: var(--line); flex: none; object-fit: cover; }
|
||||||
|
.cover[hidden] { display: none; }
|
||||||
|
.body { flex: 1; min-width: 0; }
|
||||||
|
.title { font-weight: 700; font-size: 1.1rem; overflow-wrap: anywhere; }
|
||||||
|
.meta { color: var(--muted); overflow-wrap: anywhere; }
|
||||||
|
.badge { display: inline-block; background: var(--ink); color: var(--bg); border-radius: 6px;
|
||||||
|
padding: 1px 9px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
form.manual { display: grid; gap: 8px; margin-top: 8px; }
|
||||||
|
form.manual input { padding: 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
background: var(--card); color: var(--ink); font: inherit; width: 100%; }
|
||||||
|
h2 { font-size: 1.05rem; margin: 24px 0 6px; }
|
||||||
|
ul.list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
ul.list li { display: flex; gap: 10px; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); }
|
||||||
|
ul.list .t { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
ul.list .u { color: var(--muted); font-size: .9rem; }
|
||||||
|
.labels { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 12px; margin-top: 14px; }
|
||||||
|
.small { color: var(--muted); font-size: .92rem; }
|
||||||
|
details.settings { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 14px; margin: 18px 0 0; }
|
||||||
|
details.settings summary { cursor: pointer; font-weight: 700; }
|
||||||
|
details.settings fieldset { border: 0; padding: 0; margin: 12px 0; }
|
||||||
|
details.settings legend { font-weight: 700; padding: 0; margin-bottom: 4px; }
|
||||||
|
details.settings label.opt { display: block; padding: 4px 0; }
|
||||||
|
details.settings .tool { border-top: 1px solid var(--line); padding: 12px 0 4px; }
|
||||||
|
details.settings select, details.settings input[type=number] { font: inherit; padding: 6px 8px; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; background: var(--card); color: var(--ink); }
|
||||||
|
details.settings input[type=number] { width: 6em; }
|
||||||
|
.result-note { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--ok-bg); border: 1px solid var(--ok); }
|
||||||
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>Add to library</h1>
|
||||||
|
<nav aria-label="Library links">
|
||||||
|
<a id="lnk-opac" href="#">Catalogue</a><a id="lnk-staff" href="#">Staff</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
|
||||||
|
<div class="shelf" id="shelfbox" hidden>
|
||||||
|
<label for="shelf">Shelf / room</label>
|
||||||
|
<select id="shelf"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="scan" id="scan" autocomplete="off">
|
||||||
|
<label class="sr" for="code">ISBN or barcode</label>
|
||||||
|
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN" autofocus>
|
||||||
|
<button class="primary" type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
<div class="cam-row">
|
||||||
|
<button type="button" id="cam">Scan with camera</button>
|
||||||
|
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
|
||||||
|
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
|
||||||
|
</div>
|
||||||
|
<div id="status" role="status" aria-live="polite"></div>
|
||||||
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<h2>Added this session <span id="count" class="small"></span></h2>
|
||||||
|
<ul class="list" id="session"></ul>
|
||||||
|
<div class="labels" id="labels" hidden>
|
||||||
|
<strong id="range"></strong>
|
||||||
|
<div class="small">Print barcode labels for these: the button opens the label printer with the range filled in
|
||||||
|
(you may be asked to log in to the staff interface).</div>
|
||||||
|
<div class="actions"><a id="lnk-labels" href="#" target="_blank" rel="noopener"><button type="button">Print labels</button></a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="settings" id="settings">
|
||||||
|
<summary>Barcodes and settings</summary>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Barcodes on new items</legend>
|
||||||
|
<label class="opt"><input type="radio" name="bcmode" value="auto"> Number them automatically</label>
|
||||||
|
<label class="opt"><input type="radio" name="bcmode" value="scan"> I stick a pre-printed barcode on each one: ask me to scan it</label>
|
||||||
|
<label class="opt"><input type="radio" name="bcmode" value="none"> No barcodes for now: I will add them later</label>
|
||||||
|
<div class="small" style="margin-top:6px">
|
||||||
|
Digits per barcode:
|
||||||
|
<select id="width"></select>
|
||||||
|
<span id="nextwrap"> Next barcode: <strong id="nextbc"></strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="small">Same-length numbers are what Koha's label printer needs for a range, so numbers are zero-padded (0000042). Seven or more digits keeps scanners from misreading a short all-number barcode as the pharmacy "Code 32" format.</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="tool">
|
||||||
|
<strong>Items without a barcode: <span id="missing">0</span></strong>
|
||||||
|
<div class="actions"><button type="button" id="assign">Give them barcodes</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="tool">
|
||||||
|
<strong>Print a sheet of barcodes ahead of time</strong>
|
||||||
|
<div class="small">Reserve a block of numbers, print the sheet, and stick the labels on whenever you like.</div>
|
||||||
|
<div class="actions"><label for="reserve-count" class="sr">How many</label>
|
||||||
|
<input type="number" id="reserve-count" min="1" max="2000" value="80">
|
||||||
|
<button type="button" id="reserve">Reserve numbers</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="tool-result" aria-live="polite"></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>Earlier</h2>
|
||||||
|
<ul class="list" id="earlier"></ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = '', awaiting = false, staffPort = '8081', settings = {}, type = localStorage.getItem('qa-type') || 'BOOK';
|
||||||
|
|
||||||
|
// --- small audible feedback so a stack of books can be scanned without looking
|
||||||
|
let actx;
|
||||||
|
function beep(freqs) {
|
||||||
|
try {
|
||||||
|
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
let t = actx.currentTime;
|
||||||
|
for (const f of freqs) {
|
||||||
|
const o = actx.createOscillator(), g = actx.createGain();
|
||||||
|
o.frequency.value = f; g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
|
||||||
|
o.connect(g); g.connect(actx.destination); o.start(t); o.stop(t + 0.17); t += 0.18;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
|
||||||
|
|
||||||
|
const el = (tag, props = {}, ...kids) => {
|
||||||
|
const n = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (k.includes('-')) n.setAttribute(k, v); // aria-*, data-*: attributes, not JS properties
|
||||||
|
else n[k] = v;
|
||||||
|
}
|
||||||
|
for (const k of kids) n.append(k);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body) } : {});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cover(code) {
|
||||||
|
const img = el('img', { className: 'cover', alt: '', hidden: true });
|
||||||
|
if (code && /^\d{10,13}$/.test(code)) {
|
||||||
|
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
|
||||||
|
img.src = 'https://covers.openlibrary.org/b/isbn/' + code + '-M.jpg?default=false';
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(kind, ...parts) { $('result').replaceChildren(el('div', { className: 'card ' + kind }, ...parts)); }
|
||||||
|
|
||||||
|
function bookBody(r, extra) {
|
||||||
|
return el('div', { className: 'body' },
|
||||||
|
el('div', { className: 'title' }, r.title || ''),
|
||||||
|
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber), r.location].filter(Boolean).join(' · ')),
|
||||||
|
...extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowFor(e, withUndo) {
|
||||||
|
const li = el('li', {},
|
||||||
|
el('span', { className: 'badge' }, e.barcode || '–'),
|
||||||
|
el('span', { className: 't' }, el('div', {}, e.title), el('div', { className: 'u' },
|
||||||
|
[e.author, e.callnumber, e.location, e.source].filter(Boolean).join(' · '))));
|
||||||
|
if (withUndo) {
|
||||||
|
const b = el('button', { type: 'button' }, 'Undo');
|
||||||
|
b.onclick = async () => {
|
||||||
|
b.disabled = true;
|
||||||
|
const r = await api('/api/undo', { item_id: e.item_id, biblio_id: e.biblio_id });
|
||||||
|
if (r.status === 'undone') {
|
||||||
|
sessionItems = sessionItems.filter(x => x.item_id !== e.item_id); render();
|
||||||
|
if (lastAdded === e.item_id) $('result').replaceChildren();
|
||||||
|
$('status').textContent = 'Removed "' + e.title + '".';
|
||||||
|
} else b.disabled = false;
|
||||||
|
};
|
||||||
|
li.append(b);
|
||||||
|
}
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
|
||||||
|
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
|
||||||
|
const bcs = sessionItems.map(e => e.barcode).filter(b => b && /^\d+$/.test(b));
|
||||||
|
$('labels').hidden = bcs.length === 0;
|
||||||
|
if (bcs.length) {
|
||||||
|
const sameLen = bcs.every(b => b.length === bcs[0].length);
|
||||||
|
const sorted = [...bcs].sort((a, b) => (a.length - b.length) || (a < b ? -1 : 1));
|
||||||
|
const lo = sorted[0], hi = sorted[sorted.length - 1];
|
||||||
|
$('range').textContent = lo === hi ? 'Barcode ' + lo : 'Barcodes ' + lo + ' to ' + hi;
|
||||||
|
$('lnk-labels').href = sameLen ? printLink(lo, hi) : $('lnk-labels').href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(code, opts = {}) {
|
||||||
|
if (busy) return; busy = true;
|
||||||
|
$('status').textContent = 'Looking up ' + code + '…';
|
||||||
|
try {
|
||||||
|
const r = await api('/api/add', Object.assign({ code, type }, opts));
|
||||||
|
handle(r, code);
|
||||||
|
} catch (e) {
|
||||||
|
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
|
||||||
|
} finally { busy = false; if (!awaiting) $('code').focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(r, code) {
|
||||||
|
$('status').textContent = '';
|
||||||
|
if (r.status === 'added') {
|
||||||
|
beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render();
|
||||||
|
awaiting = false; $('code').focus();
|
||||||
|
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' },
|
||||||
|
r.barcode ? 'Added as barcode ' : 'Added (no barcode yet)',
|
||||||
|
r.barcode ? el('span', { className: 'badge' }, r.barcode) : '',
|
||||||
|
(r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))]));
|
||||||
|
} else if (r.status === 'duplicate') {
|
||||||
|
beep(SOUND.duplicate);
|
||||||
|
const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy');
|
||||||
|
more.onclick = () => add(code, { copy: true });
|
||||||
|
const skip = el('button', { type: 'button' }, 'Skip');
|
||||||
|
skip.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
|
||||||
|
show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' },
|
||||||
|
'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'),
|
||||||
|
el('div', { className: 'actions' }, more, skip)]));
|
||||||
|
} else if (r.status === 'need_barcode' || r.status === 'barcode_taken' || r.status === 'barcode_invalid') {
|
||||||
|
stickerPrompt(r);
|
||||||
|
} else if (r.status === 'notfound') {
|
||||||
|
beep(SOUND.notfound); manualForm(code);
|
||||||
|
} else {
|
||||||
|
beep(SOUND.error);
|
||||||
|
show('bad', el('div', { className: 'body' }, r.message || 'Something went wrong.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- phone camera: take a photo, shrink it, have the server read the barcode
|
||||||
|
async function shrink(file, max = 2400) {
|
||||||
|
let bmp;
|
||||||
|
try { bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
|
||||||
|
catch (e) { bmp = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }); }
|
||||||
|
const w = bmp.width, h = bmp.height, k = Math.min(1, max / Math.max(w, h));
|
||||||
|
const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(h * k);
|
||||||
|
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
|
||||||
|
return new Promise(ok => c.toBlob(ok, 'image/jpeg', 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
|
||||||
|
function takePhoto(kind) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const input = $('camfile');
|
||||||
|
input.onchange = async () => {
|
||||||
|
const f = input.files[0]; input.value = '';
|
||||||
|
if (!f) return resolve(null);
|
||||||
|
$('status').textContent = 'Reading the barcode…';
|
||||||
|
try {
|
||||||
|
const blob = await shrink(f);
|
||||||
|
const r = await fetch('/api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
|
||||||
|
resolve(r.ok ? (await r.json()).codes : []);
|
||||||
|
} catch (e) { resolve([]); }
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function noBarcode() {
|
||||||
|
beep(SOUND.notfound);
|
||||||
|
$('status').textContent = '';
|
||||||
|
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, "Couldn't read a barcode from that photo"),
|
||||||
|
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$('cam').onclick = async () => {
|
||||||
|
const codes = await takePhoto('isbn');
|
||||||
|
if (codes === null) { $('status').textContent = ''; return; }
|
||||||
|
if (!codes.length) return noBarcode();
|
||||||
|
add(codes[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
function stickerPrompt(r) {
|
||||||
|
awaiting = true;
|
||||||
|
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
|
||||||
|
const form = el('form', { className: 'manual' });
|
||||||
|
const cancel = el('button', { type: 'button' }, 'Cancel');
|
||||||
|
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
|
||||||
|
$('result').replaceChildren(); $('code').focus(); };
|
||||||
|
const camBtn = el('button', { type: 'button' }, 'Scan sticker with camera');
|
||||||
|
camBtn.onclick = async () => {
|
||||||
|
const codes = await takePhoto('sticker');
|
||||||
|
if (codes === null) return;
|
||||||
|
if (!codes.length) { beep(SOUND.notfound); $('status').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
||||||
|
handle(await api('/api/finish', { token: r.token, barcode: codes[0] }), r.code);
|
||||||
|
};
|
||||||
|
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), camBtn, cancel));
|
||||||
|
form.onsubmit = async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const bc = input.value.trim(); if (!bc) return;
|
||||||
|
handle(await api('/api/finish', { token: r.token, barcode: bc }), r.code);
|
||||||
|
};
|
||||||
|
const msg = r.status === 'barcode_taken' ? 'Barcode ' + r.barcode + ' is already on another item. Scan a different sticker.'
|
||||||
|
: r.status === 'barcode_invalid' ? (r.message || 'That barcode cannot be used.')
|
||||||
|
: 'Now scan the barcode sticker you put on this one.';
|
||||||
|
if (r.status !== 'need_barcode') beep(SOUND.duplicate);
|
||||||
|
show('warn', bookBody(r, [el('div', { className: 'meta' }, msg), form]));
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
let staffBase = '';
|
||||||
|
const staffRoot = () => staffBase || ('http://' + location.hostname + ':' + staffPort);
|
||||||
|
const printLink = (from, to) => staffRoot() +
|
||||||
|
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
|
||||||
|
|
||||||
|
function rangeNote(text, from, to) {
|
||||||
|
const note = el('div', { className: 'result-note' }, text + ' ');
|
||||||
|
if (from) {
|
||||||
|
const a = el('a', { href: printLink(from, to), target: '_blank', rel: 'noopener' }, 'Print these labels');
|
||||||
|
note.append(a, el('div', { className: 'small' }, 'Pick a template and layout on the next screen (you will be asked to log in to the staff interface).'));
|
||||||
|
}
|
||||||
|
$('tool-result').replaceChildren(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(v) {
|
||||||
|
settings = v.settings;
|
||||||
|
document.querySelectorAll('input[name=bcmode]').forEach(i => { i.checked = i.value === settings.barcode_mode; });
|
||||||
|
const w = $('width');
|
||||||
|
if (!w.options.length) for (let n = 7; n <= 10; n++) w.append(el('option', { value: n }, String(n)));
|
||||||
|
w.value = settings.width;
|
||||||
|
$('nextwrap').hidden = settings.barcode_mode !== 'auto';
|
||||||
|
$('nextbc').textContent = v.next || '';
|
||||||
|
$('missing').textContent = v.without_barcode == null ? '?' : v.without_barcode;
|
||||||
|
$('assign').disabled = !v.without_barcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(patch) { applySettings(await api('/api/settings', patch)); }
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name=bcmode]').forEach(i => i.onchange = () => saveSettings({ barcode_mode: i.value }));
|
||||||
|
$('width').onchange = () => saveSettings({ width: parseInt($('width').value, 10) });
|
||||||
|
$('assign').onclick = async () => {
|
||||||
|
$('assign').disabled = true;
|
||||||
|
const r = await api('/api/barcodes/assign', {});
|
||||||
|
rangeNote(r.count ? r.count + ' item(s) now have barcodes ' + r.first + ' to ' + r.last + '.' : 'Every item already has a barcode.', r.first, r.last);
|
||||||
|
saveSettings({});
|
||||||
|
};
|
||||||
|
$('reserve').onclick = async () => {
|
||||||
|
const r = await api('/api/barcodes/reserve', { count: parseInt($('reserve-count').value, 10) });
|
||||||
|
if (r.status === 'reserved') { rangeNote('Reserved ' + r.first + ' to ' + r.last + ' (' + r.count + ' labels).', r.first, r.last); saveSettings({}); }
|
||||||
|
else rangeNote(r.message || 'Could not reserve that many.');
|
||||||
|
};
|
||||||
|
|
||||||
|
function manualForm(code) {
|
||||||
|
const f = el('form', { className: 'manual' });
|
||||||
|
const field = (name, ph, req) => el('input', { name, placeholder: ph, required: !!req, 'aria-label': ph });
|
||||||
|
const submit = el('button', { className: 'primary', type: 'submit' }, 'Add it anyway');
|
||||||
|
const cancel = el('button', { type: 'button' }, 'Cancel');
|
||||||
|
cancel.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
|
||||||
|
f.append(field('title', 'Title', true), field('author', 'Author'), field('year', 'Year'),
|
||||||
|
field('publisher', 'Publisher'), el('div', { className: 'actions' }, submit, cancel));
|
||||||
|
f.onsubmit = async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const d = Object.fromEntries(new FormData(f));
|
||||||
|
handle(await api('/api/manual', Object.assign(d, { code, type })), code);
|
||||||
|
};
|
||||||
|
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Not found: ' + code),
|
||||||
|
el('div', { className: 'meta' }, 'None of the catalogues knew this one. Type the details and it is added anyway.'), f));
|
||||||
|
f.querySelector('input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('scan').onsubmit = ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const code = $('code').value.trim(); $('code').value = '';
|
||||||
|
if (code) add(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeButtons() {
|
||||||
|
$('types').replaceChildren(...Object.entries(types).map(([k, label]) => {
|
||||||
|
const i = el('input', { type: 'radio', name: 'type', value: k, checked: k === type });
|
||||||
|
i.onchange = () => { type = k; localStorage.setItem('qa-type', k); $('code').focus(); };
|
||||||
|
return el('label', {}, i, el('span', {}, label));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const s = await api('/api/state');
|
||||||
|
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
|
||||||
|
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
|
||||||
|
staffBase = s.staff_url || '';
|
||||||
|
$('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/';
|
||||||
|
$('lnk-staff').href = staffRoot() + '/';
|
||||||
|
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
|
||||||
|
if (s.locations && s.locations.length) {
|
||||||
|
const sel = $('shelf');
|
||||||
|
sel.replaceChildren(el('option', { value: '' }, '(not set / all in one place)'),
|
||||||
|
...s.locations.map(l => el('option', { value: l.value }, l.label)));
|
||||||
|
if (![...sel.options].some(o => o.value === shelf)) shelf = '';
|
||||||
|
sel.value = shelf;
|
||||||
|
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
|
||||||
|
$('shelfbox').hidden = false;
|
||||||
|
}
|
||||||
|
api('/api/settings').then(applySettings);
|
||||||
|
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||||
|
$('code').focus();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
|
||||||
|
sure the running Quick Add reads them (at the size the page sends, 2400 px).
|
||||||
|
|
||||||
|
python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
|
||||||
|
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
|
||||||
|
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
|
||||||
|
"""
|
||||||
|
import base64, io, json, os, sys, urllib.request
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
|
||||||
|
D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||||
|
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def scan(data, kind):
|
||||||
|
req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
|
||||||
|
headers={"Content-Type": "image/jpeg", "Authorization": auth})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.load(r)["codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def shrunk(path, m=2400): # what the page does before uploading
|
||||||
|
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
|
||||||
|
k = min(1, m / max(im.size))
|
||||||
|
im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
|
||||||
|
b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
|
||||||
|
base = n[:-4]
|
||||||
|
exp_file = os.path.join(D, base + ".expect")
|
||||||
|
exp = open(exp_file).read() if os.path.exists(exp_file) else None
|
||||||
|
codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
|
||||||
|
ok = (codes[:1] == [exp]) if exp else (codes == [])
|
||||||
|
bad += not ok
|
||||||
|
print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Generate phone-photo-like test images containing real barcodes."""
|
||||||
|
import random, io, os
|
||||||
|
import zxingcpp
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
|
||||||
|
|
||||||
|
OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
random.seed(7)
|
||||||
|
|
||||||
|
|
||||||
|
def barcode(fmt, text, scale=4, height=90):
|
||||||
|
bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
|
||||||
|
img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
|
||||||
|
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
|
def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
|
||||||
|
"""Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
|
||||||
|
bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
|
||||||
|
d = ImageDraw.Draw(bg)
|
||||||
|
for _ in range(60): # clutter: stripes, blobs, text-ish bars
|
||||||
|
x, y = random.randint(0, w), random.randint(0, h)
|
||||||
|
d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
|
||||||
|
fill=tuple(random.randint(0, 255) for _ in range(3)))
|
||||||
|
label = code_img.copy()
|
||||||
|
k = (w * size_frac) / label.width
|
||||||
|
label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
|
||||||
|
pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
|
||||||
|
pad.paste(label, (30, 30))
|
||||||
|
if tilt:
|
||||||
|
pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
|
||||||
|
bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
|
||||||
|
if blur:
|
||||||
|
bg = bg.filter(ImageFilter.GaussianBlur(blur))
|
||||||
|
bg = ImageEnhance.Contrast(bg).enhance(contrast)
|
||||||
|
bg = ImageEnhance.Brightness(bg).enhance(bright)
|
||||||
|
if noise:
|
||||||
|
px = bg.load()
|
||||||
|
for _ in range(noise):
|
||||||
|
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
|
||||||
|
v = random.randint(0, 255); px[x, y] = (v, v, v)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
kw = {"quality": quality}
|
||||||
|
if exif is not None:
|
||||||
|
e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
|
||||||
|
bg.save(buf, "JPEG", **kw)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
E13 = zxingcpp.BarcodeFormat.EAN13
|
||||||
|
UPCA = zxingcpp.BarcodeFormat.UPCA
|
||||||
|
C39 = zxingcpp.BarcodeFormat.Code39
|
||||||
|
|
||||||
|
cases = {
|
||||||
|
# name: (expected, bytes)
|
||||||
|
"isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
|
||||||
|
"isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
|
||||||
|
"isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
|
||||||
|
"isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
|
||||||
|
"isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
|
||||||
|
"isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
|
||||||
|
"isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
|
||||||
|
"upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
|
||||||
|
}
|
||||||
|
for name, (exp, data) in cases.items():
|
||||||
|
open(f"{OUT}/{name}.jpg", "wb").write(data)
|
||||||
|
open(f"{OUT}/{name}.expect", "w").write(exp)
|
||||||
|
|
||||||
|
# sticker (Code 39, 7 digits) and a photo with no barcode at all
|
||||||
|
open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
|
||||||
|
open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
|
||||||
|
blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
|
||||||
|
b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
|
||||||
|
print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
|
||||||
|
for n in sorted(os.listdir(OUT)):
|
||||||
|
if n.endswith(".jpg"):
|
||||||
|
print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_asterisk_intercom.sh - "Asterisk Intercom" addon: connect the
|
||||||
|
# kiosk as a SIP extension to an *existing* Asterisk server.
|
||||||
|
#
|
||||||
|
# The legacy addon offered three options: Client Only (a Baresip SIP
|
||||||
|
# client - what this file is), Server Only, and Full (server + client).
|
||||||
|
# Server/Full downloaded and ran a third-party installer from a separate
|
||||||
|
# "Easy Asterisk" repository to stand up a whole Asterisk PBX. That
|
||||||
|
# repository has since gone through a major rework upstream, so wiring a
|
||||||
|
# full PBX install through it here no longer makes sense to maintain -
|
||||||
|
# and most kiosk deployments don't need this device to *be* the PBX
|
||||||
|
# anyway. This addon now does only the client/endpoint piece: install
|
||||||
|
# Baresip and register it as one extension against an Asterisk server
|
||||||
|
# the user already has running somewhere else. It never installs or
|
||||||
|
# manages Asterisk itself.
|
||||||
|
#
|
||||||
|
# Two other things fixed while narrowing the scope:
|
||||||
|
# - The legacy client path tracked its own version by calling out to the
|
||||||
|
# (now-reworked) Easy Asterisk repo's GitHub API and stamping a
|
||||||
|
# "<repo-version>-client" string in a side file. That coupling is
|
||||||
|
# exactly what's being dropped, so version tracking now just reads the
|
||||||
|
# real installed `baresip` package version via dpkg - one less network
|
||||||
|
# dependency and one less thing to keep in sync with an external repo.
|
||||||
|
# - The legacy addon had no uninstall option for the client at all -
|
||||||
|
# added below.
|
||||||
|
#
|
||||||
|
# Real system state: apt package, a per-user config directory under
|
||||||
|
# $KIOSK_HOME, and a systemd --user unit for $KIOSK_USER (not a system
|
||||||
|
# service - Baresip needs the desktop session's PulseAudio/PipeWire
|
||||||
|
# socket). Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, all
|
||||||
|
# stubbed at the command level in tests - there's no real D-Bus user
|
||||||
|
# session to target in a test container regardless.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
BARESIP_CONFIG_DIR="${KIOSK_HOME}/.baresip"
|
||||||
|
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
|
||||||
|
# pulseaudio-utils/pipewire-pulse: pactl, for audio routing.
|
||||||
|
ASTERISK_INTERCOM_APT_PACKAGES=(baresip pulseaudio-utils pipewire-pulse)
|
||||||
|
BARESIP_USER_SERVICE_DIR="${KIOSK_HOME}/.config/systemd/user"
|
||||||
|
|
||||||
|
# Runs `systemctl --user ...` as $KIOSK_USER with the runtime dir/D-Bus
|
||||||
|
# address it needs to find that user's session. Always call from an
|
||||||
|
# `if`/`&&`/`||` context - see run_menu's own comment on why a bare call
|
||||||
|
# that can legitimately fail must never be a standalone statement.
|
||||||
|
baresip_systemctl_user() {
|
||||||
|
local kiosk_uid
|
||||||
|
kiosk_uid=$(id -u "$KIOSK_USER" 2>/dev/null) || return 1
|
||||||
|
sudo -u "$KIOSK_USER" \
|
||||||
|
XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${kiosk_uid}/bus" \
|
||||||
|
systemctl --user "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
baresip_installed_version() {
|
||||||
|
dpkg-query -W -f='${Version}' baresip 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# "Installed" means both the package and a written account - a bare
|
||||||
|
# `apt install baresip` with no configured extension isn't something
|
||||||
|
# this menu should call done.
|
||||||
|
baresip_is_installed() {
|
||||||
|
# sudo: it's under the kiosk user's home, which the admin running this
|
||||||
|
# can't look inside (mode 750) - a plain [[ -f ]] is always false there.
|
||||||
|
command -v baresip &>/dev/null && sudo test -f "$BARESIP_CONFIG_DIR/accounts"
|
||||||
|
}
|
||||||
|
|
||||||
|
baresip_is_running() {
|
||||||
|
baresip_systemctl_user is-active --quiet baresip.service 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_asterisk_intercom_status() {
|
||||||
|
if baresip_is_installed; then
|
||||||
|
local ver
|
||||||
|
ver=$(baresip_installed_version)
|
||||||
|
if baresip_is_running; then
|
||||||
|
echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Running"
|
||||||
|
else
|
||||||
|
echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Not running"
|
||||||
|
fi
|
||||||
|
if sudo test -f "$BARESIP_CONFIG_DIR/accounts"; then
|
||||||
|
local account
|
||||||
|
account=$(sudo head -1 "$BARESIP_CONFIG_DIR/accounts" 2>/dev/null)
|
||||||
|
local extension="${account#<sip:}"
|
||||||
|
extension="${extension%%@*}"
|
||||||
|
[[ -n "$extension" ]] && echo " Extension: $extension"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Asterisk Intercom: Not installed"
|
||||||
|
fi
|
||||||
|
echo "ℹ Connects this kiosk as a SIP extension to an Asterisk server"
|
||||||
|
echo " you already have running elsewhere - it does not install or"
|
||||||
|
echo " manage Asterisk itself."
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_asterisk_intercom_menu_builder() {
|
||||||
|
if baresip_is_installed; then
|
||||||
|
MENU_LABELS=("Reconfigure (new server/extension)" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_configure_asterisk_intercom action_uninstall_asterisk_intercom)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Connect to an Asterisk server")
|
||||||
|
MENU_HANDLERS=(action_configure_asterisk_intercom)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_asterisk_intercom_menu() {
|
||||||
|
run_menu "ASTERISK INTERCOM (SIP EXTENSION)" addon_asterisk_intercom_menu_builder addon_asterisk_intercom_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_configure_asterisk_intercom() {
|
||||||
|
echo
|
||||||
|
if baresip_is_installed; then
|
||||||
|
echo "Asterisk Intercom is already configured."
|
||||||
|
ask_yes_no "Reconfigure with a different server/extension?" "n" || { pause; return; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Enter the details of the Asterisk server this kiosk should"
|
||||||
|
echo "register to as an extension (these must match what's already"
|
||||||
|
echo "configured on that server)."
|
||||||
|
echo
|
||||||
|
|
||||||
|
local server_ip=""
|
||||||
|
while [[ -z "$server_ip" ]]; do
|
||||||
|
server_ip=$(ask_text "Server IP or hostname" "")
|
||||||
|
[[ -z "$server_ip" ]] && log_error "Server address is required"
|
||||||
|
done
|
||||||
|
|
||||||
|
local server_port
|
||||||
|
server_port=$(ask_integer "Server port" 5060 1 65535)
|
||||||
|
|
||||||
|
local extension=""
|
||||||
|
while [[ -z "$extension" ]]; do
|
||||||
|
extension=$(ask_text "Extension number (e.g. 201)" "")
|
||||||
|
[[ -z "$extension" ]] && log_error "Extension is required"
|
||||||
|
done
|
||||||
|
|
||||||
|
local password=""
|
||||||
|
while [[ -z "$password" ]]; do
|
||||||
|
read -r -s -p "SIP password: " password
|
||||||
|
echo
|
||||||
|
[[ -z "$password" ]] && log_error "Password is required"
|
||||||
|
done
|
||||||
|
|
||||||
|
local answermode="manual"
|
||||||
|
if ask_yes_no "Auto-answer incoming calls (intercom mode)?" "n"; then
|
||||||
|
answermode="auto"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local transport="udp"
|
||||||
|
local media_enc=""
|
||||||
|
if ask_yes_no "Use TLS encryption?" "n"; then
|
||||||
|
transport="tls"
|
||||||
|
media_enc=";mediaenc=srtp"
|
||||||
|
if [[ "$server_port" == "5060" ]]; then
|
||||||
|
server_port=5061
|
||||||
|
echo "Note: port changed to 5061 for TLS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Configuration summary:"
|
||||||
|
echo " Server: ${server_ip}:${server_port}"
|
||||||
|
echo " Extension: ${extension}"
|
||||||
|
echo " Answer: $([[ "$answermode" == "auto" ]] && echo "Auto" || echo "Manual")"
|
||||||
|
echo " TLS: $([[ "$transport" == "tls" ]] && echo "Yes" || echo "No")"
|
||||||
|
echo
|
||||||
|
ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Installing Baresip..."
|
||||||
|
if ! command -v baresip &>/dev/null || ! command -v pactl &>/dev/null; then
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y "${ASTERISK_INTERCOM_APT_PACKAGES[@]}"; then
|
||||||
|
log_error "Failed to install baresip package"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$BARESIP_CONFIG_DIR"
|
||||||
|
|
||||||
|
sudo tee "$BARESIP_CONFIG_DIR/accounts" > /dev/null <<EOF
|
||||||
|
<sip:${extension}@${server_ip}:${server_port};transport=${transport}>;auth_pass=${password};answermode=${answermode}${media_enc}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ ! -f "$BARESIP_CONFIG_DIR/config" ]]; then
|
||||||
|
sudo tee "$BARESIP_CONFIG_DIR/config" > /dev/null <<'BARESIPCONFIG'
|
||||||
|
# Baresip configuration for Asterisk Intercom
|
||||||
|
|
||||||
|
# Audio settings
|
||||||
|
audio_player pulse,default
|
||||||
|
audio_source pulse,default
|
||||||
|
audio_alert pulse,default
|
||||||
|
|
||||||
|
# Call settings
|
||||||
|
call_local_timeout 120
|
||||||
|
call_max_calls 4
|
||||||
|
|
||||||
|
# Network settings
|
||||||
|
net_interface
|
||||||
|
|
||||||
|
# SIP settings
|
||||||
|
sip_trans_bsize 128
|
||||||
|
sip_verify_server no
|
||||||
|
|
||||||
|
# Module loading
|
||||||
|
module pulse.so
|
||||||
|
module account.so
|
||||||
|
module contact.so
|
||||||
|
module menu.so
|
||||||
|
module stdio.so
|
||||||
|
module uuid.so
|
||||||
|
module debug_cmd.so
|
||||||
|
BARESIPCONFIG
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CONFIG_DIR"
|
||||||
|
sudo chmod 600 "$BARESIP_CONFIG_DIR/accounts"
|
||||||
|
|
||||||
|
sudo mkdir -p "$BARESIP_USER_SERVICE_DIR"
|
||||||
|
sudo tee "$BARESIP_USER_SERVICE_DIR/baresip.service" > /dev/null <<'BARESIPUNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=Baresip SIP Client
|
||||||
|
After=pipewire.service pipewire-pulse.service
|
||||||
|
Wants=pipewire-pulse.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/baresip -f %h/.baresip
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PULSE_SERVER=unix:/run/user/%U/pulse/native
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
BARESIPUNIT
|
||||||
|
sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.config"
|
||||||
|
|
||||||
|
if baresip_systemctl_user daemon-reload 2>/dev/null && \
|
||||||
|
baresip_systemctl_user enable baresip.service 2>/dev/null && \
|
||||||
|
baresip_systemctl_user start baresip.service 2>/dev/null; then
|
||||||
|
log_success "Baresip service enabled and started"
|
||||||
|
else
|
||||||
|
log_warning "Baresip files written, but enabling/starting the user service failed - it will start automatically on next login. Check: systemctl --user status baresip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_success "Asterisk Intercom configured"
|
||||||
|
echo " Config dir: ${BARESIP_CONFIG_DIR}"
|
||||||
|
echo " Server: ${server_ip}:${server_port}"
|
||||||
|
echo " Extension: ${extension}"
|
||||||
|
echo
|
||||||
|
echo "Management commands (as $KIOSK_USER):"
|
||||||
|
echo " Check status: systemctl --user status baresip"
|
||||||
|
echo " Restart: systemctl --user restart baresip"
|
||||||
|
echo " View logs: journalctl --user -u baresip -f"
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_uninstall_asterisk_intercom() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove Asterisk Intercom (Baresip)?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
asterisk_intercom_do_uninstall ask
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# The actual removal, no confirmation prompt - shared with Complete
|
||||||
|
# Uninstall so that operation doesn't need to re-implement this teardown
|
||||||
|
# a second time. $1: "ask" to prompt about config removal interactively
|
||||||
|
# (the normal case), "purge" to remove config without asking (Complete
|
||||||
|
# Uninstall).
|
||||||
|
asterisk_intercom_do_uninstall() {
|
||||||
|
local data_choice="${1:-ask}"
|
||||||
|
|
||||||
|
baresip_systemctl_user stop baresip.service 2>/dev/null || true
|
||||||
|
baresip_systemctl_user disable baresip.service 2>/dev/null || true
|
||||||
|
sudo rm -f "$BARESIP_USER_SERVICE_DIR/baresip.service"
|
||||||
|
sudo apt remove -y baresip 2>/dev/null || true
|
||||||
|
|
||||||
|
local purge_config=false
|
||||||
|
if [[ "$data_choice" == "purge" ]]; then
|
||||||
|
purge_config=true
|
||||||
|
elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove saved SIP configuration too?" "n"; then
|
||||||
|
purge_config=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $purge_config; then
|
||||||
|
sudo rm -rf "$BARESIP_CONFIG_DIR"
|
||||||
|
log_success "Asterisk Intercom removed (configuration deleted)"
|
||||||
|
else
|
||||||
|
log_success "Asterisk Intercom removed (configuration preserved)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_authelia.sh - "Authelia Auto-Login" addon.
|
||||||
|
#
|
||||||
|
# Stores encrypted Authelia SSO credentials so the kiosk authenticates
|
||||||
|
# automatically on every startup. The password is AES-256-CBC encrypted
|
||||||
|
# with a key derived from this machine's /etc/machine-id via scrypt -
|
||||||
|
# the encrypted blob is useless on any other machine - and is NEVER
|
||||||
|
# stored in plain text, matching the legacy addon exactly (same
|
||||||
|
# algorithm, same salt, same node crypto calls).
|
||||||
|
#
|
||||||
|
# autheliaURL/autheliaUsername/autheliaEncryptedPassword are tracked
|
||||||
|
# fields in lib/config.sh now (load_existing_config/save_config), same
|
||||||
|
# as every other config.json field this tool manages - this is also
|
||||||
|
# what motivated fixing save_config to merge onto the existing file
|
||||||
|
# instead of rebuilding it from scratch (see lib/config.sh): the legacy
|
||||||
|
# save_config had no idea these three fields existed, so configuring
|
||||||
|
# Authelia and then visiting Sites/Touch/Navigation/Password Protection
|
||||||
|
# in the legacy menu would silently wipe the credentials on the next
|
||||||
|
# save. Real bug in the shipped script, not unique to this migration.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
addon_authelia_status() {
|
||||||
|
if [[ -n "$AUTHELIA_URL" ]]; then
|
||||||
|
echo "Authelia: configured"
|
||||||
|
echo " URL: $AUTHELIA_URL"
|
||||||
|
echo " Username: $AUTHELIA_USERNAME"
|
||||||
|
else
|
||||||
|
echo "Authelia: not configured"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_authelia_menu_builder() {
|
||||||
|
if [[ -n "$AUTHELIA_URL" ]]; then
|
||||||
|
MENU_LABELS=("Reconfigure (overwrite)" "Show server-side setup instructions again" "Clear Authelia configuration")
|
||||||
|
MENU_HANDLERS=(action_configure_authelia action_show_authelia_server_setup action_clear_authelia)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Configure Authelia auto-login")
|
||||||
|
MENU_HANDLERS=(action_configure_authelia)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_authelia_menu() {
|
||||||
|
load_existing_config
|
||||||
|
|
||||||
|
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
log_error "config.json not found at $CONFIG_PATH - run a full install first"
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_menu "AUTHELIA AUTO-LOGIN" addon_authelia_menu_builder addon_authelia_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Encryption
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Same algorithm as the legacy addon: AES-256-CBC, key derived from
|
||||||
|
# /etc/machine-id via scrypt with a fixed salt, random IV prepended to
|
||||||
|
# the ciphertext, everything base64-encoded. main.js decrypts with the
|
||||||
|
# same derivation - do not change this without updating main.js too.
|
||||||
|
encrypt_authelia_password() {
|
||||||
|
local password="$1"
|
||||||
|
command -v node &>/dev/null || return 1
|
||||||
|
|
||||||
|
node -e "
|
||||||
|
const crypto=require('crypto'),fs=require('fs');
|
||||||
|
const id=fs.readFileSync('/etc/machine-id','utf8').trim();
|
||||||
|
const key=crypto.scryptSync(id,'kiosk-authelia-v1',32);
|
||||||
|
const iv=crypto.randomBytes(16);
|
||||||
|
const c=crypto.createCipheriv('aes-256-cbc',key,iv);
|
||||||
|
const enc=Buffer.concat([c.update(process.argv[1],'utf8'),c.final()]);
|
||||||
|
process.stdout.write(Buffer.concat([iv,enc]).toString('base64'));
|
||||||
|
" "$password" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_configure_authelia() {
|
||||||
|
echo
|
||||||
|
echo "Stores encrypted Authelia credentials so the kiosk"
|
||||||
|
echo "authenticates automatically on every startup."
|
||||||
|
echo "Password is encrypted with this machine's unique ID -"
|
||||||
|
echo "the encrypted blob is useless on any other machine."
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -n "$AUTHELIA_URL" ]]; then
|
||||||
|
echo "Current config:"
|
||||||
|
echo " URL: $AUTHELIA_URL"
|
||||||
|
echo " Username: $AUTHELIA_USERNAME"
|
||||||
|
echo
|
||||||
|
ask_yes_no "Overwrite existing Authelia config?" "n" || { echo "Cancelled"; return; }
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url user pass
|
||||||
|
read -r -p "Authelia URL (e.g. https://auth.yourdomain.com): " url
|
||||||
|
[[ -z "$url" ]] && { echo "Cancelled"; return; }
|
||||||
|
read -r -p "Authelia username: " user
|
||||||
|
[[ -z "$user" ]] && { echo "Cancelled"; return; }
|
||||||
|
read -r -s -p "Authelia password: " pass
|
||||||
|
echo
|
||||||
|
[[ -z "$pass" ]] && { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
echo "Encrypting with machine ID..."
|
||||||
|
local encrypted
|
||||||
|
encrypted=$(encrypt_authelia_password "$pass")
|
||||||
|
if [[ -z "$encrypted" ]]; then
|
||||||
|
log_error "Encryption failed - is Node.js installed?"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
AUTHELIA_URL="$url"
|
||||||
|
AUTHELIA_USERNAME="$user"
|
||||||
|
AUTHELIA_ENCRYPTED_PASSWORD="$encrypted"
|
||||||
|
save_config
|
||||||
|
|
||||||
|
log_success "Authelia config saved (password encrypted, NOT stored in plain text)"
|
||||||
|
action_show_authelia_server_setup
|
||||||
|
|
||||||
|
echo
|
||||||
|
if is_service_active lightdm && ask_yes_no "Restart kiosk display now?" "n"; then
|
||||||
|
sudo systemctl restart lightdm
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_clear_authelia() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Clear Authelia configuration?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
AUTHELIA_URL=""
|
||||||
|
AUTHELIA_USERNAME=""
|
||||||
|
AUTHELIA_ENCRYPTED_PASSWORD=""
|
||||||
|
save_config
|
||||||
|
log_success "Authelia configuration cleared"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_show_authelia_server_setup() {
|
||||||
|
echo
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo " AUTHELIA SERVER-SIDE SETUP (Dockerized)"
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
echo "1. Generate the argon2 password hash on your Docker host:"
|
||||||
|
echo
|
||||||
|
echo " docker run --rm authelia/authelia:latest \\"
|
||||||
|
echo " authelia crypto hash generate argon2 \\"
|
||||||
|
echo " --password 'yourpassword'"
|
||||||
|
echo
|
||||||
|
echo " Copy the \$argon2id\$... output — that is your hash."
|
||||||
|
echo
|
||||||
|
echo "2. ADD a kiosk user to ~/docker/authelia/config/users.yml"
|
||||||
|
echo " (append — do not replace existing users):"
|
||||||
|
echo
|
||||||
|
echo " kiosk:"
|
||||||
|
echo " displayname: \"Kiosk Display\""
|
||||||
|
echo " password: '\$argon2id\$v=19\$m=65536,t=3,p=4\$<paste hash here>'"
|
||||||
|
echo " email: kiosk@local.com"
|
||||||
|
echo " groups:"
|
||||||
|
echo " - kiosk"
|
||||||
|
echo
|
||||||
|
echo "3. MERGE into ~/docker/authelia/config/configuration.yml:"
|
||||||
|
echo
|
||||||
|
echo " ── access_control ─────────────────────────────────────"
|
||||||
|
echo " Find your EXISTING access_control block and add the"
|
||||||
|
echo " kiosk rule as the FIRST rule inside it."
|
||||||
|
echo
|
||||||
|
echo " !! DO NOT create a second access_control: block !!"
|
||||||
|
echo " YAML silently ignores duplicate keys — the kiosk rule"
|
||||||
|
echo " will be invisible to Authelia and you will get a white"
|
||||||
|
echo " screen on the kiosk."
|
||||||
|
echo
|
||||||
|
echo " Authelia reads rules top-down, first match wins."
|
||||||
|
echo " The kiosk rule MUST be above any two_factor rule or"
|
||||||
|
echo " the two_factor wildcard will match first."
|
||||||
|
echo
|
||||||
|
echo " ── EXAMPLE — before (your existing config): ──────────"
|
||||||
|
echo " access_control:"
|
||||||
|
echo " default_policy: deny"
|
||||||
|
echo " rules:"
|
||||||
|
echo " - domain: '*.yourdomain.com'"
|
||||||
|
echo " policy: two_factor"
|
||||||
|
echo
|
||||||
|
echo " ── EXAMPLE — after (add kiosk rule above two_factor): ─"
|
||||||
|
echo " access_control:"
|
||||||
|
echo " default_policy: deny"
|
||||||
|
echo " rules:"
|
||||||
|
echo " - domain: '*.yourdomain.com' # <-- kiosk first"
|
||||||
|
echo " subject: 'group:kiosk'"
|
||||||
|
echo " policy: one_factor"
|
||||||
|
echo " - domain: '*.yourdomain.com' # <-- existing"
|
||||||
|
echo " policy: two_factor"
|
||||||
|
echo
|
||||||
|
echo " Why one_factor? The kiosk authenticates via the API"
|
||||||
|
echo " (/api/firstfactor — password only). TOTP and WebAuthn"
|
||||||
|
echo " require a second interactive step that is impossible"
|
||||||
|
echo " from a script, so the kiosk group must use one_factor."
|
||||||
|
echo
|
||||||
|
echo " ── session ─────────────────────────────────────────────"
|
||||||
|
echo " Keep your existing session block — no changes needed."
|
||||||
|
echo " The kiosk re-authenticates via API on every startup so"
|
||||||
|
echo " session expiry barely matters for it."
|
||||||
|
echo
|
||||||
|
echo " If you do NOT yet have a session block, add:"
|
||||||
|
echo
|
||||||
|
echo " session:"
|
||||||
|
echo " expiration: 8h"
|
||||||
|
echo " inactivity: 1h"
|
||||||
|
echo " remember_me: 7d"
|
||||||
|
echo " cookies:"
|
||||||
|
echo " - domain: yourdomain.com"
|
||||||
|
echo " authelia_url: https://auth.yourdomain.com"
|
||||||
|
echo
|
||||||
|
echo "4. Restart Authelia on your Docker host:"
|
||||||
|
echo " docker compose restart authelia"
|
||||||
|
echo
|
||||||
|
echo "────────────────────────────────────────────────────────────"
|
||||||
|
echo " NOTE: HTTP Basic Auth (per-site username/password) still"
|
||||||
|
echo " works alongside Authelia for sites that use browser-popup"
|
||||||
|
echo " authentication rather than Authelia SSO."
|
||||||
|
echo "────────────────────────────────────────────────────────────"
|
||||||
|
echo
|
||||||
|
echo " To clear Authelia config later, use this menu's"
|
||||||
|
echo " 'Clear Authelia configuration' option."
|
||||||
|
echo
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_cups.sh - "CUPS Printing" addon (from the legacy Addons menu).
|
||||||
|
#
|
||||||
|
# First Addon migrated. Genuinely mutates real system state - installs/
|
||||||
|
# purges apt packages, writes /etc/cups/cupsd.conf and a polkit rule,
|
||||||
|
# touches ufw - at fixed paths CUPS itself doesn't let us relocate the
|
||||||
|
# way $SYSTEMD_DIR/$CRON_D_DIR/etc let us relocate our own files. Only
|
||||||
|
# the polkit rule's directory is parameterized ($POLKIT_DIR, since that's
|
||||||
|
# ours to place); everything else (cupsd.conf, apt, systemctl, ufw) gets
|
||||||
|
# full command-level `sudo` stubbing in every test - there is no scratch
|
||||||
|
# equivalent for a real apt-managed subsystem's own file layout.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Also read by iso/offline/build-bundle.sh to bundle these for offline installs.
|
||||||
|
CUPS_APT_PACKAGES=(
|
||||||
|
cups cups-client cups-filters printer-driver-all
|
||||||
|
printer-driver-cups-pdf hplip printer-driver-gutenprint
|
||||||
|
foomatic-db-compressed-ppds openprinting-ppds
|
||||||
|
)
|
||||||
|
|
||||||
|
cups_is_installed() {
|
||||||
|
# Not `dpkg -l | grep -q`: grep -q exits at the first match, dpkg
|
||||||
|
# takes SIGPIPE writing the rest of its long list, and under
|
||||||
|
# install.sh's `set -o pipefail` that made this report "not
|
||||||
|
# installed" for an installed CUPS.
|
||||||
|
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' cups 2>/dev/null)" == "ii "* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
cups_is_active() {
|
||||||
|
systemctl is-active --quiet cups
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_cups_status() {
|
||||||
|
if cups_is_installed && cups_is_active; then
|
||||||
|
echo "CUPS: installed and running (http://$(get_ip_address):631)"
|
||||||
|
elif cups_is_installed; then
|
||||||
|
echo "CUPS: installed but not running"
|
||||||
|
else
|
||||||
|
echo "CUPS: not installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_cups_menu_builder() {
|
||||||
|
if cups_is_installed && cups_is_active; then
|
||||||
|
MENU_LABELS=("Reconfigure for network access" "Complete uninstall (purge)")
|
||||||
|
MENU_HANDLERS=(action_reconfigure_cups action_cups_uninstall)
|
||||||
|
elif cups_is_installed; then
|
||||||
|
MENU_LABELS=("Start CUPS" "Complete uninstall (purge)")
|
||||||
|
MENU_HANDLERS=(action_start_cups action_cups_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install CUPS printing")
|
||||||
|
MENU_HANDLERS=(action_install_cups)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_cups_menu() {
|
||||||
|
run_menu "CUPS PRINTING SUPPORT" addon_cups_menu_builder addon_cups_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
cups_apt_install() {
|
||||||
|
if ! sudo apt update; then
|
||||||
|
log_error "apt update failed - check network/package sources and try again"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! sudo apt install -y "${CUPS_APT_PACKAGES[@]}"; then
|
||||||
|
log_error "CUPS package installation failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_install_cups() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
echo "Installing CUPS from scratch..."
|
||||||
|
run_with_offline_fallback cups_apt_install || return 1
|
||||||
|
|
||||||
|
sudo systemctl enable cups 2>/dev/null || true
|
||||||
|
sudo systemctl start cups 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Waiting for CUPS to start..."
|
||||||
|
for _ in {1..30}; do
|
||||||
|
# Must stay in an `if` - a bare `cmd1 && cmd2` statement is
|
||||||
|
# subject to set -e itself when cmd1 fails, which is virtually
|
||||||
|
# guaranteed on early iterations right after install.
|
||||||
|
if cups_is_active && lpstat -r &>/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# $BUILD_USER already resolves to $SUDO_USER when the tool was run via
|
||||||
|
# sudo, so a single usermod covers it - the legacy code ran this twice
|
||||||
|
# (once for a hardcoded computed user, once again for $SUDO_USER
|
||||||
|
# directly), which was harmless but genuinely redundant.
|
||||||
|
sudo usermod -aG lpadmin "$BUILD_USER"
|
||||||
|
|
||||||
|
action_reconfigure_cups
|
||||||
|
|
||||||
|
log_success "CUPS installed"
|
||||||
|
echo " Web interface: http://$(get_ip_address):631"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_start_cups() {
|
||||||
|
sudo systemctl enable cups
|
||||||
|
sudo systemctl start cups
|
||||||
|
log_success "CUPS started"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_reconfigure_cups() {
|
||||||
|
if [[ -f /etc/cups/cupsd.conf ]]; then
|
||||||
|
sudo cp /etc/cups/cupsd.conf "/etc/cups/cupsd.conf.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v cupsctl &>/dev/null; then
|
||||||
|
sudo cupsctl --remote-admin --remote-any --share-printers 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo sed -i 's/^Listen localhost:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null || true
|
||||||
|
sudo sed -i 's/^Listen 127.0.0.1:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo mkdir -p "$POLKIT_DIR"
|
||||||
|
sudo tee "$POLKIT_DIR/kiosk-printing.pkla" > /dev/null <<EOF
|
||||||
|
[Allow kiosk printing]
|
||||||
|
Identity=unix-user:${KIOSK_USER}
|
||||||
|
Action=org.opensuse.cupspkhelper.mechanism.*
|
||||||
|
ResultAny=yes
|
||||||
|
ResultInactive=yes
|
||||||
|
ResultActive=yes
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo ufw allow 631/tcp comment 'CUPS' 2>/dev/null || true
|
||||||
|
sudo systemctl restart cups 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "CUPS configured for network access"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_cups_uninstall() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Completely remove CUPS, including all queues and settings (purge)?" "n" || { echo "Cancelled"; return; }
|
||||||
|
cups_do_uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||||
|
# operation doesn't need to re-implement CUPS teardown a second time.
|
||||||
|
cups_do_uninstall() {
|
||||||
|
echo "Performing complete CUPS uninstall..."
|
||||||
|
|
||||||
|
sudo systemctl stop cups cups-browsed 2>/dev/null || true
|
||||||
|
sudo systemctl disable cups cups-browsed 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo apt remove --purge -y cups cups-daemon cups-client cups-filters \
|
||||||
|
cups-common cups-core-drivers cups-server-common cups-browsed \
|
||||||
|
cups-ppdc cups-bsd libcups2 libcupsimage2 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo apt remove --purge -y printer-driver-all printer-driver-cups-pdf \
|
||||||
|
hplip printer-driver-gutenprint foomatic-db-compressed-ppds \
|
||||||
|
openprinting-ppds 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo rm -rf /etc/cups /var/cache/cups /var/spool/cups /var/log/cups /usr/share/cups
|
||||||
|
sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla"
|
||||||
|
|
||||||
|
sudo apt autoremove -y 2>/dev/null || true
|
||||||
|
sudo apt clean 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "CUPS completely removed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
||||||
|
# catalogue (Koha) running in Docker, shown on the kiosk as a public
|
||||||
|
# search terminal.
|
||||||
|
#
|
||||||
|
# What gets installed (all from this checkout's library/ directory, deployed to
|
||||||
|
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
||||||
|
# - Koha: the catalogue ("OPAC", public search, no login) and the staff
|
||||||
|
# interface (cataloguing, Z39.50 lookup, label printing).
|
||||||
|
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
|
||||||
|
# - MariaDB.
|
||||||
|
# Docker itself is installed on demand (lib/docker.sh).
|
||||||
|
#
|
||||||
|
# On the kiosk the catalogue is added as one of the Sites (tabs) with
|
||||||
|
# duration 0 (stay put - it's a search terminal, not a rotating display).
|
||||||
|
# Staff and Quick Add are meant for the librarian's own device; whether they
|
||||||
|
# are reachable from the LAN or only from this kiosk is the "Network access"
|
||||||
|
# choice below. NOTE: Docker publishes ports straight through iptables and
|
||||||
|
# bypasses ufw, so "this kiosk only" is enforced by binding the published
|
||||||
|
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Detection / state
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
library_env_get() {
|
||||||
|
sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
library_is_installed() {
|
||||||
|
[[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1
|
||||||
|
[[ "$(docker_container_state library-koha)" != "missing" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
library_is_running() {
|
||||||
|
[[ "$(docker_container_state library-koha)" == "running" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
library_catalogue_url() {
|
||||||
|
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when the kiosk's page list already shows the catalogue.
|
||||||
|
library_tab_exists() {
|
||||||
|
local url idx
|
||||||
|
url="$(library_catalogue_url)"
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
[[ "${URLS[$idx]}" == "$url" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_library_status() {
|
||||||
|
if ! library_is_installed; then
|
||||||
|
echo "Library Catalog: not installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local state health ip
|
||||||
|
state="$(docker_container_state library-koha)"
|
||||||
|
health="$(docker_container_health library-koha)"
|
||||||
|
ip="$(get_ip_address)"
|
||||||
|
echo "Library Catalog: ${state}${health:+ ($health)}"
|
||||||
|
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
|
||||||
|
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||||
|
echo " Network: this kiosk only"
|
||||||
|
else
|
||||||
|
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_library_menu_builder() {
|
||||||
|
if library_is_installed; then
|
||||||
|
load_existing_config
|
||||||
|
local tab_label="Show the catalogue on the kiosk"
|
||||||
|
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
|
||||||
|
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install Library Catalog")
|
||||||
|
MENU_HANDLERS=(action_library_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_library_menu() {
|
||||||
|
run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the
|
||||||
|
# deployed .env (passwords) even if a stray one exists in the checkout.
|
||||||
|
library_sync_files() {
|
||||||
|
local src="${SCRIPT_DIR}/library"
|
||||||
|
if [[ ! -f "$src/docker-compose.yml" ]]; then
|
||||||
|
log_error "library/ not found in this checkout ($src) - re-clone or git pull"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo mkdir -p "$LIBRARY_DIR" || return 1
|
||||||
|
tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1
|
||||||
|
sudo chmod 755 "$LIBRARY_DIR/install.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
library_add_tab() {
|
||||||
|
load_existing_config
|
||||||
|
if library_tab_exists; then
|
||||||
|
log_info "The catalogue is already one of the kiosk's pages."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
URLS+=("$(library_catalogue_url)")
|
||||||
|
DURS+=("0")
|
||||||
|
USERS+=("")
|
||||||
|
PASSES+=("")
|
||||||
|
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# Drop every page that points at the catalogue's local address.
|
||||||
|
library_remove_tab() {
|
||||||
|
load_existing_config
|
||||||
|
local url idx
|
||||||
|
url="$(library_catalogue_url)"
|
||||||
|
local -a nu=() nd=() nus=() np=() nn=()
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
[[ "${URLS[$idx]}" == "$url" ]] && continue
|
||||||
|
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
||||||
|
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
||||||
|
done
|
||||||
|
if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}")
|
||||||
|
HOME_TAB_INDEX=-1
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers.
|
||||||
|
library_set_bind() {
|
||||||
|
local addr="$1"
|
||||||
|
if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then
|
||||||
|
sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env"
|
||||||
|
else
|
||||||
|
echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null
|
||||||
|
fi
|
||||||
|
docker_compose "$LIBRARY_DIR" up -d
|
||||||
|
}
|
||||||
|
|
||||||
|
library_warn_if_low_memory() {
|
||||||
|
local total_mb
|
||||||
|
total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)
|
||||||
|
if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then
|
||||||
|
log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_library_install() {
|
||||||
|
echo
|
||||||
|
echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label"
|
||||||
|
echo "printing and a scan-to-add page. It needs Docker (installed for you if"
|
||||||
|
echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes"
|
||||||
|
echo "5-10 minutes."
|
||||||
|
echo
|
||||||
|
library_warn_if_low_memory
|
||||||
|
|
||||||
|
local library_name
|
||||||
|
library_name=$(ask_text "Library name (shown on the catalogue)" "My Library")
|
||||||
|
[[ -z "$library_name" ]] && library_name="My Library"
|
||||||
|
|
||||||
|
local bind="0.0.0.0"
|
||||||
|
echo
|
||||||
|
echo "Who should be able to open the catalogue, staff interface and Quick Add"
|
||||||
|
echo "page besides this kiosk?"
|
||||||
|
echo " - Yes: any device on your network (use it from your own laptop/phone;"
|
||||||
|
echo " the staff pages are protected by a generated password)."
|
||||||
|
echo " - No: only this kiosk. Safest, but you can only manage the library here."
|
||||||
|
if ! ask_yes_no "Allow other devices on the network?" "y"; then
|
||||||
|
bind="127.0.0.1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_ensure_installed || { pause; return 1; }
|
||||||
|
library_sync_files || { pause; return 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_info "Building and starting the stack - this takes a while on the first run..."
|
||||||
|
if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then
|
||||||
|
log_error "The library stack failed to start - see: sudo docker logs library-koha"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
|
||||||
|
library_add_tab
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
log_success "Library Catalog installed"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_show_access() {
|
||||||
|
local ip user pass
|
||||||
|
ip="$(get_ip_address)"
|
||||||
|
user="$(library_env_get ADMIN_USER)"
|
||||||
|
pass="$(library_env_get ADMIN_PASSWORD)"
|
||||||
|
echo
|
||||||
|
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||||
|
ip="localhost"
|
||||||
|
echo "(Network access is limited to this kiosk.)"
|
||||||
|
fi
|
||||||
|
echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/"
|
||||||
|
echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
|
echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/"
|
||||||
|
echo " Login (staff and add books): ${user} / ${pass}"
|
||||||
|
echo
|
||||||
|
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_toggle_tab() {
|
||||||
|
load_existing_config
|
||||||
|
if library_tab_exists; then
|
||||||
|
library_remove_tab
|
||||||
|
else
|
||||||
|
library_add_tab
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_network() {
|
||||||
|
local current
|
||||||
|
current="$(library_env_get BIND_ADDR)"
|
||||||
|
echo
|
||||||
|
echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then
|
||||||
|
library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect"
|
||||||
|
else
|
||||||
|
library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now"
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_start() {
|
||||||
|
docker_compose "$LIBRARY_DIR" start && log_success "Started"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_stop() {
|
||||||
|
docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_update() {
|
||||||
|
echo
|
||||||
|
echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept."
|
||||||
|
if ! ask_yes_no "Update now?" "y"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
docker_ensure_installed || { pause; return 1; }
|
||||||
|
library_sync_files || { pause; return 1; }
|
||||||
|
if sudo "$LIBRARY_DIR/install.sh" --update; then
|
||||||
|
log_success "Updated"
|
||||||
|
else
|
||||||
|
log_error "Update failed - see: sudo docker logs library-koha"
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# library_do_uninstall [purge]
|
||||||
|
# no argument: stop and remove the containers but KEEP the data volumes and
|
||||||
|
# $LIBRARY_DIR/.env - the database password is baked into the
|
||||||
|
# database volume, so a later reinstall needs the same .env.
|
||||||
|
# purge: also delete the data (all books!), the images and $LIBRARY_DIR.
|
||||||
|
library_do_uninstall() {
|
||||||
|
local mode="${1:-}"
|
||||||
|
library_remove_tab
|
||||||
|
if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then
|
||||||
|
if [[ "$mode" == "purge" ]]; then
|
||||||
|
docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true
|
||||||
|
else
|
||||||
|
docker_compose "$LIBRARY_DIR" down 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$mode" == "purge" ]]; then
|
||||||
|
sudo rm -rf "$LIBRARY_DIR"
|
||||||
|
log_success "Library Catalog and all its data removed"
|
||||||
|
else
|
||||||
|
log_success "Library Catalog stopped and removed (your books are kept)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_uninstall() {
|
||||||
|
echo
|
||||||
|
echo "This stops and removes the catalogue containers."
|
||||||
|
if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then
|
||||||
|
local confirm
|
||||||
|
confirm=$(ask_text "Type DELETE to confirm" "")
|
||||||
|
if [[ "$confirm" != "DELETE" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
pause
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
library_do_uninstall purge
|
||||||
|
else
|
||||||
|
library_do_uninstall
|
||||||
|
echo "Your books are kept; install again from this menu to bring them back."
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_lms_squeezelite.sh - "LMS Server / Squeezelite Player" addon.
|
||||||
|
#
|
||||||
|
# Two independent pieces sharing one menu, same as the legacy code: an LMS
|
||||||
|
# (Lyrion/Logitech Media Server) server the kiosk can host, and a
|
||||||
|
# Squeezelite player the kiosk can run to play music from any LMS server
|
||||||
|
# (this one or another one on the LAN). LMS itself is a real apt-managed
|
||||||
|
# subsystem with its own fixed paths (repo file, GPG keyring, ufw rules,
|
||||||
|
# /etc/squeezeboxserver) - like CUPS, those get full command-level `sudo`/
|
||||||
|
# `wget`/`apt` stubbing in tests rather than relocation. Squeezelite's own
|
||||||
|
# start script and systemd unit are ours to place, so - like power_schedule
|
||||||
|
# and the other addons - they go through $BIN_DIR/$SYSTEMD_DIR (lib/
|
||||||
|
# config.sh) instead of hardcoded /usr/local/bin and /etc/systemd/system,
|
||||||
|
# so tests can point them at a scratch directory.
|
||||||
|
#
|
||||||
|
# LMS ships under two package/service names depending on version -
|
||||||
|
# "logitechmediaserver" (older) and "lyrionmusicserver" (the project's
|
||||||
|
# current name after its rename) - so detection and every service call
|
||||||
|
# has to check both.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Also read by iso/offline/build-bundle.sh, to bundle the same versions
|
||||||
|
# for offline installs (the .deb itself plus its apt dependencies).
|
||||||
|
LMS_DEB_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb"
|
||||||
|
SQUEEZELITE_APT_PACKAGES=(squeezelite)
|
||||||
|
|
||||||
|
# True on a kiosk installed from an --offline ISO that can't reach the
|
||||||
|
# internet right now - the one case where LMS comes from the bundle.
|
||||||
|
lms_offline_install_wanted() {
|
||||||
|
grep -q '^Package: lyrionmusicserver$' "$KIOSK_OFFLINE_DIR/apt/Packages" 2>/dev/null && ! have_internet
|
||||||
|
}
|
||||||
|
|
||||||
|
lms_service_name() {
|
||||||
|
# `systemctl cat`, not `list-unit-files | grep -q`: grep -q exiting
|
||||||
|
# early can SIGPIPE systemctl, failing the pipeline under pipefail.
|
||||||
|
if systemctl cat lyrionmusicserver.service &>/dev/null; then
|
||||||
|
echo "lyrionmusicserver"
|
||||||
|
elif systemctl cat logitechmediaserver.service &>/dev/null; then
|
||||||
|
echo "logitechmediaserver"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
lms_is_installed() {
|
||||||
|
is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \
|
||||||
|
is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver
|
||||||
|
}
|
||||||
|
|
||||||
|
lms_is_running() {
|
||||||
|
is_service_active logitechmediaserver || is_service_active lyrionmusicserver
|
||||||
|
}
|
||||||
|
|
||||||
|
squeezelite_is_installed() {
|
||||||
|
is_service_active squeezelite || is_service_enabled squeezelite
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_lms_squeezelite_status() {
|
||||||
|
if lms_is_installed; then
|
||||||
|
echo "LMS Server: Installed"
|
||||||
|
if lms_is_running; then
|
||||||
|
echo " Status: Running"
|
||||||
|
else
|
||||||
|
echo " Status: Stopped"
|
||||||
|
fi
|
||||||
|
echo " Web: http://$(get_ip_address):9000"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
if squeezelite_is_installed; then
|
||||||
|
local player_name="Unknown"
|
||||||
|
if [[ -f "$BIN_DIR/squeezelite-start.sh" ]]; then
|
||||||
|
player_name=$(grep '^PLAYER_NAME=' "$BIN_DIR/squeezelite-start.sh" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown")
|
||||||
|
fi
|
||||||
|
echo "Squeezelite Player: Installed"
|
||||||
|
if is_service_active squeezelite; then
|
||||||
|
echo " Status: Running"
|
||||||
|
else
|
||||||
|
echo " Status: Stopped"
|
||||||
|
fi
|
||||||
|
echo " Name: $player_name"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ℹ A server is needed to stream music. The kiosk can run the"
|
||||||
|
echo " server (if sufficient resources) or connect to another server."
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_lms_squeezelite_menu_builder() {
|
||||||
|
MENU_LABELS=("Install/Configure LMS Server" "Install/Configure Squeezelite Player")
|
||||||
|
MENU_HANDLERS=(action_install_lms action_install_squeezelite)
|
||||||
|
|
||||||
|
if lms_is_installed; then
|
||||||
|
MENU_LABELS+=("Uninstall LMS Server")
|
||||||
|
MENU_HANDLERS+=(action_uninstall_lms)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if squeezelite_is_installed; then
|
||||||
|
MENU_LABELS+=("Uninstall Squeezelite Player")
|
||||||
|
MENU_HANDLERS+=(action_uninstall_squeezelite)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_lms_squeezelite_menu() {
|
||||||
|
run_menu "LMS SERVER / SQUEEZELITE PLAYER" addon_lms_squeezelite_menu_builder addon_lms_squeezelite_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions - LMS Server
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_install_lms() {
|
||||||
|
echo
|
||||||
|
if lms_is_installed; then
|
||||||
|
echo "LMS is already installed."
|
||||||
|
if ask_yes_no "Reconfigure port?" "n"; then
|
||||||
|
local new_port
|
||||||
|
new_port=$(ask_integer "New HTTP port" 9000 1 65535)
|
||||||
|
sudo sed -i "s/httpport:.*/httpport: $new_port/" /etc/squeezeboxserver/prefs/server.prefs 2>/dev/null || true
|
||||||
|
sudo systemctl restart lyrionmusicserver 2>/dev/null || sudo systemctl restart logitechmediaserver 2>/dev/null || true
|
||||||
|
log_success "LMS reconfigured on port $new_port"
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing Lyrion Music Server..."
|
||||||
|
|
||||||
|
if lms_offline_install_wanted; then
|
||||||
|
# Kiosk installed from an --offline ISO with no internet right now:
|
||||||
|
# the bundle carries the same LMS_DEB_URL .deb as a package.
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y lyrionmusicserver; then
|
||||||
|
log_error "Failed to install LMS from the offline bundle"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "LMS installed from the offline bundle"
|
||||||
|
else
|
||||||
|
# Try the repository method first.
|
||||||
|
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
|
||||||
|
# `|| true`: a bare, unguarded `apt update` failing here (bad
|
||||||
|
# mirror, no network) would otherwise crash the whole session
|
||||||
|
# under set -e instead of falling through to the direct-download
|
||||||
|
# fallback below, which is exactly the degrade path this is
|
||||||
|
# supposed to hit when the repository route doesn't work.
|
||||||
|
sudo apt update 2>/dev/null || true
|
||||||
|
if sudo apt install -y logitechmediaserver 2>/dev/null; then
|
||||||
|
log_success "LMS installed via repository"
|
||||||
|
else
|
||||||
|
log_warning "Repository install failed, trying direct download..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fall back to a direct .deb download if the repository didn't produce
|
||||||
|
# either possible package.
|
||||||
|
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
|
||||||
|
local lms_deb="/tmp/lms.deb"
|
||||||
|
echo "Downloading LMS ($(basename "$LMS_DEB_URL"))..."
|
||||||
|
if wget -q "$LMS_DEB_URL" -O "$lms_deb"; then
|
||||||
|
echo "Installing LMS package..."
|
||||||
|
if sudo apt install -y "$lms_deb"; then
|
||||||
|
log_success "LMS installed via direct download"
|
||||||
|
else
|
||||||
|
log_error "Failed to install LMS package"
|
||||||
|
rm -f "$lms_deb"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$lms_deb"
|
||||||
|
else
|
||||||
|
log_error "Failed to download LMS from lms-community.org"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local service_name
|
||||||
|
service_name=$(lms_service_name)
|
||||||
|
|
||||||
|
if [[ -z "$service_name" ]]; then
|
||||||
|
log_warning "Service file not found, checking installed files..."
|
||||||
|
service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs -r basename | sed 's/\.service$//' || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$service_name" ]]; then
|
||||||
|
log_error "Could not detect LMS service name"
|
||||||
|
echo "Manual steps:"
|
||||||
|
echo " 1. Find service: systemctl list-unit-files | grep -i lms"
|
||||||
|
echo " 2. Enable: sudo systemctl enable SERVICE_NAME"
|
||||||
|
echo " 3. Start: sudo systemctl start SERVICE_NAME"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Using service: $service_name"
|
||||||
|
# enable_and_start_units, not a bare `sudo systemctl enable ... | tee`
|
||||||
|
# pipe: the legacy version's `2>&1 | tee /tmp/lms-enable.log` made the
|
||||||
|
# whole statement's exit status depend on `tee` (always 0) rather than
|
||||||
|
# `systemctl enable`, so a real enable/start failure was silently
|
||||||
|
# swallowed instead of falling through to the warning below.
|
||||||
|
if enable_and_start_units "$service_name"; then
|
||||||
|
sudo ufw allow 9000/tcp comment 'LMS-HTTP' 2>/dev/null || true
|
||||||
|
sudo ufw allow 3483/tcp comment 'LMS-SlimProto' 2>/dev/null || true
|
||||||
|
sudo ufw allow 3483/udp comment 'LMS-Discovery' 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "LMS installed"
|
||||||
|
echo " Web interface: http://$(get_ip_address):9000"
|
||||||
|
else
|
||||||
|
log_warning "LMS installed, but systemctl enable/start failed - check 'systemctl status $service_name'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_uninstall_lms() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
lms_do_uninstall ask
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# The actual removal, no confirmation prompt - shared with Complete
|
||||||
|
# Uninstall so that operation doesn't need to re-implement LMS teardown a
|
||||||
|
# second time. $1: "ask" to prompt about data removal interactively (the
|
||||||
|
# normal case), "purge" to remove data without asking (Complete Uninstall).
|
||||||
|
lms_do_uninstall() {
|
||||||
|
local data_choice="${1:-ask}"
|
||||||
|
|
||||||
|
local service_name
|
||||||
|
service_name=$(lms_service_name)
|
||||||
|
|
||||||
|
if [[ -n "$service_name" ]]; then
|
||||||
|
echo "Stopping $service_name..."
|
||||||
|
sudo systemctl stop "$service_name" 2>/dev/null || true
|
||||||
|
sudo systemctl disable "$service_name" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to remove both possible package names - only one will actually
|
||||||
|
# be installed, the other is a harmless no-op.
|
||||||
|
sudo apt remove -y lyrionmusicserver 2>/dev/null || true
|
||||||
|
sudo apt remove -y logitechmediaserver 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/lms.list
|
||||||
|
sudo rm -f /usr/share/keyrings/lms-keyring.gpg
|
||||||
|
|
||||||
|
local purge_data=false
|
||||||
|
if [[ "$data_choice" == "purge" ]]; then
|
||||||
|
purge_data=true
|
||||||
|
elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove LMS data and configuration?" "n"; then
|
||||||
|
purge_data=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $purge_data; then
|
||||||
|
sudo rm -rf /var/lib/squeezeboxserver
|
||||||
|
sudo rm -rf /etc/squeezeboxserver
|
||||||
|
log_success "LMS and data removed"
|
||||||
|
else
|
||||||
|
log_success "LMS removed (data preserved)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions - Squeezelite Player
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_install_squeezelite() {
|
||||||
|
echo
|
||||||
|
if squeezelite_is_installed; then
|
||||||
|
echo "Squeezelite is already installed."
|
||||||
|
ask_yes_no "Reconfigure?" "n" || { pause; return; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v squeezelite &>/dev/null; then
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y "${SQUEEZELITE_APT_PACKAGES[@]}"; then
|
||||||
|
log_error "squeezelite package installation failed"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local player_name
|
||||||
|
player_name=$(ask_text "Player name" "Kiosk")
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "LMS Server Configuration:"
|
||||||
|
echo " Enter IP:PORT of your LMS server"
|
||||||
|
echo " Leave blank for auto-discovery on LAN"
|
||||||
|
echo
|
||||||
|
local lms_server
|
||||||
|
lms_server=$(ask_text "LMS Server (e.g., 192.168.1.100:3483)" "")
|
||||||
|
|
||||||
|
sudo tee "$BIN_DIR/squeezelite-start.sh" > /dev/null <<SQSTART
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PLAYER_NAME="$player_name"
|
||||||
|
LMS_SERVER="$lms_server"
|
||||||
|
|
||||||
|
for i in {1..20}; do
|
||||||
|
pactl info >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! pactl info >/dev/null 2>&1; then
|
||||||
|
logger "ERROR: Squeezelite - PipeWire not available"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "\$LMS_SERVER" ]]; then
|
||||||
|
exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -s "\$LMS_SERVER" -o pulse -a 80:4:: -b 512:1024 -C 5
|
||||||
|
else
|
||||||
|
exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -o pulse -a 80:4:: -b 512:1024 -C 5
|
||||||
|
fi
|
||||||
|
SQSTART
|
||||||
|
|
||||||
|
sudo chmod +x "$BIN_DIR/squeezelite-start.sh"
|
||||||
|
|
||||||
|
local kiosk_uid
|
||||||
|
kiosk_uid=$(id -u "$KIOSK_USER")
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/squeezelite.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Squeezelite
|
||||||
|
After=sound.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$KIOSK_USER
|
||||||
|
Environment="XDG_RUNTIME_DIR=/run/user/$kiosk_uid"
|
||||||
|
ExecStartPre=/bin/sleep 10
|
||||||
|
ExecStart=${BIN_DIR}/squeezelite-start.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
# Enable only, not start: squeezelite needs the kiosk user's real
|
||||||
|
# session (PipeWire, XDG_RUNTIME_DIR) up first, which is why a reboot
|
||||||
|
# is required below rather than starting it immediately.
|
||||||
|
if ! sudo systemctl enable squeezelite 2>/dev/null; then
|
||||||
|
log_warning "Squeezelite files written, but 'systemctl enable' failed - check 'systemctl status squeezelite'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Squeezelite installed: $player_name"
|
||||||
|
if [[ -n "$lms_server" ]]; then
|
||||||
|
echo " Server: $lms_server"
|
||||||
|
else
|
||||||
|
echo " Server: Auto-discovery"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "⚠️ IMPORTANT: Squeezelite requires a reboot to work properly"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Reboot now?" "n"; then
|
||||||
|
echo "Rebooting in 5 seconds..."
|
||||||
|
sleep 5
|
||||||
|
sudo reboot
|
||||||
|
else
|
||||||
|
echo "⚠️ Remember to reboot before using Squeezelite"
|
||||||
|
echo " Command: sudo reboot"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_uninstall_squeezelite() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
squeezelite_do_uninstall
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared with Complete Uninstall - same reasoning as lms_do_uninstall.
|
||||||
|
squeezelite_do_uninstall() {
|
||||||
|
sudo systemctl stop squeezelite 2>/dev/null || true
|
||||||
|
sudo systemctl disable squeezelite 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR/squeezelite.service"
|
||||||
|
sudo rm -f "$BIN_DIR/squeezelite-start.sh"
|
||||||
|
sudo apt remove -y squeezelite 2>/dev/null || true
|
||||||
|
log_success "Squeezelite removed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
|
||||||
|
# Tailscale, Netbird) plus SSH Access (GitHub/Launchpad key import).
|
||||||
|
#
|
||||||
|
# Third Addon migrated, and the biggest so far in scope (4 sub-areas).
|
||||||
|
# All four genuinely mutate real system state at fixed paths this project
|
||||||
|
# doesn't own the layout of (apt packages, /etc/wireguard, real VPN
|
||||||
|
# client CLIs) - same risk class as CUPS. Only $WIREGUARD_DIR and
|
||||||
|
# $SYSTEMD_DIR (lib/config.sh) are parameterized, since those are the
|
||||||
|
# only paths this file itself writes to; every command (apt, systemctl,
|
||||||
|
# wg, tailscale, netbird, x11vnc) gets full stubbing in every test.
|
||||||
|
#
|
||||||
|
# Tailscale and Netbird install themselves via `curl -fsSL <vendor
|
||||||
|
# url> | sh` - the vendors' own documented install method, preserved as-
|
||||||
|
# is rather than redesigned. This is NEVER allowed to run for real in
|
||||||
|
# any test: curl itself is stubbed, not just sudo, so there is no path
|
||||||
|
# by which a test could reach the network.
|
||||||
|
#
|
||||||
|
# None of x11vnc/wg/tailscale/netbird are installed in a fresh
|
||||||
|
# environment, so their "not installed" detection is real/unstubbed and
|
||||||
|
# safe to exercise end-to-end - only the "install" actions need stubs.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Also read by iso/offline/build-bundle.sh to bundle them for offline
|
||||||
|
# installs. (Tailscale/Netbird aren't bundled: they install from their
|
||||||
|
# vendors' scripts and need their vendors' servers to work anyway.)
|
||||||
|
VNC_APT_PACKAGES=(x11vnc)
|
||||||
|
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
|
||||||
|
|
||||||
|
remote_access_status() {
|
||||||
|
echo "SSH: $(ssh_status_line)"
|
||||||
|
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
|
||||||
|
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
|
||||||
|
echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")"
|
||||||
|
echo "Netbird: $(command -v netbird &>/dev/null && echo "installed" || echo "not installed")"
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_access_menu_builder() {
|
||||||
|
MENU_LABELS=("SSH Access (GitHub/Launchpad keys)" "VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
|
||||||
|
MENU_HANDLERS=(ssh_menu vnc_menu wireguard_menu tailscale_menu netbird_menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_access_menu() {
|
||||||
|
run_menu "REMOTE ACCESS" remote_access_menu_builder remote_access_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# VNC (x11vnc)
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
vnc_status() {
|
||||||
|
if is_service_active x11vnc; then
|
||||||
|
echo "VNC: running - connect to $(get_ip_address):5900"
|
||||||
|
else
|
||||||
|
echo "VNC: not installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
vnc_menu_builder() {
|
||||||
|
if is_service_active x11vnc; then
|
||||||
|
MENU_LABELS=("Reconfigure password" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_vnc_change_password action_vnc_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install x11vnc")
|
||||||
|
MENU_HANDLERS=(action_vnc_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
vnc_menu() {
|
||||||
|
run_menu "VNC REMOTE DESKTOP" vnc_menu_builder vnc_status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_vnc_install() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Install x11vnc?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y "${VNC_APT_PACKAGES[@]}"; then
|
||||||
|
log_error "x11vnc installation failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local vnc_pass
|
||||||
|
read -r -s -p "VNC password: " vnc_pass
|
||||||
|
echo
|
||||||
|
if [[ -z "$vnc_pass" ]]; then
|
||||||
|
log_error "No password provided - cancelled"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.vnc"
|
||||||
|
sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd"
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/x11vnc.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=x11vnc Remote Desktop
|
||||||
|
After=lightdm.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${KIOSK_USER}
|
||||||
|
ExecStart=/usr/bin/x11vnc -display :0 -auth guess -rfbauth ${KIOSK_HOME}/.vnc/passwd -forever -loop -noxdamage -repeat -shared
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if enable_and_start_units x11vnc; then
|
||||||
|
sudo ufw allow 5900/tcp comment 'VNC' 2>/dev/null || true
|
||||||
|
log_success "VNC installed - connect to $(get_ip_address):5900"
|
||||||
|
else
|
||||||
|
log_warning "x11vnc installed but systemctl enable/start failed - check 'systemctl status x11vnc'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_vnc_change_password() {
|
||||||
|
echo
|
||||||
|
local vnc_pass
|
||||||
|
read -r -s -p "New VNC password: " vnc_pass
|
||||||
|
echo
|
||||||
|
if [[ -z "$vnc_pass" ]]; then
|
||||||
|
log_error "No password provided - cancelled"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd"
|
||||||
|
if sudo systemctl restart x11vnc 2>/dev/null; then
|
||||||
|
log_success "VNC password updated"
|
||||||
|
else
|
||||||
|
log_warning "Password file updated, but restarting x11vnc failed - check 'systemctl status x11vnc'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_vnc_uninstall() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove VNC?" "n" || { echo "Cancelled"; return; }
|
||||||
|
vnc_do_uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||||
|
vnc_do_uninstall() {
|
||||||
|
sudo systemctl stop x11vnc 2>/dev/null || true
|
||||||
|
sudo systemctl disable x11vnc 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR/x11vnc.service"
|
||||||
|
sudo apt remove -y x11vnc 2>/dev/null || true
|
||||||
|
log_success "VNC removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# WireGuard
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
wireguard_connected() {
|
||||||
|
command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface
|
||||||
|
}
|
||||||
|
|
||||||
|
wireguard_status() {
|
||||||
|
if wireguard_connected; then
|
||||||
|
echo "WireGuard: connected"
|
||||||
|
sudo wg show 2>/dev/null | grep -E "interface:|endpoint:|allowed ips:" | sed 's/^/ /' || true
|
||||||
|
elif command -v wg &>/dev/null; then
|
||||||
|
echo "WireGuard: installed, not connected"
|
||||||
|
else
|
||||||
|
echo "WireGuard: not installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wireguard_menu_builder() {
|
||||||
|
if wireguard_connected; then
|
||||||
|
MENU_LABELS=("Show full config" "Paste new config" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_wireguard_show_config action_wireguard_paste_config action_wireguard_uninstall)
|
||||||
|
elif command -v wg &>/dev/null; then
|
||||||
|
MENU_LABELS=("Paste config" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_wireguard_paste_config action_wireguard_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install WireGuard")
|
||||||
|
MENU_HANDLERS=(action_wireguard_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wireguard_menu() {
|
||||||
|
run_menu "WIREGUARD VPN" wireguard_menu_builder wireguard_status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_wireguard_install() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Install WireGuard?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y "${WIREGUARD_APT_PACKAGES[@]}"; then
|
||||||
|
log_error "WireGuard installation failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "WireGuard installed"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Paste a config now?" "n"; then
|
||||||
|
action_wireguard_paste_config
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_wireguard_show_config() {
|
||||||
|
echo
|
||||||
|
sudo wg show all
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads a WireGuard config from stdin until EOF (Ctrl+D on a real
|
||||||
|
# terminal) - same as the legacy addon. Writes to $WIREGUARD_DIR rather
|
||||||
|
# than a hardcoded /etc/wireguard, so tests can point it at scratch space
|
||||||
|
# and verify the written content without touching the real directory.
|
||||||
|
action_wireguard_paste_config() {
|
||||||
|
echo
|
||||||
|
echo "Paste your WireGuard config (Ctrl+D when done):"
|
||||||
|
local config
|
||||||
|
config=$(cat)
|
||||||
|
|
||||||
|
if [[ -z "$config" ]]; then
|
||||||
|
log_error "No config provided"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local wg_name
|
||||||
|
wg_name=$(ask_text "Config name" "wg0")
|
||||||
|
|
||||||
|
sudo mkdir -p "$WIREGUARD_DIR"
|
||||||
|
echo "$config" | sudo tee "$WIREGUARD_DIR/${wg_name}.conf" > /dev/null
|
||||||
|
sudo chmod 600 "$WIREGUARD_DIR/${wg_name}.conf"
|
||||||
|
|
||||||
|
if enable_and_start_units "wg-quick@${wg_name}"; then
|
||||||
|
log_success "WireGuard configured: $wg_name"
|
||||||
|
else
|
||||||
|
log_warning "Config written, but systemctl enable/start failed - check 'systemctl status wg-quick@${wg_name}'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_wireguard_uninstall() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove WireGuard?" "n" || { echo "Cancelled"; return; }
|
||||||
|
wireguard_do_uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||||
|
wireguard_do_uninstall() {
|
||||||
|
sudo systemctl stop 'wg-quick@*' 2>/dev/null || true
|
||||||
|
sudo systemctl disable 'wg-quick@*' 2>/dev/null || true
|
||||||
|
sudo apt remove -y wireguard wireguard-tools 2>/dev/null || true
|
||||||
|
log_success "WireGuard removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Tailscale
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
tailscale_backend_state() {
|
||||||
|
tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"' 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
tailscale_status() {
|
||||||
|
if ! command -v tailscale &>/dev/null; then
|
||||||
|
echo "Tailscale: not installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(tailscale_backend_state)" == "Running" ]]; then
|
||||||
|
echo "Tailscale: connected"
|
||||||
|
echo " Hostname: $(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // "unknown"')"
|
||||||
|
echo " IP: $(tailscale ip -4 2>/dev/null)"
|
||||||
|
else
|
||||||
|
echo "Tailscale: installed, not connected"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tailscale_menu_builder() {
|
||||||
|
if command -v tailscale &>/dev/null; then
|
||||||
|
MENU_LABELS=("Connect (interactive)" "Connect with auth key" "Show status" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_tailscale_connect_interactive action_tailscale_connect_authkey action_tailscale_show_status action_tailscale_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install Tailscale")
|
||||||
|
MENU_HANDLERS=(action_tailscale_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tailscale_menu() {
|
||||||
|
run_menu "TAILSCALE VPN" tailscale_menu_builder tailscale_status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_tailscale_install() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Install Tailscale?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
if ! curl -fsSL https://tailscale.com/install.sh | sh; then
|
||||||
|
log_error "Tailscale installation failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Tailscale installed"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Options:"
|
||||||
|
echo " 1. Connect now (interactive)"
|
||||||
|
echo " 2. Connect with auth key"
|
||||||
|
echo " 3. Connect later"
|
||||||
|
local choice
|
||||||
|
choice=$(ask_integer "Choose" "3" 1 3)
|
||||||
|
case "$choice" in
|
||||||
|
1) action_tailscale_connect_interactive ;;
|
||||||
|
2) action_tailscale_connect_authkey ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
action_tailscale_connect_interactive() {
|
||||||
|
echo
|
||||||
|
if sudo tailscale up; then
|
||||||
|
log_success "Tailscale connected"
|
||||||
|
else
|
||||||
|
log_error "Tailscale connection failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_tailscale_connect_authkey() {
|
||||||
|
echo
|
||||||
|
echo "Get an auth key from: https://login.tailscale.com/admin/settings/keys"
|
||||||
|
local authkey
|
||||||
|
read -r -p "Enter auth key: " authkey
|
||||||
|
if [[ -z "$authkey" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sudo tailscale up --authkey="$authkey"; then
|
||||||
|
log_success "Tailscale connected"
|
||||||
|
else
|
||||||
|
log_error "Tailscale connection failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_tailscale_show_status() {
|
||||||
|
echo
|
||||||
|
tailscale status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_tailscale_uninstall() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove Tailscale?" "n" || { echo "Cancelled"; return; }
|
||||||
|
tailscale_do_uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||||
|
tailscale_do_uninstall() {
|
||||||
|
sudo tailscale down 2>/dev/null || true
|
||||||
|
sudo apt remove -y tailscale 2>/dev/null || true
|
||||||
|
log_success "Tailscale removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Netbird
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
netbird_connected() {
|
||||||
|
[[ "$(netbird status 2>/dev/null | grep "Status:" | awk '{print $2}')" == "Connected" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
netbird_status() {
|
||||||
|
if ! command -v netbird &>/dev/null; then
|
||||||
|
echo "Netbird: not installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if netbird_connected; then
|
||||||
|
echo "Netbird: connected"
|
||||||
|
netbird status 2>/dev/null | grep -E "NetBird IP:|Public key:" | sed 's/^/ /' || true
|
||||||
|
else
|
||||||
|
echo "Netbird: installed, not connected"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
netbird_menu_builder() {
|
||||||
|
if command -v netbird &>/dev/null; then
|
||||||
|
MENU_LABELS=("Connect with setup key" "Show status" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_netbird_connect action_netbird_show_status action_netbird_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install Netbird")
|
||||||
|
MENU_HANDLERS=(action_netbird_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
netbird_menu() {
|
||||||
|
run_menu "NETBIRD VPN" netbird_menu_builder netbird_status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_netbird_install() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Install Netbird?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
if ! curl -fsSL https://pkgs.netbird.io/install.sh | sh; then
|
||||||
|
log_error "Netbird installation failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Netbird installed"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Connect with a setup key now?" "n"; then
|
||||||
|
action_netbird_connect
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_netbird_connect() {
|
||||||
|
echo
|
||||||
|
echo "Get a setup key from the Netbird dashboard"
|
||||||
|
local setup_key
|
||||||
|
read -r -p "Enter setup key: " setup_key
|
||||||
|
if [[ -z "$setup_key" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sudo netbird up --setup-key "$setup_key" --allow-server-ssh; then
|
||||||
|
log_success "Netbird connected"
|
||||||
|
else
|
||||||
|
log_error "Netbird connection failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_netbird_show_status() {
|
||||||
|
echo
|
||||||
|
netbird status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_netbird_uninstall() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove Netbird?" "n" || { echo "Cancelled"; return; }
|
||||||
|
netbird_do_uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||||
|
netbird_do_uninstall() {
|
||||||
|
sudo netbird down 2>/dev/null || true
|
||||||
|
sudo apt remove -y netbird 2>/dev/null || true
|
||||||
|
log_success "Netbird removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# SSH Access - openssh-server plus public keys imported from GitHub or
|
||||||
|
# Launchpad with ssh-import-id (the same "Import SSH identity" choice
|
||||||
|
# Ubuntu's own installer offers). Keys go to the admin user running this
|
||||||
|
# menu (~/.ssh/authorized_keys), never to the restricted `kiosk` user.
|
||||||
|
# The key sets published by those services are fetched over HTTPS and
|
||||||
|
# are public data, so no secret is handled here.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
SSH_KEYS_ONLY_CONF="${SSHD_CONFIG_DIR:-/etc/ssh/sshd_config.d}/10-kiosk-keys-only.conf"
|
||||||
|
SSH_AUTH_KEYS="${SSH_AUTH_KEYS:-$HOME/.ssh/authorized_keys}"
|
||||||
|
|
||||||
|
ssh_key_count() {
|
||||||
|
[[ -f "$SSH_AUTH_KEYS" ]] && grep -cE '^(ssh-|ecdsa-|sk-)' "$SSH_AUTH_KEYS" || echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_status_line() {
|
||||||
|
local state="not installed"
|
||||||
|
if is_service_active ssh || is_service_active sshd; then
|
||||||
|
state="running"
|
||||||
|
elif dpkg -s openssh-server &>/dev/null; then
|
||||||
|
state="installed, not running"
|
||||||
|
fi
|
||||||
|
local pw="password login on"
|
||||||
|
[[ -f "$SSH_KEYS_ONLY_CONF" ]] && pw="keys only"
|
||||||
|
echo "$state, $(ssh_key_count) key(s), $pw"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_status() {
|
||||||
|
echo "SSH: $(ssh_status_line)"
|
||||||
|
echo " Keys go to: $SSH_AUTH_KEYS"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_menu_builder() {
|
||||||
|
MENU_LABELS=("Enable SSH server" "Import keys from GitHub" "Import keys from Launchpad" "List authorized keys" "Require keys (disable password login)" "Allow password login again")
|
||||||
|
MENU_HANDLERS=(action_ssh_enable action_ssh_import_github action_ssh_import_launchpad action_ssh_list_keys action_ssh_keys_only action_ssh_allow_passwords)
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_menu() {
|
||||||
|
run_menu "SSH ACCESS" ssh_menu_builder ssh_status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_enable() {
|
||||||
|
echo
|
||||||
|
sudo apt install -y openssh-server ssh-import-id || { log_error "Could not install openssh-server"; return 1; }
|
||||||
|
sudo systemctl enable --now ssh || { log_error "Could not start the SSH server"; return 1; }
|
||||||
|
# provision_configure_firewall already allows ssh; harmless if repeated.
|
||||||
|
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||||
|
sudo ufw allow ssh >/dev/null
|
||||||
|
fi
|
||||||
|
log_success "SSH server enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
# $1 = "gh" or "lp", $2 = label for messages.
|
||||||
|
ssh_import_from() {
|
||||||
|
local prefix="$1" label="$2" user
|
||||||
|
echo
|
||||||
|
if ! command -v ssh-import-id &>/dev/null; then
|
||||||
|
sudo apt install -y ssh-import-id || { log_error "Could not install ssh-import-id"; return 1; }
|
||||||
|
fi
|
||||||
|
read -r -p "$label username (blank to cancel): " user
|
||||||
|
[[ -z "$user" ]] && { echo "Cancelled"; return; }
|
||||||
|
if [[ ! "$user" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||||
|
log_error "That doesn't look like a valid $label username"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ssh-import-id "${prefix}:${user}"; then
|
||||||
|
log_success "Imported $label keys for $user ($(ssh_key_count) key(s) now authorized)"
|
||||||
|
else
|
||||||
|
log_error "Import failed - check the username and that this machine can reach $label"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_import_github() { ssh_import_from gh "GitHub"; }
|
||||||
|
action_ssh_import_launchpad() { ssh_import_from lp "Launchpad"; }
|
||||||
|
|
||||||
|
action_ssh_list_keys() {
|
||||||
|
echo
|
||||||
|
if [[ "$(ssh_key_count)" == "0" ]]; then
|
||||||
|
echo "No authorized keys for $USER yet."
|
||||||
|
else
|
||||||
|
ssh-keygen -lf "$SSH_AUTH_KEYS"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_keys_only() {
|
||||||
|
echo
|
||||||
|
if [[ "$(ssh_key_count)" == "0" ]]; then
|
||||||
|
log_error "No authorized keys yet - import some first, or you'd lock yourself out"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Log in from another machine with a key BEFORE doing this; password"
|
||||||
|
echo "logins over SSH will stop working."
|
||||||
|
ask_yes_no "Disable SSH password login?" "n" || { echo "Cancelled"; return; }
|
||||||
|
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' |
|
||||||
|
sudo install -D -m 644 /dev/stdin "$SSH_KEYS_ONLY_CONF"
|
||||||
|
if sudo sshd -t && sudo systemctl reload ssh; then
|
||||||
|
log_success "SSH now requires a key"
|
||||||
|
else
|
||||||
|
sudo rm -f "$SSH_KEYS_ONLY_CONF"
|
||||||
|
log_error "sshd rejected the config - reverted"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_allow_passwords() {
|
||||||
|
echo
|
||||||
|
sudo rm -f "$SSH_KEYS_ONLY_CONF"
|
||||||
|
sudo systemctl reload ssh 2>/dev/null || true
|
||||||
|
log_success "SSH password login allowed again"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First-time install hook (called from lib/provision.sh).
|
||||||
|
ssh_first_time_setup() {
|
||||||
|
echo
|
||||||
|
echo "SSH lets you manage the kiosk remotely. Ubuntu can import your"
|
||||||
|
echo "public keys from GitHub or Launchpad so you can log in with no password."
|
||||||
|
ask_yes_no "Enable SSH and import keys now?" "y" || { log_info "Configure later: Addons -> Remote Access -> SSH Access"; return; }
|
||||||
|
action_ssh_enable || return 0
|
||||||
|
local choice
|
||||||
|
while true; do
|
||||||
|
echo
|
||||||
|
echo "Import keys from:"
|
||||||
|
echo " 1. GitHub"
|
||||||
|
echo " 2. Launchpad"
|
||||||
|
echo " 3. Done"
|
||||||
|
choice=$(ask_integer "Choose" "3" 1 3)
|
||||||
|
case "$choice" in
|
||||||
|
1) action_ssh_import_github || true ;;
|
||||||
|
2) action_ssh_import_launchpad || true ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ "$(ssh_key_count)" != "0" ]]; then
|
||||||
|
echo "Tip: Remote Access -> SSH Access can switch SSH to key-only login"
|
||||||
|
echo "once you've confirmed a key works."
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/
|
||||||
|
# Express app) as a systemd service running as $KIOSK_USER, giving a
|
||||||
|
# browser-based editor for Sites & Page Timing, Display & Interaction,
|
||||||
|
# and Password Protection & Lockout - config.json read/write only, no
|
||||||
|
# sudo needed, since that file is already owned by $KIOSK_USER.
|
||||||
|
#
|
||||||
|
# Installed by default during first-time provisioning now
|
||||||
|
# (lib/provision.sh's provision_configure_webui, which calls the same
|
||||||
|
# install helpers this file defines) - this menu remains here for
|
||||||
|
# reconfiguring the port, restarting the service, or reinstalling it on
|
||||||
|
# a kiosk provisioned before this change.
|
||||||
|
#
|
||||||
|
# Also lets the web UI trigger a fixed, vetted set of privileged actions
|
||||||
|
# - install/reconfigure CUPS/LMS/Squeezelite/Asterisk Intercom, and
|
||||||
|
# Update - through a narrow, allow-listed root helper
|
||||||
|
# (webui_write_helper_script below), rather than by giving the service
|
||||||
|
# itself any elevated privilege. The helper is reachable only via a
|
||||||
|
# single-path passwordless sudo rule (webui_write_sudoers_file) and
|
||||||
|
# re-checks its own fixed action allow-list before dispatching anything,
|
||||||
|
# even though the sudoers rule alone already restricts which script can
|
||||||
|
# run - defense in depth. Each allow-listed action is the exact same
|
||||||
|
# interactive `action_*` function the terminal menu already uses,
|
||||||
|
# driven by piping the right answers on stdin, the same technique this
|
||||||
|
# project's own bash tests already use (see webui/lib/actions.js and
|
||||||
|
# webui/test/*.test.js). No prompt/mutation refactor of any addon file
|
||||||
|
# was needed for this to work. Everything else (WiFi, Timezone, Power/
|
||||||
|
# Display/Quiet Hours, Complete Uninstall, Remote Access, Authelia,
|
||||||
|
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
|
||||||
|
# stays terminal-only for now.
|
||||||
|
#
|
||||||
|
# No login of its own, by design: this addon assumes it'll be put behind
|
||||||
|
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in 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 no proxy in front 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
|
||||||
|
# without a proxy in front: bounded to its fixed action list, not a
|
||||||
|
# root shell, but real system mutation all the same.
|
||||||
|
#
|
||||||
|
# webui/'s own app-level logic (config.json schema/merge, API
|
||||||
|
# validation, the action allow-list/stdin synthesis, the job/SSE
|
||||||
|
# system) lives and is tested entirely under webui/ - this file only
|
||||||
|
# wires it up as a system service plus the privileged helper, and never
|
||||||
|
# touches config.json itself.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
webui_is_installed() {
|
||||||
|
[[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
webui_is_active() {
|
||||||
|
systemctl is-active --quiet kiosk-webui
|
||||||
|
}
|
||||||
|
|
||||||
|
webui_port() {
|
||||||
|
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"
|
||||||
|
else
|
||||||
|
echo "8090"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_webui_status() {
|
||||||
|
if webui_is_installed && webui_is_active; then
|
||||||
|
echo "Web UI: running at http://$(get_ip_address):$(webui_port)"
|
||||||
|
elif webui_is_installed; then
|
||||||
|
echo "Web UI: installed but not running"
|
||||||
|
else
|
||||||
|
echo "Web UI: not installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_webui_menu_builder() {
|
||||||
|
if webui_is_installed; then
|
||||||
|
MENU_LABELS=("Change port" "Restart service" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_webui_reconfigure action_webui_restart action_webui_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install Web UI")
|
||||||
|
MENU_HANDLERS=(action_webui_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_webui_menu() {
|
||||||
|
run_menu "WEB UI" addon_webui_menu_builder addon_webui_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Shared install helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Copies webui/ from this checkout onto the kiosk and installs its npm
|
||||||
|
# dependencies as $KIOSK_USER - same shape as provision_install_app in
|
||||||
|
# lib/provision.sh, but addon-scoped (opt-in) rather than core. Every
|
||||||
|
# critical step is individually guarded (matches menus/addon_cups.sh's
|
||||||
|
# action_install_cups) rather than relying on set -e to stop a bare
|
||||||
|
# sequence - menu actions are always invoked via run_menu's
|
||||||
|
# `"${MENU_HANDLERS[...]}" "$choice" || true` dispatch, which already
|
||||||
|
# exempts everything they do from set -e for the whole call, so a bare
|
||||||
|
# unguarded sequence here would silently continue past a real failure
|
||||||
|
# (e.g. attempting npm install into a directory `cp` never populated).
|
||||||
|
webui_install_app_files() {
|
||||||
|
if ! sudo mkdir -p "$WEBUI_DIR"; then
|
||||||
|
log_error "Could not create $WEBUI_DIR"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! sudo cp -r "$SCRIPT_DIR/webui/." "$WEBUI_DIR/"; then
|
||||||
|
log_error "Could not copy Web UI app files"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$WEBUI_DIR"
|
||||||
|
if offline_mode_active; then
|
||||||
|
offline_install_node_modules webui "$WEBUI_DIR" || return 1
|
||||||
|
elif ! sudo -u "$KIOSK_USER" bash -lc "cd '$WEBUI_DIR' && npm install --omit=dev --unsafe-perm"; then
|
||||||
|
log_error "npm install failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
webui_write_env_file() {
|
||||||
|
local port="$1"
|
||||||
|
sudo mkdir -p "$WEBUI_ENV_DIR"
|
||||||
|
sudo tee "$WEBUI_ENV_DIR/webui.env" > /dev/null <<EOF
|
||||||
|
PORT=${port}
|
||||||
|
BIND_ADDR=0.0.0.0
|
||||||
|
CONFIG_PATH=${CONFIG_PATH}
|
||||||
|
HELPER_PATH=${WEBUI_HELPER_PATH}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Writes the one root-owned script the Web UI is ever allowed to reach
|
||||||
|
# via sudo (see the sudoers rule webui_write_sudoers_file writes right
|
||||||
|
# after this). Sources the exact same files install.sh does - full path
|
||||||
|
# list baked in at generation time from $SCRIPT_DIR, so the generated
|
||||||
|
# script has no runtime dependency on where it happens to be invoked
|
||||||
|
# from. The fixed ALLOWED_ACTIONS allow-list inside the script itself is
|
||||||
|
# the real gate (checked in addition to, not instead of, the sudoers
|
||||||
|
# rule only permitting this one script path) - a request that reaches
|
||||||
|
# this script can only ever trigger one of these exact, already-tested
|
||||||
|
# interactive `action_*` functions, driven the same way this project's
|
||||||
|
# own bash tests already drive them: real answers piped in on stdin, in
|
||||||
|
# the exact order the function's own prompts expect them. No prompt/
|
||||||
|
# mutation refactor of any addon file needed for this to work.
|
||||||
|
webui_write_helper_script() {
|
||||||
|
sudo mkdir -p "$(dirname "$WEBUI_HELPER_PATH")"
|
||||||
|
sudo tee "$WEBUI_HELPER_PATH" > /dev/null <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# lib/provision.sh reads \$SCRIPT_DIR directly at source time (no
|
||||||
|
# fallback default, unlike the lib/config.sh path vars) - must be a real
|
||||||
|
# exported variable here, not just used to interpolate the source paths
|
||||||
|
# below, or it's unbound under set -u.
|
||||||
|
export SCRIPT_DIR="$SCRIPT_DIR"
|
||||||
|
|
||||||
|
source "$SCRIPT_DIR/lib/menu.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/config.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/electron.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/sites.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/display.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/time_sync.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/lockout.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/wifi.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/power_schedule.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/diagnostics.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_cups.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_authelia.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/clone_settings.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/provision.sh"
|
||||||
|
|
||||||
|
# Read-only status wrappers, not addon logic of their own - just a
|
||||||
|
# single fixed-shape JSON line so the web UI can show "Install" vs.
|
||||||
|
# "Reconfigure" per addon in one round trip. None of the underlying
|
||||||
|
# checks (dpkg query, systemctl is-active/is-enabled, a file-existence
|
||||||
|
# test under \$KIOSK_HOME) need root, but this script is still only
|
||||||
|
# reachable via the same sudo-gated path as everything else here - one
|
||||||
|
# access path is simpler to reason about than two, and the extra sudo
|
||||||
|
# call for a read-only check is negligible.
|
||||||
|
status_all() {
|
||||||
|
echo "{\"cups\":\$(cups_is_installed && echo true || echo false),\"lms\":\$(lms_is_installed && echo true || echo false),\"squeezelite\":\$(squeezelite_is_installed && echo true || echo false),\"asterisk_intercom\":\$(baresip_is_installed && echo true || echo false)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_ACTIONS=(
|
||||||
|
action_install_cups
|
||||||
|
action_reconfigure_cups
|
||||||
|
action_install_lms
|
||||||
|
action_install_squeezelite
|
||||||
|
action_configure_asterisk_intercom
|
||||||
|
action_upgrade
|
||||||
|
status_all
|
||||||
|
)
|
||||||
|
|
||||||
|
action="\${1:-}"
|
||||||
|
allowed=false
|
||||||
|
for a in "\${ALLOWED_ACTIONS[@]}"; do
|
||||||
|
[[ "\$a" == "\$action" ]] && allowed=true && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! \$allowed; then
|
||||||
|
echo "kiosk-webui-helper: action not permitted: \$action" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"\$action"
|
||||||
|
EOF
|
||||||
|
sudo chown root:root "$WEBUI_HELPER_PATH"
|
||||||
|
sudo chmod 750 "$WEBUI_HELPER_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grants $KIOSK_USER passwordless sudo on exactly this one script path -
|
||||||
|
# no argument wildcarding at the sudoers level, since the script's own
|
||||||
|
# ALLOWED_ACTIONS check above is the real gate. Validated with
|
||||||
|
# `visudo -c -f` on a temp file before it's moved into place: a
|
||||||
|
# malformed sudoers snippet can break sudo system-wide, so this step is
|
||||||
|
# never skipped.
|
||||||
|
webui_write_sudoers_file() {
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${WEBUI_HELPER_PATH}" > "$tmp"
|
||||||
|
|
||||||
|
if ! sudo visudo -c -f "$tmp" &>/dev/null; then
|
||||||
|
log_error "Generated sudoers rule failed validation - not installed"
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$SUDOERS_D_DIR"
|
||||||
|
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-webui"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
webui_write_unit_file() {
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${KIOSK_USER}
|
||||||
|
WorkingDirectory=${WEBUI_DIR}
|
||||||
|
EnvironmentFile=${WEBUI_ENV_DIR}/webui.env
|
||||||
|
ExecStart=/usr/bin/node ${WEBUI_DIR}/server.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_webui_install() {
|
||||||
|
echo
|
||||||
|
echo "This installs a small web app for editing Sites & Page Timing,"
|
||||||
|
echo "Display & Interaction, and Password Protection & Lockout from a"
|
||||||
|
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
|
||||||
|
echo "and Asterisk Intercom, and checking for updates. It has no login"
|
||||||
|
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
|
||||||
|
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
|
||||||
|
echo
|
||||||
|
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
local port
|
||||||
|
port=$(ask_integer "Port to listen on" "8090" 1024 65535)
|
||||||
|
|
||||||
|
echo "Installing app files and npm dependencies..."
|
||||||
|
if ! webui_install_app_files; then
|
||||||
|
log_error "Web UI install failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
webui_write_env_file "$port"
|
||||||
|
webui_write_unit_file
|
||||||
|
webui_write_helper_script
|
||||||
|
if ! webui_write_sudoers_file; then
|
||||||
|
log_error "Web UI install failed - could not grant the addon-install/Update helper permission"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-webui; then
|
||||||
|
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||||
|
log_success "Web UI installed: http://$(get_ip_address):${port}"
|
||||||
|
else
|
||||||
|
log_error "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_webui_reconfigure() {
|
||||||
|
echo
|
||||||
|
local current_port
|
||||||
|
current_port=$(webui_port)
|
||||||
|
local port
|
||||||
|
port=$(ask_integer "Port to listen on" "$current_port" 1024 65535)
|
||||||
|
|
||||||
|
if [[ "$port" == "$current_port" ]]; then
|
||||||
|
echo "No change"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
webui_write_env_file "$port"
|
||||||
|
sudo systemctl restart kiosk-webui
|
||||||
|
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||||
|
log_success "Web UI now listening on port ${port}"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_webui_restart() {
|
||||||
|
sudo systemctl restart kiosk-webui
|
||||||
|
sleep 1
|
||||||
|
if webui_is_active; then
|
||||||
|
log_success "Web UI restarted"
|
||||||
|
else
|
||||||
|
log_error "Web UI failed to restart - check: sudo journalctl -u kiosk-webui -n 50"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_webui_uninstall() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Uninstall the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||||
|
webui_do_uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||||
|
# operation doesn't need to re-implement Web UI teardown a second time.
|
||||||
|
# No `ufw delete` - matches every other addon's uninstall in this
|
||||||
|
# codebase, which never removes its own firewall rule either.
|
||||||
|
webui_do_uninstall() {
|
||||||
|
sudo systemctl stop kiosk-webui 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-webui 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service"
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
sudo rm -f "$SUDOERS_D_DIR/kiosk-webui" "$WEBUI_HELPER_PATH"
|
||||||
|
sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR"
|
||||||
|
log_success "Web UI removed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/advanced_electron.sh - "Electron Maintenance" (Advanced): the
|
||||||
|
# legacy "Manual Electron Update" and "Fix Blank Screen" items, combined
|
||||||
|
# under one submenu since both maintain the same Electron installation.
|
||||||
|
# The binary-repair logic itself (electron_install_binary) now lives in
|
||||||
|
# lib/electron.sh, shared with fresh provisioning (lib/provision.sh) -
|
||||||
|
# the same repair sequence applies whether the binary never downloaded
|
||||||
|
# during the initial `npm install` or went missing later.
|
||||||
|
#
|
||||||
|
# Real system state: $KIOSK_DIR/node_modules, package.json, lightdm.
|
||||||
|
# Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, stubbed at the
|
||||||
|
# command level in tests - there's no relocatable equivalent for another
|
||||||
|
# project's (npm/Electron's) own directory layout.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
electron_installed_version() {
|
||||||
|
local package_json="$KIOSK_DIR/package.json"
|
||||||
|
if ! sudo test -f "$package_json" 2>/dev/null; then
|
||||||
|
echo "not installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local version
|
||||||
|
version=$(sudo grep -oP '"electron"\s*:\s*"\^?\K[0-9.]+' "$package_json" 2>/dev/null || true)
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
local electron_pkg="$KIOSK_DIR/node_modules/electron/package.json"
|
||||||
|
if sudo test -f "$electron_pkg" 2>/dev/null; then
|
||||||
|
version=$(sudo grep -oP '"version"\s*:\s*"\K[0-9.]+' "$electron_pkg" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "${version:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
electron_is_running() {
|
||||||
|
pgrep -f "electron.*main.js" &>/dev/null || pgrep -f "node.*electron" &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_electron_status() {
|
||||||
|
local ver
|
||||||
|
ver=$(electron_installed_version)
|
||||||
|
echo "Electron: v${ver}"
|
||||||
|
if electron_is_running; then
|
||||||
|
echo " Running"
|
||||||
|
else
|
||||||
|
echo " Not running"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_electron_menu_builder() {
|
||||||
|
MENU_LABELS=(
|
||||||
|
"Check for updates / update Electron"
|
||||||
|
"Fix blank screen (repair Electron binary + sandbox)"
|
||||||
|
)
|
||||||
|
MENU_HANDLERS=(action_update_electron action_repair_electron)
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_electron_menu() {
|
||||||
|
run_menu "ELECTRON MAINTENANCE" advanced_electron_menu_builder advanced_electron_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_update_electron() {
|
||||||
|
echo
|
||||||
|
if ! sudo test -d "$KIOSK_DIR" 2>/dev/null; then
|
||||||
|
log_error "Kiosk directory not found: $KIOSK_DIR"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local current_version
|
||||||
|
current_version=$(electron_installed_version)
|
||||||
|
log_info "Current Electron version: $current_version"
|
||||||
|
|
||||||
|
if electron_is_running; then
|
||||||
|
log_success "Electron app is running"
|
||||||
|
else
|
||||||
|
log_warning "Electron app does not appear to be running"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
ask_yes_no "Check for latest Electron version?" "y" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
local latest_version
|
||||||
|
latest_version=$(npm view electron version 2>/dev/null || true)
|
||||||
|
if [[ -z "$latest_version" ]]; then
|
||||||
|
latest_version=$(curl -s https://registry.npmjs.org/electron/latest 2>/dev/null | grep -oP '"version"\s*:\s*"\K[0-9.]+' || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$latest_version" ]]; then
|
||||||
|
latest_version=$(curl -s https://api.github.com/repos/electron/electron/releases/latest 2>/dev/null | grep -oP '"tag_name"\s*:\s*"v\K[0-9.]+' || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$latest_version" ]]; then
|
||||||
|
log_error "Could not fetch latest Electron version - check your internet connection"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Latest stable Electron version: $latest_version"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "$current_version" == "$latest_version" ]]; then
|
||||||
|
log_success "Already running the latest version"
|
||||||
|
ask_yes_no "Reinstall Electron $latest_version anyway?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "──────────────────────────────────────────────────────────"
|
||||||
|
echo "UPDATE SUMMARY"
|
||||||
|
echo "──────────────────────────────────────────────────────────"
|
||||||
|
echo "Current version: $current_version"
|
||||||
|
echo "Target version: $latest_version"
|
||||||
|
echo "Installation: $KIOSK_DIR"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local current_major="${current_version%%.*}"
|
||||||
|
local latest_major="${latest_version%%.*}"
|
||||||
|
log_warning "Review breaking changes before updating:"
|
||||||
|
echo " https://www.electronjs.org/docs/latest/breaking-changes"
|
||||||
|
if [[ "$latest_major" != "$current_major" ]]; then
|
||||||
|
log_warning "MAJOR VERSION CHANGE (v${current_major} -> v${latest_major})"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
ask_yes_no "Reviewed breaking changes and want to proceed?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_info "Creating backup..."
|
||||||
|
local kiosk_owner
|
||||||
|
kiosk_owner=$(sudo stat -c '%U' "$KIOSK_DIR" 2>/dev/null || echo "$KIOSK_USER")
|
||||||
|
local backup_dir="${KIOSK_DIR}/backups/electron_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
sudo -u "$kiosk_owner" mkdir -p "$backup_dir"
|
||||||
|
|
||||||
|
if sudo test -f "$KIOSK_DIR/package.json" 2>/dev/null; then
|
||||||
|
sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package.json" "$backup_dir/"
|
||||||
|
fi
|
||||||
|
if sudo test -f "$KIOSK_DIR/package-lock.json" 2>/dev/null; then
|
||||||
|
sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package-lock.json" "$backup_dir/"
|
||||||
|
fi
|
||||||
|
echo "$current_version" | sudo -u "$kiosk_owner" tee "$backup_dir/electron_version.txt" > /dev/null
|
||||||
|
log_success "Backup created at: $backup_dir"
|
||||||
|
echo
|
||||||
|
|
||||||
|
ask_yes_no "Proceed with Electron update to $latest_version?" "n" || {
|
||||||
|
log_info "Update cancelled - backup preserved at: $backup_dir"
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_info "Stopping kiosk display..."
|
||||||
|
sudo systemctl stop lightdm 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" sed -i "s/\"electron\": \".*\"/\"electron\": \"^${latest_version}\"/" "$KIOSK_DIR/package.json"
|
||||||
|
|
||||||
|
if sudo test -d "$KIOSK_DIR/node_modules/electron" 2>/dev/null; then
|
||||||
|
sudo -u "$KIOSK_USER" rm -rf "$KIOSK_DIR/node_modules/electron"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Installing Electron $latest_version (this may take a few minutes)..."
|
||||||
|
if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install electron@'$latest_version'"; then
|
||||||
|
log_success "Electron updated to $latest_version"
|
||||||
|
|
||||||
|
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||||
|
if sudo test -f "$sandbox" 2>/dev/null; then
|
||||||
|
sudo chown root:root "$sandbox"
|
||||||
|
sudo chmod 4755 "$sandbox"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ask_yes_no "Restart kiosk display now?" "y"; then
|
||||||
|
sudo systemctl start lightdm
|
||||||
|
sleep 3
|
||||||
|
if systemctl is-active --quiet lightdm; then
|
||||||
|
log_success "Kiosk display started"
|
||||||
|
else
|
||||||
|
log_error "Kiosk display failed to start - check: sudo journalctl -u lightdm -n 50"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_info "Start manually with: sudo systemctl start lightdm"
|
||||||
|
fi
|
||||||
|
log_success "Backup preserved at: $backup_dir (delete once confirmed working)"
|
||||||
|
else
|
||||||
|
log_error "Electron install failed - restoring from backup..."
|
||||||
|
if sudo test -f "$backup_dir/package.json" 2>/dev/null; then
|
||||||
|
sudo -u "$KIOSK_USER" cp "$backup_dir/package.json" "$KIOSK_DIR/"
|
||||||
|
fi
|
||||||
|
if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install"; then
|
||||||
|
log_success "Restored original Electron installation"
|
||||||
|
sudo systemctl start lightdm
|
||||||
|
else
|
||||||
|
log_error "Failed to restore - manual intervention required"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_repair_electron() {
|
||||||
|
echo
|
||||||
|
echo "This will:"
|
||||||
|
echo " 1. Check if the Electron binary is present"
|
||||||
|
echo " 2. Download it if missing (~120MB)"
|
||||||
|
echo " 3. Fix chrome-sandbox permissions (setuid root)"
|
||||||
|
echo " 4. Restart the kiosk display"
|
||||||
|
echo
|
||||||
|
ask_yes_no "Continue?" "y" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
sudo systemctl stop lightdm 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Bare call, not `if ! electron_install_binary; then`: testing a
|
||||||
|
# multi-statement function as an if-condition exempts everything
|
||||||
|
# inside it from set -e for the duration (e.g. the sandbox chown/
|
||||||
|
# chmod below would silently continue past an earlier failure).
|
||||||
|
# Capturing $? right after a bare call doesn't have that problem -
|
||||||
|
# the exemption only affects whether a nonzero status halts the
|
||||||
|
# script, never the actual value $? holds.
|
||||||
|
electron_install_binary
|
||||||
|
local electron_rc=$?
|
||||||
|
if [[ $electron_rc -ne 0 ]]; then
|
||||||
|
log_error "Could not install Electron. Check internet and retry."
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Restarting kiosk display..."
|
||||||
|
sudo systemctl restart lightdm
|
||||||
|
sleep 3
|
||||||
|
if systemctl is-active --quiet lightdm && pgrep -f "electron.*main.js" &>/dev/null; then
|
||||||
|
log_success "Kiosk display is running"
|
||||||
|
else
|
||||||
|
log_warning "LightDM started but Electron may still be loading."
|
||||||
|
echo " Check: sudo tail -20 $KIOSK_DIR/../electron.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced):
|
||||||
|
# auto-starts a WiFi hotspot if the kiosk finds itself cut off 60 seconds
|
||||||
|
# after boot, so it can be reached and reconfigured remotely.
|
||||||
|
# "Cut off" is chosen at setup (EMERGENCY_HOTSPOT_CHECK_MODE in the
|
||||||
|
# generated script): by default the local network itself is unreachable -
|
||||||
|
# neither the router nor the internet answers - so a kiosk on a LAN with
|
||||||
|
# no internet (an offline install) doesn't start the hotspot on every
|
||||||
|
# boot. The old internet-only check (ping 8.8.8.8) is still an option, as
|
||||||
|
# is checking one specific address. Scripts generated before this choice
|
||||||
|
# existed keep the internet-only check until Reconfigure is run.
|
||||||
|
#
|
||||||
|
# Writes a standalone runtime script ($BIN_DIR/kiosk-emergency-hotspot)
|
||||||
|
# plus a oneshot systemd unit ($SYSTEMD_DIR) that runs it at boot - both
|
||||||
|
# of those paths are ours to place, so (like power_schedule and every
|
||||||
|
# other addon) they're parameterized instead of hardcoded. hostapd/
|
||||||
|
# dnsmasq/iptables themselves are real apt packages with their own fixed
|
||||||
|
# config locations, stubbed at the command level in tests like CUPS.
|
||||||
|
#
|
||||||
|
# The runtime script itself is a template: everything written with `\$`
|
||||||
|
# below stays literal and only resolves when the script actually runs at
|
||||||
|
# boot (on the real machine, not in this tool); only the un-escaped
|
||||||
|
# $wifi_iface/$hotspot_ssid/$hotspot_pass/$hotspot_ip/$KIOSK_USER/
|
||||||
|
# $KIOSK_DIR are substituted once, at configuration time.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
EMERGENCY_HOTSPOT_SCRIPT="$BIN_DIR/kiosk-emergency-hotspot"
|
||||||
|
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
|
||||||
|
EMERGENCY_HOTSPOT_APT_PACKAGES=(hostapd dnsmasq iptables)
|
||||||
|
|
||||||
|
emergency_hotspot_is_configured() {
|
||||||
|
[[ -f "$EMERGENCY_HOTSPOT_SCRIPT" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
emergency_hotspot_ssid() {
|
||||||
|
grep '^HOTSPOT_SSID=' "$EMERGENCY_HOTSPOT_SCRIPT" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_emergency_hotspot_status() {
|
||||||
|
if emergency_hotspot_is_configured; then
|
||||||
|
local ssid
|
||||||
|
ssid=$(emergency_hotspot_ssid)
|
||||||
|
echo "Emergency Hotspot: Configured (SSID: ${ssid:-unknown})"
|
||||||
|
else
|
||||||
|
echo "Emergency Hotspot: Not configured"
|
||||||
|
fi
|
||||||
|
echo "ℹ Auto-starts a WiFi hotspot if the kiosk can't reach its network"
|
||||||
|
echo " 60 seconds after boot, so you can connect and reconfigure remotely."
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_emergency_hotspot_menu_builder() {
|
||||||
|
if emergency_hotspot_is_configured; then
|
||||||
|
MENU_LABELS=("Reconfigure" "Disable")
|
||||||
|
MENU_HANDLERS=(action_configure_emergency_hotspot action_disable_emergency_hotspot)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Enable emergency hotspot")
|
||||||
|
MENU_HANDLERS=(action_configure_emergency_hotspot)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_emergency_hotspot_menu() {
|
||||||
|
run_menu "EMERGENCY HOTSPOT" advanced_emergency_hotspot_menu_builder advanced_emergency_hotspot_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_configure_emergency_hotspot() {
|
||||||
|
echo
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}"; then
|
||||||
|
log_error "Failed to install hostapd/dnsmasq/iptables"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo systemctl stop hostapd dnsmasq 2>/dev/null || true
|
||||||
|
sudo systemctl disable hostapd dnsmasq 2>/dev/null || true
|
||||||
|
|
||||||
|
local wifi_iface
|
||||||
|
wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1 || true)
|
||||||
|
if [[ -z "$wifi_iface" ]]; then
|
||||||
|
log_error "No WiFi interface found"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "WiFi interface: $wifi_iface"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local hotspot_ssid
|
||||||
|
hotspot_ssid=$(ask_text "Hotspot SSID" "Kiosk-Emergency")
|
||||||
|
|
||||||
|
local hotspot_pass=""
|
||||||
|
while [[ ${#hotspot_pass} -lt 8 ]]; do
|
||||||
|
read -r -s -p "Hotspot password (8+ chars): " hotspot_pass
|
||||||
|
echo
|
||||||
|
[[ ${#hotspot_pass} -lt 8 ]] && log_error "Password must be at least 8 characters"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "When should the hotspot start? (checked 60 seconds after boot)"
|
||||||
|
echo " 1) The local network is unreachable - neither the router nor the"
|
||||||
|
echo " internet answers. Recommended; right for networks without"
|
||||||
|
echo " internet too."
|
||||||
|
echo " 2) There's no internet. On a network without internet this starts"
|
||||||
|
echo " the hotspot on every boot."
|
||||||
|
echo " 3) A specific address on your network doesn't answer (e.g. a server)."
|
||||||
|
local check_mode="network" check_host="" check_choice
|
||||||
|
check_choice=$(ask_integer "Choice" 1 1 3)
|
||||||
|
case "$check_choice" in
|
||||||
|
2) check_mode="internet" ;;
|
||||||
|
3)
|
||||||
|
check_host=$(ask_text "Address to check (IP or hostname)" "")
|
||||||
|
if [[ "$check_host" =~ ^[A-Za-z0-9.:-]+$ ]]; then
|
||||||
|
check_mode="host"
|
||||||
|
else
|
||||||
|
log_warning "Not a valid address - using option 1 instead"
|
||||||
|
check_host=""
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local hotspot_ip="192.168.50.1"
|
||||||
|
|
||||||
|
sudo mkdir -p "$BIN_DIR"
|
||||||
|
sudo tee "$EMERGENCY_HOTSPOT_SCRIPT" > /dev/null <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
### KIOSK EMERGENCY HOTSPOT
|
||||||
|
### Auto-starts if the network check fails 60 seconds after boot
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# network = neither the default gateway (router) nor the internet answers
|
||||||
|
# internet = no internet (pings 8.8.8.8 / 1.1.1.1)
|
||||||
|
# host = CHECK_HOST doesn't answer
|
||||||
|
CHECK_MODE="$check_mode"
|
||||||
|
CHECK_HOST="$check_host"
|
||||||
|
WIFI_IFACE="$wifi_iface"
|
||||||
|
HOTSPOT_SSID="$hotspot_ssid"
|
||||||
|
HOTSPOT_PASS="$hotspot_pass"
|
||||||
|
HOTSPOT_IP="$hotspot_ip"
|
||||||
|
KIOSK_USER="$KIOSK_USER"
|
||||||
|
|
||||||
|
# Wait 60 seconds after boot
|
||||||
|
sleep 60
|
||||||
|
|
||||||
|
ping_ok() { ping -c 2 -W 3 "\$1" >/dev/null 2>&1; }
|
||||||
|
internet_ok() { ping_ok 8.8.8.8 || ping_ok 1.1.1.1; }
|
||||||
|
# The router counts as there if it answers ping - or, for routers set to
|
||||||
|
# ignore ping, if it answered the ARP lookup that ping just triggered.
|
||||||
|
gateway_ok() {
|
||||||
|
local gw
|
||||||
|
gw=\$(ip -4 route show default 2>/dev/null | awk '{print \$3; exit}')
|
||||||
|
[[ -n "\$gw" ]] || return 1
|
||||||
|
ping_ok "\$gw" && return 0
|
||||||
|
ip neigh show "\$gw" 2>/dev/null | grep -qE 'REACHABLE|STALE|DELAY|PROBE'
|
||||||
|
}
|
||||||
|
connected() {
|
||||||
|
case "\$CHECK_MODE" in
|
||||||
|
internet) internet_ok ;;
|
||||||
|
host) ping_ok "\$CHECK_HOST" ;;
|
||||||
|
*) gateway_ok || internet_ok ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Three tries, 10 seconds apart, so a slow DHCP lease or WiFi association
|
||||||
|
# isn't mistaken for being cut off.
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if connected; then
|
||||||
|
logger "KIOSK: Network check (\$CHECK_MODE) passed - emergency hotspot not needed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[[ \$attempt -lt 3 ]] && sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
|
logger "KIOSK: Network check (\$CHECK_MODE) failed - starting emergency hotspot"
|
||||||
|
|
||||||
|
# Stop any conflicting services
|
||||||
|
systemctl stop wpa_supplicant 2>/dev/null || true
|
||||||
|
ip link set \$WIFI_IFACE down 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Configure static IP for hotspot
|
||||||
|
ip addr flush dev \$WIFI_IFACE
|
||||||
|
ip addr add \${HOTSPOT_IP}/24 dev \$WIFI_IFACE
|
||||||
|
ip link set \$WIFI_IFACE up
|
||||||
|
|
||||||
|
# Configure dnsmasq
|
||||||
|
cat > /tmp/dnsmasq-hotspot.conf <<DNSMASQ
|
||||||
|
interface=\$WIFI_IFACE
|
||||||
|
dhcp-range=192.168.50.10,192.168.50.50,12h
|
||||||
|
dhcp-option=3,\$HOTSPOT_IP
|
||||||
|
dhcp-option=6,\$HOTSPOT_IP
|
||||||
|
server=8.8.8.8
|
||||||
|
log-queries
|
||||||
|
log-dhcp
|
||||||
|
DNSMASQ
|
||||||
|
|
||||||
|
# Start dnsmasq
|
||||||
|
dnsmasq -C /tmp/dnsmasq-hotspot.conf
|
||||||
|
|
||||||
|
# Configure hostapd
|
||||||
|
cat > /tmp/hostapd-hotspot.conf <<HOSTAPD
|
||||||
|
interface=\$WIFI_IFACE
|
||||||
|
driver=nl80211
|
||||||
|
ssid=\$HOTSPOT_SSID
|
||||||
|
hw_mode=g
|
||||||
|
channel=6
|
||||||
|
macaddr_acl=0
|
||||||
|
auth_algs=1
|
||||||
|
ignore_broadcast_ssid=0
|
||||||
|
wpa=2
|
||||||
|
wpa_passphrase=\$HOTSPOT_PASS
|
||||||
|
wpa_key_mgmt=WPA-PSK
|
||||||
|
wpa_pairwise=TKIP
|
||||||
|
rsn_pairwise=CCMP
|
||||||
|
HOSTAPD
|
||||||
|
|
||||||
|
# Start hostapd
|
||||||
|
hostapd -B /tmp/hostapd-hotspot.conf
|
||||||
|
|
||||||
|
# Enable IP forwarding (optional - for internet sharing if wired connection exists)
|
||||||
|
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
|
||||||
|
|
||||||
|
# Show notification on kiosk display
|
||||||
|
sudo -u \$KIOSK_USER DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$(id -u \$KIOSK_USER)/bus \\
|
||||||
|
notify-send -u critical -t 0 "Emergency Hotspot Active" \\
|
||||||
|
"SSID: \$HOTSPOT_SSID\\nPassword: \$HOTSPOT_PASS\\nConnect to: http://\$HOTSPOT_IP" 2>/dev/null || true
|
||||||
|
|
||||||
|
logger "KIOSK: Emergency hotspot started - SSID: \$HOTSPOT_SSID, IP: \$HOTSPOT_IP"
|
||||||
|
|
||||||
|
# Create on-screen notification HTML
|
||||||
|
sudo -u \$KIOSK_USER tee /tmp/hotspot-notification.html > /dev/null <<'NOTIFY'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 48px; margin-bottom: 20px; }
|
||||||
|
.icon { font-size: 72px; margin-bottom: 20px; }
|
||||||
|
.info { font-size: 24px; margin: 20px 0; line-height: 1.6; }
|
||||||
|
.credential {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.dismiss {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
background: white;
|
||||||
|
color: #e74c3c;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.dismiss:hover { background: #ecf0f1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">📡</div>
|
||||||
|
<h1>Emergency Hotspot Active</h1>
|
||||||
|
<div class="info">No internet connection detected<br>Hotspot created for remote access</div>
|
||||||
|
<div class="credential">SSID: <strong>\$HOTSPOT_SSID</strong></div>
|
||||||
|
<div class="credential">Password: <strong>\$HOTSPOT_PASS</strong></div>
|
||||||
|
<div class="credential">Connect to: <strong>http://\$HOTSPOT_IP</strong></div>
|
||||||
|
<button class="dismiss" onclick="window.close()">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Auto-dismiss after 5 minutes
|
||||||
|
setTimeout(() => window.close(), 300000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
NOTIFY
|
||||||
|
|
||||||
|
# Show notification window if Electron is running
|
||||||
|
if pgrep -f "electron.*main.js" >/dev/null 2>&1; then
|
||||||
|
sudo -u \$KIOSK_USER DISPLAY=:0 \\
|
||||||
|
"$KIOSK_DIR/node_modules/electron/dist/electron" \\
|
||||||
|
/tmp/hotspot-notification.html &
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo chmod +x "$EMERGENCY_HOTSPOT_SCRIPT"
|
||||||
|
|
||||||
|
sudo mkdir -p "$SYSTEMD_DIR"
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-emergency-hotspot.service" > /dev/null <<UNITEOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Emergency Hotspot
|
||||||
|
After=network.target lightdm.service
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${EMERGENCY_HOTSPOT_SCRIPT}
|
||||||
|
RemainAfterExit=yes
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNITEOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
# Enable only, not start: this is a boot-time oneshot that waits 60s
|
||||||
|
# and checks connectivity - starting it right now would just run that
|
||||||
|
# wait/check immediately, which isn't what "configure" means here.
|
||||||
|
if ! sudo systemctl enable kiosk-emergency-hotspot.service 2>/dev/null; then
|
||||||
|
log_warning "Hotspot files written, but 'systemctl enable' failed - check 'systemctl status kiosk-emergency-hotspot.service'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_success "Emergency hotspot configured"
|
||||||
|
echo " SSID: $hotspot_ssid"
|
||||||
|
echo " Password: $hotspot_pass"
|
||||||
|
echo " IP: $hotspot_ip"
|
||||||
|
echo
|
||||||
|
echo "Hotspot auto-starts if no internet is detected 60 seconds after boot."
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_disable_emergency_hotspot() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Disable emergency hotspot?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
emergency_hotspot_do_disable
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||||
|
emergency_hotspot_do_disable() {
|
||||||
|
sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-emergency-hotspot.service 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR/kiosk-emergency-hotspot.service"
|
||||||
|
sudo rm -f "$EMERGENCY_HOTSPOT_SCRIPT"
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
log_success "Emergency hotspot disabled"
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/advanced_factory_reset.sh - "Factory Reset" (Advanced): wipe
|
||||||
|
# config.json back to script defaults without touching anything else.
|
||||||
|
#
|
||||||
|
# Deliberately narrow - this only removes $CONFIG_PATH. Installed addons
|
||||||
|
# (CUPS, LMS, VPNs, etc.), the kiosk user, and the system itself are left
|
||||||
|
# alone; that's what Complete Uninstall is for.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
advanced_factory_reset_status() {
|
||||||
|
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
echo "Config: $CONFIG_PATH exists"
|
||||||
|
else
|
||||||
|
echo "Config: not found (already at defaults)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_factory_reset_menu_builder() {
|
||||||
|
MENU_LABELS=("Reset configuration to defaults")
|
||||||
|
MENU_HANDLERS=(action_factory_reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_factory_reset_menu() {
|
||||||
|
run_menu "FACTORY RESET" advanced_factory_reset_menu_builder advanced_factory_reset_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_factory_reset() {
|
||||||
|
echo
|
||||||
|
echo "This resets $CONFIG_PATH to defaults - sites, schedules,"
|
||||||
|
echo "password protection, and every other setting stored there are"
|
||||||
|
echo "cleared. Installed addons (CUPS, LMS, VPNs, etc.) are not touched."
|
||||||
|
echo
|
||||||
|
ask_yes_no "Continue?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
sudo -u "$KIOSK_USER" rm -f "$CONFIG_PATH"
|
||||||
|
log_success "Configuration reset - reconfigure via Core Settings"
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/advanced_upgrade.sh - "Upgrade" (Advanced): pull the latest code
|
||||||
|
# from git and re-apply provisioning, plus an on-demand Electron update.
|
||||||
|
#
|
||||||
|
# ubuntu-based-kiosk.sh's Upgrade extracted fresh copies of main.js/
|
||||||
|
# preload.js/etc from its own heredocs on every run - the modular tool
|
||||||
|
# has no heredocs to extract from. kiosk-app/ and provision/files/ are
|
||||||
|
# real files in this git checkout, so "get whatever's new" is just
|
||||||
|
# `git pull` followed by re-running the same steps lib/provision.sh
|
||||||
|
# already has for a fresh install - reused here, not reimplemented,
|
||||||
|
# minus the interactive first-run settings wizard and the "reboot now"
|
||||||
|
# prompt (upgrading shouldn't re-ask sites/hotspot/vconsoles or reboot
|
||||||
|
# the whole machine). Electron itself isn't versioned by this repo, so
|
||||||
|
# checking for a newer Electron is a separate step: the existing,
|
||||||
|
# already-tested action_update_electron (menus/advanced_electron.sh),
|
||||||
|
# reused as-is rather than duplicated.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh,
|
||||||
|
# lib/provision.sh, menus/advanced_electron.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
advanced_upgrade_status() {
|
||||||
|
if git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||||
|
local branch rev
|
||||||
|
branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||||
|
rev=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
echo "Installed from git: $branch @ $rev"
|
||||||
|
else
|
||||||
|
echo "Installed from git: not a git checkout (upgrade unavailable)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_upgrade_menu_builder() {
|
||||||
|
MENU_LABELS=("Check for and apply updates")
|
||||||
|
MENU_HANDLERS=(action_upgrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_upgrade_menu() {
|
||||||
|
run_menu "UPGRADE" advanced_upgrade_menu_builder advanced_upgrade_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_upgrade() {
|
||||||
|
echo
|
||||||
|
if ! command -v git &>/dev/null; then
|
||||||
|
log_error "git is not installed - can't check for updates"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||||
|
log_error "$SCRIPT_DIR is not a git checkout - re-clone the repo to get this feature"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$(git -C "$SCRIPT_DIR" status --porcelain)" ]]; then
|
||||||
|
log_error "Local changes in $SCRIPT_DIR - commit or discard them first, then retry"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Checking for updates..."
|
||||||
|
if ! git -C "$SCRIPT_DIR" fetch origin; then
|
||||||
|
log_error "Could not reach GitHub - check your internet connection"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local branch local_rev remote_rev
|
||||||
|
branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD)
|
||||||
|
local_rev=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
|
||||||
|
remote_rev=$(git -C "$SCRIPT_DIR" rev-parse "origin/$branch" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -z "$remote_rev" ]]; then
|
||||||
|
log_error "Could not find origin/$branch - is this checkout tracking a real branch?"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$local_rev" == "$remote_rev" ]]; then
|
||||||
|
log_success "Already up to date ($branch @ ${local_rev:0:8})"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "Changes available:"
|
||||||
|
git -C "$SCRIPT_DIR" --no-pager log --oneline "${local_rev}..${remote_rev}"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Pull these changes and re-apply setup (packages, app files, hardware config)?" "y"; then
|
||||||
|
if ! git -C "$SCRIPT_DIR" pull --ff-only origin "$branch"; then
|
||||||
|
log_error "Pull failed (not a fast-forward) - resolve manually in $SCRIPT_DIR"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Pulled latest code ($branch @ $(git -C "$SCRIPT_DIR" rev-parse --short HEAD))"
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_info "Re-applying setup with the updated code..."
|
||||||
|
provision_install_packages
|
||||||
|
provision_create_kiosk_user
|
||||||
|
provision_install_nodejs
|
||||||
|
|
||||||
|
# Bare call, not `if ! provision_install_app; then`: testing a
|
||||||
|
# multi-statement function as an if-condition exempts
|
||||||
|
# everything inside it from set -e for the duration (see
|
||||||
|
# lib/provision.sh's own call to this same function). $? is
|
||||||
|
# captured right after instead - accurate either way, and
|
||||||
|
# doesn't add a new exemption on top of the one this action
|
||||||
|
# already has from being invoked through run_menu's dispatch.
|
||||||
|
provision_install_app
|
||||||
|
local app_rc=$?
|
||||||
|
if [[ $app_rc -ne 0 ]]; then
|
||||||
|
log_error "Upgrade stopped - app reinstall failed, see above"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
provision_configure_display
|
||||||
|
provision_configure_firewall
|
||||||
|
provision_configure_power_management
|
||||||
|
log_success "Setup refreshed"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Restart kiosk display now to apply changes?" "y"; then
|
||||||
|
sudo systemctl restart lightdm
|
||||||
|
else
|
||||||
|
log_info "Restart later with: sudo systemctl restart lightdm"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Cancelled"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Check for and install the latest Electron version too?" "y"; then
|
||||||
|
action_update_electron
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/advanced_virtual_consoles.sh - "Virtual Consoles" (Advanced): toggle
|
||||||
|
# Ctrl+Alt+F1-F8 terminal login access for troubleshooting.
|
||||||
|
#
|
||||||
|
# Real system state: masks/unmasks the getty@ttyN systemd units and writes
|
||||||
|
# a fixed-path X11 server-flags file. Neither is relocatable (X11 only
|
||||||
|
# reads /etc/X11/xorg.conf.d/, and getty units are always system units),
|
||||||
|
# so tests use full command-level `sudo` stubbing, same approach as CUPS.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
vconsoles_are_disabled() {
|
||||||
|
local getty_masked=false
|
||||||
|
local vt_switch_disabled=false
|
||||||
|
|
||||||
|
if systemctl is-masked --quiet getty@tty1.service 2>/dev/null; then
|
||||||
|
getty_masked=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f /etc/X11/xorg.conf.d/10-serverflags.conf ]] && \
|
||||||
|
grep -q 'Option.*"DontVTSwitch".*"true"' /etc/X11/xorg.conf.d/10-serverflags.conf 2>/dev/null; then
|
||||||
|
vt_switch_disabled=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$getty_masked" == "true" || "$vt_switch_disabled" == "true" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_virtual_consoles_status() {
|
||||||
|
if vconsoles_are_disabled; then
|
||||||
|
echo "Virtual consoles: Disabled"
|
||||||
|
else
|
||||||
|
echo "Virtual consoles: Enabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_virtual_consoles_menu_builder() {
|
||||||
|
if vconsoles_are_disabled; then
|
||||||
|
MENU_LABELS=("Enable virtual consoles (Ctrl+Alt+F1-F8 for manual login)")
|
||||||
|
MENU_HANDLERS=(action_enable_virtual_consoles)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Disable virtual consoles (more secure, kiosk only)")
|
||||||
|
MENU_HANDLERS=(action_disable_virtual_consoles)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_virtual_consoles_menu() {
|
||||||
|
run_menu "VIRTUAL CONSOLES" advanced_virtual_consoles_menu_builder advanced_virtual_consoles_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_enable_virtual_consoles() {
|
||||||
|
echo
|
||||||
|
echo "Enabling virtual consoles..."
|
||||||
|
|
||||||
|
for i in {1..8}; do
|
||||||
|
sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||||
|
sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF'
|
||||||
|
Section "ServerFlags"
|
||||||
|
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||||
|
Option "DontZap" "true"
|
||||||
|
|
||||||
|
# ALLOW VT switching (Ctrl+Alt+F1-F12)
|
||||||
|
Option "DontVTSwitch" "false"
|
||||||
|
|
||||||
|
# Don't allow clients to disconnect on exit
|
||||||
|
Option "AllowClosedownGrabs" "false"
|
||||||
|
EndSection
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_success "Virtual consoles enabled"
|
||||||
|
echo " Access with Ctrl+Alt+F1 through Ctrl+Alt+F8"
|
||||||
|
echo " (Ctrl+Alt+F7 typically returns to the kiosk)"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Restart kiosk display now to apply?" "n"; then
|
||||||
|
sudo systemctl restart lightdm
|
||||||
|
else
|
||||||
|
log_warning "Remember to restart: sudo systemctl restart lightdm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_disable_virtual_consoles() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Disable all virtual consoles?" "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
echo "Disabling virtual consoles..."
|
||||||
|
|
||||||
|
for i in {1..8}; do
|
||||||
|
sudo systemctl mask "getty@tty${i}.service" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||||
|
sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF'
|
||||||
|
Section "ServerFlags"
|
||||||
|
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||||
|
Option "DontZap" "true"
|
||||||
|
|
||||||
|
# DISABLE VT switching (Ctrl+Alt+F1-F12)
|
||||||
|
Option "DontVTSwitch" "true"
|
||||||
|
|
||||||
|
# Don't allow clients to disconnect on exit
|
||||||
|
Option "AllowClosedownGrabs" "false"
|
||||||
|
EndSection
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_success "Virtual consoles disabled"
|
||||||
|
echo " You can re-enable them from this menu at any time."
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Restart kiosk display now to apply?" "n"; then
|
||||||
|
sudo systemctl restart lightdm
|
||||||
|
else
|
||||||
|
log_warning "Remember to restart: sudo systemctl restart lightdm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/clone_settings.sh - "Clone Settings" (Advanced): export the portable
|
||||||
|
# parts of this kiosk's configuration to a JSON file, and apply that file
|
||||||
|
# to other already-installed kiosks - for standing up several kiosks that
|
||||||
|
# should share the same sites/settings.
|
||||||
|
#
|
||||||
|
# This is deliberately an MVP, not the legacy Export/Import Settings
|
||||||
|
# redesigned 1:1. It moves only what's actually safe to copy between
|
||||||
|
# machines automatically:
|
||||||
|
# - config.json's portable fields (sites, display/touch, navigation,
|
||||||
|
# lockout, password protection) - plain data, no machine binding.
|
||||||
|
# - Which addons were present at export time, as an informational
|
||||||
|
# checklist on apply - NOT automated installation. That's a
|
||||||
|
# deliberately separate, bigger follow-up (each addon would need a
|
||||||
|
# non-interactive install variant, mirroring the *_do_uninstall
|
||||||
|
# helpers Complete Uninstall already composes).
|
||||||
|
#
|
||||||
|
# Explicitly NOT exported, because copying them would be actively wrong,
|
||||||
|
# not just incomplete:
|
||||||
|
# - Authelia credentials: encrypted with a key derived from this
|
||||||
|
# machine's /etc/machine-id (addon_authelia.sh) - decrypts to
|
||||||
|
# garbage on any other machine.
|
||||||
|
# - WireGuard/VPN identity: a private key is that device's identity;
|
||||||
|
# reusing one across machines is a peer conflict, not a saving.
|
||||||
|
# - Asterisk Intercom's SIP extension: most PBXes reject two
|
||||||
|
# simultaneous registrations to the same extension.
|
||||||
|
# Apply prints all three as an explicit "needs a human" checklist rather
|
||||||
|
# than silently skipping them.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh
|
||||||
|
# being sourced first (for the *_is_installed detection helpers).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
clone_settings_status() {
|
||||||
|
echo "Exports/applies sites, display, navigation, and lockout settings"
|
||||||
|
echo "between already-installed kiosks. Addon credentials that are"
|
||||||
|
echo "bound to one machine (Authelia, WireGuard, Asterisk Intercom)"
|
||||||
|
echo "are never copied - see the checklist after applying a profile."
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_settings_menu_builder() {
|
||||||
|
MENU_LABELS=("Export settings" "Apply settings (clone)")
|
||||||
|
MENU_HANDLERS=(action_export_clone_settings action_apply_clone_settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_settings_menu() {
|
||||||
|
run_menu "CLONE SETTINGS" clone_settings_menu_builder clone_settings_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# JSON array of addon identifiers currently present on this machine.
|
||||||
|
clone_detect_addons() {
|
||||||
|
local addons=()
|
||||||
|
|
||||||
|
cups_is_installed 2>/dev/null && addons+=("cups")
|
||||||
|
lms_is_installed 2>/dev/null && addons+=("lms")
|
||||||
|
squeezelite_is_installed 2>/dev/null && addons+=("squeezelite")
|
||||||
|
is_service_active x11vnc 2>/dev/null && addons+=("vnc")
|
||||||
|
command -v wg &>/dev/null && addons+=("wireguard")
|
||||||
|
command -v tailscale &>/dev/null && addons+=("tailscale")
|
||||||
|
command -v netbird &>/dev/null && addons+=("netbird")
|
||||||
|
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
||||||
|
webui_is_installed 2>/dev/null && addons+=("webui")
|
||||||
|
library_is_installed 2>/dev/null && addons+=("library")
|
||||||
|
|
||||||
|
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
local authelia_url
|
||||||
|
authelia_url=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || true)
|
||||||
|
[[ -n "$authelia_url" ]] && addons+=("authelia")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#addons[@]}" -eq 0 ]]; then
|
||||||
|
echo "[]"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${addons[@]}" | jq -R . | jq -s . || echo "[]"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_export_clone_settings() {
|
||||||
|
echo
|
||||||
|
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
log_error "config.json not found at $CONFIG_PATH - configure sites/settings first"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local out_path
|
||||||
|
out_path=$(ask_text "Export profile to" "$HOME/kiosk-clone-settings.json")
|
||||||
|
|
||||||
|
local raw_config
|
||||||
|
raw_config=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
if ! echo "$raw_config" | jq empty 2>/dev/null; then
|
||||||
|
log_error "config.json is not valid JSON - cannot export"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local settings
|
||||||
|
settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)')
|
||||||
|
|
||||||
|
local addons_present
|
||||||
|
addons_present=$(clone_detect_addons)
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--argjson settings "$settings" \
|
||||||
|
--argjson addons "$addons_present" \
|
||||||
|
--arg script_version "${SCRIPT_VERSION:-unknown}" \
|
||||||
|
'{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \
|
||||||
|
> "$out_path"
|
||||||
|
|
||||||
|
log_success "Settings exported to $out_path"
|
||||||
|
echo
|
||||||
|
echo "Included: sites, display/touch/navigation settings, lockout,"
|
||||||
|
echo "password protection (SHA-256 hash only)."
|
||||||
|
echo
|
||||||
|
echo "NOT included (needs fresh setup on each new device):"
|
||||||
|
echo " - Authelia credentials (encrypted per-machine, won't decrypt elsewhere)"
|
||||||
|
echo " - WireGuard/VPN keys (each device needs its own identity)"
|
||||||
|
echo " - Asterisk Intercom extension (most PBXes reject duplicate registrations)"
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_apply_clone_settings() {
|
||||||
|
echo
|
||||||
|
local in_path
|
||||||
|
in_path=$(ask_text "Profile file to apply" "")
|
||||||
|
if [[ -z "$in_path" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$in_path" ]]; then
|
||||||
|
log_error "File not found: $in_path"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! jq empty "$in_path" 2>/dev/null; then
|
||||||
|
log_error "Not valid JSON: $in_path"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local settings addons_present
|
||||||
|
settings=$(jq -c '.settings // {}' "$in_path" || echo '{}')
|
||||||
|
addons_present=$(jq -r '.addons_present[]? // empty' "$in_path" || true)
|
||||||
|
|
||||||
|
echo "Profile summary:"
|
||||||
|
echo " Sites: $(echo "$settings" | jq '.tabs | length // 0')"
|
||||||
|
echo " Addons expected: $(echo "$addons_present" | tr '\n' ' ')"
|
||||||
|
echo
|
||||||
|
ask_yes_no "Apply this profile? This overwrites current sites/display/lockout settings." "n" || { echo "Cancelled"; pause; return; }
|
||||||
|
|
||||||
|
sudo mkdir -p "$KIOSK_DIR"
|
||||||
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"
|
||||||
|
|
||||||
|
local existing="{}"
|
||||||
|
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
|
existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||||
|
echo "$existing" | jq empty 2>/dev/null || existing="{}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge, not replace - same reasoning as save_config: this machine's
|
||||||
|
# own Authelia fields (never in the exported settings blob) must
|
||||||
|
# survive an apply untouched. Guarded: $settings came from an
|
||||||
|
# external file - a corrupted/hand-edited profile whose "settings"
|
||||||
|
# key isn't a JSON object must not be allowed to crash the session.
|
||||||
|
local merged
|
||||||
|
if ! merged=$(echo "$existing" | jq --argjson s "$settings" '. + $s' 2>/dev/null); then
|
||||||
|
log_error "Profile's settings are not a valid JSON object - nothing was changed"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
echo "$merged" > "$tmp"
|
||||||
|
sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp"
|
||||||
|
sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH"
|
||||||
|
rm -f "$tmp"
|
||||||
|
|
||||||
|
log_success "Settings applied"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Addon checklist (from the exported profile):"
|
||||||
|
local missing_any=false
|
||||||
|
if [[ -z "$addons_present" ]]; then
|
||||||
|
echo " (profile recorded no addons)"
|
||||||
|
fi
|
||||||
|
while IFS= read -r addon; do
|
||||||
|
[[ -z "$addon" ]] && continue
|
||||||
|
case "$addon" in
|
||||||
|
cups)
|
||||||
|
if cups_is_installed; then
|
||||||
|
echo " [x] CUPS Printing - already installed"
|
||||||
|
else
|
||||||
|
echo " [ ] CUPS Printing - not installed, install via Addons"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
lms)
|
||||||
|
if lms_is_installed; then
|
||||||
|
echo " [x] LMS Server - already installed"
|
||||||
|
else
|
||||||
|
echo " [ ] LMS Server - not installed, install via Addons"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
squeezelite)
|
||||||
|
if squeezelite_is_installed; then
|
||||||
|
echo " [x] Squeezelite Player - already installed"
|
||||||
|
else
|
||||||
|
echo " [ ] Squeezelite Player - not installed, install via Addons"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
vnc)
|
||||||
|
if is_service_active x11vnc; then
|
||||||
|
echo " [x] VNC - already installed"
|
||||||
|
else
|
||||||
|
echo " [ ] VNC - not installed, install via Addons"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
wireguard)
|
||||||
|
echo " [!] WireGuard - needs a NEW keypair/peer on this device, never clone the key" ;;
|
||||||
|
tailscale)
|
||||||
|
if command -v tailscale &>/dev/null; then
|
||||||
|
echo " [x] Tailscale - installed (connect with a reusable auth key if not yet connected)"
|
||||||
|
else
|
||||||
|
echo " [ ] Tailscale - not installed, install via Addons"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
netbird)
|
||||||
|
if command -v netbird &>/dev/null; then
|
||||||
|
echo " [x] Netbird - installed (connect with a reusable setup key if not yet connected)"
|
||||||
|
else
|
||||||
|
echo " [ ] Netbird - not installed, install via Addons"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
asterisk_intercom)
|
||||||
|
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
|
||||||
|
library)
|
||||||
|
if library_is_installed; then
|
||||||
|
echo " [x] Library Catalog - already installed (the books themselves are not cloned)"
|
||||||
|
else
|
||||||
|
echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
|
authelia)
|
||||||
|
echo " [!] Authelia - needs a fresh login on this device" ;;
|
||||||
|
*)
|
||||||
|
echo " [?] $addon - unrecognized entry in profile" ;;
|
||||||
|
esac
|
||||||
|
done <<< "$addons_present"
|
||||||
|
|
||||||
|
if $missing_any; then
|
||||||
|
echo
|
||||||
|
echo "Install anything marked \"not installed\" above via the Addons menu."
|
||||||
|
fi
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/complete_uninstall.sh - "Complete Uninstall" (Core Settings): full
|
||||||
|
# teardown, returning the machine to its pre-kiosk state.
|
||||||
|
#
|
||||||
|
# Composed from every other addon's own silent uninstall helper
|
||||||
|
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
||||||
|
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
||||||
|
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
|
||||||
|
# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all,
|
||||||
|
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
|
||||||
|
# re-implementing removal logic for each addon a second time here - if an
|
||||||
|
# addon's uninstall logic changes, this picks it up automatically. Only
|
||||||
|
# the pieces no single addon owns - the kiosk user/files, Node.js/
|
||||||
|
# LightDM/Openbox, polkit rules, leftover systemd units - are handled
|
||||||
|
# directly below, same as the legacy script.
|
||||||
|
#
|
||||||
|
# Ordering matters: every addon teardown runs before the kiosk user is
|
||||||
|
# removed, because asterisk_intercom_do_uninstall still needs
|
||||||
|
# `id -u "$KIOSK_USER"` to resolve that user's systemd --user session.
|
||||||
|
#
|
||||||
|
# After this runs, the kiosk user (and therefore is_kiosk_installed) is
|
||||||
|
# gone - install.sh itself will refuse to start against this machine
|
||||||
|
# again until a fresh install re-provisions it. That's intentional:
|
||||||
|
# there is nothing left here for this tool to manage.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh /
|
||||||
|
# menus/power_schedule.sh / menus/advanced_emergency_hotspot.sh being
|
||||||
|
# sourced first (for the *_do_uninstall helpers above).
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
complete_uninstall_status() {
|
||||||
|
echo "⚠ Removes the kiosk user, every addon, and returns this machine"
|
||||||
|
echo " to its pre-kiosk state. Cannot be undone."
|
||||||
|
}
|
||||||
|
|
||||||
|
complete_uninstall_menu_builder() {
|
||||||
|
MENU_LABELS=("Completely uninstall the kiosk")
|
||||||
|
MENU_HANDLERS=(action_complete_uninstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
complete_uninstall_menu() {
|
||||||
|
run_menu "COMPLETE UNINSTALL" complete_uninstall_menu_builder complete_uninstall_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_complete_uninstall() {
|
||||||
|
echo
|
||||||
|
echo "⚠️ This will COMPLETELY REMOVE:"
|
||||||
|
echo " • Kiosk user and all data"
|
||||||
|
echo " • All kiosk configuration and sites"
|
||||||
|
echo " • All Electron/Node.js installations"
|
||||||
|
echo " • All browser caches and data"
|
||||||
|
echo " • CUPS printer system"
|
||||||
|
echo " • Squeezelite and LMS (Lyrion Music Server)"
|
||||||
|
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
|
||||||
|
echo " • Asterisk Intercom (Baresip)"
|
||||||
|
echo " • Web UI"
|
||||||
|
echo " • Library Catalog (Koha) and ALL the books in it"
|
||||||
|
echo " • LightDM and Openbox"
|
||||||
|
echo " • All kiosk schedules and services"
|
||||||
|
echo " • Emergency hotspot configuration"
|
||||||
|
echo " • LAN time server setting (back to Ubuntu's defaults)"
|
||||||
|
echo
|
||||||
|
echo "⚠️ This CANNOT be undone!"
|
||||||
|
echo
|
||||||
|
local confirm
|
||||||
|
confirm=$(ask_text "Type UNINSTALL to confirm" "")
|
||||||
|
if [[ "$confirm" != "UNINSTALL" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Beginning complete uninstall..."
|
||||||
|
|
||||||
|
echo "[1/12] Stopping kiosk display..."
|
||||||
|
sudo systemctl stop lightdm 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[2/12] Removing addons..."
|
||||||
|
cups_do_uninstall
|
||||||
|
vnc_do_uninstall
|
||||||
|
wireguard_do_uninstall
|
||||||
|
tailscale_do_uninstall
|
||||||
|
netbird_do_uninstall
|
||||||
|
lms_do_uninstall purge
|
||||||
|
squeezelite_do_uninstall
|
||||||
|
asterisk_intercom_do_uninstall purge
|
||||||
|
webui_do_uninstall
|
||||||
|
library_do_uninstall purge
|
||||||
|
|
||||||
|
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
||||||
|
power_schedule_do_remove_all
|
||||||
|
emergency_hotspot_do_disable
|
||||||
|
time_sync_do_reset
|
||||||
|
|
||||||
|
# Must come after every addon teardown above - Asterisk Intercom's
|
||||||
|
# helper still needs this user to resolve its systemd --user session.
|
||||||
|
echo "[4/12] Removing kiosk user..."
|
||||||
|
if id "$KIOSK_USER" &>/dev/null; then
|
||||||
|
sudo pkill -u "$KIOSK_USER" 2>/dev/null || true
|
||||||
|
sudo userdel -r "$KIOSK_USER" 2>/dev/null || true
|
||||||
|
log_success "Kiosk user removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[5/12] Removing kiosk files..."
|
||||||
|
sudo rm -rf "$KIOSK_DIR"
|
||||||
|
sudo rm -rf "$KIOSK_HOME"
|
||||||
|
|
||||||
|
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-*.timer
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[7/12] Removing remaining scripts..."
|
||||||
|
sudo rm -f "$BIN_DIR"/kiosk-*
|
||||||
|
sudo rm -f "$SUDOERS_D_DIR/kiosk-wifi"
|
||||||
|
sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules
|
||||||
|
sudo udevadm control --reload-rules 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[8/12] Removing Node.js..."
|
||||||
|
sudo apt-get purge -y nodejs npm 2>/dev/null || true
|
||||||
|
sudo rm -rf /usr/local/lib/node_modules
|
||||||
|
sudo rm -rf /usr/local/bin/node
|
||||||
|
sudo rm -rf /usr/local/bin/npm
|
||||||
|
|
||||||
|
echo "[9/12] Removing LightDM and Openbox..."
|
||||||
|
sudo systemctl disable lightdm 2>/dev/null || true
|
||||||
|
sudo apt-get purge -y lightdm openbox 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[10/12] Removing polkit rules..."
|
||||||
|
sudo rm -f "$POLKIT_DIR/kiosk-power.pkla"
|
||||||
|
sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla"
|
||||||
|
|
||||||
|
echo "[11/12] Re-enabling virtual consoles..."
|
||||||
|
for i in {1..8}; do
|
||||||
|
sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[12/12] Cleaning up packages..."
|
||||||
|
sudo apt-get autoremove -y 2>/dev/null || true
|
||||||
|
sudo apt-get autoclean 2>/dev/null || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_success "Kiosk completely uninstalled"
|
||||||
|
echo "The system has been returned to its pre-kiosk state."
|
||||||
|
echo "You may want to reboot to ensure all changes take effect."
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Reboot now?" "n"; then
|
||||||
|
echo "Rebooting in 3 seconds..."
|
||||||
|
sleep 3
|
||||||
|
sudo reboot
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/diagnostics.sh - "Diagnostics" menu (from the legacy Advanced menu).
|
||||||
|
#
|
||||||
|
# A deliberate change of pace after Sites/WiFi/Power: everything here is
|
||||||
|
# read-only (system/audio status, log tailing, ping+DNS) except one
|
||||||
|
# optional "play a test sound?" prompt, so there's no destructive-action
|
||||||
|
# risk profile to design around. Straight port, using $KIOSK_USER/
|
||||||
|
# $KIOSK_HOME instead of the legacy code's mix of the variable and a
|
||||||
|
# hardcoded "kiosk" literal.
|
||||||
|
#
|
||||||
|
# Only 4 of the legacy Advanced menu's 12 items are here (System
|
||||||
|
# Diagnostics, View Logs, Audio Diagnostics, Network Test) - Manual
|
||||||
|
# Electron Update, Factory Reset, Export/Import Settings, Emergency
|
||||||
|
# Hotspot, and Fix Blank Screen are mutating/destructive and belong with
|
||||||
|
# a later, more careful pass (some, like Manual Electron Update, share
|
||||||
|
# Upgrade's issue of being coupled to the legacy script's own
|
||||||
|
# self-extraction mechanism - see ubuntu-based-kiosk.sh's changelog for
|
||||||
|
# why Upgrade/Reinstall/Uninstall aren't migrated yet either).
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
diagnostics_menu_builder() {
|
||||||
|
MENU_LABELS=("System status" "View logs" "Audio diagnostics" "Network test")
|
||||||
|
MENU_HANDLERS=(action_system_diagnostics view_logs_menu action_audio_diagnostics action_network_test)
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics_menu() {
|
||||||
|
run_menu "DIAGNOSTICS" diagnostics_menu_builder
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# System status
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_system_diagnostics() {
|
||||||
|
clear
|
||||||
|
echo " ═══ SYSTEM DIAGNOSTICS ═══"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Kiosk Status ==="
|
||||||
|
systemctl status lightdm --no-pager -l 2>&1 | head -20 || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Audio Status ==="
|
||||||
|
sudo -u "$KIOSK_USER" pactl info 2>/dev/null | grep -E "Server|User" || echo "Not running"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Network ==="
|
||||||
|
echo "IP: $(get_ip_address)"
|
||||||
|
echo "VPN: $(get_vpn_ips)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Logs
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
view_logs_menu_builder() {
|
||||||
|
MENU_LABELS=("Electron log (last 50 lines)" "LightDM log (last 50 lines)" "System journal (last 100 lines)")
|
||||||
|
MENU_HANDLERS=(action_view_electron_log action_view_lightdm_log action_view_journal)
|
||||||
|
}
|
||||||
|
|
||||||
|
view_logs_menu() {
|
||||||
|
run_menu "VIEW LOGS" view_logs_menu_builder
|
||||||
|
}
|
||||||
|
|
||||||
|
action_view_electron_log() {
|
||||||
|
echo
|
||||||
|
if sudo test -f "$KIOSK_HOME/electron.log"; then
|
||||||
|
sudo tail -50 "$KIOSK_HOME/electron.log" || true
|
||||||
|
else
|
||||||
|
echo "No electron log found yet"
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_view_lightdm_log() {
|
||||||
|
echo
|
||||||
|
sudo tail -50 /var/log/lightdm/lightdm.log 2>&1 || echo "No lightdm log found"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_view_journal() {
|
||||||
|
echo
|
||||||
|
sudo journalctl -n 100 || log_error "Could not read the system journal"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Audio diagnostics
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
audio_diagnostics_pactl() {
|
||||||
|
sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" pactl "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_audio_diagnostics() {
|
||||||
|
clear
|
||||||
|
echo "═══ AUDIO DIAGNOSTICS ═══"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local issue_found=false
|
||||||
|
|
||||||
|
echo "[1/8] Checking audio hardware..."
|
||||||
|
if lspci 2>/dev/null | grep -i audio || lsusb 2>/dev/null | grep -i audio; then
|
||||||
|
log_success "Audio hardware detected"
|
||||||
|
lspci 2>/dev/null | grep -i audio || true
|
||||||
|
lsusb 2>/dev/null | grep -i audio | head -3 || true
|
||||||
|
else
|
||||||
|
log_error "No audio hardware detected"
|
||||||
|
issue_found=true
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "[2/8] Checking ALSA devices..."
|
||||||
|
if aplay -l &>/dev/null; then
|
||||||
|
log_success "ALSA devices found"
|
||||||
|
aplay -l 2>/dev/null | grep -E "^card|device" || true
|
||||||
|
else
|
||||||
|
log_error "No ALSA devices"
|
||||||
|
issue_found=true
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "[3/8] Checking PipeWire status..."
|
||||||
|
local pipewire_running=false
|
||||||
|
if audio_diagnostics_pactl info &>/dev/null; then
|
||||||
|
log_success "PipeWire accessible"
|
||||||
|
pipewire_running=true
|
||||||
|
audio_diagnostics_pactl info 2>/dev/null | grep -E "Server|User|Host" || true
|
||||||
|
else
|
||||||
|
log_error "PipeWire not accessible to kiosk user"
|
||||||
|
issue_found=true
|
||||||
|
echo " Try: sudo -u ${KIOSK_USER} systemctl --user start pipewire pipewire-pulse"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
if $pipewire_running; then
|
||||||
|
echo "[4/8] Checking audio sinks..."
|
||||||
|
local sinks
|
||||||
|
sinks=$(audio_diagnostics_pactl list sinks short 2>/dev/null) || true
|
||||||
|
if [[ -n "$sinks" ]]; then
|
||||||
|
echo "$sinks"
|
||||||
|
local default_sink
|
||||||
|
default_sink=$(audio_diagnostics_pactl get-default-sink 2>/dev/null || echo "none")
|
||||||
|
echo "Default: $default_sink"
|
||||||
|
else
|
||||||
|
log_error "No audio sinks found"
|
||||||
|
issue_found=true
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "[5/8] Checking active streams..."
|
||||||
|
local sink_inputs
|
||||||
|
sink_inputs=$(audio_diagnostics_pactl list sink-inputs short 2>/dev/null) || true
|
||||||
|
if [[ -n "$sink_inputs" ]]; then
|
||||||
|
echo "Active streams:"
|
||||||
|
echo "$sink_inputs"
|
||||||
|
else
|
||||||
|
echo "No active streams"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
echo "[4/8] Skipped - PipeWire not running"
|
||||||
|
echo "[5/8] Skipped - PipeWire not running"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[6/8] Checking Squeezelite..."
|
||||||
|
if systemctl is-active --quiet squeezelite; then
|
||||||
|
log_success "Squeezelite running"
|
||||||
|
|
||||||
|
if $pipewire_running; then
|
||||||
|
local sq_pid
|
||||||
|
sq_pid=$(pgrep -f squeezelite | head -1) || true
|
||||||
|
if [[ -n "$sq_pid" ]]; then
|
||||||
|
if audio_diagnostics_pactl list sink-inputs 2>/dev/null | grep -q "application.process.id = \"$sq_pid\""; then
|
||||||
|
log_success "Squeezelite connected to audio"
|
||||||
|
else
|
||||||
|
log_warning "Squeezelite NOT connected to audio sink"
|
||||||
|
issue_found=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Squeezelite not running"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
if $pipewire_running; then
|
||||||
|
echo "[7/8] Checking volume..."
|
||||||
|
local volume muted
|
||||||
|
volume=$(audio_diagnostics_pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oE '[0-9]+%' | head -1 || echo "unknown")
|
||||||
|
muted=$(audio_diagnostics_pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null || echo "unknown")
|
||||||
|
echo "Volume: $volume"
|
||||||
|
echo "Muted: $muted"
|
||||||
|
else
|
||||||
|
echo "[7/8] Skipped - PipeWire not running"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "[8/8] Audio test..."
|
||||||
|
if ask_yes_no "Play test sound?" "n" && $pipewire_running; then
|
||||||
|
echo "Playing beep..."
|
||||||
|
audio_diagnostics_pactl_play_test
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "═══════════════════════════════"
|
||||||
|
if $issue_found; then
|
||||||
|
echo "⚠️ ISSUES DETECTED - See above"
|
||||||
|
else
|
||||||
|
echo "✓ All checks passed"
|
||||||
|
fi
|
||||||
|
echo "═══════════════════════════════"
|
||||||
|
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_diagnostics_pactl_play_test() {
|
||||||
|
sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" paplay /usr/share/sounds/alsa/Front_Center.wav 2>/dev/null || \
|
||||||
|
sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" speaker-test -t sine -f 1000 -l 1 2>/dev/null || \
|
||||||
|
echo "No test available"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Network test
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_network_test() {
|
||||||
|
echo
|
||||||
|
echo " ═══ NETWORK TEST ═══"
|
||||||
|
echo
|
||||||
|
echo "Ping test..."
|
||||||
|
ping -c 4 8.8.8.8 || log_error "Ping failed"
|
||||||
|
echo
|
||||||
|
echo "DNS test..."
|
||||||
|
nslookup google.com || log_error "DNS lookup failed"
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/display.sh - "Display & Interaction" menu.
|
||||||
|
#
|
||||||
|
# Second menu migrated off the old single-file installer, folding together
|
||||||
|
# three small settings screens that used to be separate Core Settings
|
||||||
|
# entries (Touch controls, Navigation security, Optional Features). All
|
||||||
|
# three are simple scalar/boolean fields on config.json, so this is a
|
||||||
|
# deliberately different shape from menus/sites.sh's list CRUD - a toggle
|
||||||
|
# list where each entry shows its current value and flips/edits itself,
|
||||||
|
# saving immediately (same immediate-save pattern as Sites, so behavior
|
||||||
|
# stays consistent no matter which menu you're in).
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
display_status() {
|
||||||
|
echo "Touch gesture mode: $SWIPE_MODE"
|
||||||
|
echo "Link navigation: $ALLOW_NAVIGATION"
|
||||||
|
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
|
||||||
|
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
|
||||||
|
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
|
||||||
|
echo "Back/forward btns: $(onoff "$ENABLE_HISTORY_BUTTONS")"
|
||||||
|
}
|
||||||
|
|
||||||
|
display_menu_builder() {
|
||||||
|
MENU_LABELS=(
|
||||||
|
"Touch gesture mode (currently: $SWIPE_MODE)"
|
||||||
|
"Link navigation security (currently: $ALLOW_NAVIGATION)"
|
||||||
|
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
|
||||||
|
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
|
||||||
|
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
|
||||||
|
"Toggle on-screen back/forward buttons (currently: $(onoff "$ENABLE_HISTORY_BUTTONS"))"
|
||||||
|
)
|
||||||
|
MENU_HANDLERS=(
|
||||||
|
action_set_touch_mode
|
||||||
|
action_set_navigation_security
|
||||||
|
action_toggle_pause_button
|
||||||
|
action_toggle_keyboard_button
|
||||||
|
action_toggle_nav_button
|
||||||
|
action_toggle_history_buttons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
display_menu() {
|
||||||
|
load_existing_config
|
||||||
|
run_menu "DISPLAY & INTERACTION" display_menu_builder display_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Touch gesture mode
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_set_touch_mode() {
|
||||||
|
echo
|
||||||
|
echo "DUAL-DIRECTION (recommended for touchscreens):"
|
||||||
|
echo " 2-finger swipe = switch pages, 1-finger swipe = navigate within page"
|
||||||
|
echo "STANDARD (simpler):"
|
||||||
|
echo " 2-finger swipe = switch pages only, 1-finger swipes do nothing"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local default
|
||||||
|
[[ "$SWIPE_MODE" == "dual" ]] && default="y" || default="n"
|
||||||
|
|
||||||
|
if ask_yes_no "Use dual-direction mode?" "$default"; then
|
||||||
|
SWIPE_MODE="dual"
|
||||||
|
else
|
||||||
|
SWIPE_MODE="standard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Touch mode: $SWIPE_MODE"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Navigation security
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_set_navigation_security() {
|
||||||
|
echo
|
||||||
|
echo " r) restricted - only the loaded URL, no link clicking"
|
||||||
|
echo " s) same-origin - can click links within the same domain (recommended)"
|
||||||
|
echo " o) open - can click any link, browse anywhere"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local choice
|
||||||
|
read -r -p "(r)estricted / (s)ame-origin / (o)pen [${ALLOW_NAVIGATION}]: " choice
|
||||||
|
|
||||||
|
case "${choice,,}" in
|
||||||
|
r) ALLOW_NAVIGATION="restricted" ;;
|
||||||
|
o) ALLOW_NAVIGATION="open" ;;
|
||||||
|
s) ALLOW_NAVIGATION="same-origin" ;;
|
||||||
|
"") ;; # keep current value
|
||||||
|
*) log_warning "Unrecognized choice, keeping '$ALLOW_NAVIGATION'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
log_success "Link navigation: $ALLOW_NAVIGATION"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# On-screen button toggles
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_toggle_pause_button() {
|
||||||
|
if [[ "$ENABLE_PAUSE_BUTTON" == "true" ]]; then
|
||||||
|
ENABLE_PAUSE_BUTTON="false"
|
||||||
|
log_warning "Pause button disabled"
|
||||||
|
else
|
||||||
|
ENABLE_PAUSE_BUTTON="true"
|
||||||
|
log_success "Pause button enabled"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_toggle_keyboard_button() {
|
||||||
|
if [[ "$ENABLE_KEYBOARD_BUTTON" == "true" ]]; then
|
||||||
|
ENABLE_KEYBOARD_BUTTON="false"
|
||||||
|
log_warning "On-screen keyboard button disabled"
|
||||||
|
else
|
||||||
|
ENABLE_KEYBOARD_BUTTON="true"
|
||||||
|
log_success "On-screen keyboard button enabled"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_toggle_nav_button() {
|
||||||
|
if [[ "$ENABLE_NAV_BUTTON" == "true" ]]; then
|
||||||
|
ENABLE_NAV_BUTTON="false"
|
||||||
|
log_warning "Navigation/help button disabled"
|
||||||
|
else
|
||||||
|
ENABLE_NAV_BUTTON="true"
|
||||||
|
log_success "Navigation/help button enabled"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page back/forward: Alt+Left / Alt+Right and the mouse's side buttons
|
||||||
|
# always work; this adds clickable buttons (top middle, half transparent,
|
||||||
|
# shown on mouse movement or a tap) for screens with no keyboard or mouse
|
||||||
|
# buttons. Off by default.
|
||||||
|
action_toggle_history_buttons() {
|
||||||
|
if [[ "$ENABLE_HISTORY_BUTTONS" == "true" ]]; then
|
||||||
|
ENABLE_HISTORY_BUTTONS="false"
|
||||||
|
log_warning "On-screen back/forward buttons disabled"
|
||||||
|
else
|
||||||
|
ENABLE_HISTORY_BUTTONS="true"
|
||||||
|
log_success "On-screen back/forward buttons enabled"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/hidden_pin.sh - "Hidden Site PIN" menu.
|
||||||
|
#
|
||||||
|
# Guards access to hidden pages (duration = -1, see menus/sites.sh) via a
|
||||||
|
# flat PIN file rather than config.json - a fourth shape for the framework
|
||||||
|
# to prove out (plain file, not JSON at all).
|
||||||
|
#
|
||||||
|
# The file holds "NOPIN", or "sha256:<hex>" - the PIN is never stored as
|
||||||
|
# plain digits (a file from before that is converted by the kiosk app at
|
||||||
|
# startup; plain digits are still accepted until then). No file = the
|
||||||
|
# default PIN 1234. The kiosk app reads it on every check, so changes
|
||||||
|
# apply immediately, and slows down repeated wrong guesses.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
hidden_pin_file() {
|
||||||
|
echo "$KIOSK_DIR/.jitsi-pin"
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden_pin_status() {
|
||||||
|
local pin_file
|
||||||
|
pin_file=$(hidden_pin_file)
|
||||||
|
|
||||||
|
if sudo -u "$KIOSK_USER" test -f "$pin_file" 2>/dev/null; then
|
||||||
|
local current_pin
|
||||||
|
current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null)
|
||||||
|
if [[ "$current_pin" == "NOPIN" ]]; then
|
||||||
|
echo "Current: no PIN (hidden pages open to anyone)"
|
||||||
|
elif [[ "$current_pin" == "sha256:$(hidden_pin_hash 1234)" || "$current_pin" == "1234" ]]; then
|
||||||
|
echo "Current: default PIN (1234) - set your own"
|
||||||
|
else
|
||||||
|
echo "Current: PIN set"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current: not configured (default: 1234)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden_pin_menu_builder() {
|
||||||
|
MENU_LABELS=("Set new PIN (4-8 digits)" "Disable PIN (open access)" "Reset to default (1234)")
|
||||||
|
MENU_HANDLERS=(action_set_pin action_disable_pin action_reset_pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden_pin_menu() {
|
||||||
|
run_menu "HIDDEN SITE PIN" hidden_pin_menu_builder hidden_pin_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
hidden_pin_hash() {
|
||||||
|
echo -n "$1" | sha256sum | cut -d' ' -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_pin VALUE - VALUE is "NOPIN" or the PIN digits (stored hashed).
|
||||||
|
write_pin() {
|
||||||
|
local value="$1"
|
||||||
|
local pin_file
|
||||||
|
pin_file=$(hidden_pin_file)
|
||||||
|
|
||||||
|
[[ "$value" == "NOPIN" ]] || value="sha256:$(hidden_pin_hash "$value")"
|
||||||
|
sudo mkdir -p "$KIOSK_DIR"
|
||||||
|
echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null
|
||||||
|
sudo -u "$KIOSK_USER" chmod 600 "$pin_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_set_pin() {
|
||||||
|
echo
|
||||||
|
local new_pin confirm_pin
|
||||||
|
while true; do
|
||||||
|
read -r -s -p "Enter new PIN (4-8 digits): " new_pin
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then
|
||||||
|
echo "❌ PIN must be 4-8 digits"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -s -p "Confirm PIN: " confirm_pin
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "$new_pin" == "$confirm_pin" ]]; then
|
||||||
|
write_pin "$new_pin"
|
||||||
|
log_success "PIN updated"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "❌ PINs don't match, try again"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
action_disable_pin() {
|
||||||
|
write_pin "NOPIN"
|
||||||
|
log_success "PIN disabled - hidden pages accessible without a PIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_reset_pin() {
|
||||||
|
write_pin "1234"
|
||||||
|
log_success "PIN reset to default (1234)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/lockout.sh - "Password Protection & Lockout" menu.
|
||||||
|
#
|
||||||
|
# The unlock code and the (optional, separate) boot code are each either
|
||||||
|
# a password or a 4-8 digit PIN - the kiosk's own codes, independent of
|
||||||
|
# the admin account's sudo password. A PIN gets an on-screen number pad
|
||||||
|
# on the lock screen, so it works on a touch-only kiosk.
|
||||||
|
#
|
||||||
|
# Fifth menu migrated. Back to config.json (like Display), but with a
|
||||||
|
# sensitive field: the lockout password is SHA-256 hashed before it's
|
||||||
|
# ever written to disk (matching the Electron app's comparison logic in
|
||||||
|
# main.js) - LOCKOUT_PASSWORD must never hold plaintext.
|
||||||
|
#
|
||||||
|
# Unlike the legacy configure_password_protection wizard (walk through
|
||||||
|
# every question once, then one final "save these changes? y/n"), this
|
||||||
|
# follows the same immediate-save pattern as every other migrated menu:
|
||||||
|
# each action is a complete, standalone change. Re-running "Enable" to
|
||||||
|
# change your mind is just as easy as the old "discard changes" path,
|
||||||
|
# and there's no separate confirm-at-the-end step to forget.
|
||||||
|
#
|
||||||
|
# LOCKOUT_ACTIVE_START/LOCKOUT_ACTIVE_END are intentionally never touched
|
||||||
|
# here - the app doesn't act on them (see Readme "Configuration Files"),
|
||||||
|
# so lib/config.sh just carries whatever is already in config.json
|
||||||
|
# through unchanged.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
lockout_status() {
|
||||||
|
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
|
||||||
|
echo "Password protection: ENABLED"
|
||||||
|
echo "Inactivity lockout: ${LOCKOUT_TIMEOUT} minutes$( [[ "$LOCKOUT_TIMEOUT" == "0" ]] && echo " (disabled - boot/wake only)")"
|
||||||
|
if [[ -n "$LOCKOUT_AT_TIME" ]]; then
|
||||||
|
echo "Daily lock time: $LOCKOUT_AT_TIME"
|
||||||
|
else
|
||||||
|
echo "Daily lock time: not set"
|
||||||
|
fi
|
||||||
|
echo "Unlock code: $(lockout_code_label "$LOCKOUT_CODE_TYPE")"
|
||||||
|
echo "Lock on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")"
|
||||||
|
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
|
||||||
|
echo "Boot code: $(lockout_boot_code_label)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Password protection: disabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
lockout_menu_builder() {
|
||||||
|
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
|
||||||
|
MENU_LABELS=(
|
||||||
|
"Change unlock password/PIN (currently: $(lockout_code_label "$LOCKOUT_CODE_TYPE"))"
|
||||||
|
"Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)"
|
||||||
|
"Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})"
|
||||||
|
"Toggle lock on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))"
|
||||||
|
"Set boot password/PIN (currently: $(lockout_boot_code_label))"
|
||||||
|
"Disable password protection"
|
||||||
|
)
|
||||||
|
MENU_HANDLERS=(
|
||||||
|
action_change_password
|
||||||
|
action_change_timeout
|
||||||
|
action_change_daily_lock
|
||||||
|
action_toggle_boot_password
|
||||||
|
action_set_boot_code
|
||||||
|
action_disable_protection
|
||||||
|
)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Enable password protection")
|
||||||
|
MENU_HANDLERS=(action_enable_protection)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
lockout_menu() {
|
||||||
|
load_existing_config
|
||||||
|
run_menu "PASSWORD PROTECTION & LOCKOUT" lockout_menu_builder lockout_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Shared helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# "PIN" / "password" for menus and status lines.
|
||||||
|
lockout_code_label() {
|
||||||
|
[[ "$1" == "pin" ]] && echo "PIN" || echo "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
lockout_boot_code_label() {
|
||||||
|
if [[ -z "$BOOT_PASSWORD" ]]; then
|
||||||
|
echo "same as unlock"
|
||||||
|
else
|
||||||
|
echo "separate $(lockout_code_label "$BOOT_CODE_TYPE")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# True if $1 is a valid kiosk PIN: 4-8 digits.
|
||||||
|
lockout_valid_pin() {
|
||||||
|
[[ "$1" =~ ^[0-9]{4,8}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# prompt_code WHAT - asks whether WHAT (e.g. "unlock") is a password or a
|
||||||
|
# 4-8 digit PIN, then for the code itself twice. Sets CODE_TYPE ("password"
|
||||||
|
# or "pin") and CODE_HASH (SHA-256 hex, matching the Electron app's check
|
||||||
|
# in main.js - never plaintext). These are the kiosk's own codes, not
|
||||||
|
# any Linux account's password.
|
||||||
|
prompt_code() {
|
||||||
|
local what="$1" choice
|
||||||
|
echo "How should the $what code be entered?"
|
||||||
|
echo " 1) Password (letters, numbers, anything - needs a keyboard)"
|
||||||
|
echo " 2) PIN (4-8 digits - on-screen number pad, works on a touch screen)"
|
||||||
|
while true; do
|
||||||
|
read -r -p "Choose [1-2]: " choice
|
||||||
|
case "$choice" in
|
||||||
|
1) CODE_TYPE="password"; break ;;
|
||||||
|
2) CODE_TYPE="pin"; break ;;
|
||||||
|
*) echo "❌ Enter 1 or 2" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local label pass1 pass2
|
||||||
|
label=$(lockout_code_label "$CODE_TYPE")
|
||||||
|
while true; do
|
||||||
|
read -r -s -p "Enter $what $label: " pass1
|
||||||
|
echo
|
||||||
|
if [[ -z "$pass1" ]]; then
|
||||||
|
echo "❌ The $label cannot be empty"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$CODE_TYPE" == "pin" ]] && ! lockout_valid_pin "$pass1"; then
|
||||||
|
echo "❌ A PIN is 4-8 digits (0-9 only)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
read -r -s -p "Confirm $what $label: " pass2
|
||||||
|
echo
|
||||||
|
if [[ "$pass1" != "$pass2" ]]; then
|
||||||
|
echo "❌ They don't match, try again"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
CODE_HASH=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prompts for the unlock code into LOCKOUT_PASSWORD/LOCKOUT_CODE_TYPE.
|
||||||
|
prompt_and_hash_password() {
|
||||||
|
prompt_code "unlock"
|
||||||
|
LOCKOUT_CODE_TYPE="$CODE_TYPE"
|
||||||
|
LOCKOUT_PASSWORD="$CODE_HASH"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_enable_protection() {
|
||||||
|
echo
|
||||||
|
echo "Add password protection with automatic lockout:"
|
||||||
|
echo " • Blank screen after an inactivity period"
|
||||||
|
echo " • Password or PIN required to unlock"
|
||||||
|
echo " • Required after display schedule wake-up"
|
||||||
|
echo " • Optional: lock at a specific time daily"
|
||||||
|
echo " • Optional: locked on system boot, with the same or its own code"
|
||||||
|
echo
|
||||||
|
echo "These are the kiosk's own codes - not your admin (sudo) password."
|
||||||
|
echo
|
||||||
|
|
||||||
|
prompt_and_hash_password
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Session lockout time (minutes of inactivity)."
|
||||||
|
echo "Enter 0 to only require a password after display wake or boot."
|
||||||
|
LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "30" 0 1440)
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Lock automatically at a specific time each day?" "n"; then
|
||||||
|
LOCKOUT_AT_TIME=$(ask_time "Time to lock (24-hour HH:MM)" "17:00")
|
||||||
|
else
|
||||||
|
LOCKOUT_AT_TIME=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
BOOT_PASSWORD=""
|
||||||
|
BOOT_CODE_TYPE="password"
|
||||||
|
if ask_yes_no "Lock the kiosk on system boot/power on?" "y"; then
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Use a different password/PIN at boot than for unlocking?" "n"; then
|
||||||
|
prompt_code "boot"
|
||||||
|
BOOT_CODE_TYPE="$CODE_TYPE"
|
||||||
|
BOOT_PASSWORD="$CODE_HASH"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENABLE_PASSWORD_PROTECTION="true"
|
||||||
|
log_success "Password protection enabled (lockout: ${LOCKOUT_TIMEOUT}m)"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_disable_protection() {
|
||||||
|
ENABLE_PASSWORD_PROTECTION="false"
|
||||||
|
LOCKOUT_PASSWORD=""
|
||||||
|
LOCKOUT_TIMEOUT=0
|
||||||
|
LOCKOUT_AT_TIME=""
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||||
|
LOCKOUT_CODE_TYPE="password"
|
||||||
|
BOOT_PASSWORD=""
|
||||||
|
BOOT_CODE_TYPE="password"
|
||||||
|
log_success "Password protection disabled"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_change_password() {
|
||||||
|
echo
|
||||||
|
prompt_and_hash_password
|
||||||
|
log_success "Unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE") updated"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_change_timeout() {
|
||||||
|
echo
|
||||||
|
echo "Session lockout time (minutes of inactivity)."
|
||||||
|
echo "Enter 0 to only require a password after display wake or boot."
|
||||||
|
LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "$LOCKOUT_TIMEOUT" 0 1440)
|
||||||
|
log_success "Lockout timeout: ${LOCKOUT_TIMEOUT}m"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_change_daily_lock() {
|
||||||
|
echo
|
||||||
|
local default_prompt
|
||||||
|
[[ -n "$LOCKOUT_AT_TIME" ]] && default_prompt="y" || default_prompt="n"
|
||||||
|
|
||||||
|
if ask_yes_no "Lock automatically at a specific time each day?" "$default_prompt"; then
|
||||||
|
LOCKOUT_AT_TIME=$(ask_time "Time to lock (24-hour HH:MM)" "${LOCKOUT_AT_TIME:-17:00}")
|
||||||
|
log_success "Will lock at ${LOCKOUT_AT_TIME} daily"
|
||||||
|
else
|
||||||
|
LOCKOUT_AT_TIME=""
|
||||||
|
log_success "Daily lock time cleared"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_toggle_boot_password() {
|
||||||
|
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||||
|
log_warning "Lock on boot disabled"
|
||||||
|
else
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||||
|
log_success "Lock on boot enabled (boot code: $(lockout_boot_code_label))"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_set_boot_code() {
|
||||||
|
echo
|
||||||
|
echo "The boot lock screen can use its own password/PIN, or the unlock one."
|
||||||
|
echo "Currently: $(lockout_boot_code_label)"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Use a separate password/PIN at boot?" "y"; then
|
||||||
|
prompt_code "boot"
|
||||||
|
BOOT_CODE_TYPE="$CODE_TYPE"
|
||||||
|
BOOT_PASSWORD="$CODE_HASH"
|
||||||
|
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||||
|
log_success "Boot $(lockout_code_label "$BOOT_CODE_TYPE") set (lock on boot: on)"
|
||||||
|
else
|
||||||
|
BOOT_PASSWORD=""
|
||||||
|
BOOT_CODE_TYPE="password"
|
||||||
|
log_success "Boot uses the unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE")"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/power_schedule.sh - "Power / Display / Quiet Hours" menu.
|
||||||
|
#
|
||||||
|
# Sixth menu migrated, and the biggest and riskiest so far: it writes
|
||||||
|
# systemd timers/services, a cron entry, and shell scripts that can power
|
||||||
|
# off the physical machine, blank the display, mute audio, and (via RTC)
|
||||||
|
# wake the machine back up on a schedule. Every write goes through
|
||||||
|
# $SYSTEMD_DIR / $CRON_D_DIR / $BIN_DIR (lib/config.sh) rather than
|
||||||
|
# hardcoded /etc/systemd/system, /etc/cron.d, /usr/local/bin, so tests can
|
||||||
|
# point them at a scratch directory - this file must never assume it's
|
||||||
|
# safe to actually mutate the real system just because it's running.
|
||||||
|
#
|
||||||
|
# Deliberately out of scope: the legacy dispatcher's "6. Test schedules &
|
||||||
|
# system" led into a shared diagnostics submenu (audio test, network
|
||||||
|
# test, keyboard test, ...) that isn't specific to scheduling and belongs
|
||||||
|
# with a future Advanced/Diagnostics migration instead. What *is* in
|
||||||
|
# scope - testing the schedule you just configured - stays here as the
|
||||||
|
# same inline "test now?" prompts the legacy menu already had.
|
||||||
|
#
|
||||||
|
# Bug fixed vs. the legacy configure_power_display_quiet: it refused to
|
||||||
|
# even open "Configure power schedule" when no RTC wake was detected,
|
||||||
|
# even though shutdown-only scheduling (configure_power_schedule's own
|
||||||
|
# fallback) never needed RTC in the first place. Also: none of shutdown
|
||||||
|
# time/wake time/display off/on/quiet start/end/custom Electron reload
|
||||||
|
# time were validated as HH:MM in the legacy menu (plain `read`, no
|
||||||
|
# format check) - a typo would silently produce a broken OnCalendar=
|
||||||
|
# value. All of those now go through ask_time.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Shared helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
rtc_wake_available() {
|
||||||
|
[[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
timer_exists() {
|
||||||
|
[[ -f "$SYSTEMD_DIR/$1" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
timer_oncalendar() {
|
||||||
|
grep "^OnCalendar=" "$SYSTEMD_DIR/$1" 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Top-level menu
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
power_schedule_status() {
|
||||||
|
local any=false
|
||||||
|
|
||||||
|
if timer_exists kiosk-shutdown.timer; then
|
||||||
|
any=true
|
||||||
|
local t; t=$(timer_oncalendar kiosk-shutdown.timer) || true
|
||||||
|
echo "Power: shutdown daily at ${t:-an unknown time}"
|
||||||
|
fi
|
||||||
|
if timer_exists kiosk-display-off.timer; then
|
||||||
|
any=true
|
||||||
|
echo "Display: off at $(timer_oncalendar kiosk-display-off.timer), on at $(timer_oncalendar kiosk-display-on.timer)"
|
||||||
|
fi
|
||||||
|
if timer_exists kiosk-quiet-start.timer; then
|
||||||
|
any=true
|
||||||
|
echo "Quiet: $(timer_oncalendar kiosk-quiet-start.timer) to $(timer_oncalendar kiosk-quiet-end.timer)"
|
||||||
|
fi
|
||||||
|
if timer_exists kiosk-electron-reload.timer; then
|
||||||
|
any=true
|
||||||
|
echo "Reload: enabled ($(timer_oncalendar kiosk-electron-reload.timer))"
|
||||||
|
fi
|
||||||
|
$any || echo "No schedules configured"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if rtc_wake_available; then
|
||||||
|
echo "RTC wake: available (can schedule power on/off)"
|
||||||
|
else
|
||||||
|
echo "RTC wake: not available (display/quiet/reload scheduling still works)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
power_schedule_menu_builder() {
|
||||||
|
MENU_LABELS=(
|
||||||
|
"Configure power schedule$(rtc_wake_available || echo ' (shutdown only - no RTC wake)')"
|
||||||
|
"Configure display schedule"
|
||||||
|
"Configure quiet hours"
|
||||||
|
"Configure Electron reload schedule"
|
||||||
|
"Remove all schedules"
|
||||||
|
)
|
||||||
|
MENU_HANDLERS=(
|
||||||
|
action_configure_power_schedule
|
||||||
|
action_configure_display_schedule
|
||||||
|
action_configure_quiet_hours
|
||||||
|
electron_reload_menu
|
||||||
|
action_remove_all_schedules
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
power_schedule_menu() {
|
||||||
|
run_menu "POWER / DISPLAY / QUIET HOURS" power_schedule_menu_builder power_schedule_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Power schedule
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_configure_power_schedule() {
|
||||||
|
echo
|
||||||
|
local rtc_ok=false
|
||||||
|
rtc_wake_available && rtc_ok=true
|
||||||
|
|
||||||
|
if $rtc_ok; then
|
||||||
|
echo "RTC wake capability detected - can schedule shutdown and wake."
|
||||||
|
else
|
||||||
|
echo "RTC wake not available - shutdown only, no auto-wake."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
local shutdown_time wake_time=""
|
||||||
|
shutdown_time=$(ask_time "Shutdown time (24-hour HH:MM)" "22:00")
|
||||||
|
$rtc_ok && wake_time=$(ask_time "Wake time (24-hour HH:MM)" "06:00")
|
||||||
|
|
||||||
|
sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR/kiosk-shutdown.service" "$SYSTEMD_DIR/kiosk-shutdown.timer"
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/rtc-wake.sh"
|
||||||
|
sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake"
|
||||||
|
|
||||||
|
sudo tee "$BIN_DIR/kiosk-power-off.sh" > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
logger "KIOSK: Scheduled shutdown initiated"
|
||||||
|
systemctl poweroff
|
||||||
|
EOF
|
||||||
|
sudo chmod +x "$BIN_DIR/kiosk-power-off.sh"
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-shutdown.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Scheduled Shutdown
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${BIN_DIR}/kiosk-power-off.sh
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-shutdown.timer" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Shutdown Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* ${shutdown_time}:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if $rtc_ok && [[ -n "$wake_time" ]]; then
|
||||||
|
sudo tee "$BIN_DIR/rtc-wake.sh" > /dev/null <<'RTCSCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
WAKE_TIME="$1"
|
||||||
|
CURRENT=$(date +%s)
|
||||||
|
WAKE=$(date -d "$WAKE_TIME" +%s)
|
||||||
|
|
||||||
|
# If wake time is earlier than current time, schedule for tomorrow
|
||||||
|
[[ $WAKE -le $CURRENT ]] && WAKE=$(date -d "tomorrow $WAKE_TIME" +%s)
|
||||||
|
|
||||||
|
# Clear existing alarm
|
||||||
|
echo 0 > /sys/class/rtc/rtc0/wakealarm 2>/dev/null || true
|
||||||
|
|
||||||
|
# Set new alarm
|
||||||
|
if echo $WAKE > /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then
|
||||||
|
logger "KIOSK: RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
else
|
||||||
|
logger "KIOSK: ERROR - Failed to set RTC wake"
|
||||||
|
echo "ERROR: Failed to set RTC wake"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RTCSCRIPT
|
||||||
|
sudo chmod +x "$BIN_DIR/rtc-wake.sh"
|
||||||
|
|
||||||
|
local shutdown_hour="${shutdown_time%%:*}"
|
||||||
|
local shutdown_min="${shutdown_time##*:}"
|
||||||
|
local wake_min=$((10#$shutdown_min - 5))
|
||||||
|
local wake_hour=$((10#$shutdown_hour))
|
||||||
|
[[ $wake_min -lt 0 ]] && { wake_min=$((wake_min + 60)); wake_hour=$((wake_hour - 1)); }
|
||||||
|
[[ $wake_hour -lt 0 ]] && wake_hour=$((wake_hour + 24))
|
||||||
|
|
||||||
|
sudo tee "$CRON_D_DIR/kiosk-rtc-wake" > /dev/null <<EOF
|
||||||
|
# Set RTC wake alarm 5 minutes before shutdown
|
||||||
|
$wake_min $wake_hour * * * root ${BIN_DIR}/rtc-wake.sh "$wake_time" >> /var/log/kiosk-rtc.log 2>&1
|
||||||
|
EOF
|
||||||
|
log_info "RTC wake cron job created"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-shutdown.timer; then
|
||||||
|
log_success "Power schedule configured: shutdown at ${shutdown_time}$( [[ -n "$wake_time" ]] && echo ", wake at ${wake_time}")"
|
||||||
|
else
|
||||||
|
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-shutdown.timer'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Display schedule
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_configure_display_schedule() {
|
||||||
|
echo
|
||||||
|
if timer_exists kiosk-shutdown.timer; then
|
||||||
|
log_warning "Power shutdown configured at $(timer_oncalendar kiosk-shutdown.timer) - display will already be off by then"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
local doff don
|
||||||
|
doff=$(ask_time "Display OFF time (24-hour HH:MM)" "22:00")
|
||||||
|
don=$(ask_time "Display ON time (24-hour HH:MM)" "06:00")
|
||||||
|
|
||||||
|
sudo systemctl stop kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer}
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh"
|
||||||
|
|
||||||
|
sudo tee "$BIN_DIR/kiosk-display-off.sh" > /dev/null <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
# Turn off display using multiple methods for reliability
|
||||||
|
export DISPLAY=:0
|
||||||
|
export XAUTHORITY=${KIOSK_HOME}/.Xauthority
|
||||||
|
|
||||||
|
logger "KIOSK: Display OFF script starting"
|
||||||
|
|
||||||
|
# Method 1: xset via kiosk user
|
||||||
|
sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xset dpms force off 2>/dev/null && logger "KIOSK: xset dpms off success" || logger "KIOSK: xset dpms off failed"
|
||||||
|
|
||||||
|
# Method 2: vbetool (if available)
|
||||||
|
if command -v vbetool &>/dev/null; then
|
||||||
|
vbetool dpms off 2>/dev/null && echo "✓ vbetool off" || echo "✗ vbetool failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 3: Backlight control (laptops)
|
||||||
|
if [[ -d /sys/class/backlight ]]; then
|
||||||
|
for bl in /sys/class/backlight/*/brightness; do
|
||||||
|
if [[ -w "\$bl" ]]; then
|
||||||
|
echo 0 > "\$bl" 2>/dev/null && echo "✓ backlight off: \$bl" || echo "✗ backlight failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
logger "KIOSK: Display turned OFF (scheduled)"
|
||||||
|
EOF
|
||||||
|
sudo chmod +x "$BIN_DIR/kiosk-display-off.sh"
|
||||||
|
|
||||||
|
sudo tee "$BIN_DIR/kiosk-display-on.sh" > /dev/null <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
# Turn on display using multiple methods for reliability
|
||||||
|
export DISPLAY=:0
|
||||||
|
export XAUTHORITY=${KIOSK_HOME}/.Xauthority
|
||||||
|
|
||||||
|
logger "KIOSK: Display ON script starting"
|
||||||
|
|
||||||
|
# Method 1: xset via kiosk user
|
||||||
|
sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xset dpms force on 2>/dev/null && logger "KIOSK: xset dpms on success" || logger "KIOSK: xset dpms on failed"
|
||||||
|
|
||||||
|
# Method 2: vbetool (if available)
|
||||||
|
if command -v vbetool &>/dev/null; then
|
||||||
|
vbetool dpms on 2>/dev/null && echo "✓ vbetool on" || echo "✗ vbetool failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 3: Backlight control (laptops)
|
||||||
|
if [[ -d /sys/class/backlight ]]; then
|
||||||
|
for bl in /sys/class/backlight/*/brightness; do
|
||||||
|
if [[ -w "\$bl" ]]; then
|
||||||
|
cat "\${bl%/*}/max_brightness" > "\$bl" 2>/dev/null && echo "✓ backlight on: \$bl" || echo "✗ backlight failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 4: Wake up input (move mouse)
|
||||||
|
sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xdotool mousemove 1 1 2>/dev/null && logger "KIOSK: mouse wiggle success" || logger "KIOSK: mouse wiggle failed"
|
||||||
|
|
||||||
|
# Method 5: Signal Electron app to require password if enabled
|
||||||
|
sudo -u ${KIOSK_USER} touch ${KIOSK_DIR}/.display-wake 2>/dev/null && logger "KIOSK: password flag set" || logger "KIOSK: password flag failed"
|
||||||
|
|
||||||
|
logger "KIOSK: Display turned ON (scheduled)"
|
||||||
|
EOF
|
||||||
|
sudo chmod +x "$BIN_DIR/kiosk-display-on.sh"
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-display-off.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Display Off
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${BIN_DIR}/kiosk-display-off.sh
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-display-on.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Display On
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${BIN_DIR}/kiosk-display-on.sh
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-display-off.timer" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Display Off Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* ${doff}:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-display-on.timer" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Display On Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* ${don}:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-display-off.timer kiosk-display-on.timer; then
|
||||||
|
log_success "Display schedule configured: off at ${doff}, on at ${don}"
|
||||||
|
else
|
||||||
|
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-display-off.timer'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Test display control now?" "n"; then
|
||||||
|
echo "Testing display OFF in 3 seconds..."
|
||||||
|
sleep 3
|
||||||
|
sudo "$BIN_DIR/kiosk-display-off.sh"
|
||||||
|
echo "Waiting 5 seconds..."
|
||||||
|
sleep 5
|
||||||
|
echo "Testing display ON..."
|
||||||
|
sudo "$BIN_DIR/kiosk-display-on.sh"
|
||||||
|
log_success "Display test complete"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Quiet hours
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_configure_quiet_hours() {
|
||||||
|
echo
|
||||||
|
timer_exists kiosk-shutdown.timer && echo "Power shutdown: $(timer_oncalendar kiosk-shutdown.timer)"
|
||||||
|
if timer_exists kiosk-display-off.timer; then
|
||||||
|
echo "Display: off at $(timer_oncalendar kiosk-display-off.timer), on at $(timer_oncalendar kiosk-display-on.timer)"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
local qstart qend qmode
|
||||||
|
qstart=$(ask_time "Quiet hours start (24-hour HH:MM)" "22:00")
|
||||||
|
qend=$(ask_time "Quiet hours end (24-hour HH:MM)" "07:00")
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "What should be muted during quiet hours?"
|
||||||
|
echo " 1. All audio (mute system)"
|
||||||
|
echo " 2. Squeezelite only (stop music player)"
|
||||||
|
read -r -p "Choice [1]: " qmode
|
||||||
|
qmode="${qmode:-1}"
|
||||||
|
|
||||||
|
sudo systemctl stop kiosk-quiet-start.timer kiosk-quiet-end.timer 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-quiet-start.timer kiosk-quiet-end.timer 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer}
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh"
|
||||||
|
|
||||||
|
case "$qmode" in
|
||||||
|
2)
|
||||||
|
sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
systemctl stop squeezelite 2>/dev/null
|
||||||
|
logger "KIOSK: Quiet hours started - Squeezelite stopped"
|
||||||
|
echo "✓ Quiet hours: Squeezelite stopped"
|
||||||
|
EOF
|
||||||
|
sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
systemctl start squeezelite 2>/dev/null
|
||||||
|
logger "KIOSK: Quiet hours ended - Squeezelite started"
|
||||||
|
echo "✓ Quiet hours ended: Squeezelite started"
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
qmode=1
|
||||||
|
sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Save current volume before muting
|
||||||
|
pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%' > /tmp/kiosk-vol-backup 2>/dev/null || echo "100" > /tmp/kiosk-vol-backup
|
||||||
|
pactl set-sink-mute @DEFAULT_SINK@ 1 2>/dev/null
|
||||||
|
logger "KIOSK: Quiet hours started - all audio muted"
|
||||||
|
echo "✓ Quiet hours: All audio muted"
|
||||||
|
EOF
|
||||||
|
sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Restore previous volume
|
||||||
|
VOL=$(cat /tmp/kiosk-vol-backup 2>/dev/null || echo "100")
|
||||||
|
pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null
|
||||||
|
pactl set-sink-volume @DEFAULT_SINK@ ${VOL}% 2>/dev/null
|
||||||
|
logger "KIOSK: Quiet hours ended - audio restored to ${VOL}%"
|
||||||
|
echo "✓ Quiet hours ended: Audio restored to ${VOL}%"
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
sudo chmod +x "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh"
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-quiet-start.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Quiet Hours Start
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${BIN_DIR}/kiosk-quiet-start.sh
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-quiet-end.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Quiet Hours End
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${BIN_DIR}/kiosk-quiet-end.sh
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-quiet-start.timer" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Quiet Hours Start Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* ${qstart}:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-quiet-end.timer" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Kiosk Quiet Hours End Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* ${qend}:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
local mode_label="All audio muted"
|
||||||
|
[[ "$qmode" == "2" ]] && mode_label="Squeezelite stopped"
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-quiet-start.timer kiosk-quiet-end.timer; then
|
||||||
|
log_success "Quiet hours configured: ${qstart} to ${qend} (${mode_label})"
|
||||||
|
else
|
||||||
|
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-quiet-start.timer'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Test quiet hours now?" "n"; then
|
||||||
|
echo "Testing quiet START..."
|
||||||
|
sudo "$BIN_DIR/kiosk-quiet-start.sh"
|
||||||
|
echo "Waiting 5 seconds..."
|
||||||
|
sleep 5
|
||||||
|
echo "Testing quiet END..."
|
||||||
|
sudo "$BIN_DIR/kiosk-quiet-end.sh"
|
||||||
|
log_success "Quiet hours test complete"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Electron reload schedule (its own small nested menu, mirroring the
|
||||||
|
# legacy configure_electron_reload's "configured vs not" dispatch)
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
electron_reload_menu_builder() {
|
||||||
|
if timer_exists kiosk-electron-reload.timer; then
|
||||||
|
MENU_LABELS=("Change schedule" "Disable automatic reload")
|
||||||
|
MENU_HANDLERS=(electron_reload_custom action_disable_electron_reload)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Daily at 3am" "Every 3 days at 3am" "Custom schedule")
|
||||||
|
MENU_HANDLERS=(action_electron_reload_daily action_electron_reload_every_3_days electron_reload_custom)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
electron_reload_status() {
|
||||||
|
if timer_exists kiosk-electron-reload.timer; then
|
||||||
|
echo "Automatic reload: enabled ($(timer_oncalendar kiosk-electron-reload.timer))"
|
||||||
|
else
|
||||||
|
echo "Automatic reload: not configured"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
electron_reload_menu() {
|
||||||
|
run_menu "ELECTRON RELOAD SCHEDULE" electron_reload_menu_builder electron_reload_status
|
||||||
|
}
|
||||||
|
|
||||||
|
# setup_electron_reload_timer SCHEDULE DESCRIPTION
|
||||||
|
# SCHEDULE is a systemd OnCalendar= expression, not just a time - unlike
|
||||||
|
# the shutdown/display/quiet timers above, so it isn't run through ask_time.
|
||||||
|
setup_electron_reload_timer() {
|
||||||
|
local schedule="$1"
|
||||||
|
local description="$2"
|
||||||
|
|
||||||
|
sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer}
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-reload-electron"
|
||||||
|
|
||||||
|
sudo tee "$BIN_DIR/kiosk-reload-electron" > /dev/null <<'RELOADSCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
logger "KIOSK: Scheduled Electron reload"
|
||||||
|
systemctl restart lightdm
|
||||||
|
RELOADSCRIPT
|
||||||
|
sudo chmod +x "$BIN_DIR/kiosk-reload-electron"
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-electron-reload.service" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Reload Electron App
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${BIN_DIR}/kiosk-reload-electron
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee "$SYSTEMD_DIR/kiosk-electron-reload.timer" > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Electron Reload Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=$schedule
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-electron-reload.timer; then
|
||||||
|
log_success "Electron reload configured: $description"
|
||||||
|
else
|
||||||
|
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-electron-reload.timer'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_electron_reload_daily() {
|
||||||
|
setup_electron_reload_timer "*-*-* 03:00:00" "daily at 3am"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_electron_reload_every_3_days() {
|
||||||
|
setup_electron_reload_timer "*-*-1,4,7,10,13,16,19,22,25,28,31 03:00:00" "every 3 days at 3am"
|
||||||
|
}
|
||||||
|
|
||||||
|
electron_reload_custom() {
|
||||||
|
echo
|
||||||
|
echo "Custom schedule options:"
|
||||||
|
echo " 1. Every X days at a specific time"
|
||||||
|
echo " 2. Daily at a custom time"
|
||||||
|
echo " 3. Specific weekday"
|
||||||
|
echo " 0. Cancel"
|
||||||
|
echo
|
||||||
|
|
||||||
|
local choice
|
||||||
|
choice=$(ask_integer "Choose" "0" 0 3)
|
||||||
|
[[ "$choice" == "0" ]] && { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
local schedule="" description=""
|
||||||
|
case "$choice" in
|
||||||
|
1)
|
||||||
|
local days time day_list=""
|
||||||
|
days=$(ask_integer "Reload every X days" "3" 1 31)
|
||||||
|
time=$(ask_time "Time (24-hour HH:MM)" "03:00")
|
||||||
|
for ((d = 1; d <= 31; d += days)); do
|
||||||
|
day_list="${day_list}${d},"
|
||||||
|
done
|
||||||
|
day_list="${day_list%,}"
|
||||||
|
schedule="*-*-${day_list} ${time}:00"
|
||||||
|
description="every ${days} days at ${time}"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
local time
|
||||||
|
time=$(ask_time "Time (24-hour HH:MM)" "03:00")
|
||||||
|
schedule="*-*-* ${time}:00"
|
||||||
|
description="daily at ${time}"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
local day time
|
||||||
|
echo "Days: Mon Tue Wed Thu Fri Sat Sun"
|
||||||
|
read -r -p "Enter day: " day
|
||||||
|
time=$(ask_time "Time (24-hour HH:MM)" "03:00")
|
||||||
|
schedule="${day} *-*-* ${time}:00"
|
||||||
|
description="every ${day} at ${time}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
setup_electron_reload_timer "$schedule" "$description"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_disable_electron_reload() {
|
||||||
|
if ask_yes_no "Disable automatic Electron reload?" "n"; then
|
||||||
|
sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true
|
||||||
|
sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer}
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-reload-electron"
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
log_success "Automatic Electron reload disabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Remove all
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_remove_all_schedules() {
|
||||||
|
echo
|
||||||
|
ask_yes_no "Remove ALL power/display/quiet/reload schedules?" "n" || { echo "Cancelled"; return; }
|
||||||
|
power_schedule_do_remove_all
|
||||||
|
}
|
||||||
|
|
||||||
|
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||||
|
# operation doesn't need to re-implement schedule teardown a second time.
|
||||||
|
power_schedule_do_remove_all() {
|
||||||
|
for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do
|
||||||
|
sudo systemctl stop "${timer}.timer" 2>/dev/null || true
|
||||||
|
sudo systemctl disable "${timer}.timer" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-shutdown.{service,timer}
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer}
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer}
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer}
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh"
|
||||||
|
sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh"
|
||||||
|
sudo rm -f "$BIN_DIR/rtc-wake.sh" "$BIN_DIR/kiosk-reload-electron"
|
||||||
|
sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake"
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "All schedules removed"
|
||||||
|
}
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/sites.sh - "Sites & Page Timing" menu.
|
||||||
|
#
|
||||||
|
# First real menu built on lib/menu.sh + lib/config.sh, as a proof of
|
||||||
|
# concept for pulling menus out of the old 12k-line installer one at a
|
||||||
|
# time. Covers exactly what was asked for first: adding, deleting, and
|
||||||
|
# changing pages, and the timing (duration) of each.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Index of the page currently being edited by the nested "edit page" menu.
|
||||||
|
SITE_EDIT_IDX=""
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Small helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Normalize whatever the user typed into a URL, same rules the old
|
||||||
|
# installer used: bare host -> https://, bare IP -> http://. Plus local
|
||||||
|
# files on the kiosk itself (pages, PDFs, images): an absolute path ->
|
||||||
|
# file://, percent-encoded the same way webui/server.js's parseUrl does.
|
||||||
|
sites_parse_url() {
|
||||||
|
local raw="$1"
|
||||||
|
if [[ "$raw" =~ ^(https?|file):// ]]; then
|
||||||
|
echo "$raw"
|
||||||
|
elif [[ "$raw" == /* ]]; then
|
||||||
|
local LC_ALL=C out="" c i
|
||||||
|
for (( i = 0; i < ${#raw}; i++ )); do
|
||||||
|
c="${raw:i:1}"
|
||||||
|
case "$c" in
|
||||||
|
[A-Za-z0-9._~/!\'\(\)*-]) out+="$c" ;;
|
||||||
|
*) out+="$(printf '%%%02X' "'$c")" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
echo "file://$out"
|
||||||
|
elif [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||||
|
echo "http://${raw}"
|
||||||
|
else
|
||||||
|
echo "https://${raw}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Human label for a duration value: >0 seconds = auto-rotate, 0 = manual,
|
||||||
|
# -1 = hidden (PIN-gated).
|
||||||
|
sites_duration_label() {
|
||||||
|
local dur="$1"
|
||||||
|
if [[ "$dur" == "-1" ]]; then
|
||||||
|
echo "hidden"
|
||||||
|
elif [[ "$dur" == "0" ]]; then
|
||||||
|
echo "manual"
|
||||||
|
else
|
||||||
|
echo "auto-rotate ${dur}s"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
sites_display_label() {
|
||||||
|
local idx="$1"
|
||||||
|
local label="${URLS[$idx]}"
|
||||||
|
[[ -n "${NAMES[$idx]:-}" ]] && label="\"${NAMES[$idx]}\" - ${URLS[$idx]}"
|
||||||
|
echo "$label"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Status line shown above the main Sites menu
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
sites_status() {
|
||||||
|
if [[ "${#URLS[@]}" -eq 0 ]]; then
|
||||||
|
echo "No pages configured yet."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Current pages:"
|
||||||
|
local has_rotation=false
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
local dur="${DURS[$idx]}"
|
||||||
|
local flags=""
|
||||||
|
[[ "$dur" != "0" && "$dur" != "-1" ]] && has_rotation=true
|
||||||
|
[[ -n "${USERS[$idx]:-}" ]] && flags+=" [auth]"
|
||||||
|
[[ "$HOME_TAB_INDEX" == "$idx" ]] && flags+=" [HOME]"
|
||||||
|
|
||||||
|
printf " %2d. %s (%s)%s\n" "$((idx + 1))" "$(sites_display_label "$idx")" "$(sites_duration_label "$dur")" "$flags"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
if $has_rotation; then
|
||||||
|
echo "Auto-rotation: active (pages with duration > 0)"
|
||||||
|
else
|
||||||
|
echo "Auto-rotation: off (all pages manual/hidden)"
|
||||||
|
fi
|
||||||
|
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
|
||||||
|
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
|
||||||
|
else
|
||||||
|
echo "Home page: disabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Top-level Sites menu
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
sites_menu_builder() {
|
||||||
|
MENU_LABELS=("Add a page")
|
||||||
|
MENU_HANDLERS=(action_add_page)
|
||||||
|
|
||||||
|
if [[ "${#URLS[@]}" -gt 0 ]]; then
|
||||||
|
MENU_LABELS+=("Edit a page" "Delete a page")
|
||||||
|
MENU_HANDLERS+=(action_pick_and_edit_page action_delete_page)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#URLS[@]}" -gt 1 ]]; then
|
||||||
|
MENU_LABELS+=("Reorder pages")
|
||||||
|
MENU_HANDLERS+=(action_reorder_page)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#URLS[@]}" -gt 0 ]]; then
|
||||||
|
MENU_LABELS+=("Set/clear home page")
|
||||||
|
MENU_HANDLERS+=(action_set_home_page)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
sites_menu() {
|
||||||
|
load_existing_config
|
||||||
|
run_menu "SITES & PAGE TIMING" sites_menu_builder sites_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Add
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_add_page() {
|
||||||
|
echo
|
||||||
|
echo "Duration controls rotation:"
|
||||||
|
echo " > 0 = auto-rotates every X seconds"
|
||||||
|
echo " 0 = manual only (swipe/nav menu to reach it)"
|
||||||
|
echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)"
|
||||||
|
echo
|
||||||
|
echo "URL: a web address, or a path on this kiosk - a file (page, PDF,"
|
||||||
|
echo "image, Word .docx) like /home/kiosk/docs/menu.pdf, or a folder of"
|
||||||
|
echo "images ending in / like /home/kiosk/photos/ for a slideshow."
|
||||||
|
|
||||||
|
local raw_url url dur name needs_auth user pass
|
||||||
|
read -r -p "URL: " raw_url
|
||||||
|
if [[ -z "$raw_url" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
url=$(sites_parse_url "$raw_url")
|
||||||
|
|
||||||
|
dur=$(ask_integer "Duration in seconds (-1=hidden, 0=manual)" "180" -1 86400)
|
||||||
|
name=$(ask_text "Page name (optional, blank = show URL)" "")
|
||||||
|
|
||||||
|
user=""
|
||||||
|
pass=""
|
||||||
|
if ask_yes_no "Does this page need HTTP Basic Auth?" "n"; then
|
||||||
|
read -r -p " Username: " user
|
||||||
|
read -r -s -p " Password: " pass
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
URLS+=("$url")
|
||||||
|
DURS+=("$dur")
|
||||||
|
USERS+=("$user")
|
||||||
|
PASSES+=("$pass")
|
||||||
|
NAMES+=("$name")
|
||||||
|
|
||||||
|
log_success "Added: $url ($(sites_duration_label "$dur"))"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Edit (nested menu on the selected page)
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_pick_and_edit_page() {
|
||||||
|
echo
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
echo " $((idx + 1)). $(sites_display_label "$idx")"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
local num
|
||||||
|
num=$(ask_integer "Edit which page? (0=cancel)" "0" 0 "${#URLS[@]}")
|
||||||
|
[[ "$num" == "0" ]] && return
|
||||||
|
|
||||||
|
SITE_EDIT_IDX=$((num - 1))
|
||||||
|
run_menu "EDIT PAGE #${num}" edit_page_menu_builder edit_page_status
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_page_status() {
|
||||||
|
local idx="$SITE_EDIT_IDX"
|
||||||
|
echo "URL: ${URLS[$idx]}"
|
||||||
|
echo "Name: ${NAMES[$idx]:-(none)}"
|
||||||
|
echo "Timing: $(sites_duration_label "${DURS[$idx]}")"
|
||||||
|
if [[ -n "${USERS[$idx]:-}" ]]; then
|
||||||
|
echo "Basic auth: enabled (user: ${USERS[$idx]})"
|
||||||
|
else
|
||||||
|
echo "Basic auth: disabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_page_menu_builder() {
|
||||||
|
MENU_LABELS=("Change URL" "Change name" "Change timing (duration)" "Change Basic Auth")
|
||||||
|
MENU_HANDLERS=(action_edit_url action_edit_name action_edit_duration action_edit_auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
action_edit_url() {
|
||||||
|
local idx="$SITE_EDIT_IDX"
|
||||||
|
local raw_url
|
||||||
|
raw_url=$(ask_text "New URL" "${URLS[$idx]}")
|
||||||
|
URLS[$idx]=$(sites_parse_url "$raw_url")
|
||||||
|
log_success "URL updated"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_edit_name() {
|
||||||
|
local idx="$SITE_EDIT_IDX"
|
||||||
|
NAMES[$idx]=$(ask_text "New name (blank = show URL)" "${NAMES[$idx]:-}")
|
||||||
|
log_success "Name updated"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_edit_duration() {
|
||||||
|
local idx="$SITE_EDIT_IDX"
|
||||||
|
echo
|
||||||
|
echo " > 0 = auto-rotates every X seconds"
|
||||||
|
echo " 0 = manual only"
|
||||||
|
echo " -1 = hidden (PIN-gated)"
|
||||||
|
DURS[$idx]=$(ask_integer "Duration in seconds" "${DURS[$idx]}" -1 86400)
|
||||||
|
log_success "Timing updated: $(sites_duration_label "${DURS[$idx]}")"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
action_edit_auth() {
|
||||||
|
local idx="$SITE_EDIT_IDX"
|
||||||
|
if ask_yes_no "Enable HTTP Basic Auth for this page?" "$([[ -n "${USERS[$idx]:-}" ]] && echo y || echo n)"; then
|
||||||
|
read -r -p " Username: " USERS_new
|
||||||
|
read -r -s -p " Password: " PASSES_new
|
||||||
|
echo
|
||||||
|
USERS[$idx]="$USERS_new"
|
||||||
|
PASSES[$idx]="$PASSES_new"
|
||||||
|
log_success "Basic Auth updated"
|
||||||
|
else
|
||||||
|
USERS[$idx]=""
|
||||||
|
PASSES[$idx]=""
|
||||||
|
log_success "Basic Auth disabled"
|
||||||
|
fi
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Delete
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_delete_page() {
|
||||||
|
echo
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
echo " $((idx + 1)). $(sites_display_label "$idx")"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
local num
|
||||||
|
num=$(ask_integer "Delete which page? (0=cancel)" "0" 0 "${#URLS[@]}")
|
||||||
|
[[ "$num" == "0" ]] && { echo "Cancelled"; return; }
|
||||||
|
|
||||||
|
local del_idx=$((num - 1))
|
||||||
|
echo "Deleting: $(sites_display_label "$del_idx")"
|
||||||
|
|
||||||
|
unset 'URLS[del_idx]' 'DURS[del_idx]' 'USERS[del_idx]' 'PASSES[del_idx]' 'NAMES[del_idx]'
|
||||||
|
URLS=("${URLS[@]}")
|
||||||
|
DURS=("${DURS[@]}")
|
||||||
|
USERS=("${USERS[@]}")
|
||||||
|
PASSES=("${PASSES[@]}")
|
||||||
|
NAMES=("${NAMES[@]}")
|
||||||
|
|
||||||
|
if [[ "$HOME_TAB_INDEX" == "$del_idx" ]]; then
|
||||||
|
HOME_TAB_INDEX=-1
|
||||||
|
log_warning "Home page was deleted - home feature disabled"
|
||||||
|
elif [[ "$HOME_TAB_INDEX" -gt "$del_idx" ]]; then
|
||||||
|
HOME_TAB_INDEX=$((HOME_TAB_INDEX - 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Page deleted"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Reorder
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_reorder_page() {
|
||||||
|
echo
|
||||||
|
echo "Current order:"
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
echo " $((idx + 1)). $(sites_display_label "$idx")"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
|
||||||
|
local max="${#URLS[@]}"
|
||||||
|
local from_num to_num
|
||||||
|
from_num=$(ask_integer "Move which page? (0=cancel)" "0" 0 "$max")
|
||||||
|
[[ "$from_num" == "0" ]] && { echo "Cancelled"; return; }
|
||||||
|
to_num=$(ask_integer "Move to position?" "1" 1 "$max")
|
||||||
|
|
||||||
|
local from_idx=$((from_num - 1))
|
||||||
|
local to_idx=$((to_num - 1))
|
||||||
|
if [[ "$from_idx" == "$to_idx" ]]; then
|
||||||
|
echo "Same position - no change"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Work out where the home page (if any, and not the one being moved)
|
||||||
|
# will land, before we touch the arrays. Removing the moved item and
|
||||||
|
# then re-inserting it at $to_idx always places it at index $to_idx of
|
||||||
|
# the *final* array - no further adjustment needed there. Everything
|
||||||
|
# else only shifts by the remove and the insert individually.
|
||||||
|
local new_home_idx="$HOME_TAB_INDEX"
|
||||||
|
if [[ "$HOME_TAB_INDEX" == "$from_idx" ]]; then
|
||||||
|
new_home_idx="$to_idx"
|
||||||
|
elif [[ "$HOME_TAB_INDEX" != "-1" ]]; then
|
||||||
|
[[ "$from_idx" -lt "$HOME_TAB_INDEX" ]] && new_home_idx=$((new_home_idx - 1))
|
||||||
|
[[ "$to_idx" -le "$new_home_idx" ]] && new_home_idx=$((new_home_idx + 1))
|
||||||
|
fi
|
||||||
|
HOME_TAB_INDEX="$new_home_idx"
|
||||||
|
|
||||||
|
local move_url="${URLS[$from_idx]}" move_dur="${DURS[$from_idx]}" \
|
||||||
|
move_user="${USERS[$from_idx]}" move_pass="${PASSES[$from_idx]}" move_name="${NAMES[$from_idx]}"
|
||||||
|
|
||||||
|
unset 'URLS[from_idx]' 'DURS[from_idx]' 'USERS[from_idx]' 'PASSES[from_idx]' 'NAMES[from_idx]'
|
||||||
|
URLS=("${URLS[@]}")
|
||||||
|
DURS=("${DURS[@]}")
|
||||||
|
USERS=("${USERS[@]}")
|
||||||
|
PASSES=("${PASSES[@]}")
|
||||||
|
NAMES=("${NAMES[@]}")
|
||||||
|
|
||||||
|
URLS=("${URLS[@]:0:$to_idx}" "$move_url" "${URLS[@]:$to_idx}")
|
||||||
|
DURS=("${DURS[@]:0:$to_idx}" "$move_dur" "${DURS[@]:$to_idx}")
|
||||||
|
USERS=("${USERS[@]:0:$to_idx}" "$move_user" "${USERS[@]:$to_idx}")
|
||||||
|
PASSES=("${PASSES[@]:0:$to_idx}" "$move_pass" "${PASSES[@]:$to_idx}")
|
||||||
|
NAMES=("${NAMES[@]:0:$to_idx}" "$move_name" "${NAMES[@]:$to_idx}")
|
||||||
|
|
||||||
|
log_success "Pages reordered"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Home page
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_set_home_page() {
|
||||||
|
echo
|
||||||
|
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
|
||||||
|
echo "Current home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s timeout"
|
||||||
|
else
|
||||||
|
echo "Home page currently disabled"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
echo " $((idx + 1)). $(sites_display_label "$idx")"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
|
||||||
|
local num
|
||||||
|
num=$(ask_integer "Set which page as home? (0=disable)" "0" 0 "${#URLS[@]}")
|
||||||
|
if [[ "$num" == "0" ]]; then
|
||||||
|
HOME_TAB_INDEX=-1
|
||||||
|
log_success "Home page disabled"
|
||||||
|
save_config
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOME_TAB_INDEX=$((num - 1))
|
||||||
|
local timeout_min
|
||||||
|
timeout_min=$(ask_integer "Inactivity timeout in minutes" "2" 1 240)
|
||||||
|
INACTIVITY_TIMEOUT=$((timeout_min * 60))
|
||||||
|
|
||||||
|
log_success "Home page: #${num} (${timeout_min}m inactivity timeout)"
|
||||||
|
save_config
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/time_sync.sh - "Time Server (NTP)" (Core Settings).
|
||||||
|
#
|
||||||
|
# The kiosk keeps its clock right with systemd-timesyncd, which by default
|
||||||
|
# asks Ubuntu's time servers on the internet. On a network with no
|
||||||
|
# internet that never works and the clock slowly drifts - which matters
|
||||||
|
# here, because lockout times, quiet hours and power schedules all go by
|
||||||
|
# it. This menu points timesyncd at a time server on the local network
|
||||||
|
# instead (most routers, Windows domain controllers and NAS boxes can
|
||||||
|
# serve NTP), or, with no time server at all, sets the clock by hand.
|
||||||
|
#
|
||||||
|
# Writes one drop-in, $TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf - removing it
|
||||||
|
# (the "internet time servers" option, or Complete Uninstall via
|
||||||
|
# time_sync_do_reset) puts Ubuntu's defaults back untouched.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
TIMESYNC_DROPIN="$TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf"
|
||||||
|
|
||||||
|
# The time server(s) this menu configured, or empty for Ubuntu's defaults.
|
||||||
|
time_sync_servers() {
|
||||||
|
sed -n 's/^NTP=//p' "$TIMESYNC_DROPIN" 2>/dev/null | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
time_sync_status() {
|
||||||
|
local servers synced
|
||||||
|
servers=$(time_sync_servers)
|
||||||
|
synced=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo "unknown")
|
||||||
|
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
echo "Time servers: ${servers:-Ubuntu defaults (internet)}"
|
||||||
|
echo "Synchronized: $([[ "$synced" == "yes" ]] && echo "yes" || echo "no")"
|
||||||
|
}
|
||||||
|
|
||||||
|
time_sync_menu_builder() {
|
||||||
|
MENU_LABELS=(
|
||||||
|
"Use a time server on my network"
|
||||||
|
"Use the internet time servers (Ubuntu default)"
|
||||||
|
"Set the date and time manually"
|
||||||
|
"Show sync details"
|
||||||
|
)
|
||||||
|
MENU_HANDLERS=(
|
||||||
|
action_time_sync_lan
|
||||||
|
action_time_sync_default
|
||||||
|
action_time_sync_manual
|
||||||
|
action_time_sync_details
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
time_sync_menu() {
|
||||||
|
run_menu "TIME SERVER (NTP)" time_sync_menu_builder time_sync_status
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate a space-separated list of hostnames / IPv4 / IPv6 addresses.
|
||||||
|
time_sync_valid_servers() {
|
||||||
|
local s
|
||||||
|
[[ -n "$1" ]] || return 1
|
||||||
|
for s in $1; do
|
||||||
|
[[ "$s" =~ ^[A-Za-z0-9.:-]+$ ]] || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
time_sync_apply() {
|
||||||
|
sudo systemctl enable systemd-timesyncd 2>/dev/null || true
|
||||||
|
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||||
|
sudo systemctl restart systemd-timesyncd 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove this menu's drop-in, back to Ubuntu's default time servers.
|
||||||
|
# Also used by Complete Uninstall.
|
||||||
|
time_sync_do_reset() {
|
||||||
|
sudo rm -f "$TIMESYNC_DROPIN"
|
||||||
|
time_sync_apply
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_time_sync_lan() {
|
||||||
|
echo
|
||||||
|
echo "Enter the address of a time (NTP) server on your network - often"
|
||||||
|
echo "the router (e.g. 192.168.1.1), a Windows domain controller, or a"
|
||||||
|
echo "NAS. Several can be given, separated by spaces."
|
||||||
|
local current servers
|
||||||
|
current=$(time_sync_servers)
|
||||||
|
servers=$(ask_text "Time server(s)" "${current:-$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')}")
|
||||||
|
if ! time_sync_valid_servers "$servers"; then
|
||||||
|
log_error "Not a valid address list: '$servers'"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$TIMESYNCD_CONF_DIR"
|
||||||
|
sudo tee "$TIMESYNC_DROPIN" > /dev/null <<EOF
|
||||||
|
# Written by ubuntu-based-kiosk (Core Settings -> Time Server).
|
||||||
|
# Delete this file to go back to Ubuntu's default time servers.
|
||||||
|
[Time]
|
||||||
|
NTP=$servers
|
||||||
|
EOF
|
||||||
|
time_sync_apply
|
||||||
|
|
||||||
|
echo "Checking the time server (up to 15 seconds)..."
|
||||||
|
local i
|
||||||
|
for i in {1..15}; do
|
||||||
|
if [[ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" == "yes" ]]; then
|
||||||
|
log_success "Clock synchronized with $servers"
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
log_warning "Saved, but no sync yet. timesyncd keeps retrying in the background."
|
||||||
|
echo "If it never syncs, check the address and that the server answers"
|
||||||
|
echo "NTP (UDP port 123): Core Settings -> Time Server -> Show sync details."
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_time_sync_default() {
|
||||||
|
echo
|
||||||
|
if [[ ! -f "$TIMESYNC_DROPIN" ]]; then
|
||||||
|
echo "Already using Ubuntu's default (internet) time servers."
|
||||||
|
pause
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
time_sync_do_reset
|
||||||
|
log_success "Back on Ubuntu's default (internet) time servers"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# For a network with no time server at all: set the clock by hand.
|
||||||
|
# Automatic sync stays on afterwards - it just has nothing to sync with
|
||||||
|
# until a server becomes reachable, and then takes over again.
|
||||||
|
action_time_sync_manual() {
|
||||||
|
echo
|
||||||
|
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
echo "Enter the correct local date and time (timezone: $(timedatectl show -p Timezone --value 2>/dev/null))."
|
||||||
|
local when
|
||||||
|
when=$(ask_text "Date and time (YYYY-MM-DD HH:MM)" "$(date '+%Y-%m-%d %H:%M')")
|
||||||
|
if [[ ! "$when" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}(:[0-9]{2})?$ ]] || ! date -d "$when" &>/dev/null; then
|
||||||
|
log_error "Not a valid date/time: '$when' (expected e.g. 2026-10-03 14:30)"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
[[ "$when" =~ :[0-9]{2}:[0-9]{2}$ ]] || when="$when:00"
|
||||||
|
|
||||||
|
# timedatectl refuses set-time while automatic sync is on.
|
||||||
|
sudo timedatectl set-ntp false 2>/dev/null || true
|
||||||
|
if sudo timedatectl set-time "$when"; then
|
||||||
|
# Keep the hardware clock in step so the time survives a reboot.
|
||||||
|
sudo hwclock --systohc 2>/dev/null || true
|
||||||
|
log_success "Clock set to $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
else
|
||||||
|
log_error "Couldn't set the clock"
|
||||||
|
fi
|
||||||
|
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_time_sync_details() {
|
||||||
|
echo
|
||||||
|
timedatectl status 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
timedatectl timesync-status 2>/dev/null || echo "(timesync-status not available)"
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/timezone.sh - "Timezone" menu.
|
||||||
|
#
|
||||||
|
# Third menu migrated off the old single-file installer, and a different
|
||||||
|
# shape again: not config.json at all - talks to timedatectl/system state
|
||||||
|
# directly. Also the clearest demonstration of the framework's value: the
|
||||||
|
# original hand-numbered an 18-entry list ("1) America/New_York ... 18)
|
||||||
|
# Enter manually") in a single case statement. Here the common-zone list
|
||||||
|
# is just data, one handler (action_pick_common_timezone) handles all of
|
||||||
|
# them using the number run_menu hands it, and adding/removing a zone
|
||||||
|
# from the list never touches numbering anywhere else.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
TIMEZONE_COMMON_ZONES=(
|
||||||
|
"America/New_York" "America/Chicago" "America/Denver" "America/Los_Angeles"
|
||||||
|
"America/Phoenix" "America/Anchorage" "Pacific/Honolulu" "Europe/London"
|
||||||
|
"Europe/Paris" "Europe/Berlin" "Europe/Rome" "Asia/Tokyo" "Asia/Shanghai"
|
||||||
|
"Asia/Dubai" "Australia/Sydney" "Pacific/Auckland"
|
||||||
|
)
|
||||||
|
TIMEZONE_COMMON_LABELS=(
|
||||||
|
"US Eastern" "US Central" "US Mountain" "US Pacific" "US Arizona" "US Alaska"
|
||||||
|
"US Hawaii" "UK" "Central Europe" "Germany" "Italy" "Japan" "China" "UAE"
|
||||||
|
"Australia East" "New Zealand"
|
||||||
|
)
|
||||||
|
|
||||||
|
timezone_status() {
|
||||||
|
echo "Current timezone: $(timedatectl show -p Timezone --value)"
|
||||||
|
}
|
||||||
|
|
||||||
|
timezone_menu_builder() {
|
||||||
|
MENU_LABELS=()
|
||||||
|
MENU_HANDLERS=()
|
||||||
|
for i in "${!TIMEZONE_COMMON_ZONES[@]}"; do
|
||||||
|
MENU_LABELS+=("${TIMEZONE_COMMON_ZONES[$i]} (${TIMEZONE_COMMON_LABELS[$i]})")
|
||||||
|
MENU_HANDLERS+=(action_pick_common_timezone)
|
||||||
|
done
|
||||||
|
MENU_LABELS+=("Search for timezone by region" "Enter timezone manually")
|
||||||
|
MENU_HANDLERS+=(action_search_timezone action_manual_timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
timezone_menu() {
|
||||||
|
run_menu "TIMEZONE" timezone_menu_builder timezone_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Called by run_menu as `action_pick_common_timezone "$choice"` - $choice is
|
||||||
|
# the 1-based menu number, which lines up directly with TIMEZONE_COMMON_ZONES.
|
||||||
|
action_pick_common_timezone() {
|
||||||
|
local choice="$1"
|
||||||
|
set_timezone "${TIMEZONE_COMMON_ZONES[$((choice - 1))]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_search_timezone() {
|
||||||
|
echo
|
||||||
|
echo "Available regions:"
|
||||||
|
local regions
|
||||||
|
regions=($(timedatectl list-timezones | cut -d'/' -f1 | sort -u))
|
||||||
|
for i in "${!regions[@]}"; do
|
||||||
|
printf " %2d) %s\n" "$((i + 1))" "${regions[$i]}"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
|
||||||
|
local region_num
|
||||||
|
region_num=$(ask_integer "Select region number (0=cancel)" "0" 0 "${#regions[@]}")
|
||||||
|
[[ "$region_num" == "0" ]] && { echo "Cancelled"; return; }
|
||||||
|
local selected_region="${regions[$((region_num - 1))]}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Timezones in $selected_region:"
|
||||||
|
local timezones
|
||||||
|
timezones=($(timedatectl list-timezones | grep "^${selected_region}/"))
|
||||||
|
for i in "${!timezones[@]}"; do
|
||||||
|
printf " %3d) %s\n" "$((i + 1))" "${timezones[$i]}"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
|
||||||
|
local tz_num
|
||||||
|
tz_num=$(ask_integer "Select timezone number (0=cancel)" "0" 0 "${#timezones[@]}")
|
||||||
|
[[ "$tz_num" == "0" ]] && { echo "Cancelled"; return; }
|
||||||
|
set_timezone "${timezones[$((tz_num - 1))]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
action_manual_timezone() {
|
||||||
|
echo
|
||||||
|
local new_tz
|
||||||
|
read -r -p "Enter timezone (e.g., America/New_York): " new_tz
|
||||||
|
[[ -z "$new_tz" ]] && { echo "Cancelled"; return; }
|
||||||
|
set_timezone "$new_tz"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Shared apply logic
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set_timezone() {
|
||||||
|
local new_tz="$1"
|
||||||
|
|
||||||
|
# A few legacy US/* aliases users might type manually - normalize before
|
||||||
|
# validating against the canonical IANA list.
|
||||||
|
case "$new_tz" in
|
||||||
|
"US/Eastern") new_tz="America/New_York" ;;
|
||||||
|
"US/Central") new_tz="America/Chicago" ;;
|
||||||
|
"US/Mountain") new_tz="America/Denver" ;;
|
||||||
|
"US/Pacific") new_tz="America/Los_Angeles" ;;
|
||||||
|
"US/Alaska") new_tz="America/Anchorage" ;;
|
||||||
|
"US/Hawaii") new_tz="Pacific/Honolulu" ;;
|
||||||
|
"US/Arizona") new_tz="America/Phoenix" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! timedatectl list-timezones | grep -qx "$new_tz"; then
|
||||||
|
log_error "Invalid timezone: $new_tz"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sudo timedatectl set-timezone "$new_tz"; then
|
||||||
|
log_success "Timezone updated to $new_tz"
|
||||||
|
else
|
||||||
|
# Fallback: set timezone directly without D-Bus
|
||||||
|
sudo ln -sf "/usr/share/zoneinfo/$new_tz" /etc/localtime
|
||||||
|
echo "$new_tz" | sudo tee /etc/timezone > /dev/null
|
||||||
|
log_success "Timezone updated to $new_tz (direct)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
+501
@@ -0,0 +1,501 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/wifi.sh - "WiFi" configuration.
|
||||||
|
#
|
||||||
|
# HIGH RISK, unlike anything migrated so far: this changes live network
|
||||||
|
# configuration and, if run over SSH, can disconnect the very session
|
||||||
|
# configuring it. Every safety mechanism from the legacy configure_wifi
|
||||||
|
# is preserved exactly: a netplan backup before writing, a 60-second
|
||||||
|
# watchdog (armed only when $SSH_CONNECTION is set) that reverts to the
|
||||||
|
# backup if the new config never comes up, and an explicit "restore
|
||||||
|
# backup?" prompt if `netplan apply` itself fails outright.
|
||||||
|
#
|
||||||
|
# Netplan's directory is $NETPLAN_DIR (lib/config.sh) rather than a
|
||||||
|
# hardcoded /etc/netplan, so a test can point it at scratch space. But
|
||||||
|
# unlike every other migrated menu, there is deliberately no automated
|
||||||
|
# test - not even a stubbed one - that calls the real `netplan apply`,
|
||||||
|
# `nmcli`, `iw`, `wpa_cli`, or `sudo ip link set ... up`. Only the pure
|
||||||
|
# logic (SSID/password handling, YAML generation, backup naming) is
|
||||||
|
# covered by tests with those commands stubbed; the actual apply step
|
||||||
|
# is exercised by hand against real hardware only.
|
||||||
|
#
|
||||||
|
# Unlike the other migrated menus, this one has no sub-options - it's a
|
||||||
|
# single linear wizard, same as the legacy configure_wifi. wifi_menu runs
|
||||||
|
# it (wifi_configure) and then pauses: run_menu clears the screen as soon
|
||||||
|
# as a handler returns, which used to wipe every error message here
|
||||||
|
# before it could be read - the menu just seemed to bounce back.
|
||||||
|
#
|
||||||
|
# The WiFi config goes in its own file, $NETPLAN_DIR/60-kiosk-wifi.yaml,
|
||||||
|
# which netplan merges with the rest - not over the first *.yaml it finds,
|
||||||
|
# which on a fresh install is the installer's wired config.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
||||||
|
|
||||||
|
# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
|
||||||
|
# in every file and waits for that device - 90s, then the whole apply
|
||||||
|
# fails - if one names an interface this machine doesn't have (the
|
||||||
|
# installer's own config can carry e.g. "wlan0" while the adapter here is
|
||||||
|
# "wlp1s0"). Take such entries out of the other netplan files first
|
||||||
|
# (originals kept as *.kiosk-bak, which netplan ignores).
|
||||||
|
wifi_drop_stale_entries() {
|
||||||
|
local f
|
||||||
|
for f in "$NETPLAN_DIR"/*.yaml; do
|
||||||
|
[[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
|
||||||
|
python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
|
||||||
|
import os, shutil, sys, yaml
|
||||||
|
p = sys.argv[1]
|
||||||
|
try:
|
||||||
|
d = yaml.safe_load(open(p)) or {}
|
||||||
|
w = (d.get("network") or {}).get("wifis") or {}
|
||||||
|
stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
|
||||||
|
if stale:
|
||||||
|
shutil.copy2(p, p + ".kiosk-bak")
|
||||||
|
for n in stale:
|
||||||
|
del w[n]
|
||||||
|
if not w:
|
||||||
|
del d["network"]["wifis"]
|
||||||
|
# Not "optional" = the boot waits (up to ~2 min) for it to get an address.
|
||||||
|
opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
|
||||||
|
for n in opt:
|
||||||
|
w[n]["optional"] = True
|
||||||
|
if stale or opt:
|
||||||
|
if not stale:
|
||||||
|
shutil.copy2(p, p + ".kiosk-bak")
|
||||||
|
mode = os.stat(p).st_mode & 0o777
|
||||||
|
with open(p, "w") as fh:
|
||||||
|
yaml.safe_dump(d, fh, default_flow_style=False)
|
||||||
|
os.chmod(p, mode)
|
||||||
|
print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
PYEOF
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Why a connect attempt failed, from wpa_supplicant's own log lines in
|
||||||
|
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
|
||||||
|
# short human-readable reason, or nothing if the log says nothing useful.
|
||||||
|
wifi_diagnose() {
|
||||||
|
local log
|
||||||
|
log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
|
||||||
|
if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
|
||||||
|
echo "wrong password (the network rejected the key)"
|
||||||
|
elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
|
||||||
|
echo "network not found - wrong name, or out of range of this adapter"
|
||||||
|
elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
|
||||||
|
echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every wireless interface, one per line.
|
||||||
|
wifi_list_ifaces() {
|
||||||
|
local d
|
||||||
|
for d in /sys/class/net/*; do
|
||||||
|
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||||
|
basename "$d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# "USB" / "PCI" / "" - what an interface hangs off, to tell a dongle
|
||||||
|
# from a built-in card when there are two.
|
||||||
|
wifi_iface_bus() {
|
||||||
|
local sub
|
||||||
|
sub=$(readlink -f "/sys/class/net/$1/device/subsystem" 2>/dev/null) || return 0
|
||||||
|
case "$sub" in
|
||||||
|
*/usb) echo "USB" ;;
|
||||||
|
*/pci) echo "PCI" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The interface to configure: the only one, or the user's pick when there
|
||||||
|
# are several (e.g. built-in card plus a USB dongle).
|
||||||
|
wifi_choose_iface() {
|
||||||
|
local -a ifaces
|
||||||
|
mapfile -t ifaces < <(wifi_list_ifaces)
|
||||||
|
if (( ${#ifaces[@]} == 0 )); then
|
||||||
|
wifi_detect_iface
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if (( ${#ifaces[@]} == 1 )); then
|
||||||
|
echo "${ifaces[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "Several WiFi adapters found:"
|
||||||
|
local i bus
|
||||||
|
for i in "${!ifaces[@]}"; do
|
||||||
|
bus=$(wifi_iface_bus "${ifaces[$i]}")
|
||||||
|
printf ' %d. %s%s\n' "$((i + 1))" "${ifaces[$i]}" "${bus:+ ($bus)}"
|
||||||
|
done
|
||||||
|
} >&2
|
||||||
|
local choice
|
||||||
|
read -r -p "Which one? [1]: " choice </dev/tty >&2
|
||||||
|
choice=${choice:-1}
|
||||||
|
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ifaces[@]} )); then
|
||||||
|
echo "${ifaces[$((choice - 1))]}"
|
||||||
|
else
|
||||||
|
echo "${ifaces[0]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# What the adapter is actually doing, printed after a failed connect so the
|
||||||
|
# cause (regulatory domain, weak signal, DHCP, driver) is visible, not guessed.
|
||||||
|
wifi_debug_dump() {
|
||||||
|
local i="$1"
|
||||||
|
echo "--- link ---"
|
||||||
|
iw dev "$i" link 2>&1 | head -8
|
||||||
|
echo "--- regulatory domain ---"
|
||||||
|
iw reg get 2>&1 | grep -m2 "country\|global" || true
|
||||||
|
echo "--- wpa_supplicant log (last 15) ---"
|
||||||
|
journalctl -b -u "netplan-wpa-$i" -n 15 --no-pager -o cat 2>&1 | cut -c1-160
|
||||||
|
echo "--- networkd ---"
|
||||||
|
networkctl status "$i" --no-pager 2>&1 | head -12
|
||||||
|
echo "(full log: /var/lib/kiosk-firstboot/install.log, or journalctl -b -u 'netplan-wpa-*')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Two-letter country code for the WiFi regulatory domain: the one already
|
||||||
|
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
|
||||||
|
# Without a country set, Linux uses the restrictive "world" domain: many
|
||||||
|
# 5 GHz channels become listen-only, so a network shows up in scans but
|
||||||
|
# the adapter can't join it.
|
||||||
|
wifi_default_country() {
|
||||||
|
local c=""
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" && -r /etc/modprobe.d/kiosk-regdom.conf ]]; then
|
||||||
|
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' /etc/modprobe.d/kiosk-regdom.conf | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" ]]; then
|
||||||
|
local loc="${LANG:-}"
|
||||||
|
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
|
||||||
|
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
echo "${c^^}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wifi_menu() {
|
||||||
|
local rc=0
|
||||||
|
wifi_configure || rc=$?
|
||||||
|
echo
|
||||||
|
pause
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The first wireless interface, by what it is (it has a wireless/ or
|
||||||
|
# phy80211 entry in sysfs) rather than by name - adapters aren't always
|
||||||
|
# named wl*. Falls back to the wl* naming convention.
|
||||||
|
wifi_detect_iface() {
|
||||||
|
local d
|
||||||
|
for d in /sys/class/net/*; do
|
||||||
|
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||||
|
basename "$d"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
local name
|
||||||
|
for d in /sys/class/net/wl*; do
|
||||||
|
[[ -e "$d" ]] || continue
|
||||||
|
name=$(basename "$d")
|
||||||
|
echo "$name"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear a software WiFi block (the rfkill "soft" switch) - with it set,
|
||||||
|
# `ip link set up` fails with "RF-kill". The rfkill tool isn't installed
|
||||||
|
# on a server, so via sysfs. A hard block (physical switch / BIOS) can't
|
||||||
|
# be cleared from here and is reported instead.
|
||||||
|
wifi_unblock() {
|
||||||
|
local r type
|
||||||
|
for r in /sys/class/rfkill/rfkill*; do
|
||||||
|
[[ -e "$r/type" ]] || continue
|
||||||
|
type=$(cat "$r/type" 2>/dev/null)
|
||||||
|
[[ "$type" == "wlan" ]] || continue
|
||||||
|
if [[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]]; then
|
||||||
|
echo "WiFi is switched off in software (rfkill) - switching it on..."
|
||||||
|
echo 0 | sudo tee "$r/soft" >/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]]; then
|
||||||
|
log_warning "WiFi is switched off by a hardware switch or the BIOS/UEFI - turn it on there"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# YAML single-quoted scalar: the only special character inside is the
|
||||||
|
# quote itself, doubled. Safe for any SSID/password (", \, #, : ...) -
|
||||||
|
# double quotes broke on a password containing " or \.
|
||||||
|
wifi_yaml_quote() {
|
||||||
|
local v="$1"
|
||||||
|
printf "'%s'" "${v//\'/\'\'}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# IFACE's first IPv4 address (without the /prefix), or nothing.
|
||||||
|
wifi_iface_ip() {
|
||||||
|
ip -4 -o addr show dev "$1" 2>/dev/null \
|
||||||
|
| awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# True if IFACE has an IPv4 address.
|
||||||
|
wifi_iface_has_ip() {
|
||||||
|
ip -4 addr show dev "$1" 2>/dev/null | grep -q "inet "
|
||||||
|
}
|
||||||
|
|
||||||
|
wifi_configure() {
|
||||||
|
echo
|
||||||
|
echo " ═══ WIFI CONFIGURATION ═══"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# netplan needs wpa_supplicant to join WiFi at all; iw/wpa_cli scan.
|
||||||
|
# Missing on a server install that skipped WiFi in the installer -
|
||||||
|
# install them here (from the offline bundle if there's no internet,
|
||||||
|
# which is exactly the situation this menu is usually opened in).
|
||||||
|
if ! command -v wpa_supplicant &>/dev/null || ! command -v iw &>/dev/null; then
|
||||||
|
echo "WiFi support (wpasupplicant, iw) isn't installed yet - installing..."
|
||||||
|
if ! run_with_offline_fallback sudo apt install -y wpasupplicant iw; then
|
||||||
|
log_error "Couldn't install WiFi support (needs internet, wired, or an --offline install ISO)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local has_tools=false
|
||||||
|
if command -v nmcli &>/dev/null || command -v iw &>/dev/null || command -v wpa_cli &>/dev/null; then
|
||||||
|
has_tools=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! $has_tools; then
|
||||||
|
log_error "No WiFi tools found (nmcli, iw, or wpa_cli)"
|
||||||
|
echo "Install: sudo apt install network-manager wireless-tools wpasupplicant"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local wifi_iface
|
||||||
|
if ! wifi_iface=$(wifi_choose_iface); then
|
||||||
|
log_warning "No WiFi hardware detected"
|
||||||
|
echo "Network interfaces found: $(ls /sys/class/net 2>/dev/null | tr '\n' ' ')"
|
||||||
|
echo "If you have a USB WiFi adapter, make sure it's plugged in. A built-in"
|
||||||
|
echo "adapter with no driver shows up in 'lspci -k' / 'lsusb' but not above."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Interface: $wifi_iface"
|
||||||
|
echo "Current IP: $(get_ip_address)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -n "${SSH_CONNECTION:-}" ]]; then
|
||||||
|
log_warning "SSH detected - changes auto-revert after 60s if the connection fails"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
ask_yes_no "Configure WiFi?" "n" || return 0
|
||||||
|
|
||||||
|
wifi_unblock
|
||||||
|
echo "Bringing up interface..."
|
||||||
|
local link_err
|
||||||
|
if ! link_err=$(sudo ip link set "$wifi_iface" up 2>&1); then
|
||||||
|
log_error "Failed to bring up $wifi_iface: ${link_err:-unknown error}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "Scanning for networks (this takes 5-10 seconds)..."
|
||||||
|
local scan_results=""
|
||||||
|
|
||||||
|
if command -v nmcli &>/dev/null; then
|
||||||
|
if sudo nmcli device wifi rescan 2>/dev/null; then
|
||||||
|
sleep 5
|
||||||
|
scan_results=$(nmcli -t -f SSID,SIGNAL device wifi list 2>/dev/null | sort -t: -k2 -rn | cut -d: -f1 | grep -v "^$" | uniq)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$scan_results" ]] && command -v iw &>/dev/null; then
|
||||||
|
local scan_tmp
|
||||||
|
scan_tmp=$(mktemp)
|
||||||
|
if sudo iw dev "$wifi_iface" scan 2>/dev/null | grep -E "^BSS|SSID:" > "$scan_tmp"; then
|
||||||
|
scan_results=$(grep "SSID:" "$scan_tmp" | sed 's/.*SSID: //' | grep -v "^$" | sort -u)
|
||||||
|
fi
|
||||||
|
rm -f "$scan_tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$scan_results" ]]; then
|
||||||
|
sudo wpa_cli -i "$wifi_iface" scan >/dev/null 2>&1 || true
|
||||||
|
sleep 5
|
||||||
|
scan_results=$(sudo wpa_cli -i "$wifi_iface" scan_results 2>/dev/null | awk -F'\t' 'NR>1 && $5!="" {print $5}' | sort -u)
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ssid=""
|
||||||
|
if [[ -z "$scan_results" ]]; then
|
||||||
|
log_warning "No networks found in scan"
|
||||||
|
echo "This could mean:"
|
||||||
|
echo " • WiFi is disabled in BIOS/UEFI"
|
||||||
|
echo " • Hardware WiFi switch is off"
|
||||||
|
echo " • Driver not loaded"
|
||||||
|
echo " • Networks out of range"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Enter SSID manually anyway?" "n"; then
|
||||||
|
read -r -p "SSID: " ssid
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "Available networks (strongest first):"
|
||||||
|
echo "$scan_results" | nl -w2 -s'. '
|
||||||
|
echo " 0. Manual entry"
|
||||||
|
echo
|
||||||
|
local choice
|
||||||
|
read -r -p "Select network number or enter SSID: " choice
|
||||||
|
if [[ "$choice" == "0" ]]; then
|
||||||
|
read -r -p "SSID: " ssid
|
||||||
|
elif [[ "$choice" =~ ^[0-9]+$ ]]; then
|
||||||
|
ssid=$(echo "$scan_results" | sed -n "${choice}p")
|
||||||
|
else
|
||||||
|
ssid="$choice"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$ssid" ]]; then
|
||||||
|
log_error "No SSID provided"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local password
|
||||||
|
read -r -s -p "Password for '$ssid': " password
|
||||||
|
echo
|
||||||
|
if [[ -z "$password" ]]; then
|
||||||
|
log_error "No password provided"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local country c_in
|
||||||
|
country=$(wifi_default_country)
|
||||||
|
read -r -p "Country code for WiFi regulations (2 letters, e.g. US) [${country:-none}]: " c_in
|
||||||
|
c_in=${c_in:-$country}
|
||||||
|
c_in=${c_in^^}
|
||||||
|
if [[ -n "$c_in" && ! "$c_in" =~ ^[A-Z]{2}$ ]]; then
|
||||||
|
log_warning "'$c_in' isn't a 2-letter country code - skipping it"
|
||||||
|
c_in=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
apply_wifi_config "$wifi_iface" "$ssid" "$password" "$c_in"
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply_wifi_config IFACE SSID PASSWORD
|
||||||
|
# Split out from wifi_menu so a test can drive it directly without going
|
||||||
|
# through interface detection/scanning, which don't exist in a container.
|
||||||
|
apply_wifi_config() {
|
||||||
|
local wifi_iface="$1"
|
||||||
|
local ssid="$2"
|
||||||
|
local password="$3"
|
||||||
|
local country="${4:-}"
|
||||||
|
|
||||||
|
# Our own file, merged by netplan with the others (wired config etc).
|
||||||
|
local netplan_file="$WIFI_NETPLAN_FILE"
|
||||||
|
sudo mkdir -p "$NETPLAN_DIR"
|
||||||
|
|
||||||
|
local backup=""
|
||||||
|
if [[ -f "$netplan_file" ]]; then
|
||||||
|
backup="${netplan_file}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
sudo cp "$netplan_file" "$backup"
|
||||||
|
log_success "Backup: $backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local temp_plan
|
||||||
|
temp_plan=$(mktemp --suffix=.yaml)
|
||||||
|
cat > "$temp_plan" <<EOF
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
renderer: networkd
|
||||||
|
wifis:
|
||||||
|
$wifi_iface:
|
||||||
|
dhcp4: true
|
||||||
|
dhcp6: false
|
||||||
|
optional: true
|
||||||
|
${country:+ regulatory-domain: \"$country\"
|
||||||
|
} access-points:
|
||||||
|
$(wifi_yaml_quote "$ssid"):
|
||||||
|
password: $(wifi_yaml_quote "$password")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ -n "${SSH_CONNECTION:-}" ]]; then
|
||||||
|
# Reverts if the WiFi interface still has no address after 60s -
|
||||||
|
# not "can't ping 8.8.8.8", which on a network without internet
|
||||||
|
# would undo a perfectly good connection every time. No backup
|
||||||
|
# means there was no WiFi file before: revert = remove ours.
|
||||||
|
local watchdog
|
||||||
|
watchdog=$(mktemp --suffix=.sh)
|
||||||
|
cat > "$watchdog" <<'WATCHEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# $1 = backup ("" if none), $2 = netplan file, $3 = WiFi interface
|
||||||
|
sleep 60
|
||||||
|
if ! ip -4 addr show dev "$3" 2>/dev/null | grep -q "inet "; then
|
||||||
|
if [[ -n "$1" && -f "$1" ]]; then cp "$1" "$2"; else rm -f "$2"; fi
|
||||||
|
netplan apply 2>/dev/null
|
||||||
|
echo "WiFi config reverted - $3 got no address" | wall
|
||||||
|
fi
|
||||||
|
rm -f "$0"
|
||||||
|
WATCHEOF
|
||||||
|
chmod +x "$watchdog"
|
||||||
|
nohup sudo bash "$watchdog" "$backup" "$netplan_file" "$wifi_iface" >/dev/null 2>&1 &
|
||||||
|
echo "Watchdog started - will revert in 60s if $wifi_iface gets no address"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo cp "$temp_plan" "$netplan_file"
|
||||||
|
sudo chmod 0600 "$netplan_file"
|
||||||
|
rm -f "$temp_plan"
|
||||||
|
|
||||||
|
sudo bash -c "$(declare -f wifi_drop_stale_entries); NETPLAN_DIR='$NETPLAN_DIR' WIFI_NETPLAN_FILE='$netplan_file' wifi_drop_stale_entries"
|
||||||
|
[[ -n "$country" ]] && sudo iw reg set "$country" 2>/dev/null
|
||||||
|
echo "Applying configuration..."
|
||||||
|
local netplan_log
|
||||||
|
netplan_log=$(mktemp)
|
||||||
|
if sudo netplan apply > "$netplan_log" 2>&1; then
|
||||||
|
cat "$netplan_log"
|
||||||
|
echo "Waiting for $wifi_iface to connect (up to 45 seconds)..."
|
||||||
|
local _
|
||||||
|
for _ in {1..45}; do
|
||||||
|
wifi_iface_has_ip "$wifi_iface" && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if wifi_iface_has_ip "$wifi_iface"; then
|
||||||
|
log_success "Connected to $ssid: $(wifi_iface_ip "$wifi_iface")"
|
||||||
|
[[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert"
|
||||||
|
else
|
||||||
|
log_warning "Config applied but $wifi_iface has no address yet"
|
||||||
|
local why
|
||||||
|
why=$(wifi_diagnose "$wifi_iface")
|
||||||
|
if [[ -n "$why" ]]; then
|
||||||
|
log_error "Couldn't connect to $ssid: $why"
|
||||||
|
elif iw dev "$wifi_iface" link 2>/dev/null | grep -q "^Connected"; then
|
||||||
|
log_error "Joined the network but got no address - the router's DHCP isn't answering (or is slow)"
|
||||||
|
else
|
||||||
|
echo "Not joined, and the log doesn't say why - not necessarily the password."
|
||||||
|
fi
|
||||||
|
wifi_debug_dump "$wifi_iface"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "netplan apply failed"
|
||||||
|
echo "Error log:"
|
||||||
|
cat "$netplan_log"
|
||||||
|
if ask_yes_no "Undo this WiFi change?" "y"; then
|
||||||
|
if [[ -n "$backup" ]]; then
|
||||||
|
sudo cp "$backup" "$netplan_file"
|
||||||
|
else
|
||||||
|
sudo rm -f "$netplan_file"
|
||||||
|
fi
|
||||||
|
# Last resort after everything else failed: report, don't crash
|
||||||
|
# the session if even the restore-and-reapply doesn't work.
|
||||||
|
if sudo netplan apply; then
|
||||||
|
log_success "Backup restored and applied"
|
||||||
|
else
|
||||||
|
log_error "Failed to reapply the restored backup - manual intervention needed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$netplan_log"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Section "ServerFlags"
|
||||||
|
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||||
|
Option "DontZap" "true"
|
||||||
|
|
||||||
|
# Allow VT switching (Ctrl+Alt+F1-F8): virtual consoles are ENABLED by
|
||||||
|
# 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
|
||||||
|
Option "AllowClosedownGrabs" "false"
|
||||||
|
EndSection
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Section "Device"
|
||||||
|
Identifier "Intel Graphics"
|
||||||
|
Driver "intel"
|
||||||
|
Option "AccelMethod" "sna"
|
||||||
|
Option "TearFree" "true"
|
||||||
|
Option "DRI" "3"
|
||||||
|
EndSection
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Section "InputClass"
|
||||||
|
Identifier "Touch screen use libinput"
|
||||||
|
MatchIsTouchscreen "on"
|
||||||
|
Driver "libinput"
|
||||||
|
EndSection
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
event=button/power.*
|
||||||
|
action=/usr/local/bin/kiosk-power-button.sh
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
event=button/power PBTN
|
||||||
|
action=/usr/local/bin/kiosk-power-button.sh
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
event=button/power PWRF
|
||||||
|
action=/usr/local/bin/kiosk-power-button.sh
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Allow kiosk power operations]
|
||||||
|
Identity=unix-user:kiosk
|
||||||
|
Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.reboot;org.freedesktop.login1.reboot-multiple-sessions;org.freedesktop.login1.suspend;org.freedesktop.login1.suspend-multiple-sessions
|
||||||
|
ResultAny=yes
|
||||||
|
ResultInactive=yes
|
||||||
|
ResultActive=yes
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Login]
|
||||||
|
HandlePowerKey=ignore
|
||||||
|
HandlePowerKeyLongPress=poweroff
|
||||||
|
HandleSuspendKey=ignore
|
||||||
|
HandleHibernateKey=ignore
|
||||||
|
HandleLidSwitch=ignore
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Kiosk Display Hotplug Handler
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/kiosk-hotplug.sh
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Keep WiFi adapters awake. Power saving (the adapter dozing between
|
||||||
|
# packets, and the USB port suspending an idle dongle) is what makes
|
||||||
|
# cheap dongles like the Realtek RTL8188CUS drop or stall a connection.
|
||||||
|
# Both are ordinary software settings, so nothing here can harm the
|
||||||
|
# hardware: the cost is a fraction of a watt.
|
||||||
|
|
||||||
|
# Radio power save off on every WiFi interface as it appears.
|
||||||
|
ACTION=="add", SUBSYSTEM=="net", KERNEL=="wl*", RUN+="/usr/sbin/iw dev $name set power_save off"
|
||||||
|
|
||||||
|
# No USB autosuspend for the common RTL8188/8192 CU/CUS/RU dongles.
|
||||||
|
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="8176|8177|8178|8179|817a|817b|817c|817d|817e|8191", TEST=="power/control", ATTR{power/control}="on"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
SUBSYSTEM=="drm", ACTION=="change", TAG+="systemd", ENV{SYSTEMD_WANTS}="kiosk-hotplug.service"
|
||||||
Executable
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Mirror any connected external display (e.g. HDMI-out to a monitor/TV) onto
|
||||||
|
# the primary display, forcing it to the primary's exact resolution.
|
||||||
|
/usr/local/bin/kiosk-mirror-display.sh
|
||||||
|
|
||||||
|
# AGGRESSIVE DPMS disable - multiple methods
|
||||||
|
xset s off
|
||||||
|
xset s noblank
|
||||||
|
xset -dpms
|
||||||
|
xset s 0 0
|
||||||
|
xset dpms 0 0 0
|
||||||
|
xset dpms force on
|
||||||
|
|
||||||
|
# Keep screen on forever - watchdog (schedule-aware)
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep 300 # Every 5 minutes
|
||||||
|
|
||||||
|
# Check if display schedule is active
|
||||||
|
schedule_active=false
|
||||||
|
if systemctl is-active --quiet kiosk-display-off.timer && systemctl is-active --quiet kiosk-display-on.timer; then
|
||||||
|
# Get display off and on times from systemd timers
|
||||||
|
doff=$(systemctl cat kiosk-display-off.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||||
|
don=$(systemctl cat kiosk-display-on.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||||
|
|
||||||
|
if [ -n "$doff" ] && [ -n "$don" ]; then
|
||||||
|
# Get current time in HH:MM format
|
||||||
|
current_time=$(date +%H:%M)
|
||||||
|
|
||||||
|
# Convert times to minutes since midnight for comparison
|
||||||
|
# Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times)
|
||||||
|
current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
|
||||||
|
off_mins=$(( 10#$(echo "$doff" | cut -d: -f1) * 60 + 10#$(echo "$doff" | cut -d: -f2) ))
|
||||||
|
on_mins=$(( 10#$(echo "$don" | cut -d: -f1) * 60 + 10#$(echo "$don" | cut -d: -f2) ))
|
||||||
|
|
||||||
|
# Check if we're in the "display off" window
|
||||||
|
if [ "$off_mins" -lt "$on_mins" ]; then
|
||||||
|
# Normal case: off time is before on time (e.g., 22:00 to 06:00 next day)
|
||||||
|
if [ "$current_mins" -ge "$off_mins" ] && [ "$current_mins" -lt "$on_mins" ]; then
|
||||||
|
schedule_active=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Overnight case: off time is after on time (e.g., 06:00 to 22:00)
|
||||||
|
if [ "$current_mins" -ge "$off_mins" ] || [ "$current_mins" -lt "$on_mins" ]; then
|
||||||
|
schedule_active=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only force display on if NOT in scheduled off period
|
||||||
|
if [ "$schedule_active" = "false" ]; then
|
||||||
|
xset s reset 2>/dev/null
|
||||||
|
xset dpms force on 2>/dev/null
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
# Start PipeWire user services
|
||||||
|
systemctl --user start pipewire pipewire-pulse wireplumber
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Wait for PipeWire to be ready
|
||||||
|
for i in {1..15}; do
|
||||||
|
pactl info >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Wait for ALSA devices
|
||||||
|
for i in {1..10}; do
|
||||||
|
pactl list sinks short | grep -q alsa && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Route audio to HDMI if an external display is connected/mirrored, else built-in
|
||||||
|
/usr/local/bin/kiosk-audio-route.sh
|
||||||
|
|
||||||
|
# Set audio levels (speakers 100%, mic 100%, mic unmuted)
|
||||||
|
pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||||
|
pactl set-source-volume @DEFAULT_SOURCE@ 100%
|
||||||
|
pactl set-source-mute @DEFAULT_SOURCE@ 0
|
||||||
|
|
||||||
|
# Audio watchdog - checks every 30 seconds (quiet hours aware)
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Check if quiet hours is active
|
||||||
|
quiet_active=false
|
||||||
|
if systemctl is-active --quiet kiosk-quiet-start.timer && systemctl is-active --quiet kiosk-quiet-end.timer; then
|
||||||
|
# Get quiet hours start and end times from systemd timers
|
||||||
|
qstart=$(systemctl cat kiosk-quiet-start.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||||
|
qend=$(systemctl cat kiosk-quiet-end.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||||
|
|
||||||
|
if [ -n "$qstart" ] && [ -n "$qend" ]; then
|
||||||
|
# Convert times to minutes since midnight for comparison
|
||||||
|
# Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times)
|
||||||
|
current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
|
||||||
|
start_mins=$(( 10#$(echo "$qstart" | cut -d: -f1) * 60 + 10#$(echo "$qstart" | cut -d: -f2) ))
|
||||||
|
end_mins=$(( 10#$(echo "$qend" | cut -d: -f1) * 60 + 10#$(echo "$qend" | cut -d: -f2) ))
|
||||||
|
|
||||||
|
# Check if we're in quiet hours window
|
||||||
|
if [ "$start_mins" -lt "$end_mins" ]; then
|
||||||
|
# Normal case: start time is before end time (e.g., 08:00 to 17:00)
|
||||||
|
if [ "$current_mins" -ge "$start_mins" ] && [ "$current_mins" -lt "$end_mins" ]; then
|
||||||
|
quiet_active=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Overnight case: start time is after end time (e.g., 22:00 to 07:00)
|
||||||
|
if [ "$current_mins" -ge "$start_mins" ] || [ "$current_mins" -lt "$end_mins" ]; then
|
||||||
|
quiet_active=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if PipeWire is running
|
||||||
|
if ! pactl info >/dev/null 2>&1; then
|
||||||
|
logger "KIOSK: Audio dead, restarting PipeWire"
|
||||||
|
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||||
|
sleep 5
|
||||||
|
# Only restore audio levels if NOT in quiet hours
|
||||||
|
if [ "$quiet_active" = "false" ]; then
|
||||||
|
pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||||
|
pactl set-source-volume @DEFAULT_SOURCE@ 100%
|
||||||
|
pactl set-source-mute @DEFAULT_SOURCE@ 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
# Other services
|
||||||
|
unclutter -idle 0.1 -root &
|
||||||
|
XDG_RUNTIME_DIR=/run/user/$(id -u) xbindkeys &
|
||||||
|
|
||||||
|
# Create boot flag for password requirement on boot
|
||||||
|
touch /home/kiosk/kiosk-app/.boot-flag
|
||||||
|
|
||||||
|
# Start kiosk app AFTER audio is ready
|
||||||
|
sleep 2
|
||||||
|
/home/kiosk/kiosk-app/start.sh &
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# PipeWire noise cancellation configuration for kiosk microphone
|
||||||
|
# This creates a virtual source with echo cancellation and noise suppression
|
||||||
|
|
||||||
|
context.modules = [
|
||||||
|
{ name = libpipewire-module-echo-cancel
|
||||||
|
args = {
|
||||||
|
# audio.channels = 1
|
||||||
|
# capture.props = {
|
||||||
|
# node.name = "Echo Cancellation Capture"
|
||||||
|
# }
|
||||||
|
# source.props = {
|
||||||
|
# node.name = "Echo Cancellation Source"
|
||||||
|
# node.description = "Noise-Cancelled Microphone"
|
||||||
|
# }
|
||||||
|
# sink.props = {
|
||||||
|
# node.name = "Echo Cancellation Sink"
|
||||||
|
# }
|
||||||
|
# playback.props = {
|
||||||
|
# node.name = "Echo Cancellation Playback"
|
||||||
|
# }
|
||||||
|
aec.method = webrtc
|
||||||
|
aec.args = {
|
||||||
|
# WebRTC audio processing settings
|
||||||
|
webrtc.gain_control = true
|
||||||
|
webrtc.extended_filter = true
|
||||||
|
webrtc.high_pass_filter = true
|
||||||
|
webrtc.noise_suppression = true
|
||||||
|
webrtc.noise_suppression_level = 3
|
||||||
|
webrtc.voice_detection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Assumes DISPLAY/XAUTHORITY are set (for xrandr) and pactl already has a
|
||||||
|
# working PipeWire/pulse socket for the invoking context.
|
||||||
|
|
||||||
|
QUERY=$(xrandr --query 2>/dev/null)
|
||||||
|
[ -z "$QUERY" ] && exit 0
|
||||||
|
|
||||||
|
PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}')
|
||||||
|
[ -z "$PRIMARY_OUTPUT" ] && exit 0
|
||||||
|
|
||||||
|
EXTERNAL_CONNECTED=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '/ connected/ && $1!=p{f=1} END{print (f==1)?"yes":"no"}')
|
||||||
|
|
||||||
|
HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) ~ /hdmi/{print $2; exit}')
|
||||||
|
NON_HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) !~ /hdmi/{print $2; exit}')
|
||||||
|
|
||||||
|
route_to() {
|
||||||
|
local sink="$1" label="$2"
|
||||||
|
if [ -z "$sink" ]; then
|
||||||
|
logger "KIOSK: no $label audio sink found, leaving routing unchanged"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
pactl set-default-sink "$sink" 2>/dev/null \
|
||||||
|
&& logger "KIOSK: audio routed to $label sink ($sink)" \
|
||||||
|
|| logger "KIOSK: failed to route audio to $label sink ($sink)"
|
||||||
|
pactl list sink-inputs short 2>/dev/null | awk '{print $1}' | while read -r sid; do
|
||||||
|
pactl move-sink-input "$sid" "$sink" 2>/dev/null
|
||||||
|
done
|
||||||
|
pactl set-sink-volume "$sink" 100% 2>/dev/null
|
||||||
|
pactl set-sink-mute "$sink" 0 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$EXTERNAL_CONNECTED" = "yes" ]; then
|
||||||
|
route_to "$HDMI_SINK" "HDMI"
|
||||||
|
else
|
||||||
|
route_to "$NON_HDMI_SINK" "built-in"
|
||||||
|
fi
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Give X a moment to finish enumerating the output after the hotplug event
|
||||||
|
sleep 2
|
||||||
|
sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority /usr/local/bin/kiosk-mirror-display.sh
|
||||||
|
|
||||||
|
kiosk_uid=$(id -u kiosk)
|
||||||
|
sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" /usr/local/bin/kiosk-audio-route.sh
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Assumes it's run with DISPLAY/XAUTHORITY already set for the kiosk user's
|
||||||
|
# X session (either inherited, as from Openbox autostart, or exported by the
|
||||||
|
# caller). Mirrors every connected non-primary output at the primary's exact
|
||||||
|
# current resolution so kiosk content isn't cropped/letterboxed/blank on a
|
||||||
|
# TV/monitor with a different native resolution than the kiosk panel.
|
||||||
|
|
||||||
|
QUERY=$(xrandr --query 2>/dev/null)
|
||||||
|
[ -z "$QUERY" ] && exit 0
|
||||||
|
|
||||||
|
PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}')
|
||||||
|
[ -z "$PRIMARY_OUTPUT" ] && exit 0
|
||||||
|
|
||||||
|
PRIMARY_RES=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '$1==p{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+x[0-9]+\+/){split($i,a,"+"); print a[1]; exit}}')
|
||||||
|
[ -z "$PRIMARY_RES" ] && exit 0
|
||||||
|
|
||||||
|
for OUT in $(echo "$QUERY" | awk '/ connected/{print $1}'); do
|
||||||
|
[ "$OUT" = "$PRIMARY_OUTPUT" ] && continue
|
||||||
|
|
||||||
|
HAS_NATIVE=$(echo "$QUERY" | awk -v out="$OUT" -v res="$PRIMARY_RES" '
|
||||||
|
$0 ~ "^"out" " {infound=1; next}
|
||||||
|
/^[^ \t]/ {infound=0}
|
||||||
|
infound && $1==res {print "yes"; exit}
|
||||||
|
')
|
||||||
|
|
||||||
|
if [ "$HAS_NATIVE" = "yes" ]; then
|
||||||
|
xrandr --output "$OUT" --mode "$PRIMARY_RES" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \
|
||||||
|
&& logger "KIOSK: mirrored $OUT at native $PRIMARY_RES" \
|
||||||
|
|| logger "KIOSK: mirror of $OUT at $PRIMARY_RES failed"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# $OUT doesn't natively list the primary's resolution - force a matching mode
|
||||||
|
CVT_LINE=$(cvt "${PRIMARY_RES%x*}" "${PRIMARY_RES#*x}" 2>/dev/null | grep Modeline)
|
||||||
|
MODENAME=$(echo "$CVT_LINE" | sed -n 's/^Modeline "\([^"]*\)".*/\1/p')
|
||||||
|
TIMINGS=$(echo "$CVT_LINE" | sed -n 's/^Modeline "[^"]*" *//p')
|
||||||
|
|
||||||
|
if [ -z "$MODENAME" ] || [ -z "$TIMINGS" ]; then
|
||||||
|
logger "KIOSK: could not generate a $PRIMARY_RES mode for $OUT (cvt failed or missing)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
xrandr --newmode "$MODENAME" $TIMINGS 2>/dev/null
|
||||||
|
xrandr --addmode "$OUT" "$MODENAME" 2>/dev/null
|
||||||
|
xrandr --output "$OUT" --mode "$MODENAME" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \
|
||||||
|
&& logger "KIOSK: mirrored $OUT at forced $PRIMARY_RES ($MODENAME)" \
|
||||||
|
|| logger "KIOSK: mirror of $OUT at forced $PRIMARY_RES failed"
|
||||||
|
done
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Power button handler - sends SIGUSR1 to Electron to show power menu
|
||||||
|
# This runs as ROOT from acpid, so it can signal any process
|
||||||
|
|
||||||
|
logger "KIOSK POWER: Button pressed"
|
||||||
|
|
||||||
|
# Find the Electron main process (runs as kiosk user)
|
||||||
|
PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$PIDS" ]; then
|
||||||
|
logger "KIOSK POWER: No Electron process found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Send SIGUSR1 to all Electron processes (the main one will handle it)
|
||||||
|
for PID in $PIDS; do
|
||||||
|
logger "KIOSK POWER: Sending SIGUSR1 to PID $PID"
|
||||||
|
kill -USR1 $PID 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
logger "KIOSK POWER: Signal sent"
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%')
|
||||||
|
NEW=$((CURRENT - 5))
|
||||||
|
[[ $NEW -lt 0 ]] && NEW=0
|
||||||
|
pactl set-sink-volume @DEFAULT_SINK@ ${NEW}%
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%')
|
||||||
|
NEW=$((CURRENT + 5))
|
||||||
|
[[ $NEW -gt 100 ]] && NEW=100
|
||||||
|
pactl set-sink-volume @DEFAULT_SINK@ ${NEW}%
|
||||||
+560
@@ -0,0 +1,560 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# kiosk-wifi-helper - the root side of the kiosk app's WiFi screen
|
||||||
|
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
|
||||||
|
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
|
||||||
|
#
|
||||||
|
# The kiosk app reaches it through a root-owned socket
|
||||||
|
# (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:
|
||||||
|
#
|
||||||
|
# kiosk-wifi-helper ifaces {"adapters":[{"name","kind","connected","ssid","ip"}],"selected"}
|
||||||
|
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
|
||||||
|
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
|
||||||
|
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
|
||||||
|
# kiosk-wifi-helper region-status
|
||||||
|
# {"country","locale","keyboard","locales":[],"keyboards":[]}
|
||||||
|
# kiosk-wifi-helper region-set stdin {"country"?,"locale"?,"keyboard"?} -> {"ok",...}
|
||||||
|
# WiFi country (regulatory domain), system
|
||||||
|
# locale and keyboard layout; every field optional
|
||||||
|
#
|
||||||
|
# Every reply is one JSON line on stdout, exit 0 on success. status, scan
|
||||||
|
# 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
|
||||||
|
# gets no address - a typo in the password doesn't lose a WiFi network
|
||||||
|
# that was working. The password comes in on stdin, never argv (argv is
|
||||||
|
# visible to every user in ps).
|
||||||
|
################################################################################
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
|
||||||
|
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
|
||||||
|
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
||||||
|
REGDOM_CONF="${KIOSK_REGDOM_CONF:-/etc/modprobe.d/kiosk-regdom.conf}"
|
||||||
|
LOCALE_FILE="${KIOSK_LOCALE_FILE:-/etc/default/locale}"
|
||||||
|
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
|
||||||
|
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
|
||||||
|
|
||||||
|
reply_error() {
|
||||||
|
jq -cn --arg e "$1" '{ok:false,error:$e}'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The first wireless interface, by what it is (sysfs wireless/ or
|
||||||
|
# phy80211), falling back to the wl* naming convention. Same as
|
||||||
|
# menus/wifi.sh's wifi_detect_iface.
|
||||||
|
detect_iface() {
|
||||||
|
local d
|
||||||
|
for d in $SYS_CLASS/net/*; do
|
||||||
|
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||||
|
basename "$d"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for d in $SYS_CLASS/net/wl*; do
|
||||||
|
[[ -e "$d" ]] || continue
|
||||||
|
basename "$d"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear an rfkill soft block (a hard block can't be cleared from software).
|
||||||
|
unblock() {
|
||||||
|
local r
|
||||||
|
for r in $SYS_CLASS/rfkill/rfkill*; do
|
||||||
|
[[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
|
||||||
|
[[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]] && echo 0 > "$r/soft" 2>/dev/null
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hard_blocked() {
|
||||||
|
local r
|
||||||
|
for r in $SYS_CLASS/rfkill/rfkill*; do
|
||||||
|
[[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
|
||||||
|
[[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
iface_ip() {
|
||||||
|
ip -4 -o addr show dev "$1" 2>/dev/null \
|
||||||
|
| awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# YAML single-quoted scalar - the quote itself doubled is the only escape.
|
||||||
|
yaml_quote() {
|
||||||
|
local v="$1"
|
||||||
|
printf "'%s'" "${v//\'/\'\'}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# iw prints a backslash, a leading/trailing space and anything
|
||||||
|
# unprintable in an SSID as \xNN (a backslash itself is always \x5c,
|
||||||
|
# so there are no other escapes) - %b turns those back into the bytes.
|
||||||
|
unescape_ssid() {
|
||||||
|
printf '%b' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SSID the interface is associated with right now, if any.
|
||||||
|
current_ssid() {
|
||||||
|
unescape_ssid "$(iw dev "$1" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p' | head -1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SSID in our netplan file, if any (the first access point's key).
|
||||||
|
configured_ssid() {
|
||||||
|
[[ -f "$WIFI_NETPLAN_FILE" ]] || return 0
|
||||||
|
python3 -I -c '
|
||||||
|
import sys, yaml
|
||||||
|
try:
|
||||||
|
d = yaml.safe_load(open(sys.argv[1]))
|
||||||
|
for w in (d.get("network", {}).get("wifis") or {}).values():
|
||||||
|
for ssid in (w.get("access-points") or {}):
|
||||||
|
print(ssid); sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
pass' "$WIFI_NETPLAN_FILE" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Why a connect attempt failed, from wpa_supplicant's own log lines in
|
||||||
|
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
|
||||||
|
# short human-readable reason, or nothing if the log says nothing useful.
|
||||||
|
diagnose_failure() {
|
||||||
|
local log
|
||||||
|
log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
|
||||||
|
if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
|
||||||
|
echo "wrong password (the network rejected the key)"
|
||||||
|
elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
|
||||||
|
echo "network not found - wrong name, or out of range of this adapter"
|
||||||
|
elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
|
||||||
|
echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefer the adapter our netplan file already configures (if it's still
|
||||||
|
# there) over "first one found", so a built-in card plus a USB dongle
|
||||||
|
# don't swap places between calls.
|
||||||
|
prefer_configured_iface() {
|
||||||
|
[[ -f "$WIFI_NETPLAN_FILE" ]] || return 1
|
||||||
|
python3 -I -c '
|
||||||
|
import sys, os, yaml
|
||||||
|
try:
|
||||||
|
d = yaml.safe_load(open(sys.argv[1]))
|
||||||
|
for n in (d.get("network", {}).get("wifis") or {}):
|
||||||
|
if os.path.exists(sys.argv[2] + "/net/" + str(n)):
|
||||||
|
print(n); sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Two-letter country code for the WiFi regulatory domain: the one already
|
||||||
|
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
|
||||||
|
# Without a country set, Linux uses the restrictive "world" domain: many
|
||||||
|
# 5 GHz channels become listen-only, so a network shows up in scans but
|
||||||
|
# the adapter can't join it.
|
||||||
|
default_country() {
|
||||||
|
local c=""
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" && -f "$REGDOM_CONF" ]]; then
|
||||||
|
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' "$REGDOM_CONF" | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" ]]; then
|
||||||
|
local loc="${LANG:-}"
|
||||||
|
[[ -r "$LOCALE_FILE" ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1)
|
||||||
|
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
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() {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Region: WiFi country, system locale, keyboard layout -----------------
|
||||||
|
|
||||||
|
current_locale() {
|
||||||
|
[[ -r "$LOCALE_FILE" ]] || return 0
|
||||||
|
sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
current_keyboard() {
|
||||||
|
localectl status 2>/dev/null | sed -n 's/^[[:space:]]*X11 Layout:[[:space:]]*//p' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locales that are generated on this system (setting any other one would
|
||||||
|
# leave the whole system on a locale that doesn't exist).
|
||||||
|
list_locales() {
|
||||||
|
locale -a 2>/dev/null | grep -i 'utf-\?8' | sed 's/\.[Uu][Tt][Ff]-\?8$/.UTF-8/' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
list_keyboards() {
|
||||||
|
localectl list-x11-keymap-layouts 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
do_region_status() {
|
||||||
|
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||||
|
--argjson ls "$(list_locales | jq -R . | jq -cs .)" \
|
||||||
|
--argjson ks "$(list_keyboards | jq -R . | jq -cs .)" \
|
||||||
|
'{ok:true,country:$c,locale:$l,keyboard:$k,locales:$ls,keyboards:$ks}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist the WiFi country: kernel option for the regulatory domain (so it
|
||||||
|
# applies from boot, before any WiFi config exists), the netplan file if
|
||||||
|
# there is one (so wpa_supplicant gets it too), and now via iw.
|
||||||
|
set_country() {
|
||||||
|
local c="$1"
|
||||||
|
printf '# Written by kiosk-wifi-helper (Region setting)\noptions cfg80211 ieee80211_regdom=%s\n' "$c" > "$REGDOM_CONF"
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
sed -i '/^[[:space:]]*regulatory-domain:/d' "$WIFI_NETPLAN_FILE"
|
||||||
|
sed -i "s/^\([[:space:]]*\)optional: true\$/&\n\1regulatory-domain: \"$c\"/" "$WIFI_NETPLAN_FILE"
|
||||||
|
fi
|
||||||
|
iw reg set "$c" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
do_region_set() {
|
||||||
|
local input country locale keyboard
|
||||||
|
input="$REQ_INPUT"
|
||||||
|
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"
|
||||||
|
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
country=${country^^}
|
||||||
|
|
||||||
|
if [[ -n "$country" ]]; then
|
||||||
|
[[ "$country" =~ ^[A-Z]{2}$ ]] || reply_error "A country code is 2 letters, e.g. US"
|
||||||
|
# Reject codes that aren't real countries when the tz database is here.
|
||||||
|
if [[ -r /usr/share/zoneinfo/iso3166.tab ]] && ! grep -q "^$country[[:space:]]" /usr/share/zoneinfo/iso3166.tab; then
|
||||||
|
reply_error "$country isn't a known country code"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -n "$locale" ]]; then
|
||||||
|
list_locales | grep -qxF -- "$locale" || reply_error "Locale $locale isn't installed on this system"
|
||||||
|
fi
|
||||||
|
if [[ -n "$keyboard" ]]; then
|
||||||
|
list_keyboards | grep -qxF -- "$keyboard" || reply_error "Unknown keyboard layout: $keyboard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local err
|
||||||
|
[[ -n "$country" ]] && set_country "$country"
|
||||||
|
if [[ -n "$locale" ]]; then
|
||||||
|
err=$(localectl set-locale "LANG=$locale" 2>&1) || reply_error "Couldn't set the locale: ${err:0:200}"
|
||||||
|
fi
|
||||||
|
if [[ -n "$keyboard" ]]; then
|
||||||
|
err=$(localectl set-x11-keymap "$keyboard" 2>&1) || reply_error "Couldn't set the keyboard: ${err:0:200}"
|
||||||
|
# Console layout too, when there's a console map of the same name.
|
||||||
|
localectl list-keymaps 2>/dev/null | grep -qxF -- "$keyboard" && localectl set-keymap "$keyboard" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||||
|
--argjson restart "$([[ -n "$locale$keyboard" ]] && echo true || echo false)" \
|
||||||
|
'{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() {
|
||||||
|
local iface ssid="" ip="" configured
|
||||||
|
configured=$(configured_ssid)
|
||||||
|
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}'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
ssid=$(current_ssid "$iface")
|
||||||
|
ip=$(iface_ip "$iface")
|
||||||
|
jq -cn --arg i "$iface" --arg s "$ssid" --arg a "$ip" --arg c "$configured" \
|
||||||
|
'{ok:true,iface:$i,connected:($s != "" and $a != ""),ssid:$s,ip:$a,configured:$c}'
|
||||||
|
}
|
||||||
|
|
||||||
|
do_scan() {
|
||||||
|
need_iface
|
||||||
|
unblock
|
||||||
|
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
|
||||||
|
ip link set "$IFACE" up 2>/dev/null || reply_error "Couldn't switch on $IFACE"
|
||||||
|
|
||||||
|
# "Device or resource busy" while wpa_supplicant is mid-scan - retry.
|
||||||
|
local out="" try
|
||||||
|
for try in 1 2 3; do
|
||||||
|
out=$(iw dev "$IFACE" scan 2>/dev/null) && break
|
||||||
|
out=""
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
# wpa_supplicant owns the interface and refuses iw: use its last scan.
|
||||||
|
if [[ -z "$out" ]] && command -v wpa_cli >/dev/null; then
|
||||||
|
wpa_cli -i "$IFACE" scan >/dev/null 2>&1
|
||||||
|
sleep 4
|
||||||
|
wpa_cli -i "$IFACE" scan_results 2>/dev/null | awk -F'\t' '
|
||||||
|
NR > 1 && $5 != "" { secure = ($4 ~ /WPA|WEP|RSN|SAE/) ? 1 : 0; print $3 "\t" secure "\t" $5 }' \
|
||||||
|
> "$TMP_SCAN"
|
||||||
|
else
|
||||||
|
# BSS blocks: signal, SSID, and RSN/WPA/Privacy for "secured".
|
||||||
|
printf '%s\n' "$out" | awk '
|
||||||
|
function flush() { if (ssid != "") print sig "\t" sec "\t" ssid; ssid = ""; sig = -100; sec = 0 }
|
||||||
|
/^BSS / { flush() }
|
||||||
|
/^\tsignal:/ { sig = int($2) }
|
||||||
|
/^\tSSID: / { sub(/^\tSSID: /, ""); ssid = $0 }
|
||||||
|
/^\t(RSN|WPA):/ { sec = 1 }
|
||||||
|
/^\tcapability:.*Privacy/ { sec = 1 }
|
||||||
|
END { flush() }' \
|
||||||
|
| while IFS=$'\t' read -r sig sec ssid; do
|
||||||
|
printf '%s\t%s\t%s\n' "$sig" "$sec" "$(unescape_ssid "$ssid")"
|
||||||
|
done > "$TMP_SCAN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strongest first, one entry per SSID.
|
||||||
|
jq -cR --arg i "$IFACE" -n '
|
||||||
|
[inputs | split("\t") | select(length >= 3)
|
||||||
|
| {signal: (.[0] | tonumber? // -100), secure: (.[1] == "1"), ssid: (.[2:] | join("\t"))}]
|
||||||
|
| group_by(.ssid) | map(max_by(.signal)) | sort_by(-.signal)
|
||||||
|
| {ok: true, iface: $i, networks: .}' < "$TMP_SCAN"
|
||||||
|
}
|
||||||
|
|
||||||
|
# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
|
||||||
|
# in every file and waits for that device - 90s, then the whole apply
|
||||||
|
# fails - if one names an interface this machine doesn't have (the
|
||||||
|
# installer's own config can carry e.g. "wlan0" while the adapter here is
|
||||||
|
# "wlp1s0"). Take such entries out of the other netplan files first
|
||||||
|
# (originals kept as *.kiosk-bak, which netplan ignores).
|
||||||
|
drop_stale_entries() {
|
||||||
|
local f
|
||||||
|
for f in "$NETPLAN_DIR"/*.yaml; do
|
||||||
|
[[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
|
||||||
|
python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
|
||||||
|
import os, shutil, sys, yaml
|
||||||
|
p = sys.argv[1]
|
||||||
|
try:
|
||||||
|
d = yaml.safe_load(open(p)) or {}
|
||||||
|
w = (d.get("network") or {}).get("wifis") or {}
|
||||||
|
stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
|
||||||
|
if stale:
|
||||||
|
shutil.copy2(p, p + ".kiosk-bak")
|
||||||
|
for n in stale:
|
||||||
|
del w[n]
|
||||||
|
if not w:
|
||||||
|
del d["network"]["wifis"]
|
||||||
|
# Not "optional" = the boot waits (up to ~2 min) for it to get an address.
|
||||||
|
opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
|
||||||
|
for n in opt:
|
||||||
|
w[n]["optional"] = True
|
||||||
|
if stale or opt:
|
||||||
|
if not stale:
|
||||||
|
shutil.copy2(p, p + ".kiosk-bak")
|
||||||
|
mode = os.stat(p).st_mode & 0o777
|
||||||
|
with open(p, "w") as fh:
|
||||||
|
yaml.safe_dump(d, fh, default_flow_style=False)
|
||||||
|
os.chmod(p, mode)
|
||||||
|
print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
PYEOF
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
do_connect() {
|
||||||
|
need_iface
|
||||||
|
local input ssid password
|
||||||
|
input="$REQ_INPUT"
|
||||||
|
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
|
||||||
|
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
|
||||||
|
local ssid_bytes
|
||||||
|
ssid_bytes=$(printf '%s' "$ssid" | wc -c)
|
||||||
|
(( ssid_bytes >= 1 && ssid_bytes <= 32 )) || reply_error "A network name is 1-32 characters"
|
||||||
|
# No control characters (newlines etc.) in either - they can't be in a
|
||||||
|
# real SSID/passphrase and would only serve to break the YAML.
|
||||||
|
[[ "$ssid$password" == *[[:cntrl:]]* ]] && reply_error "The name or password contains an invalid character"
|
||||||
|
if [[ -n "$password" ]]; then
|
||||||
|
local plen=${#password}
|
||||||
|
if ! (( plen >= 8 && plen <= 63 )) && ! [[ "$password" =~ ^[0-9A-Fa-f]{64}$ ]]; then
|
||||||
|
reply_error "A WiFi password is 8-63 characters"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
unblock
|
||||||
|
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
|
||||||
|
|
||||||
|
local country backup="" new
|
||||||
|
country=$(default_country)
|
||||||
|
[[ -n "$country" ]] && iw reg set "$country" 2>/dev/null
|
||||||
|
mkdir -p "$NETPLAN_DIR"
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp -p "$WIFI_NETPLAN_FILE" "$backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
new=$(mktemp "$NETPLAN_DIR/.kiosk-wifi.XXXXXX")
|
||||||
|
{
|
||||||
|
echo "network:"
|
||||||
|
echo " version: 2"
|
||||||
|
echo " renderer: networkd"
|
||||||
|
echo " wifis:"
|
||||||
|
echo " $IFACE:"
|
||||||
|
echo " dhcp4: true"
|
||||||
|
echo " dhcp6: false"
|
||||||
|
echo " optional: true"
|
||||||
|
[[ -n "$country" ]] && echo " regulatory-domain: \"$country\""
|
||||||
|
echo " access-points:"
|
||||||
|
if [[ -n "$password" ]]; then
|
||||||
|
echo " $(yaml_quote "$ssid"):"
|
||||||
|
echo " password: $(yaml_quote "$password")"
|
||||||
|
else
|
||||||
|
echo " $(yaml_quote "$ssid"): {}"
|
||||||
|
fi
|
||||||
|
} > "$new"
|
||||||
|
chmod 0600 "$new"
|
||||||
|
mv -f "$new" "$WIFI_NETPLAN_FILE"
|
||||||
|
|
||||||
|
drop_stale_entries >/dev/null
|
||||||
|
local err
|
||||||
|
if ! err=$(netplan apply 2>&1); then
|
||||||
|
restore "$backup"
|
||||||
|
reply_error "netplan apply failed: ${err:0:300}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local i addr="" now
|
||||||
|
for (( i = 0; i < CONNECT_WAIT; i++ )); do
|
||||||
|
now=$(current_ssid "$IFACE")
|
||||||
|
addr=$(iface_ip "$IFACE")
|
||||||
|
[[ "$now" == "$ssid" && -n "$addr" ]] && break
|
||||||
|
addr=""
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$addr" ]]; then
|
||||||
|
restore "$backup"
|
||||||
|
local why
|
||||||
|
why=$(diagnose_failure "$IFACE")
|
||||||
|
if [[ -n "$why" ]]; then
|
||||||
|
reply_error "Couldn't connect to \"$ssid\": $why."
|
||||||
|
fi
|
||||||
|
if [[ -n "$backup" ]]; then
|
||||||
|
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range. The previous WiFi setting was put back."
|
||||||
|
fi
|
||||||
|
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range."
|
||||||
|
fi
|
||||||
|
jq -cn --arg s "$ssid" --arg a "$addr" --arg i "$IFACE" '{ok:true,ssid:$s,ip:$a,iface:$i}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Put the previous netplan WiFi file back (or remove ours if there was
|
||||||
|
# none) and re-apply, after a failed connect.
|
||||||
|
restore() {
|
||||||
|
if [[ -n "$1" && -f "$1" ]]; then
|
||||||
|
mv -f "$1" "$WIFI_NETPLAN_FILE"
|
||||||
|
else
|
||||||
|
rm -f "$WIFI_NETPLAN_FILE"
|
||||||
|
fi
|
||||||
|
netplan apply >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || reply_error "must run as root (sudo)"
|
||||||
|
command -v jq >/dev/null || { echo '{"ok":false,"error":"jq is not installed"}'; exit 1; }
|
||||||
|
|
||||||
|
TMP_SCAN=$(mktemp)
|
||||||
|
trap 'rm -f "$TMP_SCAN"' EXIT
|
||||||
|
|
||||||
|
# One at a time - two overlapping netplan applies/backups would race.
|
||||||
|
exec 9> "$LOCK_FILE"
|
||||||
|
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
ifaces) do_ifaces ;;
|
||||||
|
status) do_status ;;
|
||||||
|
scan) do_scan ;;
|
||||||
|
connect) do_connect ;;
|
||||||
|
region-status) do_region_status ;;
|
||||||
|
region-set) do_region_set ;;
|
||||||
|
*) reply_error "unknown action: ${1:-}" ;;
|
||||||
|
esac
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user