Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,2 @@
|
||||
node_modules/
|
||||
webui/test/fixtures/.fake-state/
|
||||
@@ -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
+228
@@ -0,0 +1,228 @@
|
||||
#!/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,
|
||||
# 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).
|
||||
# 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=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/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/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"
|
||||
"Hidden Site PIN"
|
||||
"Password Protection & Lockout"
|
||||
"WiFi"
|
||||
"Power/Display/Quiet Hours"
|
||||
"Complete Uninstall"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
sites_menu
|
||||
display_menu
|
||||
timezone_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")
|
||||
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_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
|
||||
################################################################################
|
||||
### 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
|
||||
# License: GPL v3 - Keep derivatives open sour
|
||||
# Repository: https://github.com/outis1one/ubk/
|
||||
# License: GPL v3 - Keep derivatives open source
|
||||
# Repository: https://github.com/outis1one/ubuntu-based-kiosk/
|
||||
#
|
||||
# TARGET SYSTEMS:
|
||||
# - Ubuntu 24.04+ Server (minimal install recommended)
|
||||
@@ -35,7 +39,7 @@ set -euo pipefail
|
||||
### SECTION 1: CONSTANTS & GLOBALS
|
||||
################################################################################
|
||||
|
||||
SCRIPT_VERSION="0.9.7"
|
||||
SCRIPT_VERSION="0.9.7-3"
|
||||
KIOSK_USER="kiosk"
|
||||
BUILD_USER="${SUDO_USER:-$(whoami)}"
|
||||
KIOSK_HOME="/home/${KIOSK_USER}"
|
||||
@@ -2146,7 +2150,75 @@ disable_emergency_hotspot() {
|
||||
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
|
||||
@@ -3475,6 +3547,124 @@ full_reinstall() {
|
||||
echo ""
|
||||
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
|
||||
################################################################################
|
||||
@@ -6575,6 +6765,52 @@ fi
|
||||
echo "[26/27] Finalizing installation..."
|
||||
log_success "Core installation complete!"
|
||||
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 " • Rerun this script to configure addons"
|
||||
echo " • Reboot to start the kiosk"
|
||||
@@ -8957,12 +9193,13 @@ advanced_menu() {
|
||||
echo " 4. Audio Diagnostics"
|
||||
echo " 5. Fix Squeezelite Audio"
|
||||
echo " 6. Factory Reset Config"
|
||||
echo " 7. Virtual Consoles (Ctrl+Alt+F1-F8)"
|
||||
echo " 9. Emergency Hotspot"
|
||||
echo " 10. Network Test"
|
||||
echo " 0. Return"
|
||||
echo
|
||||
read -r -p "Choose [0-10]: " choice
|
||||
|
||||
|
||||
case "$choice" in
|
||||
1) manual_electron_update ;;
|
||||
2) system_diagnostics ;;
|
||||
@@ -8970,6 +9207,7 @@ advanced_menu() {
|
||||
4) audio_diagnostics ;;
|
||||
5) fix_squeezelite_audio ;;
|
||||
6) factory_reset ;;
|
||||
7) configure_virtual_consoles ;;
|
||||
9) configure_emergency_hotspot ;;
|
||||
10) network_test ;;
|
||||
0) return ;;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
+1675
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "kiosk-app",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"dependencies": {
|
||||
"electron": "^42.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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,139 @@
|
||||
<!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">Default PIN: 1234 (4-8 digits)</div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pinFile = path.join(__dirname, '.jitsi-pin');
|
||||
let correctPin = '1234';
|
||||
let enteredPin = '';
|
||||
try {
|
||||
const stored = fs.readFileSync(pinFile, 'utf8').trim();
|
||||
if (stored !== 'NOPIN') correctPin = stored;
|
||||
else correctPin = null;
|
||||
} catch(e) {}
|
||||
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 (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 submitPin() {
|
||||
if (enteredPin.length < 4) {
|
||||
document.getElementById('error').textContent = '❌ PIN must be 4-8 digits';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (correctPin === null || enteredPin === correctPin) {
|
||||
ipcRenderer.send('pin-correct');
|
||||
} else {
|
||||
document.getElementById('error').textContent = '❌ Incorrect PIN';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
enteredPin = '';
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -0,0 +1,931 @@
|
||||
const {contextBridge,ipcRenderer}=require('electron');
|
||||
|
||||
console.log('════════════════════════════════════════════════════════════');
|
||||
console.log(' Gestures:');
|
||||
console.log(' 3-finger DOWN: Toggle hidden tabs (PIN required)');
|
||||
console.log(' 2-finger HORIZONTAL: Switch between sites');
|
||||
console.log(' 1-finger HORIZONTAL: Navigate within page');
|
||||
console.log(' Navigation: Top-left key icon for site menu');
|
||||
console.log('════════════════════════════════════════════════════════════');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
notifyActivity: () => ipcRenderer.send('user-activity'),
|
||||
showKeyboard: () => ipcRenderer.send('show-keyboard'),
|
||||
closeKeyboard: () => ipcRenderer.send('close-keyboard'),
|
||||
keyboardActivity: () => ipcRenderer.send('keyboard-activity'),
|
||||
showPauseDialog: () => ipcRenderer.send('show-pause-dialog')
|
||||
});
|
||||
|
||||
// Pause button state (MUST be outside DOMContentLoaded to persist across page loads)
|
||||
let pauseButton=null;
|
||||
let pauseButtonShouldShow=false;
|
||||
let pauseButtonShown=false;
|
||||
let pauseButtonHideTimer=null;
|
||||
const PAUSE_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity
|
||||
|
||||
// Pause button functions (must be outside DOMContentLoaded for IPC listener)
|
||||
function createPauseButton(){
|
||||
if(pauseButton)return;
|
||||
|
||||
pauseButton=document.createElement('div');
|
||||
pauseButton.id='electron-pause-button';
|
||||
pauseButton.innerHTML='<div style="display:flex;gap:4px;"><div style="width:6px;height:24px;background:white;border-radius:2px;"></div><div style="width:6px;height:24px;background:white;border-radius:2px;"></div></div>';
|
||||
pauseButton.title='Pause rotation';
|
||||
pauseButton.style.cssText=`
|
||||
position:fixed;bottom:20px;left:20px;width:60px;height:60px;
|
||||
background:rgba(230,126,34,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
font-size:32px;cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
pauseButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ipcRenderer.send('show-pause-dialog');
|
||||
});
|
||||
|
||||
document.body.appendChild(pauseButton);
|
||||
}
|
||||
|
||||
function showPauseButton(){
|
||||
if(!pauseButton)createPauseButton();
|
||||
pauseButton.style.display='flex';
|
||||
pauseButtonShown=true;
|
||||
|
||||
// Clear existing hide timer
|
||||
if(pauseButtonHideTimer){
|
||||
clearTimeout(pauseButtonHideTimer);
|
||||
pauseButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
pauseButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[PAUSE-BTN] Auto-hiding after '+PAUSE_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hidePauseButton();
|
||||
},PAUSE_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hidePauseButton(){
|
||||
if(pauseButtonHideTimer){
|
||||
clearTimeout(pauseButtonHideTimer);
|
||||
pauseButtonHideTimer=null;
|
||||
}
|
||||
if(pauseButton){
|
||||
pauseButton.style.display='none';
|
||||
pauseButtonShown=false;
|
||||
}
|
||||
}
|
||||
|
||||
// Declare variables at top level so IPC handlers and DOMContentLoaded can share them
|
||||
let keyboardButtonEnabled=true;
|
||||
let keyboardVisible=false;
|
||||
let keyboardIcon=null;
|
||||
let navButtonEnabled=true;
|
||||
let navButton=null;
|
||||
let navButtonShown=false;
|
||||
let navButtonHideTimer=null;
|
||||
let navMenu=null;
|
||||
let navMenuVisible=false;
|
||||
let navMenuTimer=null;
|
||||
const NAV_MENU_TIMEOUT=30000; // 30 seconds
|
||||
const NAV_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity
|
||||
|
||||
// Listen for pause button visibility control from main process
|
||||
// CRITICAL: This must be outside DOMContentLoaded so it doesn't reset on page load
|
||||
ipcRenderer.on('pause-button-visibility',(event,shouldShow)=>{
|
||||
console.log('[PAUSE-BTN] Visibility update: shouldShow='+shouldShow);
|
||||
pauseButtonShouldShow=shouldShow;
|
||||
if(!shouldShow){
|
||||
// If button should not show on this site, hide it immediately
|
||||
console.log('[PAUSE-BTN] Hiding button (manual site)');
|
||||
hidePauseButton();
|
||||
}else{
|
||||
console.log('[PAUSE-BTN] Button enabled - will show on user interaction');
|
||||
}
|
||||
// If shouldShow is true, button will appear on user interaction
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
|
||||
keyboardButtonEnabled=enabled;
|
||||
console.log('[KEYBOARD-BTN] Keyboard button enabled: '+enabled);
|
||||
// Note: keyboardIcon may not exist yet if page hasn't loaded
|
||||
if(keyboardIcon&&!enabled){
|
||||
keyboardIcon.style.display='none';
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
|
||||
navButtonEnabled=enabled;
|
||||
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
|
||||
if(navButton&&!enabled){
|
||||
navButton.style.display='none';
|
||||
}
|
||||
if(navMenu&&!enabled){
|
||||
navMenu.style.display='none';
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('DOMContentLoaded',()=>{
|
||||
document.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
|
||||
const SWIPE_THRESHOLD=120;
|
||||
const SWIPE_MAX_TIME=500;
|
||||
const SWIPE_TOLERANCE=50;
|
||||
|
||||
let touchStartX=0;
|
||||
let touchStartY=0;
|
||||
let touchStartTime=0;
|
||||
let fingerCount=0;
|
||||
let lastKeyboardRequest=0;
|
||||
let keyboardAutoClosedThisSession=false;
|
||||
const KEYBOARD_REQUEST_THROTTLE=1000;
|
||||
|
||||
const activityEvents=[
|
||||
'mousedown','mouseup','mousemove','click','dblclick',
|
||||
'wheel','scroll',
|
||||
'keydown','keyup','keypress',
|
||||
'touchstart','touchmove','touchend',
|
||||
'pointerdown','pointerup','pointermove',
|
||||
'input','change'
|
||||
];
|
||||
|
||||
let lastActivityNotification=0;
|
||||
const ACTIVITY_THROTTLE=1000;
|
||||
|
||||
function notifyActivity(){
|
||||
const now=Date.now();
|
||||
if(now-lastActivityNotification>ACTIVITY_THROTTLE){
|
||||
if(window.electronAPI?.notifyActivity){
|
||||
window.electronAPI.notifyActivity();
|
||||
lastActivityNotification=now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityEvents.forEach(eventType=>{
|
||||
document.addEventListener(eventType,notifyActivity,{
|
||||
passive:true,
|
||||
capture:true
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-state-changed',(event,visible)=>{
|
||||
keyboardVisible=visible;
|
||||
if(visible){
|
||||
showKeyboardIcon();
|
||||
keyboardAutoClosedThisSession=false;
|
||||
}else{
|
||||
hideKeyboardIcon();
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-auto-closed',()=>{
|
||||
keyboardAutoClosedThisSession=true;
|
||||
});
|
||||
|
||||
function createKeyboardIcon(){
|
||||
if(keyboardIcon||!keyboardButtonEnabled)return;
|
||||
|
||||
|
||||
keyboardIcon=document.createElement('div');
|
||||
keyboardIcon.id='electron-keyboard-icon';
|
||||
keyboardIcon.innerHTML='⌨️';
|
||||
keyboardIcon.style.cssText=`
|
||||
position:fixed;bottom:20px;right:20px;width:60px;height:60px;
|
||||
background:rgba(52,152,219,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
font-size:32px;cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
keyboardIcon.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
keyboardAutoClosedThisSession=false;
|
||||
if(keyboardVisible){
|
||||
ipcRenderer.send('close-keyboard');
|
||||
}else{
|
||||
ipcRenderer.send('show-keyboard');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(keyboardIcon);
|
||||
}
|
||||
|
||||
function showKeyboardIcon(){
|
||||
if(!keyboardButtonEnabled)return;
|
||||
if(!keyboardIcon)createKeyboardIcon();
|
||||
if(keyboardIcon)keyboardIcon.style.display='flex';
|
||||
}
|
||||
|
||||
function hideKeyboardIcon(){
|
||||
if(keyboardIcon)keyboardIcon.style.display='none';
|
||||
}
|
||||
|
||||
function createNavButton(){
|
||||
if(navButton||!navButtonEnabled)return;
|
||||
|
||||
navButton=document.createElement('div');
|
||||
navButton.id='electron-nav-button';
|
||||
// Use SVG key icon instead of emoji for better compatibility
|
||||
navButton.innerHTML='<svg width="32" height="32" viewBox="0 0 24 24" fill="white"><path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>';
|
||||
navButton.title='Navigation Menu';
|
||||
navButton.style.cssText=`
|
||||
position:fixed;top:20px;left:20px;width:60px;height:60px;
|
||||
background:rgba(155,89,182,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
navButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Button clicked');
|
||||
try{
|
||||
toggleNavMenu();
|
||||
}catch(err){
|
||||
console.error('[NAV] Error toggling menu:',err);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(navButton);
|
||||
}
|
||||
|
||||
// Power button in top-right corner (follows same show/hide logic as nav button)
|
||||
let powerButton=null;
|
||||
let powerButtonHideTimer=null;
|
||||
const POWER_BUTTON_HIDE_DELAY=5000; // Same as nav button
|
||||
function createPowerButton(){
|
||||
if(powerButton)return;
|
||||
powerButton=document.createElement('div');
|
||||
powerButton.id='electron-power-button';
|
||||
// Power icon SVG
|
||||
powerButton.innerHTML='<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42C17.99 7.86 19 9.81 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.19 1.01-4.14 2.58-5.42L6.17 5.17C4.23 6.82 3 9.26 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9c0-2.74-1.23-5.18-3.17-6.83z"/></svg>';
|
||||
powerButton.title='Power Menu';
|
||||
powerButton.style.cssText=`
|
||||
position:fixed;top:20px;right:20px;width:60px;height:60px;
|
||||
background:rgba(231,76,60,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
powerButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[POWER] Button clicked');
|
||||
ipcRenderer.send('show-power-menu');
|
||||
});
|
||||
document.body.appendChild(powerButton);
|
||||
}
|
||||
|
||||
function showPowerButton(){
|
||||
if(!powerButton)createPowerButton();
|
||||
if(powerButton){
|
||||
powerButton.style.display='flex';
|
||||
}
|
||||
|
||||
// Clear existing hide timer
|
||||
if(powerButtonHideTimer){
|
||||
clearTimeout(powerButtonHideTimer);
|
||||
powerButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
powerButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[POWER-BTN] Auto-hiding after '+POWER_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hidePowerButton();
|
||||
},POWER_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hidePowerButton(){
|
||||
if(powerButtonHideTimer){
|
||||
clearTimeout(powerButtonHideTimer);
|
||||
powerButtonHideTimer=null;
|
||||
}
|
||||
if(powerButton){
|
||||
powerButton.style.display='none';
|
||||
}
|
||||
}
|
||||
|
||||
function showNavButton(){
|
||||
if(!navButtonEnabled)return;
|
||||
if(!navButton)createNavButton();
|
||||
if(navButton){
|
||||
navButton.style.display='flex';
|
||||
navButtonShown=true;
|
||||
}
|
||||
|
||||
// Clear existing hide timer
|
||||
if(navButtonHideTimer){
|
||||
clearTimeout(navButtonHideTimer);
|
||||
navButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
navButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[NAV-BTN] Auto-hiding after '+NAV_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hideNavButton();
|
||||
},NAV_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hideNavButton(){
|
||||
if(navButtonHideTimer){
|
||||
clearTimeout(navButtonHideTimer);
|
||||
navButtonHideTimer=null;
|
||||
}
|
||||
if(navButton){
|
||||
navButton.style.display='none';
|
||||
navButtonShown=false;
|
||||
}
|
||||
}
|
||||
|
||||
function createNavMenu(){
|
||||
if(navMenu)return;
|
||||
console.log('[NAV] Creating navigation menu');
|
||||
|
||||
navMenu=document.createElement('div');
|
||||
navMenu.id='electron-nav-menu';
|
||||
navMenu.style.cssText=`
|
||||
position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;
|
||||
z-index:999998;pointer-events:auto;
|
||||
`;
|
||||
|
||||
const content=document.createElement('div');
|
||||
content.style.cssText=`
|
||||
position:relative;background:rgba(44,62,80,0.98);border-radius:20px;padding:40px;
|
||||
max-width:90%;max-height:90%;overflow:hidden;
|
||||
box-shadow:0 10px 40px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
const closeBtn=document.createElement('div');
|
||||
closeBtn.innerHTML='✕';
|
||||
closeBtn.style.cssText=`
|
||||
position:absolute;top:10px;right:10px;font-size:32px;color:white;
|
||||
cursor:pointer;width:40px;height:40px;display:flex;align-items:center;
|
||||
justify-content:center;border-radius:50%;background:rgba(231,76,60,0.8);
|
||||
user-select:none;
|
||||
`;
|
||||
closeBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Close button clicked');
|
||||
hideNavMenu();
|
||||
});
|
||||
content.appendChild(closeBtn);
|
||||
|
||||
const columns=document.createElement('div');
|
||||
columns.style.cssText='display:flex;gap:40px;margin-top:20px;max-height:70vh;';
|
||||
|
||||
// Column 1: Sites (scrollable)
|
||||
const sitesCol=document.createElement('div');
|
||||
sitesCol.style.cssText='flex:1;min-width:300px;display:flex;flex-direction:column;';
|
||||
sitesCol.innerHTML='<h2 style="color:white;margin-bottom:20px;">Sites</h2>';
|
||||
const sitesList=document.createElement('div');
|
||||
sitesList.id='nav-sites-list';
|
||||
sitesList.style.cssText='display:flex;flex-direction:column;gap:10px;overflow-y:auto;padding-right:10px;';
|
||||
sitesCol.appendChild(sitesList);
|
||||
|
||||
// Column 2: Gesture Cheat Sheet (fixed, no scroll)
|
||||
const cheatCol=document.createElement('div');
|
||||
cheatCol.style.cssText='flex:1;min-width:300px;overflow-y:hidden;';
|
||||
cheatCol.innerHTML=`
|
||||
<h2 style="color:white;margin-bottom:20px;">Touch Gestures</h2>
|
||||
<div style="color:#ecf0f1;line-height:1.8;font-size:16px;">
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#3498db;">2-Finger Horizontal Swipe</div>
|
||||
<div style="padding-left:15px;">Switch between sites</div>
|
||||
</div>
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#3498db;">1-Finger Horizontal Swipe</div>
|
||||
<div style="padding-left:15px;">Navigate within page (arrow keys)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#9b59b6;">3-Finger Down Swipe</div>
|
||||
<div style="padding-left:15px;">Toggle hidden tabs (PIN required)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:25px;padding-top:15px;border-top:1px solid rgba(255,255,255,0.2);">
|
||||
<div style="font-weight:bold;color:#e74c3c;">Keyboard Shortcuts</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Tab</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+]</kbd> Next tab</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Shift+Tab</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+[</kbd> Previous tab</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">F10</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+H</kbd> Toggle hidden tabs</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Escape</kbd> Return to normal tabs</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Alt+Delete</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Alt+P</kbd> Power menu</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+K</kbd> Toggle keyboard</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
columns.appendChild(sitesCol);
|
||||
columns.appendChild(cheatCol);
|
||||
content.appendChild(columns);
|
||||
navMenu.appendChild(content);
|
||||
|
||||
navMenu.addEventListener('click',(e)=>{
|
||||
if(e.target===navMenu){
|
||||
console.log('[NAV] Background clicked, closing menu');
|
||||
hideNavMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent clicks inside content from closing menu
|
||||
content.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.appendChild(navMenu);
|
||||
console.log('[NAV] Navigation menu created and appended to body');
|
||||
}
|
||||
|
||||
function toggleNavMenu(){
|
||||
console.log('[NAV] Toggle menu, current state:',navMenuVisible);
|
||||
if(navMenuVisible){
|
||||
hideNavMenu();
|
||||
}else{
|
||||
showNavMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function showNavMenu(){
|
||||
console.log('[NAV] Showing navigation menu');
|
||||
try{
|
||||
if(!navMenu){
|
||||
createNavMenu();
|
||||
}
|
||||
|
||||
// Request sites data
|
||||
loadSitesIntoNav();
|
||||
|
||||
navMenu.style.display='flex';
|
||||
navMenuVisible=true;
|
||||
|
||||
// Force reflow and repaint to ensure proper rendering
|
||||
navMenu.offsetHeight;
|
||||
navMenu.style.opacity='0';
|
||||
setTimeout(()=>{
|
||||
navMenu.style.transition='opacity 0.15s ease-in';
|
||||
navMenu.style.opacity='1';
|
||||
},10);
|
||||
|
||||
// Set 30-second auto-dismiss timer
|
||||
if(navMenuTimer){
|
||||
clearTimeout(navMenuTimer);
|
||||
}
|
||||
navMenuTimer=setTimeout(()=>{
|
||||
console.log('[NAV] Auto-dismissing menu after 30 seconds');
|
||||
hideNavMenu();
|
||||
},NAV_MENU_TIMEOUT);
|
||||
|
||||
console.log('[NAV] Menu displayed, 30-second timer started');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error showing menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
function hideNavMenu(){
|
||||
console.log('[NAV] Hiding navigation menu');
|
||||
try{
|
||||
if(navMenuTimer){
|
||||
clearTimeout(navMenuTimer);
|
||||
navMenuTimer=null;
|
||||
}
|
||||
if(navMenu){
|
||||
navMenu.style.display='none';
|
||||
navMenu.style.opacity='1';
|
||||
navMenu.style.transition='';
|
||||
}
|
||||
navMenuVisible=false;
|
||||
console.log('[NAV] Menu hidden');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error hiding menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
// Power menu overlay (with 30-second auto-dismiss)
|
||||
let powerMenu=null;
|
||||
let powerMenuVisible=false;
|
||||
let powerMenuTimer=null;
|
||||
const POWER_MENU_TIMEOUT=30000;
|
||||
let powerMenuInfo={version:'',localIP:'',vpnIP:''};
|
||||
|
||||
function createPowerMenu(){
|
||||
if(powerMenu)return;
|
||||
console.log('[POWER-MENU] Creating power menu');
|
||||
|
||||
powerMenu=document.createElement('div');
|
||||
powerMenu.id='electron-power-menu';
|
||||
powerMenu.style.cssText=`
|
||||
position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;
|
||||
z-index:999998;pointer-events:auto;
|
||||
`;
|
||||
|
||||
const content=document.createElement('div');
|
||||
content.style.cssText=`
|
||||
position:relative;background:rgba(44,62,80,0.98);border-radius:20px;padding:40px;
|
||||
min-width:400px;max-width:90%;box-shadow:0 10px 40px rgba(0,0,0,0.5);text-align:center;
|
||||
`;
|
||||
|
||||
const closeBtn=document.createElement('div');
|
||||
closeBtn.innerHTML='✕';
|
||||
closeBtn.style.cssText=`
|
||||
position:absolute;top:10px;right:10px;font-size:32px;color:white;
|
||||
cursor:pointer;width:40px;height:40px;display:flex;align-items:center;
|
||||
justify-content:center;border-radius:50%;background:rgba(231,76,60,0.8);
|
||||
user-select:none;
|
||||
`;
|
||||
closeBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
hidePowerMenu();
|
||||
});
|
||||
content.appendChild(closeBtn);
|
||||
|
||||
const title=document.createElement('h2');
|
||||
title.textContent='Power Options';
|
||||
title.style.cssText='color:white;margin-bottom:20px;font-size:28px;';
|
||||
content.appendChild(title);
|
||||
|
||||
const infoDiv=document.createElement('div');
|
||||
infoDiv.id='power-menu-info';
|
||||
infoDiv.style.cssText='color:#bdc3c7;margin-bottom:30px;font-size:14px;line-height:1.6;';
|
||||
content.appendChild(infoDiv);
|
||||
|
||||
const buttonsDiv=document.createElement('div');
|
||||
buttonsDiv.style.cssText='display:flex;flex-direction:column;gap:15px;';
|
||||
|
||||
const btnStyle=`
|
||||
padding:20px 40px;font-size:20px;border:none;border-radius:10px;
|
||||
cursor:pointer;font-weight:bold;transition:transform 0.2s,opacity 0.2s;
|
||||
`;
|
||||
|
||||
const shutdownBtn=document.createElement('button');
|
||||
shutdownBtn.textContent='⏻ Shutdown';
|
||||
shutdownBtn.style.cssText=btnStyle+'background:#e74c3c;color:white;';
|
||||
shutdownBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','shutdown');
|
||||
});
|
||||
|
||||
const restartBtn=document.createElement('button');
|
||||
restartBtn.textContent='↻ Restart';
|
||||
restartBtn.style.cssText=btnStyle+'background:#f39c12;color:white;';
|
||||
restartBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','restart');
|
||||
});
|
||||
|
||||
const reloadBtn=document.createElement('button');
|
||||
reloadBtn.textContent='⟳ Reload App';
|
||||
reloadBtn.style.cssText=btnStyle+'background:#3498db;color:white;';
|
||||
reloadBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','reload');
|
||||
});
|
||||
|
||||
const cancelBtn=document.createElement('button');
|
||||
cancelBtn.textContent='Cancel';
|
||||
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
|
||||
cancelBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
});
|
||||
|
||||
buttonsDiv.appendChild(shutdownBtn);
|
||||
buttonsDiv.appendChild(restartBtn);
|
||||
buttonsDiv.appendChild(reloadBtn);
|
||||
buttonsDiv.appendChild(cancelBtn);
|
||||
content.appendChild(buttonsDiv);
|
||||
|
||||
powerMenu.appendChild(content);
|
||||
|
||||
powerMenu.addEventListener('click',(e)=>{
|
||||
if(e.target===powerMenu){
|
||||
hidePowerMenu();
|
||||
}
|
||||
});
|
||||
|
||||
content.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.appendChild(powerMenu);
|
||||
}
|
||||
|
||||
function showPowerMenu(info){
|
||||
console.log('[POWER-MENU] Showing power menu');
|
||||
try{
|
||||
if(!powerMenu)createPowerMenu();
|
||||
|
||||
// Update info display
|
||||
const infoDiv=document.getElementById('power-menu-info');
|
||||
if(infoDiv&&info){
|
||||
let infoText='Version: '+info.version+'<br>Local: '+info.localIP;
|
||||
if(info.vpnIP){
|
||||
infoText+='<br>VPN: '+info.vpnIP;
|
||||
}
|
||||
infoDiv.innerHTML=infoText;
|
||||
}
|
||||
|
||||
powerMenu.style.display='flex';
|
||||
powerMenuVisible=true;
|
||||
|
||||
// Set 30-second auto-dismiss timer
|
||||
if(powerMenuTimer){
|
||||
clearTimeout(powerMenuTimer);
|
||||
}
|
||||
powerMenuTimer=setTimeout(()=>{
|
||||
console.log('[POWER-MENU] Auto-dismissing after 30 seconds');
|
||||
hidePowerMenu();
|
||||
},POWER_MENU_TIMEOUT);
|
||||
|
||||
}catch(err){
|
||||
console.error('[POWER-MENU] Error showing menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
function hidePowerMenu(){
|
||||
console.log('[POWER-MENU] Hiding power menu');
|
||||
try{
|
||||
if(powerMenuTimer){
|
||||
clearTimeout(powerMenuTimer);
|
||||
powerMenuTimer=null;
|
||||
}
|
||||
if(powerMenu){
|
||||
powerMenu.style.display='none';
|
||||
}
|
||||
powerMenuVisible=false;
|
||||
}catch(err){
|
||||
console.error('[POWER-MENU] Error hiding menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for power menu display request from main process
|
||||
ipcRenderer.on('display-power-menu',(event,info)=>{
|
||||
showPowerMenu(info);
|
||||
});
|
||||
|
||||
function loadSitesIntoNav(){
|
||||
console.log('[NAV] Requesting config from main process');
|
||||
try{
|
||||
ipcRenderer.send('get-config');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error requesting config:',err);
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on('config-data',(event,config)=>{
|
||||
console.log('[NAV] Received config data:',config);
|
||||
try{
|
||||
const sitesList=document.getElementById('nav-sites-list');
|
||||
if(!sitesList){
|
||||
console.error('[NAV] Sites list element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!config||!config.tabs){
|
||||
console.error('[NAV] Invalid config data');
|
||||
sitesList.innerHTML='<div style="color:white;padding:10px;">No sites configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sitesList.innerHTML='';
|
||||
let siteCount=0;
|
||||
|
||||
config.tabs.forEach((tab,index)=>{
|
||||
// Skip hidden tabs (duration === -1)
|
||||
if(tab.duration===-1){
|
||||
console.log('[NAV] Skipping hidden tab at index',index);
|
||||
return;
|
||||
}
|
||||
|
||||
const siteBtn=document.createElement('div');
|
||||
const displayName=tab.name||tab.url;
|
||||
siteBtn.textContent=displayName;
|
||||
siteBtn.style.cssText=`
|
||||
padding:15px 20px;background:rgba(52,152,219,0.7);color:white;
|
||||
border-radius:10px;cursor:pointer;font-size:18px;
|
||||
transition:all 0.3s;border:3px solid rgba(52,152,219,0.9);
|
||||
user-select:none;font-weight:normal;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.2);
|
||||
`;
|
||||
siteBtn.addEventListener('mouseenter',()=>{
|
||||
siteBtn.style.background='rgba(41,128,185,1)';
|
||||
siteBtn.style.borderColor='rgba(255,255,255,0.9)';
|
||||
siteBtn.style.fontWeight='bold';
|
||||
siteBtn.style.transform='translateY(-2px)';
|
||||
siteBtn.style.boxShadow='0 4px 12px rgba(0,0,0,0.4)';
|
||||
});
|
||||
siteBtn.addEventListener('mouseleave',()=>{
|
||||
siteBtn.style.background='rgba(52,152,219,0.7)';
|
||||
siteBtn.style.borderColor='rgba(52,152,219,0.9)';
|
||||
siteBtn.style.fontWeight='normal';
|
||||
siteBtn.style.transform='translateY(0)';
|
||||
siteBtn.style.boxShadow='0 2px 8px rgba(0,0,0,0.2)';
|
||||
});
|
||||
siteBtn.addEventListener('mousedown',()=>{
|
||||
siteBtn.style.background='rgba(31,97,141,1)';
|
||||
siteBtn.style.transform='translateY(0)';
|
||||
siteBtn.style.boxShadow='0 1px 4px rgba(0,0,0,0.3)';
|
||||
});
|
||||
siteBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Navigating to tab',index);
|
||||
try{
|
||||
ipcRenderer.send('navigate-to-tab',index);
|
||||
hideNavMenu();
|
||||
}catch(err){
|
||||
console.error('[NAV] Error navigating:',err);
|
||||
}
|
||||
});
|
||||
|
||||
sitesList.appendChild(siteBtn);
|
||||
siteCount++;
|
||||
});
|
||||
|
||||
console.log('[NAV] Loaded',siteCount,'sites into menu');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error processing config data:',err);
|
||||
}
|
||||
});
|
||||
|
||||
function isTextInput(el){
|
||||
if(!el)return false;
|
||||
const tag=(el.tagName||'').toLowerCase();
|
||||
const type=(el.type||'').toLowerCase();
|
||||
const editable=el.isContentEditable||el.contentEditable==='true';
|
||||
return(tag==='input'&&['text','email','password','search','tel','url','number'].includes(type))||tag==='textarea'||editable;
|
||||
}
|
||||
|
||||
document.addEventListener('focusin',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
showKeyboardIcon();
|
||||
}
|
||||
},true);
|
||||
|
||||
document.addEventListener('focusout',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
setTimeout(()=>{
|
||||
if(!isTextInput(document.activeElement)){
|
||||
hideKeyboardIcon();
|
||||
}
|
||||
},100);
|
||||
}
|
||||
},true);
|
||||
|
||||
document.addEventListener('mousedown',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
if(keyboardVisible){
|
||||
if(window.electronAPI?.keyboardActivity){
|
||||
window.electronAPI.keyboardActivity();
|
||||
}
|
||||
}else{
|
||||
keyboardAutoClosedThisSession=false;
|
||||
const now=Date.now();
|
||||
if(now-lastKeyboardRequest>KEYBOARD_REQUEST_THROTTLE){
|
||||
lastKeyboardRequest=now;
|
||||
setTimeout(()=>ipcRenderer.send('show-keyboard'),50);
|
||||
}
|
||||
}
|
||||
}
|
||||
},true);
|
||||
|
||||
// Shared debounce prevents double-firing when both touch and pointer events fire
|
||||
let lastSwipeSent=0;
|
||||
function sendSwipeIPC(direction){
|
||||
const now=Date.now();
|
||||
if(now-lastSwipeSent<500)return;
|
||||
lastSwipeSent=now;
|
||||
ipcRenderer.send(direction);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart',e=>{
|
||||
if(e.touches.length>=1){
|
||||
touchStartX=e.touches[0].clientX;
|
||||
touchStartY=e.touches[0].clientY;
|
||||
touchStartTime=Date.now();
|
||||
fingerCount=e.touches.length;
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
document.addEventListener('touchend',e=>{
|
||||
if(e.changedTouches.length>=1){
|
||||
const touchEndX=e.changedTouches[0].clientX;
|
||||
const touchEndY=e.changedTouches[0].clientY;
|
||||
const deltaX=touchEndX-touchStartX;
|
||||
const deltaY=touchEndY-touchStartY;
|
||||
const deltaTime=Date.now()-touchStartTime;
|
||||
|
||||
if(deltaTime>SWIPE_MAX_TIME)return;
|
||||
|
||||
const absX=Math.abs(deltaX);
|
||||
const absY=Math.abs(deltaY);
|
||||
|
||||
if(fingerCount===3&&absY>SWIPE_THRESHOLD&&absX<SWIPE_TOLERANCE&&deltaY>0){
|
||||
console.log('[TOUCH] 3-finger DOWN - toggle hidden tabs');
|
||||
ipcRenderer.send('toggle-hidden');
|
||||
}else if(fingerCount===2&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
console.log('[TOUCH] 2-finger HORIZONTAL - change tab');
|
||||
sendSwipeIPC(deltaX>0?'swipe-right':'swipe-left');
|
||||
}else if(fingerCount===1&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
const key=deltaX>0?'ArrowRight':'ArrowLeft';
|
||||
const keyCode=deltaX>0?39:37;
|
||||
['keydown','keyup'].forEach(eventType=>{
|
||||
document.dispatchEvent(new KeyboardEvent(eventType,{
|
||||
key:key,code:key,keyCode:keyCode,which:keyCode,bubbles:true,cancelable:true
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
// Pointer event fallback — handles devices/drivers where touchstart/touchend don't fire
|
||||
// (e.g. Electron 42 on some Linux touchscreen drivers that only generate PointerEvents)
|
||||
let ptrIds=new Set();
|
||||
let ptrPeak=0;
|
||||
let ptrStartX=0,ptrStartY=0,ptrStartTime=0;
|
||||
|
||||
document.addEventListener('pointerdown',e=>{
|
||||
if(e.pointerType!=='touch')return;
|
||||
ptrIds.add(e.pointerId);
|
||||
if(ptrIds.size===1){ptrStartX=e.clientX;ptrStartY=e.clientY;ptrStartTime=Date.now();ptrPeak=1;}
|
||||
else{ptrPeak=Math.max(ptrPeak,ptrIds.size);}
|
||||
},{passive:true});
|
||||
|
||||
document.addEventListener('pointerup',e=>{
|
||||
if(e.pointerType!=='touch')return;
|
||||
ptrIds.delete(e.pointerId);
|
||||
if(ptrIds.size!==0)return;
|
||||
const deltaTime=Date.now()-ptrStartTime;
|
||||
if(deltaTime>SWIPE_MAX_TIME){ptrPeak=0;return;}
|
||||
const deltaX=e.clientX-ptrStartX;
|
||||
const deltaY=e.clientY-ptrStartY;
|
||||
const absX=Math.abs(deltaX);
|
||||
const absY=Math.abs(deltaY);
|
||||
if(ptrPeak===3&&absY>SWIPE_THRESHOLD&&absX<SWIPE_TOLERANCE&&deltaY>0){
|
||||
console.log('[TOUCH] 3-finger DOWN (ptr) - toggle hidden tabs');
|
||||
ipcRenderer.send('toggle-hidden');
|
||||
}else if(ptrPeak===2&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
console.log('[TOUCH] 2-finger HORIZONTAL (ptr) - change tab');
|
||||
sendSwipeIPC(deltaX>0?'swipe-right':'swipe-left');
|
||||
}else if(ptrPeak===1&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
const key=deltaX>0?'ArrowRight':'ArrowLeft';
|
||||
const keyCode=deltaX>0?39:37;
|
||||
['keydown','keyup'].forEach(eventType=>{
|
||||
document.dispatchEvent(new KeyboardEvent(eventType,{
|
||||
key:key,code:key,keyCode:keyCode,which:keyCode,bubbles:true,cancelable:true
|
||||
}));
|
||||
});
|
||||
}
|
||||
ptrPeak=0;
|
||||
},{passive:true});
|
||||
|
||||
// Show pause button on user interaction (for rotation sites only)
|
||||
let lastUserInteraction=0;
|
||||
const USER_INTERACTION_THROTTLE=500;
|
||||
|
||||
function handleUserInteraction(eventType){
|
||||
const now=Date.now();
|
||||
if(now-lastUserInteraction<USER_INTERACTION_THROTTLE)return;
|
||||
lastUserInteraction=now;
|
||||
|
||||
console.log('[PAUSE-BTN] User interaction ('+eventType+') - shouldShow='+pauseButtonShouldShow+', shown='+pauseButtonShown);
|
||||
// Show/refresh pause button if allowed on this site
|
||||
if(pauseButtonShouldShow){
|
||||
if(!pauseButtonShown){
|
||||
console.log('[PAUSE-BTN] Showing pause button now');
|
||||
}else{
|
||||
console.log('[PAUSE-BTN] Resetting auto-hide timer');
|
||||
}
|
||||
showPauseButton(); // This will reset the hide timer
|
||||
}
|
||||
|
||||
// Always show navigation button on user interaction (if enabled)
|
||||
if(navButtonEnabled){
|
||||
showNavButton();
|
||||
}
|
||||
|
||||
// Always show power button on user interaction
|
||||
showPowerButton();
|
||||
}
|
||||
|
||||
// Show pause button on any user interaction
|
||||
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
|
||||
pauseButtonTriggers.forEach(eventType=>{
|
||||
document.addEventListener(eventType,()=>handleUserInteraction(eventType),{passive:true,capture:true});
|
||||
});
|
||||
});
|
||||
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
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
#!/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}"
|
||||
: "${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}"
|
||||
|
||||
# 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_PASSWORD_PROTECTION="false"
|
||||
LOCKOUT_PASSWORD=""
|
||||
LOCKOUT_TIMEOUT=0
|
||||
LOCKOUT_AT_TIME=""
|
||||
LOCKOUT_ACTIVE_START=""
|
||||
LOCKOUT_ACTIVE_END=""
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
AUTHELIA_URL=""
|
||||
AUTHELIA_USERNAME=""
|
||||
AUTHELIA_ENCRYPTED_PASSWORD=""
|
||||
|
||||
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 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"
|
||||
|
||||
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"
|
||||
|
||||
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 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 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 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,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
#!/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
|
||||
|
||||
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,360 @@
|
||||
#!/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"
|
||||
}
|
||||
|
||||
provision_install_packages() {
|
||||
echo "[1/10] Installing packages..."
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
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
|
||||
|
||||
if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then
|
||||
sudo apt install -y intel-gpu-tools xserver-xorg-video-intel \
|
||||
i965-va-driver intel-media-va-driver
|
||||
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..."
|
||||
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||
sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$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
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
fi
|
||||
echo "Node.js: $(node -v)"
|
||||
}
|
||||
|
||||
provision_install_app() {
|
||||
echo "[4/10] Installing kiosk app..."
|
||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
||||
|
||||
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
|
||||
|
||||
log_success "Display configured"
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
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,295 @@
|
||||
#!/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"
|
||||
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() {
|
||||
command -v baresip &>/dev/null && [[ -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 [[ -f "$BARESIP_CONFIG_DIR/accounts" ]]; then
|
||||
local account
|
||||
account=$(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; then
|
||||
if ! sudo apt install -y baresip; then
|
||||
log_error "Failed to install baresip package"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
sudo apt install -y pulseaudio-utils pipewire-pulse 2>/dev/null || true
|
||||
|
||||
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,161 @@
|
||||
#!/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.
|
||||
################################################################################
|
||||
|
||||
cups_is_installed() {
|
||||
dpkg -l 2>/dev/null | grep -q "^ii\s\+cups\s"
|
||||
}
|
||||
|
||||
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
|
||||
################################################################################
|
||||
|
||||
action_install_cups() {
|
||||
echo
|
||||
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
echo "Installing CUPS from scratch..."
|
||||
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 cups-client cups-filters printer-driver-all \
|
||||
printer-driver-cups-pdf hplip printer-driver-gutenprint \
|
||||
foomatic-db-compressed-ppds openprinting-ppds; then
|
||||
log_error "CUPS package installation failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
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,362 @@
|
||||
#!/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.
|
||||
################################################################################
|
||||
|
||||
lms_service_name() {
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then
|
||||
echo "lyrionmusicserver"
|
||||
elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; 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..."
|
||||
|
||||
# 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 v9.0.3..."
|
||||
if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -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
|
||||
|
||||
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 ! sudo apt install -y squeezelite; 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,434 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
|
||||
# Tailscale, Netbird).
|
||||
#
|
||||
# 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.
|
||||
################################################################################
|
||||
|
||||
remote_access_status() {
|
||||
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=("VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
|
||||
MENU_HANDLERS=(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 ! sudo apt install -y x11vnc; 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 ! sudo apt install -y wireguard wireguard-tools; 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"; 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"
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/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 ! 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/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,306 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced):
|
||||
# auto-starts a WiFi hotspot if no internet is detected 60 seconds after
|
||||
# boot, so the kiosk can be reached and reconfigured remotely.
|
||||
#
|
||||
# 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"
|
||||
|
||||
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 no internet is detected 60"
|
||||
echo " 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 ! sudo apt install -y hostapd dnsmasq iptables; 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
|
||||
|
||||
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 no internet connection 60 seconds after boot
|
||||
################################################################################
|
||||
|
||||
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
|
||||
|
||||
# Check for internet connectivity
|
||||
if ping -c 3 -W 5 8.8.8.8 >/dev/null 2>&1; then
|
||||
logger "KIOSK: Internet connected - emergency hotspot not needed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
logger "KIOSK: No internet detected - 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,259 @@
|
||||
#!/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")
|
||||
|
||||
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" ;;
|
||||
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,154 @@
|
||||
#!/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, power_schedule_do_remove_all,
|
||||
# emergency_hotspot_do_disable) 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 " • LightDM and Openbox"
|
||||
echo " • All kiosk schedules and services"
|
||||
echo " • Emergency hotspot configuration"
|
||||
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
|
||||
|
||||
echo "[3/12] Removing schedules and emergency hotspot..."
|
||||
power_schedule_do_remove_all
|
||||
emergency_hotspot_do_disable
|
||||
|
||||
# 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 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 /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,133 @@
|
||||
#!/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")"
|
||||
}
|
||||
|
||||
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"))"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
action_set_touch_mode
|
||||
action_set_navigation_security
|
||||
action_toggle_pause_button
|
||||
action_toggle_keyboard_button
|
||||
action_toggle_nav_button
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/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).
|
||||
#
|
||||
# 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)"
|
||||
else
|
||||
echo "Current: PIN set (${#current_pin} digits)"
|
||||
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
|
||||
################################################################################
|
||||
|
||||
write_pin() {
|
||||
local value="$1"
|
||||
local pin_file
|
||||
pin_file=$(hidden_pin_file)
|
||||
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null
|
||||
sudo -u "$KIOSK_USER" chmod 600 "$pin_file"
|
||||
log_warning "Restart the kiosk display for this to take effect"
|
||||
}
|
||||
|
||||
action_set_pin() {
|
||||
echo
|
||||
local new_pin confirm_pin
|
||||
while true; do
|
||||
read -r -p "Enter new PIN (4-8 digits): " new_pin
|
||||
|
||||
if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then
|
||||
echo "❌ PIN must be 4-8 digits"
|
||||
continue
|
||||
fi
|
||||
|
||||
read -r -p "Confirm PIN: " confirm_pin
|
||||
|
||||
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,187 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/lockout.sh - "Password Protection & Lockout" menu.
|
||||
#
|
||||
# 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 "Password on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")"
|
||||
else
|
||||
echo "Password protection: disabled"
|
||||
fi
|
||||
}
|
||||
|
||||
lockout_menu_builder() {
|
||||
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
|
||||
MENU_LABELS=(
|
||||
"Change lockout password"
|
||||
"Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)"
|
||||
"Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})"
|
||||
"Toggle require password on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))"
|
||||
"Disable password protection"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
action_change_password
|
||||
action_change_timeout
|
||||
action_change_daily_lock
|
||||
action_toggle_boot_password
|
||||
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
|
||||
################################################################################
|
||||
|
||||
# Prompts for a new password twice, hashes it, and assigns to
|
||||
# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up.
|
||||
prompt_and_hash_password() {
|
||||
local pass1 pass2
|
||||
while true; do
|
||||
read -r -s -p "Enter password: " pass1
|
||||
echo
|
||||
read -r -s -p "Confirm password: " pass2
|
||||
echo
|
||||
|
||||
if [[ -z "$pass1" ]]; then
|
||||
echo "❌ Password cannot be empty"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$pass1" != "$pass2" ]]; then
|
||||
echo "❌ Passwords don't match, try again"
|
||||
continue
|
||||
fi
|
||||
|
||||
LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_enable_protection() {
|
||||
echo
|
||||
echo "Add password protection with automatic lockout:"
|
||||
echo " • Blank screen after an inactivity period"
|
||||
echo " • Password required to unlock"
|
||||
echo " • Password required after display schedule wake-up"
|
||||
echo " • Optional: lock at a specific time daily"
|
||||
echo " • Optional: require password on system boot"
|
||||
echo
|
||||
|
||||
echo "Set lockout password:"
|
||||
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
|
||||
if ask_yes_no "Require password on system boot/power on?" "y"; then
|
||||
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||
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"
|
||||
log_success "Password protection disabled"
|
||||
save_config
|
||||
}
|
||||
|
||||
action_change_password() {
|
||||
echo
|
||||
prompt_and_hash_password
|
||||
log_success "Password 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 "Password on boot disabled"
|
||||
else
|
||||
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||
log_success "Password on boot enabled"
|
||||
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"
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
#!/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://.
|
||||
sites_parse_url() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^https?:// ]]; then
|
||||
echo "$raw"
|
||||
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
|
||||
|
||||
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,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
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#!/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 - so wifi_menu
|
||||
# IS the action, not a run_menu wrapper.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
wifi_menu() {
|
||||
echo
|
||||
echo " ═══ WIFI CONFIGURATION ═══"
|
||||
echo
|
||||
|
||||
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
|
||||
wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1)
|
||||
|
||||
if [[ -z "$wifi_iface" ]]; then
|
||||
log_warning "No WiFi hardware detected"
|
||||
echo "If you have a USB WiFi adapter, ensure it's plugged in."
|
||||
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
|
||||
|
||||
echo "Bringing up interface..."
|
||||
if ! sudo ip link set "$wifi_iface" up 2>/dev/null; then
|
||||
log_error "Failed to bring up interface"
|
||||
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
|
||||
|
||||
apply_wifi_config "$wifi_iface" "$ssid" "$password"
|
||||
}
|
||||
|
||||
# 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"
|
||||
|
||||
# `|| true`: under set -e + pipefail (this whole tool runs under both),
|
||||
# `ls` matching nothing exits non-zero even with stderr silenced, which
|
||||
# would abort this function outright instead of falling through to the
|
||||
# default filename below. Same class of bug as the run_menu fix in
|
||||
# lib/menu.sh - masked here in practice because cloud-init almost
|
||||
# always leaves a *.yaml file behind, but not guaranteed.
|
||||
local netplan_file
|
||||
netplan_file=$(ls "$NETPLAN_DIR"/*.yaml 2>/dev/null | head -1) || true
|
||||
[[ -z "$netplan_file" ]] && netplan_file="$NETPLAN_DIR/50-cloud-init.yaml"
|
||||
|
||||
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
|
||||
access-points:
|
||||
"$ssid":
|
||||
password: "$password"
|
||||
EOF
|
||||
|
||||
if [[ -n "${SSH_CONNECTION:-}" ]] && [[ -n "$backup" ]]; then
|
||||
local watchdog
|
||||
watchdog=$(mktemp --suffix=.sh)
|
||||
cat > "$watchdog" <<'WATCHEOF'
|
||||
#!/bin/bash
|
||||
sleep 60
|
||||
if [[ -f "$1" && -f "$2" ]]; then
|
||||
ip=$(hostname -I | awk '{print $1}')
|
||||
if [[ -z "$ip" ]] || ! ping -c 2 8.8.8.8 >/dev/null 2>&1; then
|
||||
cp "$1" "$2"
|
||||
netplan apply 2>/dev/null
|
||||
echo "WiFi config reverted - connection failed" | wall
|
||||
fi
|
||||
fi
|
||||
rm -f "$0"
|
||||
WATCHEOF
|
||||
chmod +x "$watchdog"
|
||||
nohup sudo bash "$watchdog" "$backup" "$netplan_file" >/dev/null 2>&1 &
|
||||
echo "Watchdog started - will revert in 60s if the connection fails"
|
||||
fi
|
||||
|
||||
sudo cp "$temp_plan" "$netplan_file"
|
||||
sudo chmod 0600 "$netplan_file"
|
||||
rm -f "$temp_plan"
|
||||
|
||||
echo "Applying configuration..."
|
||||
local netplan_log
|
||||
netplan_log=$(mktemp)
|
||||
if sudo netplan apply > "$netplan_log" 2>&1; then
|
||||
cat "$netplan_log"
|
||||
sleep 10
|
||||
local new_ip
|
||||
new_ip=$(get_ip_address)
|
||||
if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then
|
||||
log_success "Connected: $ssid ($new_ip)"
|
||||
[[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert"
|
||||
else
|
||||
log_warning "Config applied but no IP yet"
|
||||
echo "Check: sudo journalctl -u systemd-networkd -f"
|
||||
fi
|
||||
else
|
||||
log_error "netplan apply failed"
|
||||
echo "Error log:"
|
||||
cat "$netplan_log"
|
||||
if [[ -n "$backup" ]] && ask_yes_no "Restore backup?" "y"; then
|
||||
sudo cp "$backup" "$netplan_file"
|
||||
# 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,10 @@
|
||||
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
|
||||
@@ -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 @@
|
||||
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}%
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
echo "Testing power button configuration..."
|
||||
echo
|
||||
|
||||
echo "1. Checking acpid service..."
|
||||
if systemctl is-active --quiet acpid; then
|
||||
echo " ✓ acpid is running"
|
||||
else
|
||||
echo " ✗ acpid is NOT running"
|
||||
echo " Fix: sudo systemctl enable --now acpid"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "2. Checking ACPI event handler..."
|
||||
if [ -f /etc/acpi/events/kiosk-power-button ]; then
|
||||
echo " ✓ Event handler exists"
|
||||
cat /etc/acpi/events/kiosk-power-button
|
||||
else
|
||||
echo " ✗ Event handler not found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "3. Checking power button script..."
|
||||
if [ -x /usr/local/bin/kiosk-power-button.sh ]; then
|
||||
echo " ✓ Script exists and is executable"
|
||||
else
|
||||
echo " ✗ Script not found or not executable"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "4. Checking Electron process..."
|
||||
PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo " ✓ Found Electron PIDs: $PIDS"
|
||||
else
|
||||
echo " ✗ No Electron process found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "5. Testing power button trigger NOW..."
|
||||
if [ -x /usr/local/bin/kiosk-power-button.sh ]; then
|
||||
echo " Running: /usr/local/bin/kiosk-power-button.sh"
|
||||
/usr/local/bin/kiosk-power-button.sh
|
||||
echo " Check if power menu appeared!"
|
||||
else
|
||||
echo " Script not found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "6. To watch ACPI events: sudo acpi_listen"
|
||||
echo " Then press power button and look for 'button/power' events"
|
||||
@@ -1,715 +0,0 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
### UBK Mumble/Talkiepi Intercom Setup ###
|
||||
### Simple CLI-based Installation ###
|
||||
################################################################################
|
||||
#
|
||||
# This script provides a lightweight intercom solution using:
|
||||
# - Murmur: The Mumble server
|
||||
# - talkiepi: A simple, lightweight barnard-based Mumble client
|
||||
#
|
||||
# Advantages over talkkonnect:
|
||||
# - Much simpler codebase and build process
|
||||
# - Fewer dependencies
|
||||
# - Direct CLI arguments for server, user, password, channel
|
||||
# - More stable and less fragile
|
||||
#
|
||||
################################################################################
|
||||
|
||||
VERSION="2.0.0"
|
||||
KIOSK_USER="${KIOSK_USER:-kiosk}"
|
||||
|
||||
################################################################################
|
||||
### HELPER FUNCTIONS
|
||||
################################################################################
|
||||
|
||||
log_error() {
|
||||
echo "[ERROR] $*" >&2
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo "✓ $*"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo "⚠ $*"
|
||||
}
|
||||
|
||||
pause() {
|
||||
read -r -p "Press Enter to continue..."
|
||||
}
|
||||
|
||||
get_ip_address() {
|
||||
hostname -I | awk '{print $1}' || echo "No IP"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### STATUS CHECK FUNCTIONS
|
||||
################################################################################
|
||||
|
||||
check_murmur_status() {
|
||||
local installed=false
|
||||
local running=false
|
||||
|
||||
if systemctl list-unit-files | grep -q "mumble-server.service"; then
|
||||
installed=true
|
||||
if systemctl is-active --quiet mumble-server; then
|
||||
running=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$installed:$running"
|
||||
}
|
||||
|
||||
check_talkiepi_status() {
|
||||
local installed=false
|
||||
local running=false
|
||||
|
||||
if [[ -f /usr/local/bin/talkiepi ]] || [[ -f /etc/systemd/system/talkiepi.service ]]; then
|
||||
installed=true
|
||||
if systemctl is-active --quiet talkiepi; then
|
||||
running=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$installed:$running"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### MURMUR SERVER INSTALLATION
|
||||
################################################################################
|
||||
|
||||
install_murmur_server() {
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " INSTALLING MURMUR SERVER"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
|
||||
echo "[1/5] Installing mumble-server package..."
|
||||
sudo apt update
|
||||
sudo apt install -y mumble-server
|
||||
|
||||
echo "[2/5] Getting configuration details..."
|
||||
read -r -s -p "SuperUser password: " superuser_pass
|
||||
echo
|
||||
read -r -s -p "Server password (clients need this): " server_pass
|
||||
echo
|
||||
read -r -p "Welcome text [Welcome to Kiosk Intercom]: " welcome_text
|
||||
welcome_text="${welcome_text:-Welcome to Kiosk Intercom}"
|
||||
|
||||
echo "[3/5] Creating configuration..."
|
||||
local config_file="/etc/mumble-server.ini"
|
||||
|
||||
# Create config if it doesn't exist
|
||||
if [[ ! -f "$config_file" ]]; then
|
||||
sudo tee "$config_file" > /dev/null <<'MURMURCONF'
|
||||
# Murmur configuration file
|
||||
|
||||
# Database location
|
||||
database=/var/lib/mumble-server/mumble-server.sqlite
|
||||
|
||||
# Network settings
|
||||
port=64738
|
||||
host=0.0.0.0
|
||||
|
||||
# Logging
|
||||
logfile=/var/log/mumble-server/mumble-server.log
|
||||
|
||||
# Limits
|
||||
users=10
|
||||
bandwidth=72000
|
||||
|
||||
# Welcome message
|
||||
welcometext=Welcome
|
||||
|
||||
# Server password
|
||||
serverpassword=
|
||||
|
||||
# Allow pings
|
||||
allowping=true
|
||||
|
||||
# Enable HTML
|
||||
allowhtml=true
|
||||
MURMURCONF
|
||||
log_success "Config file created"
|
||||
fi
|
||||
|
||||
# Update configuration
|
||||
sudo sed -i "s|^welcometext=.*|welcometext=$welcome_text|" "$config_file"
|
||||
sudo sed -i "s|^port=.*|port=64738|" "$config_file"
|
||||
sudo sed -i "s|^users=.*|users=10|" "$config_file"
|
||||
sudo sed -i "s|^bandwidth=.*|bandwidth=72000|" "$config_file"
|
||||
|
||||
if [[ -n "$server_pass" ]]; then
|
||||
sudo sed -i "s|^serverpassword=.*|serverpassword=$server_pass|" "$config_file"
|
||||
fi
|
||||
|
||||
echo "[4/5] Setting SuperUser password..."
|
||||
sudo systemctl stop mumble-server 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
echo "$superuser_pass" | sudo murmurd -ini "$config_file" -supw - 2>/dev/null || {
|
||||
log_warning "Could not set SuperUser password via murmurd command"
|
||||
echo "You can set it later with: sudo murmurd -ini $config_file -supw YOUR_PASSWORD"
|
||||
}
|
||||
|
||||
echo "[5/5] Starting service..."
|
||||
sudo systemctl enable mumble-server
|
||||
sudo systemctl start mumble-server
|
||||
|
||||
# Configure firewall
|
||||
sudo ufw allow 64738/tcp comment 'Mumble/Murmur' 2>/dev/null || true
|
||||
sudo ufw allow 64738/udp comment 'Mumble/Murmur' 2>/dev/null || true
|
||||
|
||||
sleep 3
|
||||
|
||||
local server_ip=$(get_ip_address)
|
||||
log_success "Murmur server installed"
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " Server: $server_ip:64738"
|
||||
echo " SuperUser: SuperUser / $superuser_pass"
|
||||
[[ -n "$server_pass" ]] && echo " Password: $server_pass"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### TALKIEPI CLIENT INSTALLATION
|
||||
################################################################################
|
||||
|
||||
install_talkiepi_with_config() {
|
||||
local server_addr="$1"
|
||||
local server_port="$2"
|
||||
local tp_user="$3"
|
||||
local tp_pass="$4"
|
||||
local tp_channel="$5"
|
||||
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " INSTALLING TALKIEPI CLIENT"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
echo "talkiepi is a lightweight Mumble client based on barnard"
|
||||
echo "Much simpler and more stable than talkkonnect"
|
||||
echo
|
||||
|
||||
local TARGET_USER="$KIOSK_USER"
|
||||
local TARGET_UID=$(id -u "$TARGET_USER")
|
||||
local TARGET_HOME="/home/$TARGET_USER"
|
||||
|
||||
# Verify home directory exists
|
||||
if [ ! -d "$TARGET_HOME" ]; then
|
||||
log_error "Home directory does not exist: $TARGET_HOME"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- System Prep ------------------------------------------------------
|
||||
echo "[1/5] Installing system dependencies..."
|
||||
sudo apt update
|
||||
sudo apt install -y git golang libopenal-dev libopus-dev
|
||||
|
||||
# --- Clone and Build Talkiepi -----------------------------------------
|
||||
echo "[2/5] Cloning talkiepi repository..."
|
||||
cd "$TARGET_HOME"
|
||||
if [ -d "talkiepi" ]; then
|
||||
sudo rm -rf talkiepi
|
||||
fi
|
||||
|
||||
# Clone as the target user
|
||||
sudo -u "$TARGET_USER" git clone https://github.com/dchote/talkiepi.git
|
||||
cd talkiepi
|
||||
|
||||
echo "[3/5] Building talkiepi..."
|
||||
export GOPATH="$TARGET_HOME/gocode"
|
||||
export GOBIN="$TARGET_HOME/bin"
|
||||
|
||||
# Install gopus dependency
|
||||
sudo -u "$TARGET_USER" go get github.com/dchote/gopus
|
||||
|
||||
# Build talkiepi
|
||||
sudo -u "$TARGET_USER" go build -o "$TARGET_HOME/bin/talkiepi" cmd/talkiepi/main.go
|
||||
|
||||
if [ ! -f "$TARGET_HOME/bin/talkiepi" ]; then
|
||||
log_error "Build failed!"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Stop any running instances
|
||||
if systemctl is-active --quiet talkiepi 2>/dev/null; then
|
||||
sudo systemctl stop talkiepi
|
||||
fi
|
||||
|
||||
# Install binary
|
||||
sudo cp "$TARGET_HOME/bin/talkiepi" /usr/local/bin/talkiepi
|
||||
sudo chmod +x /usr/local/bin/talkiepi
|
||||
log_success "Binary installed to /usr/local/bin/talkiepi"
|
||||
|
||||
# --- User Permissions -------------------------------------------------
|
||||
echo "[4/5] Setting up permissions..."
|
||||
if ! groups "$TARGET_USER" | grep -q audio; then
|
||||
sudo usermod -a -G audio "$TARGET_USER"
|
||||
log_success "Added $TARGET_USER to 'audio' group"
|
||||
fi
|
||||
|
||||
# --- Create Systemd Service -------------------------------------------
|
||||
echo "[5/5] Creating systemd service..."
|
||||
|
||||
# Build the command line arguments
|
||||
local TALKIEPI_ARGS="-server ${server_addr}:${server_port} -username ${tp_user}"
|
||||
|
||||
if [[ -n "$tp_pass" ]]; then
|
||||
TALKIEPI_ARGS="$TALKIEPI_ARGS -password ${tp_pass}"
|
||||
fi
|
||||
|
||||
if [[ -n "$tp_channel" ]]; then
|
||||
TALKIEPI_ARGS="$TALKIEPI_ARGS -channel ${tp_channel}"
|
||||
fi
|
||||
|
||||
# Add insecure flag for local/self-signed certs
|
||||
TALKIEPI_ARGS="$TALKIEPI_ARGS -insecure"
|
||||
|
||||
sudo tee /etc/systemd/system/talkiepi.service > /dev/null <<EOFSVC
|
||||
[Unit]
|
||||
Description=Talkiepi Lightweight Mumble Client
|
||||
After=network-online.target sound.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$TARGET_HOME
|
||||
ExecStart=/usr/local/bin/talkiepi $TALKIEPI_ARGS
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/$TARGET_UID"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOFSVC
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable talkiepi
|
||||
sudo systemctl start talkiepi
|
||||
|
||||
sleep 3
|
||||
|
||||
log_success "talkiepi installed successfully!"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " Server: $server_addr:$server_port"
|
||||
echo " Username: $tp_user"
|
||||
echo " Channel: ${tp_channel:-Root}"
|
||||
echo " Binary: /usr/local/bin/talkiepi"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
|
||||
# Show status
|
||||
if systemctl is-active --quiet talkiepi; then
|
||||
log_success "Service is running"
|
||||
echo "View logs: sudo journalctl -u talkiepi -f"
|
||||
else
|
||||
log_warning "Service may have issues"
|
||||
echo "Check logs: sudo journalctl -u talkiepi -n 50"
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
install_talkiepi_only() {
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " TALKIEPI - CONNECT TO EXISTING SERVER"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
|
||||
echo "Enter Murmur/Mumble server details:"
|
||||
read -r -p "Server address (IP or domain): " server_addr
|
||||
read -r -p "Port [64738]: " server_port
|
||||
server_port="${server_port:-64738}"
|
||||
read -r -p "Username: " tp_user
|
||||
read -r -s -p "Password (leave empty if none): " tp_pass
|
||||
echo
|
||||
read -r -p "Channel [Root]: " tp_channel
|
||||
tp_channel="${tp_channel:-Root}"
|
||||
|
||||
install_talkiepi_with_config "$server_addr" "$server_port" "$tp_user" "$tp_pass" "$tp_channel"
|
||||
}
|
||||
|
||||
install_murmur_and_talkiepi() {
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " ALL-IN-ONE: SERVER + CLIENT"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
|
||||
echo "Installing Murmur server..."
|
||||
install_murmur_server
|
||||
|
||||
echo
|
||||
echo "Now installing talkiepi client..."
|
||||
echo "Configuring to connect to local server..."
|
||||
|
||||
# Auto-configure for local server
|
||||
AUTO_SERVER="127.0.0.1"
|
||||
AUTO_PORT="64738"
|
||||
read -r -p "Username for talkiepi [kiosk]: " tp_user
|
||||
tp_user="${tp_user:-kiosk}"
|
||||
read -r -s -p "Server password: " tp_pass
|
||||
echo
|
||||
|
||||
install_talkiepi_with_config "$AUTO_SERVER" "$AUTO_PORT" "$tp_user" "$tp_pass" "Root"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### SERVICE MANAGEMENT
|
||||
################################################################################
|
||||
|
||||
toggle_murmur_service() {
|
||||
echo
|
||||
if systemctl is-active --quiet mumble-server; then
|
||||
echo "Stopping Murmur server..."
|
||||
sudo systemctl stop mumble-server
|
||||
log_success "Murmur stopped"
|
||||
else
|
||||
echo "Starting Murmur server..."
|
||||
sudo systemctl start mumble-server
|
||||
sleep 2
|
||||
if systemctl is-active --quiet mumble-server; then
|
||||
log_success "Murmur started"
|
||||
else
|
||||
log_error "Failed to start - check logs: sudo journalctl -u mumble-server"
|
||||
fi
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
toggle_talkiepi_service() {
|
||||
echo
|
||||
if systemctl is-active --quiet talkiepi; then
|
||||
echo "Stopping talkiepi..."
|
||||
sudo systemctl stop talkiepi
|
||||
log_success "talkiepi stopped"
|
||||
else
|
||||
echo "Starting talkiepi..."
|
||||
sudo systemctl start talkiepi
|
||||
sleep 2
|
||||
if systemctl is-active --quiet talkiepi; then
|
||||
log_success "talkiepi started"
|
||||
else
|
||||
log_error "Failed to start - check logs: sudo journalctl -u talkiepi"
|
||||
fi
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### LOGS AND DIAGNOSTICS
|
||||
################################################################################
|
||||
|
||||
view_talkiepi_logs() {
|
||||
echo
|
||||
echo "Recent talkiepi logs:"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
sudo journalctl -u talkiepi -n 50 --no-pager
|
||||
echo
|
||||
pause
|
||||
}
|
||||
|
||||
view_murmur_logs() {
|
||||
echo
|
||||
echo "Recent Murmur server logs:"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
sudo journalctl -u mumble-server -n 50 --no-pager
|
||||
echo
|
||||
pause
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### UNINSTALLATION
|
||||
################################################################################
|
||||
|
||||
uninstall_murmur() {
|
||||
echo
|
||||
read -r -p "Uninstall Murmur server? (yes/no): " confirm
|
||||
[[ "$confirm" != "yes" ]] && return
|
||||
|
||||
echo "Uninstalling..."
|
||||
sudo systemctl stop mumble-server 2>/dev/null || true
|
||||
sudo systemctl disable mumble-server 2>/dev/null || true
|
||||
sudo apt remove -y mumble-server 2>/dev/null || true
|
||||
|
||||
read -r -p "Remove configuration and database? (y/n): " remove_data
|
||||
if [[ "$remove_data" =~ ^[Yy]$ ]]; then
|
||||
sudo rm -rf /var/lib/mumble-server
|
||||
sudo rm -f /etc/mumble-server.ini
|
||||
log_success "Murmur and data removed"
|
||||
else
|
||||
log_success "Murmur removed (data preserved)"
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
uninstall_talkiepi() {
|
||||
echo
|
||||
read -r -p "Uninstall talkiepi? (yes/no): " confirm
|
||||
[[ "$confirm" != "yes" ]] && return
|
||||
|
||||
echo "Uninstalling talkiepi..."
|
||||
|
||||
# Stop and disable service
|
||||
if systemctl is-active --quiet talkiepi 2>/dev/null; then
|
||||
sudo systemctl stop talkiepi
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet talkiepi 2>/dev/null; then
|
||||
sudo systemctl disable talkiepi
|
||||
fi
|
||||
|
||||
# Remove systemd service file
|
||||
if [[ -f /etc/systemd/system/talkiepi.service ]]; then
|
||||
sudo rm -f /etc/systemd/system/talkiepi.service
|
||||
sudo systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# Remove binary
|
||||
if [[ -f /usr/local/bin/talkiepi ]]; then
|
||||
sudo rm -f /usr/local/bin/talkiepi
|
||||
fi
|
||||
|
||||
# Remove source directory
|
||||
read -r -p "Also remove source directory? (y/n): " remove_source
|
||||
if [[ "$remove_source" =~ ^[Yy]$ ]] && [[ -d "/home/$KIOSK_USER/talkiepi" ]]; then
|
||||
sudo rm -rf "/home/$KIOSK_USER/talkiepi"
|
||||
sudo rm -rf "/home/$KIOSK_USER/gocode"
|
||||
sudo rm -rf "/home/$KIOSK_USER/bin"
|
||||
fi
|
||||
|
||||
log_success "talkiepi uninstalled"
|
||||
pause
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### MAIN MENU
|
||||
################################################################################
|
||||
|
||||
show_status() {
|
||||
local murmur_status=$(check_murmur_status)
|
||||
local murmur_installed=$(echo "$murmur_status" | cut -d: -f1)
|
||||
local murmur_running=$(echo "$murmur_status" | cut -d: -f2)
|
||||
|
||||
local tp_status=$(check_talkiepi_status)
|
||||
local tp_installed=$(echo "$tp_status" | cut -d: -f1)
|
||||
local tp_running=$(echo "$tp_status" | cut -d: -f2)
|
||||
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " INTERCOM STATUS"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
|
||||
# Murmur status
|
||||
if [[ "$murmur_installed" == "true" ]]; then
|
||||
echo "Murmur Server: ✓ Installed"
|
||||
if [[ "$murmur_running" == "true" ]]; then
|
||||
echo " Status: Running"
|
||||
local server_ip=$(get_ip_address)
|
||||
echo " Address: $server_ip:64738"
|
||||
else
|
||||
echo " Status: Stopped"
|
||||
fi
|
||||
echo
|
||||
else
|
||||
echo "Murmur Server: Not installed"
|
||||
echo
|
||||
fi
|
||||
|
||||
# talkiepi status
|
||||
if [[ "$tp_installed" == "true" ]]; then
|
||||
echo "talkiepi Client: ✓ Installed"
|
||||
if [[ "$tp_running" == "true" ]]; then
|
||||
echo " Status: Running"
|
||||
else
|
||||
echo " Status: Stopped"
|
||||
fi
|
||||
echo
|
||||
else
|
||||
echo "talkiepi Client: Not installed"
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
main_menu() {
|
||||
while true; do
|
||||
clear
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " UBK INTERCOM SETUP v${VERSION}"
|
||||
echo " Mumble/Talkiepi (Lightweight & Stable)"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
|
||||
show_status
|
||||
|
||||
local murmur_status=$(check_murmur_status)
|
||||
local murmur_installed=$(echo "$murmur_status" | cut -d: -f1)
|
||||
local murmur_running=$(echo "$murmur_status" | cut -d: -f2)
|
||||
|
||||
local tp_status=$(check_talkiepi_status)
|
||||
local tp_installed=$(echo "$tp_status" | cut -d: -f1)
|
||||
local tp_running=$(echo "$tp_status" | cut -d: -f2)
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " INSTALLATION OPTIONS"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
|
||||
local menu_num=1
|
||||
|
||||
# Installation options (shown when nothing installed)
|
||||
if [[ "$murmur_installed" == "false" && "$tp_installed" == "false" ]]; then
|
||||
echo " $menu_num. Install All-in-One (Server + Client)"
|
||||
local opt_all_in_one=$menu_num
|
||||
((menu_num++))
|
||||
echo " $menu_num. Install Murmur Server Only"
|
||||
local opt_server_only=$menu_num
|
||||
((menu_num++))
|
||||
echo " $menu_num. Install talkiepi Client Only"
|
||||
local opt_client_only=$menu_num
|
||||
((menu_num++))
|
||||
else
|
||||
# Murmur management options
|
||||
if [[ "$murmur_installed" == "true" ]]; then
|
||||
echo
|
||||
echo "Murmur Server:"
|
||||
if [[ "$murmur_running" == "true" ]]; then
|
||||
echo " $menu_num. Stop Murmur"
|
||||
else
|
||||
echo " $menu_num. Start Murmur"
|
||||
fi
|
||||
local opt_murmur_toggle=$menu_num
|
||||
((menu_num++))
|
||||
|
||||
echo " $menu_num. View Murmur Logs"
|
||||
local opt_murmur_logs=$menu_num
|
||||
((menu_num++))
|
||||
|
||||
echo " $menu_num. Uninstall Murmur"
|
||||
local opt_murmur_uninstall=$menu_num
|
||||
((menu_num++))
|
||||
else
|
||||
echo
|
||||
echo " $menu_num. Install Murmur Server"
|
||||
local opt_install_murmur=$menu_num
|
||||
((menu_num++))
|
||||
fi
|
||||
|
||||
# talkiepi management options
|
||||
if [[ "$tp_installed" == "true" ]]; then
|
||||
echo
|
||||
echo "talkiepi Client:"
|
||||
if [[ "$tp_running" == "true" ]]; then
|
||||
echo " $menu_num. Stop talkiepi"
|
||||
else
|
||||
echo " $menu_num. Start talkiepi"
|
||||
fi
|
||||
local opt_tp_toggle=$menu_num
|
||||
((menu_num++))
|
||||
|
||||
echo " $menu_num. View talkiepi Logs"
|
||||
local opt_tp_logs=$menu_num
|
||||
((menu_num++))
|
||||
|
||||
echo " $menu_num. Uninstall talkiepi"
|
||||
local opt_tp_uninstall=$menu_num
|
||||
((menu_num++))
|
||||
else
|
||||
echo
|
||||
echo " $menu_num. Install talkiepi Client"
|
||||
local opt_install_tp=$menu_num
|
||||
((menu_num++))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " 0. Exit"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
read -r -p "Choose [0-$((menu_num-1))]: " choice
|
||||
|
||||
# Handle menu selection
|
||||
case "$choice" in
|
||||
0)
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
;;
|
||||
${opt_all_in_one:-})
|
||||
install_murmur_and_talkiepi
|
||||
;;
|
||||
${opt_server_only:-})
|
||||
install_murmur_server
|
||||
;;
|
||||
${opt_client_only:-})
|
||||
install_talkiepi_only
|
||||
;;
|
||||
${opt_install_murmur:-})
|
||||
install_murmur_server
|
||||
;;
|
||||
${opt_install_tp:-})
|
||||
install_talkiepi_only
|
||||
;;
|
||||
${opt_murmur_toggle:-})
|
||||
toggle_murmur_service
|
||||
;;
|
||||
${opt_murmur_logs:-})
|
||||
view_murmur_logs
|
||||
;;
|
||||
${opt_murmur_uninstall:-})
|
||||
uninstall_murmur
|
||||
;;
|
||||
${opt_tp_toggle:-})
|
||||
toggle_talkiepi_service
|
||||
;;
|
||||
${opt_tp_logs:-})
|
||||
view_talkiepi_logs
|
||||
;;
|
||||
${opt_tp_uninstall:-})
|
||||
uninstall_talkiepi
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
sleep 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
################################################################################
|
||||
### ENTRY POINT
|
||||
################################################################################
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
log_error "This script should not be run as root"
|
||||
echo "Please run as: ./setup_intercom_simple.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if kiosk user exists
|
||||
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||
log_warning "User '$KIOSK_USER' does not exist"
|
||||
read -r -p "Enter the username to use for installation: " KIOSK_USER
|
||||
|
||||
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||
log_error "User '$KIOSK_USER' not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run main menu
|
||||
main_menu
|
||||
@@ -1,784 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ======================================================================
|
||||
# File: install_talkkonnect_x86_complete.sh
|
||||
# Purpose: Complete Talkkonnect installation for Ubuntu 24.04 x86_64
|
||||
# Fixes Opus architecture issues and creates working config
|
||||
# Author: Compiled from troubleshooting session
|
||||
# Date: November 2025
|
||||
# ======================================================================
|
||||
set -e
|
||||
|
||||
echo "======================================================================="
|
||||
echo "TalkKonnect Complete Installation Script for x86_64"
|
||||
echo "This script will:"
|
||||
echo " 1. Install system dependencies"
|
||||
echo " 2. Install Go 1.24.1"
|
||||
echo " 3. Clone and patch TalkKonnect for x86_64"
|
||||
echo " 4. Build the binary"
|
||||
echo " 5. Create configuration files"
|
||||
echo " 6. Set up systemd service"
|
||||
echo "======================================================================="
|
||||
echo ""
|
||||
|
||||
# Check if this is a Chromebook or multi-user audio setup
|
||||
echo "Audio Setup Detection:"
|
||||
echo " - If you have audio working under a different user (e.g. 'kiosk'),"
|
||||
echo " talkkonnect should run as that user to share the PipeWire session"
|
||||
echo " - This also enables audio ducking between applications"
|
||||
echo ""
|
||||
read -p "Run talkkonnect as a different user? [kiosk]: " TARGET_USER
|
||||
TARGET_USER=${TARGET_USER:-kiosk}
|
||||
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
# Verify the target user exists
|
||||
if ! id "$TARGET_USER" &>/dev/null; then
|
||||
echo "[!] Error: User '$TARGET_USER' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get target user's UID for XDG_RUNTIME_DIR
|
||||
TARGET_UID=$(id -u "$TARGET_USER")
|
||||
TARGET_HOME=$(eval echo ~"$TARGET_USER")
|
||||
echo "[+] Will install for user: $TARGET_USER (UID: $TARGET_UID)"
|
||||
echo "[+] Home directory: $TARGET_HOME"
|
||||
else
|
||||
TARGET_UID=$(id -u)
|
||||
TARGET_HOME="$HOME"
|
||||
echo "[+] Installing for current user: $USER"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Press Enter to continue or Ctrl-C to abort..."
|
||||
|
||||
# --- System Prep ------------------------------------------------------
|
||||
echo ""
|
||||
echo "[+] Updating system packages..."
|
||||
sudo apt update
|
||||
sudo apt upgrade -y
|
||||
|
||||
echo "[+] Installing dependencies..."
|
||||
sudo apt install -y wget git build-essential pkg-config \
|
||||
libasound2-dev libopus-dev libopus0 libopusfile-dev \
|
||||
libpipewire-0.3-dev libevdev-dev libopenal-dev alsa-utils
|
||||
|
||||
# --- Go Installation --------------------------------------------------
|
||||
echo ""
|
||||
echo "[+] Installing Go 1.24.1..."
|
||||
sudo rm -rf /usr/local/go /usr/lib/go* /usr/bin/go
|
||||
wget https://go.dev/dl/go1.24.1.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz
|
||||
sudo ln -sf /usr/local/go/bin/go /usr/bin/go
|
||||
rm -f go1.24.1.linux-amd64.tar.gz
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
go version
|
||||
|
||||
# --- Clone TalkKonnect ------------------------------------------------
|
||||
echo ""
|
||||
echo "[+] Cloning TalkKonnect repository..."
|
||||
cd ~
|
||||
if [ -d "talkkonnect" ]; then
|
||||
echo "[!] Removing existing talkkonnect directory..."
|
||||
rm -rf talkkonnect
|
||||
fi
|
||||
|
||||
git clone https://github.com/talkkonnect/talkkonnect.git
|
||||
cd talkkonnect
|
||||
|
||||
echo "[+] Current version:"
|
||||
git log --oneline -1
|
||||
|
||||
# --- Critical Fix: Patch gopus for x86_64 -----------------------------
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
echo "[+] APPLYING X86_64 OPUS FIX"
|
||||
echo "======================================================================="
|
||||
echo "[+] The talkkonnect gopus package embeds Opus source for ARM"
|
||||
echo "[+] but it's incomplete for x86_64. We'll patch it to use system libopus"
|
||||
echo ""
|
||||
|
||||
# Create vendor directory
|
||||
go mod vendor
|
||||
|
||||
# Check that gopus exists in vendor
|
||||
if [ ! -d "vendor/github.com/talkkonnect/gopus" ]; then
|
||||
echo "[!] ERROR: gopus not found in vendor!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[+] Backing up original opus_nonshared.go..."
|
||||
cp vendor/github.com/talkkonnect/gopus/opus_nonshared.go \
|
||||
vendor/github.com/talkkonnect/gopus/opus_nonshared.go.original
|
||||
|
||||
echo "[+] Creating x86_64-compatible opus_nonshared.go..."
|
||||
cat > vendor/github.com/talkkonnect/gopus/opus_nonshared.go << 'EOFOPUS'
|
||||
// +build amd64,cgo 386,cgo
|
||||
|
||||
package gopus
|
||||
|
||||
// #cgo pkg-config: opus
|
||||
// #cgo LDFLAGS: -lm
|
||||
//
|
||||
// #include <stdio.h>
|
||||
// #include <stdlib.h>
|
||||
// #include <opus.h>
|
||||
//
|
||||
// enum {
|
||||
// gopus_ok = OPUS_OK,
|
||||
// gopus_bad_arg = OPUS_BAD_ARG,
|
||||
// gopus_small_buffer = OPUS_BUFFER_TOO_SMALL,
|
||||
// gopus_internal = OPUS_INTERNAL_ERROR,
|
||||
// gopus_invalid_packet = OPUS_INVALID_PACKET,
|
||||
// gopus_unimplemented = OPUS_UNIMPLEMENTED,
|
||||
// gopus_invalid_state = OPUS_INVALID_STATE,
|
||||
// gopus_alloc_fail = OPUS_ALLOC_FAIL,
|
||||
// };
|
||||
//
|
||||
// enum {
|
||||
// gopus_application_voip = OPUS_APPLICATION_VOIP,
|
||||
// gopus_application_audio = OPUS_APPLICATION_AUDIO,
|
||||
// gopus_restricted_lowdelay = OPUS_APPLICATION_RESTRICTED_LOWDELAY,
|
||||
// gopus_bitrate_max = OPUS_BITRATE_MAX,
|
||||
// };
|
||||
//
|
||||
// void gopus_setvbr(OpusEncoder *encoder, int vbr) {
|
||||
// opus_encoder_ctl(encoder, OPUS_SET_VBR(vbr));
|
||||
// }
|
||||
//
|
||||
// void gopus_setbitrate(OpusEncoder *encoder, int bitrate) {
|
||||
// opus_encoder_ctl(encoder, OPUS_SET_BITRATE(bitrate));
|
||||
// }
|
||||
//
|
||||
// opus_int32 gopus_bitrate(OpusEncoder *encoder) {
|
||||
// opus_int32 bitrate;
|
||||
// opus_encoder_ctl(encoder, OPUS_GET_BITRATE(&bitrate));
|
||||
// return bitrate;
|
||||
// }
|
||||
//
|
||||
// void gopus_setapplication(OpusEncoder *encoder, int application) {
|
||||
// opus_encoder_ctl(encoder, OPUS_SET_APPLICATION(application));
|
||||
// }
|
||||
//
|
||||
// opus_int32 gopus_application(OpusEncoder *encoder) {
|
||||
// opus_int32 application;
|
||||
// opus_encoder_ctl(encoder, OPUS_GET_APPLICATION(&application));
|
||||
// return application;
|
||||
// }
|
||||
//
|
||||
// void gopus_encoder_resetstate(OpusEncoder *encoder) {
|
||||
// opus_encoder_ctl(encoder, OPUS_RESET_STATE);
|
||||
// }
|
||||
//
|
||||
// void gopus_decoder_resetstate(OpusDecoder *decoder) {
|
||||
// opus_decoder_ctl(decoder, OPUS_RESET_STATE);
|
||||
// }
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type Application int
|
||||
|
||||
const (
|
||||
Voip Application = C.gopus_application_voip
|
||||
Audio Application = C.gopus_application_audio
|
||||
RestrictedLowDelay Application = C.gopus_restricted_lowdelay
|
||||
)
|
||||
|
||||
const (
|
||||
BitrateMaximum = C.gopus_bitrate_max
|
||||
)
|
||||
|
||||
type Encoder struct {
|
||||
data []byte
|
||||
cEncoder *C.struct_OpusEncoder
|
||||
}
|
||||
|
||||
func NewEncoder(sampleRate, channels int, application Application) (*Encoder, error) {
|
||||
encoder := &Encoder{}
|
||||
encoder.data = make([]byte, int(C.opus_encoder_get_size(C.int(channels))))
|
||||
encoder.cEncoder = (*C.struct_OpusEncoder)(unsafe.Pointer(&encoder.data[0]))
|
||||
|
||||
ret := C.opus_encoder_init(encoder.cEncoder, C.opus_int32(sampleRate), C.int(channels), C.int(application))
|
||||
if err := getErr(ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encoder, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) Encode(pcm []int16, frameSize, maxDataBytes int) ([]byte, error) {
|
||||
pcmPtr := (*C.opus_int16)(unsafe.Pointer(&pcm[0]))
|
||||
|
||||
data := make([]byte, maxDataBytes)
|
||||
dataPtr := (*C.uchar)(unsafe.Pointer(&data[0]))
|
||||
|
||||
encodedC := C.opus_encode(e.cEncoder, pcmPtr, C.int(frameSize), dataPtr, C.opus_int32(len(data)))
|
||||
encoded := int(encodedC)
|
||||
|
||||
if encoded < 0 {
|
||||
return nil, getErr(C.int(encodedC))
|
||||
}
|
||||
return data[0:encoded], nil
|
||||
}
|
||||
|
||||
func (e *Encoder) SetVbr(vbr bool) {
|
||||
var cVbr C.int
|
||||
if vbr {
|
||||
cVbr = 1
|
||||
} else {
|
||||
cVbr = 0
|
||||
}
|
||||
C.gopus_setvbr(e.cEncoder, cVbr)
|
||||
}
|
||||
|
||||
func (e *Encoder) SetBitrate(bitrate int) {
|
||||
C.gopus_setbitrate(e.cEncoder, C.int(bitrate))
|
||||
}
|
||||
|
||||
func (e *Encoder) Bitrate() int {
|
||||
return int(C.gopus_bitrate(e.cEncoder))
|
||||
}
|
||||
|
||||
func (e *Encoder) SetApplication(application Application) {
|
||||
C.gopus_setapplication(e.cEncoder, C.int(application))
|
||||
}
|
||||
|
||||
func (e *Encoder) Application() Application {
|
||||
return Application(C.gopus_application(e.cEncoder))
|
||||
}
|
||||
|
||||
func (e *Encoder) ResetState() {
|
||||
C.gopus_encoder_resetstate(e.cEncoder)
|
||||
}
|
||||
|
||||
type Decoder struct {
|
||||
data []byte
|
||||
cDecoder *C.struct_OpusDecoder
|
||||
channels int
|
||||
}
|
||||
|
||||
func NewDecoder(sampleRate, channels int) (*Decoder, error) {
|
||||
decoder := &Decoder{}
|
||||
decoder.data = make([]byte, int(C.opus_decoder_get_size(C.int(channels))))
|
||||
decoder.cDecoder = (*C.struct_OpusDecoder)(unsafe.Pointer(&decoder.data[0]))
|
||||
|
||||
ret := C.opus_decoder_init(decoder.cDecoder, C.opus_int32(sampleRate), C.int(channels))
|
||||
if err := getErr(ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder.channels = channels
|
||||
|
||||
return decoder, nil
|
||||
}
|
||||
|
||||
func (d *Decoder) Decode(data []byte, frameSize int, fec bool) ([]int16, error) {
|
||||
var dataPtr *C.uchar
|
||||
if len(data) > 0 {
|
||||
dataPtr = (*C.uchar)(unsafe.Pointer(&data[0]))
|
||||
}
|
||||
dataLen := C.opus_int32(len(data))
|
||||
|
||||
output := make([]int16, d.channels*frameSize)
|
||||
outputPtr := (*C.opus_int16)(unsafe.Pointer(&output[0]))
|
||||
|
||||
var cFec C.int
|
||||
if fec {
|
||||
cFec = 1
|
||||
} else {
|
||||
cFec = 0
|
||||
}
|
||||
|
||||
cRet := C.opus_decode(d.cDecoder, dataPtr, dataLen, outputPtr, C.int(frameSize), cFec)
|
||||
ret := int(cRet)
|
||||
|
||||
if ret < 0 {
|
||||
return nil, getErr(cRet)
|
||||
}
|
||||
return output[:ret*d.channels], nil
|
||||
}
|
||||
|
||||
func (d *Decoder) ResetState() {
|
||||
C.gopus_decoder_resetstate(d.cDecoder)
|
||||
}
|
||||
|
||||
func GetSamplesPerFrame(data []byte, samplingRate int) (int, error) {
|
||||
dataPtr := (*C.uchar)(unsafe.Pointer(&data[0]))
|
||||
cSamplingRate := C.opus_int32(samplingRate)
|
||||
cRet := C.opus_packet_get_samples_per_frame(dataPtr, cSamplingRate)
|
||||
return int(cRet), nil
|
||||
}
|
||||
|
||||
func CountFrames(data []byte) (int, error) {
|
||||
dataPtr := (*C.uchar)(unsafe.Pointer(&data[0]))
|
||||
cLen := C.opus_int32(len(data))
|
||||
|
||||
cRet := C.opus_packet_get_nb_frames(dataPtr, cLen)
|
||||
if err := getErr(cRet); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(cRet), nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadArgument = errors.New("bad argument")
|
||||
ErrSmallBuffer = errors.New("buffer is too small")
|
||||
ErrInternal = errors.New("internal error")
|
||||
ErrInvalidPacket = errors.New("invalid packet")
|
||||
ErrUnimplemented = errors.New("unimplemented")
|
||||
ErrInvalidState = errors.New("invalid state")
|
||||
ErrAllocFail = errors.New("allocation failed")
|
||||
ErrUnknown = errors.New("unknown error")
|
||||
)
|
||||
|
||||
func getErr(code C.int) error {
|
||||
switch code {
|
||||
case C.gopus_ok:
|
||||
return nil
|
||||
case C.gopus_bad_arg:
|
||||
return ErrBadArgument
|
||||
case C.gopus_small_buffer:
|
||||
return ErrSmallBuffer
|
||||
case C.gopus_internal:
|
||||
return ErrInternal
|
||||
case C.gopus_invalid_packet:
|
||||
return ErrInvalidPacket
|
||||
case C.gopus_unimplemented:
|
||||
return ErrUnimplemented
|
||||
case C.gopus_invalid_state:
|
||||
return ErrInvalidState
|
||||
case C.gopus_alloc_fail:
|
||||
return ErrAllocFail
|
||||
default:
|
||||
return ErrUnknown
|
||||
}
|
||||
}
|
||||
EOFOPUS
|
||||
|
||||
echo "[+] Patched opus_nonshared.go to use system libopus"
|
||||
|
||||
# --- Build TalkKonnect ------------------------------------------------
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
echo "[+] BUILDING TALKKONNECT"
|
||||
echo "======================================================================="
|
||||
cd ~/talkkonnect/cmd/talkkonnect
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS="$(pkg-config --cflags opus)"
|
||||
export CGO_LDFLAGS="$(pkg-config --libs opus) -lm"
|
||||
|
||||
echo "[+] Building (this may take a few minutes)..."
|
||||
go build -mod=vendor -v -o ~/talkkonnect-binary . 2>&1 | tee /tmp/talkkonnect_build.log
|
||||
|
||||
if [ ! -f ~/talkkonnect-binary ]; then
|
||||
echo ""
|
||||
echo "[!] BUILD FAILED!"
|
||||
echo "[!] Last 50 lines of build log:"
|
||||
tail -50 /tmp/talkkonnect_build.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[+] Installing binary..."
|
||||
|
||||
# Stop any running talkkonnect instances first
|
||||
if systemctl is-active --quiet talkkonnect 2>/dev/null; then
|
||||
echo "[+] Stopping existing talkkonnect service..."
|
||||
sudo systemctl stop talkkonnect
|
||||
fi
|
||||
|
||||
# Kill any stray processes
|
||||
if pgrep -x talkkonnect > /dev/null; then
|
||||
echo "[+] Killing running talkkonnect processes..."
|
||||
sudo pkill -9 talkkonnect
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Now install the binary
|
||||
sudo cp ~/talkkonnect-binary /usr/local/bin/talkkonnect
|
||||
sudo chmod +x /usr/local/bin/talkkonnect
|
||||
rm ~/talkkonnect-binary
|
||||
|
||||
echo "[+] Binary installed successfully!"
|
||||
echo "[+] Checking dependencies:"
|
||||
ldd /usr/local/bin/talkkonnect | grep -E "(opus|alsa)" || echo " (may be statically linked)"
|
||||
|
||||
# --- User Permissions -------------------------------------------------
|
||||
echo ""
|
||||
echo "[+] Setting up user permissions..."
|
||||
|
||||
# Add target user to input group for keyboard PTT
|
||||
if ! groups "$TARGET_USER" | grep -q input; then
|
||||
sudo usermod -a -G input "$TARGET_USER"
|
||||
echo "[+] Added $TARGET_USER to 'input' group"
|
||||
if [ "$TARGET_USER" = "$USER" ]; then
|
||||
echo "[!] IMPORTANT: Log out and back in for this to take effect!"
|
||||
echo "[!] Or run: newgrp input"
|
||||
NEEDS_RELOGIN=true
|
||||
fi
|
||||
else
|
||||
echo "[+] User $TARGET_USER already in 'input' group"
|
||||
NEEDS_RELOGIN=false
|
||||
fi
|
||||
|
||||
# Add target user to audio group
|
||||
if ! groups "$TARGET_USER" | grep -q audio; then
|
||||
sudo usermod -a -G audio "$TARGET_USER"
|
||||
echo "[+] Added $TARGET_USER to 'audio' group"
|
||||
fi
|
||||
|
||||
# --- Configuration ----------------------------------------------------
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
echo "[+] CREATING CONFIGURATION"
|
||||
echo "======================================================================="
|
||||
|
||||
# Verify target user's home directory exists
|
||||
if [ ! -d "$TARGET_HOME" ]; then
|
||||
echo "[!] Error: Home directory for user '$TARGET_USER' does not exist: $TARGET_HOME"
|
||||
echo "[!] Please ensure the user account is properly set up with a home directory"
|
||||
echo "[!] You may need to run: sudo mkhomedir_helper $TARGET_USER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG_DIR="$TARGET_HOME/.config/talkkonnect"
|
||||
|
||||
# Create config directory with appropriate permissions
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
# Need to use sudo to create directory in another user's home
|
||||
sudo -u "$TARGET_USER" mkdir -p "$CONFIG_DIR"
|
||||
echo "[+] Created config directory as user: $TARGET_USER"
|
||||
else
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
echo "[+] Created config directory"
|
||||
fi
|
||||
|
||||
# Create config file (use tee with sudo to handle permissions)
|
||||
sudo tee "$CONFIG_DIR/talkkonnect.xml" > /dev/null << 'EOFXML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="talkkonnect/xml">
|
||||
<global>
|
||||
<software>
|
||||
<settings outputdevice="default"
|
||||
logfilenameandpath="/home/user/.config/talkkonnect/talkkonnect.log"
|
||||
logging="both"
|
||||
daemonize="false"
|
||||
cancelconnect="false"
|
||||
simplexwithvox="false"
|
||||
nextserverindex="0"/>
|
||||
<autoprovisioning enabled="false"/>
|
||||
</software>
|
||||
<hardware>
|
||||
<settings targetboard="pc"
|
||||
voiceactivitytimersecs="200"/>
|
||||
<io>
|
||||
<pins enabled="false"/>
|
||||
</io>
|
||||
</hardware>
|
||||
</global>
|
||||
|
||||
<accounts>
|
||||
<account name="default" default="true">
|
||||
<serverandport>your.mumble.server:64738</serverandport>
|
||||
<username>your_username</username>
|
||||
<password>your_password</password>
|
||||
<insecure>false</insecure>
|
||||
<register>false</register>
|
||||
<certificate></certificate>
|
||||
<channel>Root</channel>
|
||||
<ident></ident>
|
||||
<tokens enabled="false"></tokens>
|
||||
<voicetargets enabled="false">
|
||||
<id id="1" iscurrent="false" name="default">
|
||||
<channels></channels>
|
||||
<users></users>
|
||||
</id>
|
||||
</voicetargets>
|
||||
</account>
|
||||
</accounts>
|
||||
|
||||
<beacon enabled="false"/>
|
||||
|
||||
<audio>
|
||||
<input>
|
||||
<settings enabled="true"
|
||||
device="default"
|
||||
samplerate="48000"
|
||||
channels="1"
|
||||
codec="opus"
|
||||
framespersecond="50"/>
|
||||
</input>
|
||||
<output>
|
||||
<settings enabled="true"
|
||||
device="default"
|
||||
samplerate="48000"
|
||||
channels="1"/>
|
||||
</output>
|
||||
</audio>
|
||||
|
||||
<ptt enabled="false">
|
||||
<usbkeyboard enabled="false"/>
|
||||
</ptt>
|
||||
|
||||
<voiceactivity enabled="true">
|
||||
<settings threshold="0.3"
|
||||
holdtimems="1000"
|
||||
holdtimeoutms="2000"/>
|
||||
</voiceactivity>
|
||||
|
||||
<sounds enabled="false"/>
|
||||
|
||||
<txtts enabled="false"/>
|
||||
|
||||
<smtp enabled="false"/>
|
||||
|
||||
<api enabled="false"/>
|
||||
|
||||
<printxml enabled="false"/>
|
||||
|
||||
</document>
|
||||
EOFXML
|
||||
|
||||
# Update the log path to use actual username (needs sudo since file was created with tee)
|
||||
sudo sed -i "s|/home/user/|$TARGET_HOME/|g" "$CONFIG_DIR/talkkonnect.xml"
|
||||
|
||||
# Set proper ownership - always needed since we used sudo tee
|
||||
sudo chown -R "$TARGET_USER:$TARGET_USER" "$CONFIG_DIR"
|
||||
sudo chmod 755 "$CONFIG_DIR"
|
||||
sudo chmod 644 "$CONFIG_DIR/talkkonnect.xml"
|
||||
echo "[+] Set ownership of config directory to $TARGET_USER"
|
||||
|
||||
echo "[+] Created configuration file: $CONFIG_DIR/talkkonnect.xml"
|
||||
|
||||
# --- Audio Mixer Detection --------------------------------------------
|
||||
echo ""
|
||||
echo "[+] Detecting audio configuration for user: $TARGET_USER..."
|
||||
|
||||
# Detect audio devices as the target user
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
# Get target user's runtime directory
|
||||
AUDIO_DEVICES=$(sudo -u "$TARGET_USER" XDG_RUNTIME_DIR=/run/user/$TARGET_UID aplay -l 2>/dev/null)
|
||||
PIPEWIRE_SINKS=$(sudo -u "$TARGET_USER" XDG_RUNTIME_DIR=/run/user/$TARGET_UID pactl list sinks short 2>/dev/null)
|
||||
else
|
||||
AUDIO_DEVICES=$(aplay -l 2>/dev/null)
|
||||
PIPEWIRE_SINKS=$(pactl list sinks short 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Determine best audio device
|
||||
AUDIO_DEVICE="default"
|
||||
AUDIO_BACKEND="alsa"
|
||||
|
||||
if echo "$AUDIO_DEVICES" | grep -q "card 0:"; then
|
||||
# ALSA devices found
|
||||
CARD_NAME=$(echo "$AUDIO_DEVICES" | grep "card 0:" | head -1 | sed 's/.*card 0: \([^[]*\).*/\1/' | xargs)
|
||||
echo "[+] Found ALSA card: $CARD_NAME"
|
||||
|
||||
# Use hw:0,0 for direct ALSA access (better for Chromebooks)
|
||||
AUDIO_DEVICE="hw:0,0"
|
||||
echo "[+] Using ALSA device: $AUDIO_DEVICE"
|
||||
|
||||
elif echo "$PIPEWIRE_SINKS" | grep -v "auto_null" | grep -q "alsa_output"; then
|
||||
# PipeWire/PulseAudio sinks found
|
||||
SINK_NAME=$(echo "$PIPEWIRE_SINKS" | grep -v "auto_null" | grep "alsa_output" | head -1 | awk '{print $2}')
|
||||
echo "[+] Found PipeWire sink: $SINK_NAME"
|
||||
AUDIO_DEVICE="pulse"
|
||||
AUDIO_BACKEND="pulse"
|
||||
echo "[+] Using PulseAudio/PipeWire backend"
|
||||
else
|
||||
echo "[!] Warning: No specific audio device detected, using 'default'"
|
||||
fi
|
||||
|
||||
# Try to unmute and set volume
|
||||
MIXER_CONTROL=$(sudo -u "$TARGET_USER" amixer scontrols 2>/dev/null | grep -oP "Simple mixer control '\K[^']+(?=',0)" | head -1)
|
||||
|
||||
if [ -n "$MIXER_CONTROL" ]; then
|
||||
echo "[+] Found mixer control: $MIXER_CONTROL"
|
||||
|
||||
# Try to unmute and set volume
|
||||
sudo -u "$TARGET_USER" amixer set "$MIXER_CONTROL" unmute 2>/dev/null && echo "[+] Unmuted $MIXER_CONTROL" || echo "[!] Could not unmute $MIXER_CONTROL"
|
||||
sudo -u "$TARGET_USER" amixer set "$MIXER_CONTROL" 80% 2>/dev/null && echo "[+] Set $MIXER_CONTROL to 80%" || echo "[!] Could not set volume"
|
||||
else
|
||||
echo "[!] Warning: No ALSA mixer controls found"
|
||||
echo "[!] Audio may work through PipeWire, but you may need to configure it manually"
|
||||
fi
|
||||
|
||||
echo "[+] Audio configuration summary:"
|
||||
echo " Backend: $AUDIO_BACKEND"
|
||||
echo " Device: $AUDIO_DEVICE"
|
||||
if [ -n "$CARD_NAME" ]; then
|
||||
echo " Card: $CARD_NAME"
|
||||
fi
|
||||
|
||||
# --- Systemd Service --------------------------------------------------
|
||||
echo ""
|
||||
echo "[+] Creating systemd service for user: $TARGET_USER..."
|
||||
SERVICE_FILE="/etc/systemd/system/talkkonnect.service"
|
||||
|
||||
sudo tee "$SERVICE_FILE" > /dev/null <<EOFSVC
|
||||
[Unit]
|
||||
Description=TalkKonnect Headless Mumble Transceiver
|
||||
After=network-online.target sound.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$TARGET_HOME
|
||||
ExecStart=/usr/local/bin/talkkonnect -config $CONFIG_DIR/talkkonnect.xml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Use target user's PipeWire/PulseAudio session
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/$TARGET_UID"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOFSVC
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable talkkonnect
|
||||
|
||||
echo "[+] Systemd service created and enabled"
|
||||
|
||||
# --- Audio Ducking Setup (Optional) -----------------------------------
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo ""
|
||||
echo "[+] Setting up audio ducking for user: $TARGET_USER..."
|
||||
echo "[+] This will lower other audio when talkkonnect is transmitting"
|
||||
|
||||
# Create a script to enable ducking
|
||||
DUCKING_SCRIPT="$TARGET_HOME/.config/talkkonnect/enable-ducking.sh"
|
||||
cat > /tmp/enable-ducking.sh << 'EOFDUCKING'
|
||||
#!/bin/bash
|
||||
# Enable audio ducking - lowers music/media when on PTT
|
||||
pactl load-module module-role-ducking \
|
||||
trigger_roles=phone \
|
||||
ducking_roles=music,video \
|
||||
volume=30% 2>/dev/null || echo "Ducking already enabled"
|
||||
EOFDUCKING
|
||||
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
sudo cp /tmp/enable-ducking.sh "$DUCKING_SCRIPT"
|
||||
sudo chown "$TARGET_USER:$TARGET_USER" "$DUCKING_SCRIPT"
|
||||
sudo chmod +x "$DUCKING_SCRIPT"
|
||||
else
|
||||
cp /tmp/enable-ducking.sh "$DUCKING_SCRIPT"
|
||||
chmod +x "$DUCKING_SCRIPT"
|
||||
fi
|
||||
rm /tmp/enable-ducking.sh
|
||||
|
||||
echo "[+] Ducking script created: $DUCKING_SCRIPT"
|
||||
echo "[+] Run as $TARGET_USER to enable: $DUCKING_SCRIPT"
|
||||
fi
|
||||
|
||||
# --- Completion -------------------------------------------------------
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
echo "✓✓✓ INSTALLATION COMPLETE! ✓✓✓"
|
||||
echo "======================================================================="
|
||||
echo ""
|
||||
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo "⚠️ IMPORTANT: Talkkonnect is configured to run as user: $TARGET_USER"
|
||||
echo " This allows it to share the PipeWire audio session with your apps"
|
||||
echo " All configuration files are in: $TARGET_HOME/.config/talkkonnect/"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$NEEDS_RELOGIN" = true ]; then
|
||||
echo "⚠️ IMPORTANT: You were added to the 'input' group"
|
||||
echo " You must LOG OUT and LOG BACK IN for keyboard PTT to work!"
|
||||
echo " (Or run: newgrp input)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "BEFORE RUNNING: Edit the configuration file with your Mumble server details"
|
||||
echo ""
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo " 1. Edit config file (as $TARGET_USER or with sudo):"
|
||||
echo " sudo nano $CONFIG_DIR/talkkonnect.xml"
|
||||
else
|
||||
echo " 1. Edit config file:"
|
||||
echo " nano $CONFIG_DIR/talkkonnect.xml"
|
||||
fi
|
||||
echo ""
|
||||
echo " 2. Update these settings:"
|
||||
echo " - <serverandport>: Your Mumble server address and port"
|
||||
echo " - <username>: Your Mumble username"
|
||||
echo " - <password>: Your Mumble password (if required)"
|
||||
echo " - <channel>: Channel to join on connect"
|
||||
echo " - <insecure>: Set to 'true' if server has self-signed cert"
|
||||
echo ""
|
||||
echo " 3. Optional - Enable keyboard/USB button PTT (voice activation is enabled by default):"
|
||||
echo " - Set <ptt enabled=\"true\">"
|
||||
echo " - For USB mini keyboard: Find device with 'sudo evtest'"
|
||||
echo " - Set <usbkeyboard enabled=\"true\" device=\"/dev/input/eventX\" keycode=\"KEY_F13\"/>"
|
||||
echo " - Tip: Program your USB keyboard to send F13-F24 to avoid conflicts"
|
||||
echo " - Set <voiceactivity enabled=\"false\"/>"
|
||||
if [ "$TARGET_USER" = "$USER" ]; then
|
||||
echo " - Make sure you're in 'input' group: groups | grep input"
|
||||
fi
|
||||
echo ""
|
||||
echo "TESTING:"
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo " Test manually as $TARGET_USER:"
|
||||
echo " sudo -u $TARGET_USER /usr/local/bin/talkkonnect -config $CONFIG_DIR/talkkonnect.xml"
|
||||
else
|
||||
echo " Test manually first:"
|
||||
echo " /usr/local/bin/talkkonnect -config $CONFIG_DIR/talkkonnect.xml"
|
||||
fi
|
||||
echo ""
|
||||
echo " Once working, start the service:"
|
||||
echo " sudo systemctl start talkkonnect"
|
||||
echo ""
|
||||
echo " Check status:"
|
||||
echo " sudo systemctl status talkkonnect"
|
||||
echo ""
|
||||
echo " View logs:"
|
||||
echo " journalctl -u talkkonnect -f"
|
||||
echo " cat $CONFIG_DIR/talkkonnect.log"
|
||||
echo ""
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo "AUDIO DUCKING:"
|
||||
echo " To enable audio ducking (lower other apps when transmitting):"
|
||||
echo " sudo -u $TARGET_USER $CONFIG_DIR/enable-ducking.sh"
|
||||
echo ""
|
||||
fi
|
||||
echo "FILES:"
|
||||
echo " Binary: /usr/local/bin/talkkonnect"
|
||||
echo " Config: $CONFIG_DIR/talkkonnect.xml"
|
||||
echo " Service: /etc/systemd/system/talkkonnect.service"
|
||||
echo " Source: ~/talkkonnect"
|
||||
echo " Logs: $CONFIG_DIR/talkkonnect.log"
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo " Ducking: $CONFIG_DIR/enable-ducking.sh"
|
||||
fi
|
||||
echo ""
|
||||
echo "TROUBLESHOOTING:"
|
||||
echo " - Build log: /tmp/talkkonnect_build.log"
|
||||
echo " - Runtime logs: $CONFIG_DIR/talkkonnect.log"
|
||||
echo " - System logs: journalctl -u talkkonnect -f"
|
||||
echo " - If audio issues: check 'aplay -L' and 'arecord -L'"
|
||||
echo " - If 'unable to unmute' error: run 'amixer scontrols' to see controls"
|
||||
echo " - If cert errors: set <insecure>true</insecure> in config"
|
||||
echo " - For keyboard PTT: ensure you're in 'input' group (groups)"
|
||||
echo " - If crash after connect: ensure voicetargets section exists in XML"
|
||||
echo ""
|
||||
echo "KNOWN ISSUES:"
|
||||
echo " - Voice activation is enabled by default (PTT disabled)"
|
||||
echo " - Keyboard PTT requires logout/login after installation"
|
||||
echo " - Self-signed certs need <insecure>true</insecure>"
|
||||
if [ "$TARGET_USER" != "$USER" ]; then
|
||||
echo " - Config files owned by $TARGET_USER - edit with sudo or su"
|
||||
echo " - Audio ducking must be enabled manually (see above)"
|
||||
fi
|
||||
echo ""
|
||||
echo "CHROMEBOOK-SPECIFIC:"
|
||||
echo " - If no audio: ensure $TARGET_USER can access /dev/snd devices"
|
||||
echo " - Check audio as target user: sudo -u $TARGET_USER aplay -l"
|
||||
echo " - PipeWire session: /run/user/$TARGET_UID/pipewire-0"
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+12585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/actions.js - the web UI's allow-list of privileged actions,
|
||||
// and how to turn a web form's fields into the exact stdin sequence the
|
||||
// real bash `action_*` function expects.
|
||||
//
|
||||
// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the
|
||||
// generated kiosk-webui-helper script) - kept in sync by hand rather
|
||||
// than shared/generated, since both lists are short and deliberately
|
||||
// curated. A mismatch between the two just means an action fails closed
|
||||
// on whichever side is missing it, never open on both: server.js checks
|
||||
// this list before spawning anything, and the helper script re-checks
|
||||
// its own list before dispatching regardless of what server.js sent.
|
||||
//
|
||||
// Every buildStdin() below was verified against the real menus/*.sh
|
||||
// source (prompt order, defaults, and which fields reject a blank
|
||||
// answer and re-prompt) - see webui/test/actions.test.js, which drives
|
||||
// the actual bash functions with this exact output and checks the real
|
||||
// resulting state (cups_is_installed, lms_is_installed, etc), not just
|
||||
// that the process exits 0.
|
||||
|
||||
const ACTIONS = {
|
||||
install_cups: {
|
||||
helperAction: 'action_install_cups',
|
||||
label: 'Install CUPS Printing',
|
||||
fields: [],
|
||||
// action_install_cups's only prompt is "Install CUPS printing?
|
||||
// (n)" - the web UI's own install button is the confirmation,
|
||||
// so this always answers yes. No pause() in this function.
|
||||
buildStdin() {
|
||||
return 'y\n';
|
||||
},
|
||||
},
|
||||
|
||||
reconfigure_cups: {
|
||||
helperAction: 'action_reconfigure_cups',
|
||||
label: 'Reconfigure CUPS for network access',
|
||||
fields: [],
|
||||
// No prompts at all, no pause().
|
||||
buildStdin() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
install_lms: {
|
||||
helperAction: 'action_install_lms',
|
||||
label: 'Install / reconfigure LMS Server',
|
||||
// fields.alreadyInstalled must reflect real current state
|
||||
// (server.js fills this in from lms_is_installed via the
|
||||
// helper's own status check before offering the reconfigure
|
||||
// fields) - action_install_lms branches on it internally and a
|
||||
// wrong guess here desyncs the stdin sequence from what the
|
||||
// real function actually prompts for.
|
||||
fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled) {
|
||||
if (!f.reconfigurePort) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const port = Number(f.newPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('newPort must be an integer 1-65535');
|
||||
}
|
||||
return `y\n${port}\n\n`; // accept, new port, pause()
|
||||
}
|
||||
return '\n'; // fresh install: fully automated except pause()
|
||||
},
|
||||
},
|
||||
|
||||
install_squeezelite: {
|
||||
helperAction: 'action_install_squeezelite',
|
||||
label: 'Install / reconfigure Squeezelite Player',
|
||||
// If already installed, the real function first asks
|
||||
// "Reconfigure?" (default n) and, if declined, returns
|
||||
// immediately after just the pause() - it does NOT fall through
|
||||
// to the player-name/server prompts. f.reconfigure must be
|
||||
// explicit (not inferred from other fields) so the web UI can
|
||||
// offer "leave it as-is" without also having to resend the
|
||||
// current values.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?"
|
||||
lines.push(f.playerName || ''); // blank -> "Kiosk" default
|
||||
lines.push(f.lmsServer || ''); // blank -> auto-discovery
|
||||
// "Reboot now?" is always answered "n" here regardless of
|
||||
// what the UI shows - triggering a real `sudo reboot` from
|
||||
// inside a one-click addon-install action is out of scope
|
||||
// for this pass (see webui phase-2 plan). The UI surfaces
|
||||
// "reboot required to start Squeezelite" as an info banner
|
||||
// instead of a real remote reboot trigger.
|
||||
lines.push('n');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
configure_asterisk_intercom: {
|
||||
helperAction: 'action_configure_asterisk_intercom',
|
||||
label: 'Configure Asterisk Intercom',
|
||||
// Same shape as Squeezelite's reconfigure gate: if already
|
||||
// installed, the real function asks "Reconfigure with a
|
||||
// different server/extension?" (default n) and returns after
|
||||
// just the pause() if declined - the rest of this sequence is
|
||||
// never reached in that case.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
// Only present at all when baresip_is_installed is already
|
||||
// true - a fresh install has no "Reconfigure?" prompt.
|
||||
if (f.alreadyInstalled) lines.push('y');
|
||||
|
||||
// Server IP and extension reject a blank answer and
|
||||
// re-prompt (a `while [[ -z ... ]]` loop in the real
|
||||
// function) - sending an empty line here would desync the
|
||||
// rest of the sequence by consuming a second prompt cycle,
|
||||
// so these are validated up front instead.
|
||||
if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required');
|
||||
lines.push(String(f.serverIp).trim());
|
||||
|
||||
lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : '');
|
||||
|
||||
if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required');
|
||||
lines.push(String(f.extension).trim());
|
||||
|
||||
// Password also rejects blank and re-prompts, same reason.
|
||||
if (!f.password) throw new Error('password is required');
|
||||
lines.push(f.password);
|
||||
|
||||
lines.push(f.autoAnswer ? 'y' : 'n');
|
||||
lines.push(f.useTls ? 'y' : 'n');
|
||||
// "Proceed with installation?" (default y) - already
|
||||
// confirmed by the web click that got us here.
|
||||
lines.push('y');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
upgrade: {
|
||||
helperAction: 'action_upgrade',
|
||||
label: 'Check for and apply updates',
|
||||
fields: [],
|
||||
buildStdin() {
|
||||
// action_upgrade's own flow: "Pull these changes...?" (y),
|
||||
// then - only if there was anything to pull -
|
||||
// "Restart kiosk display now...?" (y), then always
|
||||
// "Check for and install the latest Electron...?", answered
|
||||
// n here. That sub-flow's own prompts default to declining
|
||||
// and aren't a good fit for one-click automation yet (see
|
||||
// webui phase-2 plan, "Explicitly deferred"). Answering "n"
|
||||
// to a prompt that never actually gets shown (nothing to
|
||||
// pull, or the display-restart question) is harmless - a
|
||||
// synthesized line bash never reads is simply left unread,
|
||||
// not an error.
|
||||
return 'y\ny\nn\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getAction(name) {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined;
|
||||
}
|
||||
|
||||
module.exports = { ACTIONS, getAction };
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/config.js - config.json read/write for the web UI.
|
||||
//
|
||||
// Mirrors lib/config.sh's load_existing_config()/save_config() contract
|
||||
// exactly, in JS instead of jq, so the web UI and the terminal menus stay
|
||||
// in sync against the same file without either one going through the
|
||||
// other. kiosk-app/main.js already reads this same config.json directly
|
||||
// in JS (its own fs.readFileSync/JSON.parse, no bash involved) - this is
|
||||
// established precedent in this repo, not a new pattern.
|
||||
//
|
||||
// Tracks exactly the fields Sites & Page Timing (menus/sites.sh),
|
||||
// Display & Interaction (menus/display.sh), and Password Protection &
|
||||
// Lockout (menus/lockout.sh) track. lockoutActiveStart/lockoutActiveEnd
|
||||
// are deliberately excluded, matching lockout.sh's own header comment:
|
||||
// "the app doesn't act on them... lib/config.sh just carries whatever is
|
||||
// already in config.json through unchanged." Anything else present in an
|
||||
// existing file (autheliaURL, autheliaUsername,
|
||||
// autheliaEncryptedPassword, lockoutActiveStart/End, or any future field)
|
||||
// is opaque passthrough data - saveConfig() merges onto it, never
|
||||
// rebuilds from nothing, so none of it is ever silently deleted. That
|
||||
// exact failure mode was a real, previously-fixed bug in lib/config.sh's
|
||||
// own history (see its header/body comments) and must not be
|
||||
// reintroduced here.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH;
|
||||
if (!CONFIG_PATH) {
|
||||
throw new Error("CONFIG_PATH environment variable is required (path to the kiosk app's config.json)");
|
||||
}
|
||||
|
||||
const SCALAR_DEFAULTS = {
|
||||
swipeMode: 'dual',
|
||||
allowNavigation: 'same-origin',
|
||||
homeTabIndex: -1,
|
||||
inactivityTimeout: 120,
|
||||
enablePauseButton: true,
|
||||
enableKeyboardButton: true,
|
||||
enableNavButton: true,
|
||||
enablePasswordProtection: false,
|
||||
lockoutTimeout: 0,
|
||||
lockoutAtTime: '',
|
||||
requirePasswordOnBoot: false,
|
||||
};
|
||||
|
||||
function readExisting() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// Missing file or invalid JSON - same fallback save_config() uses
|
||||
// in lib/config.sh ("{}" when the file is absent/unparsable).
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeTab(t) {
|
||||
return {
|
||||
url: typeof t.url === 'string' ? t.url : '',
|
||||
duration: Number.isFinite(Number(t.duration)) ? Number(t.duration) : 0,
|
||||
username: typeof t.username === 'string' ? t.username : '',
|
||||
password: typeof t.password === 'string' ? t.password : '',
|
||||
name: typeof t.name === 'string' ? t.name : '',
|
||||
};
|
||||
}
|
||||
|
||||
function hashPassword(plaintext) {
|
||||
return crypto.createHash('sha256').update(plaintext, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
// Mirrors load_existing_config(). Never returns a site's Basic Auth
|
||||
// password or the lockout password hash - both are write-only from here,
|
||||
// same as the terminal menus, which never display a stored password back
|
||||
// either (sites.sh's edit_page_status only ever shows the username;
|
||||
// lockout.sh has no "show current password" path at all).
|
||||
function loadConfig() {
|
||||
const existing = readExisting();
|
||||
const tabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : [];
|
||||
|
||||
const out = {
|
||||
tabs: tabs.map((t) => ({
|
||||
url: t.url,
|
||||
duration: t.duration,
|
||||
username: t.username,
|
||||
hasPassword: t.password.length > 0,
|
||||
name: t.name,
|
||||
})),
|
||||
};
|
||||
for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) {
|
||||
out[key] = key in existing ? existing[key] : def;
|
||||
}
|
||||
out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0;
|
||||
out.dualSwipe = out.swipeMode === 'dual';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mirrors save_config(): merge known fields onto whatever's already on
|
||||
// disk (see file header). `patch` fields are applied only when present -
|
||||
// omitting a field means "leave it as it is", so each frontend section
|
||||
// (Sites / Display / Lockout) can PUT just the fields it owns.
|
||||
function saveConfig(patch) {
|
||||
const existing = readExisting();
|
||||
const merged = { ...existing };
|
||||
|
||||
for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) {
|
||||
if (!(key in merged)) merged[key] = def;
|
||||
}
|
||||
for (const key of Object.keys(SCALAR_DEFAULTS)) {
|
||||
if (key in patch) merged[key] = patch[key];
|
||||
}
|
||||
|
||||
// Password: only touched when the caller explicitly provides a new
|
||||
// plaintext value to hash. Disabling protection clears the whole
|
||||
// lockout state, mirroring lockout.sh's action_disable_protection()
|
||||
// exactly (not just the enabled flag - the password/timeout/daily
|
||||
// lock time too).
|
||||
if (!('lockoutPassword' in merged)) merged.lockoutPassword = '';
|
||||
if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) {
|
||||
merged.lockoutPassword = hashPassword(patch.newLockoutPassword);
|
||||
}
|
||||
if (patch.enablePasswordProtection === false) {
|
||||
merged.lockoutPassword = '';
|
||||
merged.lockoutTimeout = 0;
|
||||
merged.lockoutAtTime = '';
|
||||
merged.requirePasswordOnBoot = false;
|
||||
}
|
||||
|
||||
if (Array.isArray(patch.tabs)) {
|
||||
const existingTabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : [];
|
||||
merged.tabs = patch.tabs.map((t, i) => {
|
||||
const norm = normalizeTab(t);
|
||||
if (typeof t.password !== 'string') {
|
||||
// No new password supplied for this tab - keep whatever
|
||||
// was already stored at this position (tabs are
|
||||
// positional, not ID-based, matching the bash arrays).
|
||||
norm.password = existingTabs[i] ? existingTabs[i].password : '';
|
||||
}
|
||||
return norm;
|
||||
});
|
||||
} else if (!Array.isArray(merged.tabs)) {
|
||||
merged.tabs = [];
|
||||
}
|
||||
|
||||
merged.autoswitch = true;
|
||||
merged.enableTouch = true;
|
||||
merged.dualSwipe = merged.swipeMode === 'dual';
|
||||
|
||||
const dir = path.dirname(CONFIG_PATH);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
|
||||
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o644 });
|
||||
fs.renameSync(tmp, CONFIG_PATH);
|
||||
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/jobs.js - runs one privileged action at a time via the
|
||||
// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper),
|
||||
// and keeps an in-memory log so /api/actions/:name/run's caller and any
|
||||
// number of SSE stream reconnects all see the same output. No database -
|
||||
// this tool manages one kiosk, a Map is plenty.
|
||||
//
|
||||
// HELPER_PATH and SUDO_CMD are both overridable via environment (see
|
||||
// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake
|
||||
// script and clear SUDO_CMD, so the real test suite never needs actual
|
||||
// root or a real addon install - the same principle as every stubbed
|
||||
// bash test in this project, just on the Node side.
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { getAction } = require('./actions');
|
||||
|
||||
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||
|
||||
const jobs = new Map();
|
||||
let activeJobId = null;
|
||||
|
||||
function startJob(actionName, fields) {
|
||||
const action = getAction(actionName);
|
||||
if (!action) {
|
||||
const err = new Error(`Unknown action: ${actionName}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (activeJobId) {
|
||||
const err = new Error('Another action is already running - wait for it to finish first');
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// buildStdin() validates its own required fields and throws a plain
|
||||
// Error with a human-readable message on bad input - treated as a
|
||||
// 400 here, before anything is spawned.
|
||||
let stdin;
|
||||
try {
|
||||
stdin = action.buildStdin(fields || {});
|
||||
} catch (e) {
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const job = {
|
||||
id: jobId,
|
||||
name: actionName,
|
||||
label: action.label,
|
||||
status: 'running',
|
||||
log: [],
|
||||
exitCode: null,
|
||||
listeners: new Set(),
|
||||
};
|
||||
jobs.set(jobId, job);
|
||||
activeJobId = jobId;
|
||||
|
||||
const child = SUDO_CMD
|
||||
? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction])
|
||||
: spawn(HELPER_PATH, [action.helperAction]);
|
||||
|
||||
const appendLine = (chunk) => {
|
||||
const text = chunk.toString();
|
||||
job.log.push(text);
|
||||
for (const listener of job.listeners) listener(text);
|
||||
};
|
||||
child.stdout.on('data', appendLine);
|
||||
child.stderr.on('data', appendLine);
|
||||
|
||||
const finish = (status, exitCode) => {
|
||||
if (job.status !== 'running') return; // 'error' and 'close' can both fire
|
||||
job.status = status;
|
||||
job.exitCode = exitCode;
|
||||
for (const listener of job.listeners) listener(null);
|
||||
if (activeJobId === jobId) activeJobId = null;
|
||||
};
|
||||
child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code));
|
||||
child.on('error', (err) => {
|
||||
job.log.push(`\n[error] ${err.message}\n`);
|
||||
finish('failed', null);
|
||||
});
|
||||
|
||||
child.stdin.write(stdin);
|
||||
child.stdin.end();
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
function getJob(jobId) {
|
||||
return jobs.get(jobId);
|
||||
}
|
||||
|
||||
module.exports = { startJob, getJob };
|
||||
Generated
+828
@@ -0,0 +1,828 @@
|
||||
{
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"express": "^4.19.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
'use strict';
|
||||
|
||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||
// user-controlled value (site URL/name/username, addon form fields) is
|
||||
// set via .value or .textContent, never innerHTML, so nothing typed
|
||||
// into a form can execute as markup - the one new attack surface a
|
||||
// browser-based config UI has that the terminal menus never did.
|
||||
|
||||
const msgEl = document.getElementById('msg');
|
||||
let currentConfig = null;
|
||||
|
||||
function showMessage(text, isError) {
|
||||
msgEl.textContent = text;
|
||||
msgEl.hidden = false;
|
||||
msgEl.className = 'banner ' + (isError ? 'error' : 'success');
|
||||
clearTimeout(showMessage._t);
|
||||
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Sidebar navigation */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(`page-${btn.dataset.page}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Config API (Sites / Display / Lockout) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async function apiGet() {
|
||||
const res = await fetch('/api/config');
|
||||
if (!res.ok) throw new Error('Failed to load configuration');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(patch) {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Save failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Sites & Page Timing */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const sitesList = document.getElementById('sites-list');
|
||||
const siteRowTemplate = document.getElementById('site-row-template');
|
||||
|
||||
function renderSites(tabs) {
|
||||
sitesList.textContent = '';
|
||||
tabs.forEach((tab, idx) => sitesList.appendChild(buildSiteRow(tab, idx)));
|
||||
if (tabs.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'hint';
|
||||
p.textContent = 'No pages configured yet.';
|
||||
sitesList.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSiteRow(tab, idx) {
|
||||
const node = siteRowTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.index = String(idx);
|
||||
|
||||
node.querySelector('.site-url').value = tab.url || '';
|
||||
node.querySelector('.site-name').value = tab.name || '';
|
||||
node.querySelector('.site-duration').value = tab.duration ?? 180;
|
||||
|
||||
const authEnable = node.querySelector('.site-auth-enable');
|
||||
const authUser = node.querySelector('.site-auth-username');
|
||||
const authState = node.querySelector('.auth-state');
|
||||
const hasAuth = !!(tab.username || tab.hasPassword);
|
||||
authEnable.checked = hasAuth;
|
||||
authUser.value = tab.username || '';
|
||||
authState.textContent = hasAuth ? '(enabled)' : '(disabled)';
|
||||
|
||||
node.querySelector('.remove-site').addEventListener('click', () => {
|
||||
node.remove();
|
||||
if (!sitesList.querySelector('.site-row')) renderSites([]);
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
document.getElementById('add-site').addEventListener('click', () => {
|
||||
if (sitesList.querySelector('.hint')) sitesList.textContent = '';
|
||||
sitesList.appendChild(buildSiteRow({ url: '', name: '', duration: 180, username: '', hasPassword: false }, sitesList.children.length));
|
||||
updateHomeTabOptions(collectTabs());
|
||||
});
|
||||
document.getElementById('save-sites').addEventListener('click', saveSites);
|
||||
|
||||
function collectTabs() {
|
||||
return Array.from(sitesList.querySelectorAll('.site-row')).map((row) => {
|
||||
const tab = {
|
||||
url: row.querySelector('.site-url').value.trim(),
|
||||
name: row.querySelector('.site-name').value.trim(),
|
||||
duration: parseInt(row.querySelector('.site-duration').value, 10),
|
||||
};
|
||||
const authEnabled = row.querySelector('.site-auth-enable').checked;
|
||||
if (authEnabled) {
|
||||
tab.username = row.querySelector('.site-auth-username').value;
|
||||
const newPass = row.querySelector('.site-auth-password').value;
|
||||
if (newPass) tab.password = newPass;
|
||||
} else {
|
||||
tab.username = '';
|
||||
tab.password = '';
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSites() {
|
||||
const tabs = collectTabs();
|
||||
if (tabs.some((t) => !t.url)) {
|
||||
showMessage('Every site needs a URL', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentConfig = await apiPut({ tabs });
|
||||
showMessage('Sites saved');
|
||||
renderSites(currentConfig.tabs);
|
||||
populateAll(currentConfig);
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
sitesList.addEventListener('change', () => { updateHomeTabOptions(collectTabs()); });
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Display & Interaction */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const displayForm = document.getElementById('display-form');
|
||||
const homeTabSelect = document.getElementById('home-tab-select');
|
||||
|
||||
function updateHomeTabOptions(tabs) {
|
||||
const previous = homeTabSelect.value;
|
||||
homeTabSelect.textContent = '';
|
||||
const disabledOpt = document.createElement('option');
|
||||
disabledOpt.value = '-1';
|
||||
disabledOpt.textContent = 'Disabled';
|
||||
homeTabSelect.appendChild(disabledOpt);
|
||||
tabs.forEach((tab, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(idx);
|
||||
opt.textContent = tab.name || tab.url || `Page ${idx + 1}`;
|
||||
homeTabSelect.appendChild(opt);
|
||||
});
|
||||
const stillValid = Array.from(homeTabSelect.options).some((o) => o.value === previous);
|
||||
homeTabSelect.value = stillValid ? previous : '-1';
|
||||
}
|
||||
|
||||
displayForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(displayForm);
|
||||
const patch = {
|
||||
swipeMode: fd.get('swipeMode'),
|
||||
allowNavigation: fd.get('allowNavigation'),
|
||||
enablePauseButton: fd.get('enablePauseButton') === 'on',
|
||||
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
|
||||
enableNavButton: fd.get('enableNavButton') === 'on',
|
||||
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
|
||||
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
|
||||
};
|
||||
try {
|
||||
currentConfig = await apiPut(patch);
|
||||
showMessage('Display & Interaction saved');
|
||||
populateAll(currentConfig);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Password Protection & Lockout */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const lockoutForm = document.getElementById('lockout-form');
|
||||
const lockoutEnabled = document.getElementById('lockout-enabled');
|
||||
const lockoutFields = document.getElementById('lockout-fields');
|
||||
const dailyLockEnabled = document.getElementById('daily-lock-enabled');
|
||||
const lockoutAtTime = document.getElementById('lockout-at-time');
|
||||
const passwordLabel = document.getElementById('password-label');
|
||||
|
||||
function refreshLockoutFieldVisibility() {
|
||||
lockoutFields.hidden = !lockoutEnabled.checked;
|
||||
}
|
||||
lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility);
|
||||
|
||||
dailyLockEnabled.addEventListener('change', () => {
|
||||
lockoutAtTime.disabled = !dailyLockEnabled.checked;
|
||||
if (!dailyLockEnabled.checked) lockoutAtTime.value = '';
|
||||
});
|
||||
|
||||
lockoutForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(lockoutForm);
|
||||
const enable = fd.get('enablePasswordProtection') === 'on';
|
||||
const newPassword = fd.get('newLockoutPassword') || '';
|
||||
const confirmPassword = document.getElementById('lockout-password-confirm').value;
|
||||
|
||||
if (newPassword && newPassword !== confirmPassword) {
|
||||
showMessage("Passwords don't match", true);
|
||||
return;
|
||||
}
|
||||
if (enable && !newPassword && !(currentConfig && currentConfig.hasLockoutPassword)) {
|
||||
showMessage('Set a lockout password before enabling password protection', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const patch = { enablePasswordProtection: enable };
|
||||
if (enable) {
|
||||
if (newPassword) patch.newLockoutPassword = newPassword;
|
||||
patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10);
|
||||
patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : '';
|
||||
patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on';
|
||||
}
|
||||
|
||||
try {
|
||||
currentConfig = await apiPut(patch);
|
||||
showMessage('Password Protection & Lockout saved');
|
||||
populateAll(currentConfig);
|
||||
lockoutForm.querySelector('[name=newLockoutPassword]').value = '';
|
||||
document.getElementById('lockout-password-confirm').value = '';
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
function populateAll(config) {
|
||||
displayForm.elements.swipeMode.value = config.swipeMode;
|
||||
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
||||
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
|
||||
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
|
||||
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
|
||||
updateHomeTabOptions(config.tabs);
|
||||
homeTabSelect.value = String(config.homeTabIndex);
|
||||
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
||||
|
||||
lockoutEnabled.checked = !!config.enablePasswordProtection;
|
||||
passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password';
|
||||
lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout;
|
||||
dailyLockEnabled.checked = !!config.lockoutAtTime;
|
||||
lockoutAtTime.disabled = !config.lockoutAtTime;
|
||||
lockoutAtTime.value = config.lockoutAtTime || '';
|
||||
lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot;
|
||||
refreshLockoutFieldVisibility();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Shared: run a privileged action and stream its log via SSE */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
// jobPanelEls = { panel, status, log }. Returns a promise resolving to
|
||||
// {status, exitCode} once the job finishes (or rejects on a request-level
|
||||
// error before a job even started, e.g. validation).
|
||||
async function runAction(actionName, fields, jobPanelEls) {
|
||||
const res = await fetch(`/api/actions/${actionName}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields || {}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Could not start action');
|
||||
|
||||
jobPanelEls.panel.classList.add('open');
|
||||
jobPanelEls.log.textContent = '';
|
||||
setJobStatus(jobPanelEls.status, 'running');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const source = new EventSource(`/api/actions/jobs/${body.jobId}/stream`);
|
||||
source.addEventListener('log', (ev) => {
|
||||
jobPanelEls.log.textContent += JSON.parse(ev.data);
|
||||
jobPanelEls.log.scrollTop = jobPanelEls.log.scrollHeight;
|
||||
});
|
||||
source.addEventListener('done', (ev) => {
|
||||
const result = JSON.parse(ev.data);
|
||||
setJobStatus(jobPanelEls.status, result.status);
|
||||
source.close();
|
||||
resolve(result);
|
||||
});
|
||||
source.onerror = () => {
|
||||
source.close();
|
||||
reject(new Error('Lost connection to the log stream'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setJobStatus(el, status) {
|
||||
el.className = `job-status ${status}`;
|
||||
if (status === 'running') {
|
||||
el.innerHTML = '';
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner';
|
||||
el.appendChild(spinner);
|
||||
el.appendChild(document.createTextNode('Running'));
|
||||
} else {
|
||||
el.textContent = status === 'success' ? 'Success' : 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Addons */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const addonsList = document.getElementById('addons-list');
|
||||
const addonCardTemplate = document.getElementById('addon-card-template');
|
||||
|
||||
const ICONS = {
|
||||
printer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V3h12v6M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7"/></svg>',
|
||||
music: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||
speaker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><circle cx="12" cy="14" r="4"/><circle cx="12" cy="6" r="1"/></svg>',
|
||||
phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .3 2 .6 2.9a2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.2-1.2a2 2 0 0 1 2.1-.5c.9.3 1.9.5 2.9.6a2 2 0 0 1 1.8 2Z"/></svg>',
|
||||
};
|
||||
|
||||
function pillHtml(state) {
|
||||
if (state === true) return '<span class="pill installed">Installed</span>';
|
||||
if (state === false) return '<span class="pill not-installed">Not installed</span>';
|
||||
return '<span class="pill unknown">Unknown</span>';
|
||||
}
|
||||
|
||||
let addonStatus = {};
|
||||
|
||||
async function loadAddonStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/addons/status');
|
||||
if (res.ok) addonStatus = await res.json();
|
||||
} catch (e) {
|
||||
// leave addonStatus as-is (pills show "Unknown"); not fatal to the page
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by addon, so a single card can be refreshed in place after its
|
||||
// own job finishes (see refreshAddonCard) without touching the other
|
||||
// three, and - critically - without recreating the job-panel/log the
|
||||
// user is currently looking at. An earlier version called the full
|
||||
// renderAddons() rebuild after every successful job "to update the
|
||||
// pill"; that raced (and usually lost to) the same success/log state it
|
||||
// had just written a moment earlier, since rebuilding the whole list
|
||||
// replaces the job-panel node with a fresh empty one. Caught by an
|
||||
// actual headless-browser run, not just reading the code - the log
|
||||
// looked fine reading it, but watching it in Chromium showed the
|
||||
// "Success" state flash and vanish.
|
||||
const ADDON_RECIPES = {};
|
||||
|
||||
function buildAddonCard(recipe) {
|
||||
ADDON_RECIPES[recipe.key] = recipe;
|
||||
const node = addonCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.addon = recipe.key;
|
||||
fillAddonCard(node, recipe);
|
||||
return node;
|
||||
}
|
||||
|
||||
// (Re)fills everything in a card EXCEPT the job-panel/log, which is
|
||||
// left exactly as it is - so refreshing a card's install-state after a
|
||||
// job finishes doesn't erase the result the user just watched stream in.
|
||||
function fillAddonCard(node, { key, title, desc, icon, buildForm }) {
|
||||
node.querySelector('.addon-icon').innerHTML = ICONS[icon];
|
||||
node.querySelector('.addon-name').textContent = title;
|
||||
node.querySelector('.addon-desc').textContent = desc;
|
||||
node.querySelector('.pill').outerHTML = pillHtml(addonStatus[key]);
|
||||
|
||||
const actionsEl = node.querySelector('.addon-actions');
|
||||
const formEl = node.querySelector('.addon-form');
|
||||
actionsEl.textContent = '';
|
||||
formEl.textContent = '';
|
||||
formEl.className = 'addon-form';
|
||||
|
||||
const jobPanelEls = {
|
||||
panel: node.querySelector('.job-panel'),
|
||||
status: node.querySelector('.job-status'),
|
||||
log: node.querySelector('.job-log'),
|
||||
};
|
||||
buildForm({ node, actionsEl, formEl, jobPanelEls, installed: addonStatus[key] === true });
|
||||
}
|
||||
|
||||
// Called after one addon's own job finishes - refreshes just that
|
||||
// card's pill/buttons/form (e.g. "Install" -> "Reconfigure") in place.
|
||||
async function refreshAddonCard(key) {
|
||||
await loadAddonStatus();
|
||||
const node = addonsList.querySelector(`[data-addon="${key}"]`);
|
||||
if (node) fillAddonCard(node, ADDON_RECIPES[key]);
|
||||
}
|
||||
|
||||
function addSubmitAction(formEl, jobPanelEls, actionName, collectFields, onDone) {
|
||||
formEl.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = formEl.querySelector('button[type=submit]');
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const result = await runAction(actionName, collectFields(), jobPanelEls);
|
||||
if (result.status === 'success') {
|
||||
showMessage('Done');
|
||||
if (onDone) await onDone();
|
||||
} else {
|
||||
showMessage('Action failed - see the log below', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAddons() {
|
||||
addonsList.textContent = '';
|
||||
|
||||
// CUPS: no fields at all - the button itself is the whole form.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'cups', title: 'CUPS Printing', desc: 'Network printer sharing', icon: 'printer',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = installed ? 'Reconfigure for network access' : 'Install CUPS Printing';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = installed ? 'reconfigure_cups' : 'install_cups';
|
||||
const result = await runAction(action, {}, jobPanelEls);
|
||||
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('cups'); }
|
||||
else showMessage('Action failed - see the log below', true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
},
|
||||
}));
|
||||
|
||||
// LMS Server: fresh install has no fields; once installed, an
|
||||
// optional port-reconfigure field.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'lms', title: 'LMS Server', desc: 'Lyrion / Logitech Media Server', icon: 'music',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
if (!installed) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Install LMS Server';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await runAction('install_lms', { alreadyInstalled: false }, jobPanelEls);
|
||||
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('lms'); }
|
||||
else showMessage('Action failed - see the log below', true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
return;
|
||||
}
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'secondary';
|
||||
toggleBtn.textContent = 'Reconfigure port';
|
||||
toggleBtn.addEventListener('click', () => formEl.classList.toggle('open'));
|
||||
actionsEl.appendChild(toggleBtn);
|
||||
|
||||
const portLabel = document.createElement('label');
|
||||
portLabel.innerHTML = 'New HTTP port';
|
||||
const portInput = document.createElement('input');
|
||||
portInput.type = 'number'; portInput.min = '1'; portInput.max = '65535'; portInput.value = '9000';
|
||||
portLabel.appendChild(portInput);
|
||||
formEl.appendChild(portLabel);
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = 'Apply new port';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'install_lms', () => ({
|
||||
alreadyInstalled: true, reconfigurePort: true, newPort: parseInt(portInput.value, 10),
|
||||
}), () => refreshAddonCard('lms'));
|
||||
},
|
||||
}));
|
||||
|
||||
// Squeezelite: player name + LMS server, both for install and
|
||||
// reconfigure - the form is the same either way.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'squeezelite', title: 'Squeezelite Player', desc: 'Turns this kiosk into an LMS-connected speaker', icon: 'speaker',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
formEl.classList.add('open');
|
||||
|
||||
const nameLabel = document.createElement('label');
|
||||
nameLabel.textContent = 'Player name';
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text'; nameInput.value = 'Kiosk'; nameInput.placeholder = 'Kiosk';
|
||||
nameLabel.appendChild(nameInput);
|
||||
formEl.appendChild(nameLabel);
|
||||
|
||||
const serverLabel = document.createElement('label');
|
||||
serverLabel.innerHTML = 'LMS server <span class="hint">(IP:PORT, blank for auto-discovery)</span>';
|
||||
const serverInput = document.createElement('input');
|
||||
serverInput.type = 'text'; serverInput.placeholder = '192.168.1.100:3483';
|
||||
serverLabel.appendChild(serverInput);
|
||||
formEl.appendChild(serverLabel);
|
||||
|
||||
const rebootHint = document.createElement('p');
|
||||
rebootHint.className = 'hint';
|
||||
rebootHint.textContent = 'A reboot is required after install/reconfigure before Squeezelite starts.';
|
||||
formEl.appendChild(rebootHint);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = installed ? 'Reconfigure Squeezelite' : 'Install Squeezelite';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'install_squeezelite', () => ({
|
||||
alreadyInstalled: installed, reconfigure: true,
|
||||
playerName: nameInput.value.trim(), lmsServer: serverInput.value.trim(),
|
||||
}), () => refreshAddonCard('squeezelite'));
|
||||
},
|
||||
}));
|
||||
|
||||
// Asterisk Intercom: server/extension/password/options, always shown.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'asterisk_intercom', title: 'Asterisk Intercom', desc: 'SIP extension client (Baresip)', icon: 'phone',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
formEl.classList.add('open');
|
||||
|
||||
const mk = (label, type, opts) => {
|
||||
const l = document.createElement('label');
|
||||
l.textContent = label;
|
||||
const i = document.createElement('input');
|
||||
i.type = type;
|
||||
Object.assign(i, opts || {});
|
||||
l.appendChild(i);
|
||||
formEl.appendChild(l);
|
||||
return i;
|
||||
};
|
||||
const ip = mk('Server IP or hostname', 'text', { placeholder: '10.0.0.5' });
|
||||
const port = mk('Server port', 'number', { placeholder: '5060', min: '1', max: '65535' });
|
||||
const ext = mk('Extension number', 'text', { placeholder: '201' });
|
||||
const pass = mk('SIP password', 'password', { autocomplete: 'new-password' });
|
||||
|
||||
const autoAnswerLabel = document.createElement('label');
|
||||
autoAnswerLabel.className = 'checkbox';
|
||||
const autoAnswer = document.createElement('input');
|
||||
autoAnswer.type = 'checkbox';
|
||||
autoAnswerLabel.appendChild(autoAnswer);
|
||||
autoAnswerLabel.appendChild(document.createTextNode('Auto-answer incoming calls (intercom mode)'));
|
||||
formEl.appendChild(autoAnswerLabel);
|
||||
|
||||
const tlsLabel = document.createElement('label');
|
||||
tlsLabel.className = 'checkbox';
|
||||
const useTls = document.createElement('input');
|
||||
useTls.type = 'checkbox';
|
||||
tlsLabel.appendChild(useTls);
|
||||
tlsLabel.appendChild(document.createTextNode('Use TLS encryption'));
|
||||
formEl.appendChild(tlsLabel);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = installed ? 'Reconfigure Asterisk Intercom' : 'Connect to Asterisk server';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'configure_asterisk_intercom', () => ({
|
||||
alreadyInstalled: installed, reconfigure: true,
|
||||
serverIp: ip.value.trim(), serverPort: port.value ? parseInt(port.value, 10) : undefined,
|
||||
extension: ext.value.trim(), password: pass.value,
|
||||
autoAnswer: autoAnswer.checked, useTls: useTls.checked,
|
||||
}), () => refreshAddonCard('asterisk_intercom'));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Update */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
document.getElementById('run-upgrade').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await runAction('upgrade', {}, {
|
||||
panel: document.getElementById('job-panel-upgrade'),
|
||||
status: document.getElementById('job-status-upgrade'),
|
||||
log: document.getElementById('job-log-upgrade'),
|
||||
});
|
||||
showMessage(result.status === 'success' ? 'Update finished' : 'Update failed - see the log below', result.status !== 'success');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Init */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
currentConfig = await apiGet();
|
||||
renderSites(currentConfig.tabs);
|
||||
populateAll(currentConfig);
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
await loadAddonStatus();
|
||||
renderAddons();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,240 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kiosk Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<nav class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<strong>Kiosk Web UI</strong>
|
||||
<span id="brand-sub">this kiosk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<button class="nav-item active" data-page="sites">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||
Sites & Timing
|
||||
</button>
|
||||
<button class="nav-item" data-page="display">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>
|
||||
Display
|
||||
</button>
|
||||
<button class="nav-item" data-page="lockout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||
Lockout
|
||||
</button>
|
||||
<button class="nav-item" data-page="addons">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
|
||||
Addons
|
||||
</button>
|
||||
<button class="nav-item" data-page="update">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<div id="msg" class="banner" hidden></div>
|
||||
|
||||
<!-- Sites & Page Timing -->
|
||||
<section class="page active" id="page-sites">
|
||||
<div class="page-header">
|
||||
<h1>Sites & Page Timing</h1>
|
||||
<p>The pages this kiosk rotates through, and how long each stays on screen.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div id="sites-list"></div>
|
||||
<div class="button-row">
|
||||
<button type="button" id="add-site" class="secondary">Add a page</button>
|
||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display & Interaction -->
|
||||
<section class="page" id="page-display">
|
||||
<div class="page-header">
|
||||
<h1>Display & Interaction</h1>
|
||||
<p>Touch gestures, link navigation, on-screen buttons, and the home page.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form id="display-form">
|
||||
<label>
|
||||
Touch gesture mode
|
||||
<select name="swipeMode">
|
||||
<option value="dual">Dual-direction (recommended for touchscreens)</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Link navigation security
|
||||
<select name="allowNavigation">
|
||||
<option value="restricted">Restricted - only the loaded URL</option>
|
||||
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
|
||||
<option value="open">Open - any link</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Home page</legend>
|
||||
<label>
|
||||
Home page
|
||||
<select name="homeTabIndex" id="home-tab-select">
|
||||
<option value="-1">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inactivity timeout (minutes)
|
||||
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="button-row"><button type="submit">Save Display & Interaction</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password Protection & Lockout -->
|
||||
<section class="page" id="page-lockout">
|
||||
<div class="page-header">
|
||||
<h1>Password Protection & Lockout</h1>
|
||||
<p>Blank the screen after inactivity and require a password to unlock.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form id="lockout-form">
|
||||
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
||||
|
||||
<div id="lockout-fields">
|
||||
<label>
|
||||
<span id="password-label">Set lockout password</span>
|
||||
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Inactivity lockout timeout (minutes, 0 = boot/wake only)
|
||||
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily</label>
|
||||
<label>
|
||||
Daily lock time
|
||||
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
|
||||
</div>
|
||||
|
||||
<div class="button-row"><button type="submit">Save Password Protection & Lockout</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Addons -->
|
||||
<section class="page" id="page-addons">
|
||||
<div class="page-header">
|
||||
<h1>Addons</h1>
|
||||
<p>Install and configure the same addons the terminal menu offers. Uninstall isn't available here yet.</p>
|
||||
</div>
|
||||
<div id="addons-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Update -->
|
||||
<section class="page" id="page-update">
|
||||
<div class="page-header">
|
||||
<h1>Update</h1>
|
||||
<p>Pull the latest code from git and re-apply setup (packages, app files, hardware config).</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="button-row"><button type="button" id="run-upgrade">Check for and apply updates</button></div>
|
||||
<div class="job-panel" id="job-panel-upgrade">
|
||||
<div class="job-panel-header">
|
||||
<span>Update</span>
|
||||
<span class="job-status" id="job-status-upgrade"></span>
|
||||
</div>
|
||||
<pre class="job-log" id="job-log-upgrade"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="site-row-template">
|
||||
<div class="site-row card-inset">
|
||||
<div class="site-row-grid">
|
||||
<label>
|
||||
URL
|
||||
<input type="text" class="site-url" placeholder="example.com or https://example.com">
|
||||
</label>
|
||||
<label>
|
||||
Name (optional)
|
||||
<input type="text" class="site-name" placeholder="Shown instead of the URL">
|
||||
</label>
|
||||
<label>
|
||||
Duration (seconds; -1=hidden, 0=manual, >0=auto-rotate)
|
||||
<input type="number" class="site-duration" min="-1" max="86400" step="1" value="180">
|
||||
</label>
|
||||
</div>
|
||||
<details class="site-auth">
|
||||
<summary>HTTP Basic Auth <span class="auth-state"></span></summary>
|
||||
<label class="checkbox"><input type="checkbox" class="site-auth-enable"> Requires a username/password</label>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" class="site-auth-username">
|
||||
</label>
|
||||
<label>
|
||||
New password <span class="hint">(leave blank to keep the current one)</span>
|
||||
<input type="password" class="site-auth-password" autocomplete="new-password">
|
||||
</label>
|
||||
</details>
|
||||
<button type="button" class="remove-site danger">Remove page</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="addon-card-template">
|
||||
<div class="card addon-card">
|
||||
<div class="addon-card-top">
|
||||
<div class="addon-title">
|
||||
<div class="addon-icon"></div>
|
||||
<div>
|
||||
<div class="addon-name"></div>
|
||||
<div class="addon-desc"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill unknown">checking…</span>
|
||||
</div>
|
||||
<div class="button-row addon-actions"></div>
|
||||
<form class="addon-form"></form>
|
||||
<div class="job-panel">
|
||||
<div class="job-panel-header">
|
||||
<span>Log</span>
|
||||
<span class="job-status"></span>
|
||||
</div>
|
||||
<pre class="job-log"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,395 @@
|
||||
/* Design tokens - light by default, dark via prefers-color-scheme.
|
||||
No external font/CDN dependency (self-hosted admin tool shouldn't
|
||||
phone out to Google Fonts) - a well-tuned system-ui stack plus real
|
||||
spacing/depth/motion is what actually reads as "modern", not the
|
||||
typeface. */
|
||||
|
||||
:root {
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
|
||||
--bg: #f5f6f8;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eef0f3;
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #cbd0d8;
|
||||
--text: #14161a;
|
||||
--text-dim: #5c6370;
|
||||
--text-faint: #8b929e;
|
||||
--accent: #3b6ff0;
|
||||
--accent-hover: #2f5cd6;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #e8effe;
|
||||
--success: #1c8a5b;
|
||||
--success-soft: #e3f6ec;
|
||||
--warning: #a9660a;
|
||||
--warning-soft: #fdf1de;
|
||||
--danger: #d13a3a;
|
||||
--danger-soft: #fbe8e8;
|
||||
--shadow-sm: 0 1px 2px rgba(20, 22, 26, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(20, 22, 26, 0.08);
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--sidebar-w: 232px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #101216;
|
||||
--bg-elevated: #17191f;
|
||||
--bg-sunken: #0c0d10;
|
||||
--border: #262a33;
|
||||
--border-strong: #363c48;
|
||||
--text: #eceef2;
|
||||
--text-dim: #9aa1ad;
|
||||
--text-faint: #6b7280;
|
||||
--accent: #5b8cff;
|
||||
--accent-hover: #7ba0ff;
|
||||
--accent-text: #0a0e18;
|
||||
--accent-soft: #17233f;
|
||||
--success: #3ecf8e;
|
||||
--success-soft: #103527;
|
||||
--warning: #e2a53f;
|
||||
--warning-soft: #3a2c11;
|
||||
--danger: #f0605f;
|
||||
--danger-soft: #3a1616;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Shell: fixed sidebar + main content */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elevated);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 22px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-mark svg { width: 17px; height: 17px; }
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-text strong { font-size: 14px; }
|
||||
.brand-text span { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.85; }
|
||||
.nav-item:hover { background: var(--bg-sunken); color: var(--text); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.nav-item.active svg { opacity: 1; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 32px 40px 60px;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
|
||||
.page-header p { margin: 0; color: var(--text-dim); font-size: 13.5px; }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Cards, forms, buttons */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; }
|
||||
|
||||
.card-inset {
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
|
||||
.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; }
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="time"],
|
||||
select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
align-self: flex-start;
|
||||
transition: background-color 0.12s ease, transform 0.05s ease;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
button.secondary:hover { background: var(--bg-sunken); }
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
button.danger:hover { background: var(--danger-soft); }
|
||||
|
||||
.button-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Addon cards, status pills, job log panel */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.addon-card { display: flex; flex-direction: column; gap: 12px; }
|
||||
.addon-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.addon-title { display: flex; align-items: center; gap: 10px; }
|
||||
.addon-icon {
|
||||
width: 34px; height: 34px; border-radius: 9px;
|
||||
background: var(--bg-sunken); display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-dim); flex-shrink: 0;
|
||||
}
|
||||
.addon-icon svg { width: 18px; height: 18px; }
|
||||
.addon-name { font-weight: 600; font-size: 14px; }
|
||||
.addon-desc { color: var(--text-faint); font-size: 12px; margin-top: 1px; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 9px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
|
||||
}
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; }
|
||||
.pill.installed { background: var(--success-soft); color: var(--success); }
|
||||
.pill.installed::before { background: var(--success); }
|
||||
.pill.not-installed { background: var(--bg-sunken); color: var(--text-faint); }
|
||||
.pill.not-installed::before { background: var(--text-faint); }
|
||||
.pill.unknown { background: var(--warning-soft); color: var(--warning); }
|
||||
.pill.unknown::before { background: var(--warning); }
|
||||
|
||||
.addon-form { display: none; }
|
||||
.addon-form.open { display: flex; padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px; }
|
||||
|
||||
.job-panel {
|
||||
display: none;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.job-panel.open { display: block; }
|
||||
.job-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.job-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; }
|
||||
.job-status.running { color: var(--accent); }
|
||||
.job-status.success { color: var(--success); }
|
||||
.job-status.failed { color: var(--danger); }
|
||||
.spinner {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--accent-soft); border-top-color: var(--accent);
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.job-log {
|
||||
margin: 0; padding: 12px 14px;
|
||||
font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
max-height: 320px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Site rows (Sites & Page Timing) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.site-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.site-auth {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.site-auth summary { cursor: pointer; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||
.site-auth[open] summary { margin-bottom: 10px; }
|
||||
.auth-state { color: var(--text-faint); font-weight: normal; }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Toast banner */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.banner {
|
||||
border-radius: var(--radius);
|
||||
padding: 11px 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
max-width: 360px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
}
|
||||
.banner.success { background: var(--success-soft); border: 1px solid var(--success); color: var(--success); }
|
||||
.banner.error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.shell { flex-direction: column; }
|
||||
.sidebar { width: 100%; height: auto; position: static; flex-direction: row; align-items: center; padding: 12px; overflow-x: auto; }
|
||||
.brand { padding: 0 10px 0 0; }
|
||||
.nav { flex-direction: row; }
|
||||
.sidebar-footer { display: none; }
|
||||
.main { padding: 20px; }
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
// webui/server.js - Kiosk Web UI: browser-based config editor for Sites &
|
||||
// Page Timing, Display & Interaction, and Password Protection & Lockout -
|
||||
// the three Core Settings menus that are pure config.json read/write with
|
||||
// no privileged system mutation involved (see menus/addon_webui.sh's
|
||||
// header for why the rest of Core Settings/Addons/Advanced aren't here).
|
||||
//
|
||||
// No login of its own by design: Authelia runs elsewhere, and the admin
|
||||
// site goes behind the user's own Caddy reverse proxy with Authelia
|
||||
// forward-auth in front of it, the same way every other self-hosted app
|
||||
// they run is protected. This process only binds where it's told to
|
||||
// (BIND_ADDR/PORT below) and trusts whatever's in front of it.
|
||||
//
|
||||
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
|
||||
// installs) - the same user Electron runs as, and the owner of
|
||||
// config.json - so it never needs sudo.
|
||||
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const express = require('express');
|
||||
const { loadConfig, saveConfig } = require('./lib/config');
|
||||
const { ACTIONS } = require('./lib/actions');
|
||||
const { startJob, getJob } = require('./lib/jobs');
|
||||
|
||||
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const SWIPE_MODES = ['dual', 'standard'];
|
||||
const NAV_MODES = ['restricted', 'same-origin', 'open'];
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
// Same normalization rule as menus/sites.sh's sites_parse_url(): bare
|
||||
// host -> https://, bare IPv4 -> http://, else passed through as-is.
|
||||
function parseUrl(raw) {
|
||||
if (/^https?:\/\//.test(raw)) return raw;
|
||||
if (/^\d+\.\d+\.\d+\.\d+/.test(raw)) return `http://${raw}`;
|
||||
return `https://${raw}`;
|
||||
}
|
||||
|
||||
function badRequest(res, message) {
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
app.get('/api/config', (req, res) => {
|
||||
res.json(loadConfig());
|
||||
});
|
||||
|
||||
app.put('/api/config', (req, res) => {
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const patch = {};
|
||||
|
||||
if (body.swipeMode !== undefined) {
|
||||
if (!SWIPE_MODES.includes(body.swipeMode)) return badRequest(res, 'swipeMode must be "dual" or "standard"');
|
||||
patch.swipeMode = body.swipeMode;
|
||||
}
|
||||
if (body.allowNavigation !== undefined) {
|
||||
if (!NAV_MODES.includes(body.allowNavigation)) {
|
||||
return badRequest(res, 'allowNavigation must be "restricted", "same-origin", or "open"');
|
||||
}
|
||||
patch.allowNavigation = body.allowNavigation;
|
||||
}
|
||||
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
|
||||
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
|
||||
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
|
||||
|
||||
let tabCount;
|
||||
if (body.tabs !== undefined) {
|
||||
if (!Array.isArray(body.tabs)) return badRequest(res, 'tabs must be an array');
|
||||
for (const t of body.tabs) {
|
||||
if (!t || typeof t.url !== 'string' || t.url.trim() === '') return badRequest(res, 'Every site needs a URL');
|
||||
const dur = Number(t.duration);
|
||||
if (!Number.isInteger(dur) || dur < -1 || dur > 86400) {
|
||||
return badRequest(res, 'Duration must be a whole number between -1 and 86400');
|
||||
}
|
||||
}
|
||||
patch.tabs = body.tabs.map((t) => ({ ...t, url: parseUrl(t.url.trim()) }));
|
||||
tabCount = patch.tabs.length;
|
||||
}
|
||||
|
||||
if (body.homeTabIndex !== undefined) {
|
||||
const idx = Number(body.homeTabIndex);
|
||||
const count = tabCount !== undefined ? tabCount : loadConfig().tabs.length;
|
||||
if (!Number.isInteger(idx) || idx < -1 || idx >= count) return badRequest(res, 'homeTabIndex is out of range');
|
||||
patch.homeTabIndex = idx;
|
||||
}
|
||||
|
||||
if (body.inactivityTimeoutMinutes !== undefined) {
|
||||
const min = Number(body.inactivityTimeoutMinutes);
|
||||
if (!Number.isInteger(min) || min < 1 || min > 240) return badRequest(res, 'Inactivity timeout must be 1-240 minutes');
|
||||
patch.inactivityTimeout = min * 60;
|
||||
}
|
||||
|
||||
if (body.enablePasswordProtection !== undefined) patch.enablePasswordProtection = !!body.enablePasswordProtection;
|
||||
|
||||
if (body.lockoutTimeoutMinutes !== undefined) {
|
||||
const min = Number(body.lockoutTimeoutMinutes);
|
||||
if (!Number.isInteger(min) || min < 0 || min > 1440) return badRequest(res, 'Lockout timeout must be 0-1440 minutes');
|
||||
patch.lockoutTimeout = min;
|
||||
}
|
||||
|
||||
if (body.lockoutAtTime !== undefined) {
|
||||
if (body.lockoutAtTime !== '' && !TIME_RE.test(body.lockoutAtTime)) {
|
||||
return badRequest(res, 'lockoutAtTime must be HH:MM (24-hour) or empty');
|
||||
}
|
||||
patch.lockoutAtTime = body.lockoutAtTime;
|
||||
}
|
||||
|
||||
if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot;
|
||||
|
||||
if (body.newLockoutPassword !== undefined) {
|
||||
if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) {
|
||||
return badRequest(res, 'Password cannot be empty');
|
||||
}
|
||||
patch.newLockoutPassword = body.newLockoutPassword;
|
||||
}
|
||||
|
||||
// Mirrors action_enable_protection() always requiring a password up
|
||||
// front - lockout.sh has no path that enables protection without one.
|
||||
if (patch.enablePasswordProtection === true) {
|
||||
const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0;
|
||||
if (!hasNewPassword && !loadConfig().hasLockoutPassword) {
|
||||
return badRequest(res, 'Set a lockout password before enabling password protection');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(saveConfig(patch));
|
||||
} catch (e) {
|
||||
console.error('saveConfig failed:', e);
|
||||
res.status(500).json({ error: 'Failed to save configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Addons: install/reconfigure via the allow-listed root helper */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/actions', (req, res) => {
|
||||
const list = Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, fields: a.fields }));
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
app.get('/api/addons/status', (req, res) => {
|
||||
const cmd = SUDO_CMD || HELPER_PATH;
|
||||
const args = SUDO_CMD ? [HELPER_PATH, 'status_all'] : ['status_all'];
|
||||
execFile(cmd, args, { timeout: 10_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('status_all failed:', stderr || err.message);
|
||||
return res.status(500).json({ error: 'Could not read addon status' });
|
||||
}
|
||||
try {
|
||||
res.json(JSON.parse(stdout.trim()));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Malformed status response' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/actions/:name/run', (req, res) => {
|
||||
try {
|
||||
const job = startJob(req.params.name, req.body || {});
|
||||
res.json({ jobId: job.id, status: job.status, label: job.label });
|
||||
} catch (e) {
|
||||
res.status(e.status || 500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/actions/jobs/:jobId', (req, res) => {
|
||||
const job = getJob(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: 'Unknown job' });
|
||||
res.json({ id: job.id, name: job.name, label: job.label, status: job.status, exitCode: job.exitCode, log: job.log.join('') });
|
||||
});
|
||||
|
||||
// Server-Sent Events: replays whatever's already logged, then streams
|
||||
// new lines as they arrive, then a final `done` event - works whether
|
||||
// the client connects before the job starts producing output or
|
||||
// reconnects partway through (e.g. after a page reload).
|
||||
app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
|
||||
const job = getJob(req.params.jobId);
|
||||
if (!job) return res.status(404).end();
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
if (job.log.length) {
|
||||
res.write(`event: log\ndata: ${JSON.stringify(job.log.join(''))}\n\n`);
|
||||
}
|
||||
if (job.status !== 'running') {
|
||||
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const listener = (text) => {
|
||||
if (text === null) {
|
||||
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.write(`event: log\ndata: ${JSON.stringify(text)}\n\n`);
|
||||
}
|
||||
};
|
||||
job.listeners.add(listener);
|
||||
req.on('close', () => job.listeners.delete(listener));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 8090;
|
||||
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
||||
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, BIND_ADDR, () => {
|
||||
console.log(`Kiosk Web UI listening on ${BIND_ADDR}:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/api.test.js - integration test: starts the real server.js
|
||||
// app on a random port against a scratch config.json and hits GET/PUT
|
||||
// /api/config with real HTTP requests (Node's built-in fetch). Run with:
|
||||
// node test/api.test.js
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
|
||||
const app = require('../server');
|
||||
|
||||
let failures = 0;
|
||||
async function check(label, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS: ${label}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`FAIL: ${label} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const port = server.address().port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
await check('GET /api/config returns defaults on a fresh install', async () => {
|
||||
const res = await fetch(`${base}/api/config`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.deepStrictEqual(body.tabs, []);
|
||||
assert.strictEqual(body.swipeMode, 'dual');
|
||||
});
|
||||
|
||||
await check('PUT /api/config saves and round-trips display settings', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ swipeMode: 'standard', allowNavigation: 'restricted', enableNavButton: false }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.swipeMode, 'standard');
|
||||
assert.strictEqual(body.allowNavigation, 'restricted');
|
||||
assert.strictEqual(body.enableNavButton, false);
|
||||
});
|
||||
|
||||
await check('PUT rejects an invalid allowNavigation value', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowNavigation: 'wide-open' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT rejects a duration out of range', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tabs: [{ url: 'example.com', duration: 999999 }] }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT normalizes bare hostnames/IPs the same way sites_parse_url does', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tabs: [
|
||||
{ url: 'example.com', duration: 30, name: 'bare host' },
|
||||
{ url: '192.168.1.50', duration: 30, name: 'bare ip' },
|
||||
{ url: 'https://already.example.com', duration: 30, name: 'already a url' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.tabs[0].url, 'https://example.com');
|
||||
assert.strictEqual(body.tabs[1].url, 'http://192.168.1.50');
|
||||
assert.strictEqual(body.tabs[2].url, 'https://already.example.com');
|
||||
});
|
||||
|
||||
await check('PUT rejects homeTabIndex out of range', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ homeTabIndex: 99 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT accepts a valid homeTabIndex and converts inactivity minutes to stored seconds', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ homeTabIndex: 1, inactivityTimeoutMinutes: 5 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.homeTabIndex, 1);
|
||||
assert.strictEqual(body.inactivityTimeout, 300);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.inactivityTimeout, 300);
|
||||
});
|
||||
|
||||
await check('PUT rejects enabling password protection with no password set', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enablePasswordProtection: true }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT enables password protection when a new password is supplied, and never echoes it back', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enablePasswordProtection: true, newLockoutPassword: 'hunter2', lockoutTimeoutMinutes: 15 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.enablePasswordProtection, true);
|
||||
assert.strictEqual(body.hasLockoutPassword, true);
|
||||
assert.strictEqual(body.lockoutPassword, undefined);
|
||||
assert.strictEqual(JSON.stringify(body).includes('hunter2'), false);
|
||||
});
|
||||
|
||||
await check('PUT rejects a malformed lockoutAtTime', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lockoutAtTime: '25:99' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT re-enabling protection without a new password succeeds once one is already set', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enablePasswordProtection: true, lockoutTimeoutMinutes: 20 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.lockoutTimeout, 20);
|
||||
});
|
||||
|
||||
server.close();
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ALL DONE');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/config.test.js - unit tests for lib/config.js against a
|
||||
// scratch config.json. Run with: node test/config.test.js
|
||||
//
|
||||
// Mirrors this project's bash test convention (PASS/FAIL lines, ALL DONE
|
||||
// at the end) rather than pulling in a test framework dependency.
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-config-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
|
||||
const { loadConfig, saveConfig } = require('../lib/config');
|
||||
|
||||
let failures = 0;
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS: ${label}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`FAIL: ${label} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
check('loadConfig on a missing file returns documented defaults', () => {
|
||||
const cfg = loadConfig();
|
||||
assert.deepStrictEqual(cfg.tabs, []);
|
||||
assert.strictEqual(cfg.swipeMode, 'dual');
|
||||
assert.strictEqual(cfg.allowNavigation, 'same-origin');
|
||||
assert.strictEqual(cfg.homeTabIndex, -1);
|
||||
assert.strictEqual(cfg.inactivityTimeout, 120);
|
||||
assert.strictEqual(cfg.enablePasswordProtection, false);
|
||||
assert.strictEqual(cfg.hasLockoutPassword, false);
|
||||
assert.strictEqual(cfg.dualSwipe, true);
|
||||
});
|
||||
|
||||
check('saveConfig creates the file and round-trips scalar fields', () => {
|
||||
const result = saveConfig({ swipeMode: 'standard', allowNavigation: 'open', enablePauseButton: false });
|
||||
assert.strictEqual(result.swipeMode, 'standard');
|
||||
assert.strictEqual(result.allowNavigation, 'open');
|
||||
assert.strictEqual(result.enablePauseButton, false);
|
||||
assert.strictEqual(result.dualSwipe, false);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.swipeMode, 'standard');
|
||||
assert.strictEqual(onDisk.autoswitch, true);
|
||||
assert.strictEqual(onDisk.enableTouch, true);
|
||||
});
|
||||
|
||||
check('saveConfig merge preserves fields this app never tracks (the previously-fixed clobber bug)', () => {
|
||||
// Simulate a file with Authelia + quiet-hours fields already set, the
|
||||
// way the terminal addon/menus would have written them - config.js
|
||||
// must never know these exist and must never delete them.
|
||||
const existing = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
existing.autheliaURL = 'https://auth.example.com';
|
||||
existing.autheliaUsername = 'kiosk';
|
||||
existing.autheliaEncryptedPassword = 'deadbeef';
|
||||
existing.lockoutActiveStart = '22:00';
|
||||
existing.lockoutActiveEnd = '06:00';
|
||||
fs.writeFileSync(process.env.CONFIG_PATH, JSON.stringify(existing));
|
||||
|
||||
saveConfig({ enableNavButton: false });
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.autheliaURL, 'https://auth.example.com');
|
||||
assert.strictEqual(onDisk.autheliaUsername, 'kiosk');
|
||||
assert.strictEqual(onDisk.autheliaEncryptedPassword, 'deadbeef');
|
||||
assert.strictEqual(onDisk.lockoutActiveStart, '22:00');
|
||||
assert.strictEqual(onDisk.lockoutActiveEnd, '06:00');
|
||||
assert.strictEqual(onDisk.enableNavButton, false);
|
||||
});
|
||||
|
||||
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
|
||||
const result = saveConfig({
|
||||
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],
|
||||
});
|
||||
assert.strictEqual(result.tabs[0].hasPassword, true);
|
||||
assert.strictEqual(result.tabs[0].username, 'bob');
|
||||
assert.strictEqual(result.tabs[0].password, undefined);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.tabs[0].password, 'hunter2'); // stored plaintext by design, matches lib/config.sh's own PASSES/USERS handling for Basic Auth (not the lockout password)
|
||||
});
|
||||
|
||||
check('saveConfig tabs: omitting password on an existing tab keeps the stored one (positional identity)', () => {
|
||||
saveConfig({
|
||||
tabs: [{ url: 'https://a.example.com', duration: 45, name: 'A renamed', username: 'bob' }],
|
||||
});
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.tabs[0].password, 'hunter2');
|
||||
assert.strictEqual(onDisk.tabs[0].duration, 45);
|
||||
assert.strictEqual(onDisk.tabs[0].name, 'A renamed');
|
||||
});
|
||||
|
||||
check('saveConfig lockout password is SHA-256 hashed, matching lockout.sh/main.js', () => {
|
||||
const crypto = require('crypto');
|
||||
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'correcthorse' });
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
const expected = crypto.createHash('sha256').update('correcthorse', 'utf8').digest('hex');
|
||||
assert.strictEqual(onDisk.lockoutPassword, expected);
|
||||
|
||||
const result = loadConfig();
|
||||
assert.strictEqual(result.hasLockoutPassword, true);
|
||||
assert.strictEqual(result.lockoutPassword, undefined);
|
||||
});
|
||||
|
||||
check('saveConfig disabling password protection clears the whole lockout state (matches action_disable_protection)', () => {
|
||||
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'x', lockoutTimeout: 30 });
|
||||
const before = loadConfig();
|
||||
assert.strictEqual(before.hasLockoutPassword, true);
|
||||
|
||||
saveConfig({ enablePasswordProtection: false });
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.lockoutPassword, '');
|
||||
assert.strictEqual(onDisk.lockoutTimeout, 0);
|
||||
assert.strictEqual(onDisk.lockoutAtTime, '');
|
||||
assert.strictEqual(onDisk.requirePasswordOnBoot, false);
|
||||
});
|
||||
|
||||
check('saveConfig with invalid JSON already on disk falls back to {} rather than crashing', () => {
|
||||
fs.writeFileSync(process.env.CONFIG_PATH, '{not valid json');
|
||||
const result = saveConfig({ swipeMode: 'dual' });
|
||||
assert.strictEqual(result.swipeMode, 'dual');
|
||||
});
|
||||
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ALL DONE');
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Variant of fake-helper.sh that also implements status_all with real,
|
||||
# minimally stateful JSON (tracked via marker files alongside itself),
|
||||
# for browser/manual smoke testing of the Addons page's pills/buttons
|
||||
# actually flipping after a real install - not just that a job reports
|
||||
# success. jobs.test.js intentionally uses the plainer fake-helper.sh
|
||||
# instead, to exercise the malformed-status-response error path.
|
||||
STATE_DIR="$(dirname "$0")/.fake-state"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
if [[ "$1" == "status_all" ]]; then
|
||||
state() { [[ -f "$STATE_DIR/$1" ]] && echo true || echo false; }
|
||||
echo "{\"cups\":$(state cups),\"lms\":$(state lms),\"squeezelite\":$(state squeezelite),\"asterisk_intercom\":$(state asterisk_intercom)}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fake-helper: action=$1"
|
||||
stdin_content=$(cat)
|
||||
echo "fake-helper: stdin-bytes=${#stdin_content}"
|
||||
sleep 0.3
|
||||
|
||||
case "$1" in
|
||||
action_install_cups | action_reconfigure_cups) touch "$STATE_DIR/cups" ;;
|
||||
action_install_lms) touch "$STATE_DIR/lms" ;;
|
||||
action_install_squeezelite) touch "$STATE_DIR/squeezelite" ;;
|
||||
action_configure_asterisk_intercom) touch "$STATE_DIR/asterisk_intercom" ;;
|
||||
esac
|
||||
|
||||
echo "fake-helper: done"
|
||||
exit "${FAKE_HELPER_EXIT_CODE:-0}"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# webui/test/fixtures/fake-helper.sh - stands in for the real, root-owned
|
||||
# kiosk-webui-helper (menus/addon_webui.sh) in webui/test/jobs.test.js,
|
||||
# so the job/SSE system can be tested without real root or a real addon
|
||||
# install. Echoes what it received, sleeps briefly (long enough for the
|
||||
# "another job is already running" test to reliably observe it), then
|
||||
# exits with a controllable code.
|
||||
echo "fake-helper: action=$1"
|
||||
stdin_content=$(cat)
|
||||
echo "fake-helper: stdin-bytes=${#stdin_content}"
|
||||
sleep 0.2
|
||||
echo "fake-helper: done"
|
||||
exit "${FAKE_HELPER_EXIT_CODE:-0}"
|
||||
@@ -0,0 +1,190 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/jobs.test.js - integration test for the addon-install job
|
||||
// system (/api/actions/*, /api/addons/status) against a fake helper
|
||||
// script (test/fixtures/fake-helper.sh) instead of the real, root-owned
|
||||
// kiosk-webui-helper - no real root, apt, or system mutation involved,
|
||||
// matching this project's rule of never touching real system state in
|
||||
// tests. Run with: node test/jobs.test.js
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh');
|
||||
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
|
||||
|
||||
const app = require('../server');
|
||||
|
||||
let failures = 0;
|
||||
async function check(label, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS: ${label}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`FAIL: ${label} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForDone(base, jobId, timeoutMs = 3000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const res = await fetch(`${base}/api/actions/jobs/${jobId}`);
|
||||
const body = await res.json();
|
||||
if (body.status !== 'running') return body;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('timed out waiting for job to finish');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const port = server.address().port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
await check('GET /api/actions lists the allow-listed actions with their fields', async () => {
|
||||
const res = await fetch(`${base}/api/actions`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
const names = body.map((a) => a.name);
|
||||
assert.ok(names.includes('install_cups'));
|
||||
assert.ok(names.includes('configure_asterisk_intercom'));
|
||||
const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom');
|
||||
assert.ok(asterisk.fields.includes('serverIp'));
|
||||
});
|
||||
|
||||
await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => {
|
||||
// fake-helper.sh doesn't implement status_all specially - it just
|
||||
// echoes/exits 0 with non-JSON text, so this exercises the
|
||||
// malformed-response error path rather than a real status shape.
|
||||
const res = await fetch(`${base}/api/addons/status`);
|
||||
assert.strictEqual(res.status, 500);
|
||||
});
|
||||
|
||||
let jobId;
|
||||
await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => {
|
||||
const res = await fetch(`${base}/api/actions/install_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(body.jobId);
|
||||
assert.strictEqual(body.status, 'running');
|
||||
jobId = body.jobId;
|
||||
});
|
||||
|
||||
await check('a second action while one is running is rejected with 409', async () => {
|
||||
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 409);
|
||||
});
|
||||
|
||||
await check('the job completes successfully and the log shows what the helper received', async () => {
|
||||
const body = await waitForDone(base, jobId);
|
||||
assert.strictEqual(body.status, 'success');
|
||||
assert.strictEqual(body.exitCode, 0);
|
||||
assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log);
|
||||
assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log);
|
||||
assert.ok(body.log.includes('fake-helper: done'), body.log);
|
||||
});
|
||||
|
||||
await check('after completion, a new action is accepted again (not stuck busy)', async () => {
|
||||
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
await waitForDone(base, body.jobId);
|
||||
});
|
||||
|
||||
await check('unknown action name is rejected with 400, no job created', async () => {
|
||||
const res = await fetch(`${base}/api/actions/definitely_not_real/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('missing required field is rejected with 400 before spawning anything', async () => {
|
||||
const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(/serverIp/.test(body.error), body.error);
|
||||
});
|
||||
|
||||
await check('a failing helper is reflected as status failed with the real exit code', async () => {
|
||||
process.env.FAKE_HELPER_EXIT_CODE = '1';
|
||||
// jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module
|
||||
// load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned
|
||||
// shell script every time, so no re-require needed here.
|
||||
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { jobId: failId } = await res.json();
|
||||
const body = await waitForDone(base, failId);
|
||||
assert.strictEqual(body.status, 'failed');
|
||||
assert.strictEqual(body.exitCode, 1);
|
||||
delete process.env.FAKE_HELPER_EXIT_CODE;
|
||||
});
|
||||
|
||||
await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => {
|
||||
const res = await fetch(`${base}/api/actions/install_lms/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ alreadyInstalled: false }),
|
||||
});
|
||||
const { jobId: streamJobId } = await res.json();
|
||||
|
||||
const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`);
|
||||
assert.strictEqual(streamRes.status, 200);
|
||||
assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream');
|
||||
|
||||
const reader = streamRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let raw = '';
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes('event: done') && Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
raw += decoder.decode(value, { stream: true });
|
||||
}
|
||||
assert.ok(raw.includes('event: log'), raw);
|
||||
assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw);
|
||||
assert.ok(raw.includes('event: done'), raw);
|
||||
assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw);
|
||||
});
|
||||
|
||||
server.close();
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ALL DONE');
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user