initial commit

This commit is contained in:
2026-09-26 17:37:13 +02:00
commit 839bebd8d4
226 changed files with 58701 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 B

+43
View File
@@ -0,0 +1,43 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Rust / Tauri
target/
# Environment and secrets
.env
.env.*
!.env.example
# Test coverage
coverage/
.nyc_output/
# TypeScript
*.tsbuildinfo
# OS
Thumbs.db
*.orig
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+674
View File
@@ -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>.
+239
View File
@@ -0,0 +1,239 @@
# Linux for the ASUS Zenbook Duo
This project adds better Linux support for the Zenbook Duo by running a small background service that reacts to the keyboard being attached/detached (USB or Bluetooth) and keeps the dual-screen experience usable.
## Quick Start (Non-technical)
### What you need
- An ASUS Zenbook Duo
- GNOME on Wayland, KDE Plasma on Wayland, or Niri
- A Terminal and your sudo password (the installer needs to change system settings)
### Install (recommended)
```bash
./install.sh
```
Notes:
- `install.sh` auto-detects GNOME, KDE Plasma, or Niri and then runs the matching setup script plus the UI installer.
- If you use multiple local accounts, run the installer from the account that should own the session agent and autostart entries; the Rust runtime itself is shared machine-wide, so other users can still open the UI after install.
- If you prefer to run it with sudo, use `sudo -E ./install.sh` (so per-user setup targets your user session).
- If you re-run the installer, restart the session agent: `systemctl --user restart zenbook-duo-session-agent.service`
- Log out and back in (needed for permission changes).
### Build (no install)
```bash
./build.sh
```
Use `./build.sh --runtime-only` to build only the Rust runtime, or `./build.sh --ui-only` to build only the Control Panel UI.
Manual fallback:
```bash
./setup-gnome.sh
# or
./setup-kde.sh
# or
./setup-niri.sh
```
### Optional: install or update just the Control Panel app (UI)
If you only want to build/update the desktop app:
```bash
./install-ui.sh
```
Use `./install-ui.sh --build-only --dir .` to compile without installing, or `./install-ui.sh --install-only --dir .` to install already-built artifacts from the current checkout.
On desktop sessions, the installer now prefers a graphical admin-password prompt for the system steps and falls back to terminal `sudo` when needed.
### Uninstall
To remove the background service and system changes:
```bash
./uninstall.sh
```
To remove the optional UI app:
- Fedora / RHEL-based: `sudo dnf remove zenbook-duo-control`
- Debian / Ubuntu-based: `sudo apt remove zenbook-duo-control`
- Arch / CachyOS: `sudo rm -f /usr/local/bin/zenbook-duo-control /usr/share/applications/zenbook-duo-control.desktop /usr/share/pixmaps/zenbook-duo-control.png`
---
## Advanced (Technical)
### Screenshots
![Zenbook Duo Control USB](sc.png)
![Zenbook Duo Control BLUETOOTH](sc2.png)
### Features
| Feature | USB | Bluetooth |
|---------|:---:|:---------:|
| Toggle bottom screen on when keyboard removed | ✅ | ✅ |
| Toggle bottom screen off when keyboard placed | ✅ | ✅ |
| Toggle bluetooth on when keyboard removed | ✅ | ✅ |
| Toggle bluetooth off when keyboard placed (if it was off before) | ✅ | ✅ |
| Screen brightness sync | ✅ | ✅ |
| Reset airplane mode on keyboard attach/detach | ✅ | N/A |
| Keyboard backlight set on boot/attach | ✅ | ✅ |
| Keyboard backlight sync across attach/detach | ✅ | ✅ |
| Keyboard backlight cycle (F4) | ✅ | ✅ |
| Correct state on boot/resume (suspend & hibernate) | ✅ | ✅ |
| Auto rotation | ✅ | ✅ |
| Function keys (F1 mute, F2 volume down, F3 volume up, F10 bluetooth) | ✅ | ✅ |
| Function keys (F5 brightness down, F6 brightness up) | ✅ | ✅ |
| Function keys (F7 swap displays) | ✅ | ✅ |
| Function keys (F9 mic mute) | ✅ | ❌ |
| Function keys (F11 emojis) | ✅ | ✅ (Fn+F11) |
| Function keys (F8 airplane mode, F12 ASUS software) | ❌ | ❌ |
| Correct state on lock/unlock | ✅ | ✅ |
| Fn layer (top row) | ✅ | ✅ |
Notes:
- USB top row defaults to media keys; hold `Fn` for `F1`-`F12`.
- Do not install hwdb remaps for `KEYBOARD_KEY_7003*` on USB (it overrides the Fn layer).
### Requirements
- ASUS Zenbook Duo (USB vendor `0B05`, product `1B2C` or `1B2D` on UX8406M-class models)
- Linux with GNOME on Wayland, KDE Plasma on Wayland, or Niri (tested with Fedora)
- `systemd` for service management
- GNOME: `gdctl` (part of `mutter`) for display configuration
- KDE: `kscreen-doctor` (part of `kscreen`) for display configuration
- Niri: `niri msg` for display configuration
### What `./setup-gnome.sh` / `./setup-kde.sh` / `./setup-niri.sh` change
- Installs dependencies:
- Common: `usbutils`, `iio-sensor-proxy`, `systemd`
- GNOME: `mutter`/`gdctl` (via `setup-gnome.sh`)
- KDE: `kscreen`/`kscreen-doctor` (via `setup-kde.sh`)
- Niri: `niri` (via `setup-niri.sh`)
- Adds your user to the `input` group (logout/login required)
- Installs a udev rule for the Zenbook Duo keyboard
- Installs/enables Rust runtime units:
- `zenbook-duo-rust-daemon.service` (system daemon)
- `zenbook-duo-rust-lifecycle.service` (boot/shutdown + sleep hook)
- `zenbook-duo-session-agent.service` (user session)
- The session agent is enabled from the user manager's `default.target`, then syncs the current dock state when your graphical session comes up after reboot/login
- Installs Rust runtime binaries to `/usr/local/libexec/zenbook-duo`
- Adds a managed sudoers.d drop-in for the exact brightness writes used by the session agent
Contributor note: the desktop setup scripts are thin wrappers around `setup-common.sh`. When adding or changing supported systems, update the shared helper for common behavior and keep only package names/manual dependency hints in the per-desktop wrapper.
### Contributor compatibility checks
Use the version bump helper when preparing a release:
```bash
./bump-version.sh patch # or minor, major, or an explicit version like 0.3.4
```
Use the root check script before changing installer, runtime, or UI behavior:
```bash
./check.sh installers # shell syntax + installer smoke tests
./check.sh rust # Rust runtime unit tests
./check.sh frontend # React/TypeScript production build
./check.sh all # full compatibility pass
```
Supported matrix covered by the installer smoke tests:
| Desktop backend | Setup wrapper | Display command |
|-----------------|---------------|-----------------|
| GNOME on Wayland | `setup-gnome.sh` | `gdctl` |
| KDE Plasma on Wayland | `setup-kde.sh` | `kscreen-doctor` |
| Niri | `setup-niri.sh` | `niri msg` |
| Distro family | Package manager |
|---------------|-----------------|
| Fedora / RHEL-based | `dnf` |
| Debian / Ubuntu-based | `apt` |
| Arch / CachyOS | `pacman` |
Compatibility checklist for maintainers:
- Keep common setup behavior in `setup-common.sh`; keep desktop wrappers limited to backend-specific packages and manual dependency hints.
- Keep the managed sudoers.d drop-in aligned with the exact backlight devices present on the target system; do not add wildcard command arguments to sudoers.
- Keep settings defaults aligned across `setup-common.sh`, the Rust `DuoSettings` defaults, and the frontend default settings helper. The installer writes `setupCompleted=true`; a missing settings file should still show first-run setup.
- Preserve GNOME, KDE, and Niri command arguments when refactoring display code unless a backend-specific behavior change is intentional and tested.
- Keep desktop readiness probes centralized in the Rust session helpers so GNOME, KDE, and Niri fallback behavior stays consistent.
- Update `tests/install-stdin-test.sh` whenever supported desktops, package managers, service units, defaults, or installer entrypoints change.
- Run the narrow `./check.sh` target for the area you touched; run `./check.sh all` before handing off broad cross-area changes.
### Troubleshooting
- Nothing happens when docking/undocking:
- Check the services are running: `systemctl status zenbook-duo-rust-daemon.service` and `systemctl --user status zenbook-duo-session-agent.service`
- Watch daemon logs: `journalctl -u zenbook-duo-rust-daemon.service -f`
- Reboot/login or resume comes up in the wrong layout:
- After login or resume, the lifecycle handler and session agent re-sync the current attached/detached state without a manual restart
- Check `systemctl --user status zenbook-duo-session-agent.service`; an early `No supported session backend became ready before timeout; continuing to wait` warning is OK if the service remains active
- Confirm your user manager has the desktop-session environment: `systemctl --user show-environment | grep -E 'DISPLAY|WAYLAND_DISPLAY|NIRI_SOCKET|XDG_CURRENT_DESKTOP|XDG_SESSION_DESKTOP|DESKTOP_SESSION|XDG_SESSION_TYPE'`
- If those variables are missing after reinstalling, rerun `./install.sh` from an active desktop session, then log out and back in once
- Keyboard media/Fn keys stop working after suspend or reattaching the keyboard:
- The optional USB media remap helper is stopped before sleep and retried automatically after resume, so a manual service restart should not be needed.
- If recovery still fails, check `journalctl -u zenbook-duo-rust-daemon.service -f` for `USB media remap auto-start failed` or repeated `No such device` messages.
- You do not need a separate `/etc/udev/rules.d/*uinput*` rule for this project.
- `KBLIGHT - Device lost, re-scanning` in a loop:
- You likely need to log out and back in so your session gets the `input` group membership
- If the Control Panel says `Service/Rust runtime unavailable` after switching to another local account:
- Confirm the system daemon is running: `systemctl status zenbook-duo-rust-daemon.service`
- If the daemon is stopped or stale, rerun `./install.sh` from the account that should own the session agent and autostart entries, then log out and back in once
### Upgrading from older versions
If you previously installed a hwdb key remap, remove it so `Fn`+`F1`-`F12` works on USB:
```bash
sudo rm -f /etc/udev/hwdb.d/90-zenbook-duo-keyboard.hwdb
sudo systemd-hwdb update
sudo udevadm trigger
```
### Supported distros
| Distro | Package Manager |
|--------|----------------|
| Fedora / RHEL-based | `dnf` |
| Debian / Ubuntu-based | `apt` |
| Arch / CachyOS | `pacman` |
Other distros: install dependencies manually and run `./setup-gnome.sh`, `./setup-kde.sh`, or `./setup-niri.sh` (it exits if it cannot detect your package manager).
### Control Panel UI (Tauri + React)
- Build & install: `./install-ui.sh`
- Build only: `./build.sh --ui-only`
- Arch / CachyOS note: `install-ui.sh` builds the UI locally, then installs `zenbook-duo-control` to `/usr/local/bin` and desktop assets under `/usr/share`
- Dev mode:
```bash
cd ui-tauri-react
npm install
npm run dev
```
## Fedora: “Nobara-like” setup helper
If you’re on Fedora and want a more “Nobara-like” out-of-box experience (RPM Fusion, codecs, common gaming tools), there’s an optional helper script:
```bash
./nobara-like.sh
```
It can also add the Nobara COPR repo definitions **disabled by default**, so you can cherry-pick packages without mixing repos during normal upgrades.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="${BASH_SOURCE[0]:-${0}}"
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)"
TAURI_DIR="${SCRIPT_DIR}/ui-tauri-react/src-tauri"
RUNTIME_BINS=(
zenbook-duo-daemon
zenbook-duo-session-agent
zenbook-duo-lifecycle
zenbook-duo-usb-remap-helper
)
usage() {
cat <<'EOF'
build-rust-runtime.sh - compile Zenbook Duo background service binaries
Usage:
./build-rust-runtime.sh
Builds release binaries under ui-tauri-react/src-tauri/target/release/.
Install them with ./install-rust-runtime.sh --install-only
EOF
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "ERROR: Missing required command: $1" >&2
exit 1
}
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
if [ "$#" -gt 0 ]; then
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 1
fi
need_cmd cargo
if [ ! -f "${TAURI_DIR}/Cargo.toml" ]; then
echo "ERROR: Could not find Tauri crate at ${TAURI_DIR}" >&2
exit 1
fi
echo "Building Rust runtime binaries..."
cargo build --release \
--manifest-path "${TAURI_DIR}/Cargo.toml" \
--bin "${RUNTIME_BINS[0]}" \
--bin "${RUNTIME_BINS[1]}" \
--bin "${RUNTIME_BINS[2]}" \
--bin "${RUNTIME_BINS[3]}"
echo "Built runtime binaries:"
for binary in "${RUNTIME_BINS[@]}"; do
echo " ${TAURI_DIR}/target/release/${binary}"
done
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="${BASH_SOURCE[0]:-}"
if [ -n "${SCRIPT_PATH}" ] && [ "${SCRIPT_PATH}" != "bash" ] && [ "${SCRIPT_PATH}" != "-" ] && [ -f "${SCRIPT_PATH}" ]; then
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)"
else
SCRIPT_DIR="$(pwd)"
fi
usage() {
cat <<'EOF'
build.sh - compile Zenbook Duo runtime and UI without installing
Usage:
./build.sh [--runtime-only | --ui-only]
Builds:
- Background services -> ui-tauri-react/src-tauri/target/release/
- Control Panel UI -> ui-tauri-react/src-tauri/target/release/bundle/
Install built artifacts with:
./install-rust-runtime.sh --install-only
./install-ui.sh --install-only --dir .
EOF
}
BUILD_RUNTIME=true
BUILD_UI=true
while [ "$#" -gt 0 ]; do
case "$1" in
--runtime-only)
BUILD_UI=false
shift
;;
--ui-only)
BUILD_RUNTIME=false
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ "${BUILD_RUNTIME}" = true ]; then
echo "==> Building Rust runtime"
"${SCRIPT_DIR}/build-rust-runtime.sh"
fi
if [ "${BUILD_UI}" = true ]; then
echo "==> Building Control Panel UI"
"${SCRIPT_DIR}/install-ui.sh" --dir "${SCRIPT_DIR}" --build-only --skip-prereqs
fi
echo
echo "Build complete. Install with:"
echo " ./install-rust-runtime.sh --install-only"
echo " ./install-ui.sh --install-only --dir ."
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat <<'EOF'
bump-version.sh - update Zenbook Duo release metadata
Usage:
./bump-version.sh patch # 0.3.3 -> 0.3.4
./bump-version.sh minor # 0.3.3 -> 0.4.0
./bump-version.sh major # 0.3.3 -> 1.0.0
./bump-version.sh 0.3.4 # set an explicit version
Updates:
- ui-tauri-react/package.json
- ui-tauri-react/package-lock.json
- ui-tauri-react/src-tauri/Cargo.toml
- ui-tauri-react/src-tauri/Cargo.lock
- ui-tauri-react/src-tauri/tauri.conf.json
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || "${1:-}" == "help" ]]; then
usage
exit 0
fi
if [[ $# -ne 1 ]]; then
echo "ERROR: expected exactly one version argument" >&2
echo >&2
usage >&2
exit 1
fi
node - "${ROOT_DIR}" "$1" <<'NODE'
const fs = require('fs');
const path = require('path');
const [, , rootDir, requested] = process.argv;
const packageJsonPath = path.join(rootDir, 'ui-tauri-react/package.json');
const packageLockPath = path.join(rootDir, 'ui-tauri-react/package-lock.json');
const cargoTomlPath = path.join(rootDir, 'ui-tauri-react/src-tauri/Cargo.toml');
const cargoLockPath = path.join(rootDir, 'ui-tauri-react/src-tauri/Cargo.lock');
const tauriConfigPath = path.join(rootDir, 'ui-tauri-react/src-tauri/tauri.conf.json');
function read(file) {
return fs.readFileSync(file, 'utf8');
}
function write(file, contents) {
fs.writeFileSync(file, contents);
}
function parseVersion(version, label) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) {
throw new Error(`${label} must be a plain semver version like 0.3.4`);
}
return match.slice(1).map(Number);
}
function nextVersion(current, bump) {
const [major, minor, patch] = parseVersion(current, 'Current version');
switch (bump) {
case 'patch':
return `${major}.${minor}.${patch + 1}`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'major':
return `${major + 1}.0.0`;
default:
parseVersion(bump, 'Requested version');
return bump;
}
}
function replaceOne(file, regex, replacement) {
const original = read(file);
let count = 0;
const updated = original.replace(regex, (...args) => {
count += 1;
return typeof replacement === 'function' ? replacement(...args) : replacement;
});
if (count !== 1) {
throw new Error(`Expected exactly one match in ${path.relative(rootDir, file)}, found ${count}`);
}
write(file, updated);
}
function verifyContains(file, regex, message) {
if (!regex.test(read(file))) {
throw new Error(message);
}
}
const currentVersion = JSON.parse(read(packageJsonPath)).version;
const targetVersion = nextVersion(currentVersion, requested);
replaceOne(
packageJsonPath,
/("version"\s*:\s*")[^"]+(")/,
(_, prefix, suffix) => `${prefix}${targetVersion}${suffix}`,
);
replaceOne(
packageLockPath,
/("name"\s*:\s*"ui-tauri-react",\n\s*"version"\s*:\s*")[^"]+(")/,
(_, prefix, suffix) => `${prefix}${targetVersion}${suffix}`,
);
replaceOne(
packageLockPath,
/(""\s*:\s*\{\n\s*"name"\s*:\s*"ui-tauri-react",\n\s*"version"\s*:\s*")[^"]+(")/,
(_, prefix, suffix) => `${prefix}${targetVersion}${suffix}`,
);
replaceOne(
cargoTomlPath,
/(^version\s*=\s*")[^"]+(")/m,
(_, prefix, suffix) => `${prefix}${targetVersion}${suffix}`,
);
replaceOne(
cargoLockPath,
/(\[\[package\]\]\nname = "zenbook-duo-control"\nversion = ")[^"]+(")/,
(_, prefix, suffix) => `${prefix}${targetVersion}${suffix}`,
);
replaceOne(
tauriConfigPath,
/("version"\s*:\s*")[^"]+(")/,
(_, prefix, suffix) => `${prefix}${targetVersion}${suffix}`,
);
verifyContains(packageJsonPath, new RegExp(`"version"\\s*:\\s*"${targetVersion}"`), 'package.json version did not update');
verifyContains(packageLockPath, new RegExp(`"version"\\s*:\\s*"${targetVersion}"`), 'package-lock.json version did not update');
verifyContains(cargoTomlPath, new RegExp(`^version\\s*=\\s*"${targetVersion}"`, 'm'), 'Cargo.toml version did not update');
verifyContains(cargoLockPath, new RegExp(`name = "zenbook-duo-control"\\nversion = "${targetVersion}"`), 'Cargo.lock version did not update');
verifyContains(tauriConfigPath, new RegExp(`"version"\\s*:\\s*"${targetVersion}"`), 'tauri.conf.json version did not update');
console.log(`Bumped version: ${currentVersion} -> ${targetVersion}`);
NODE
Executable
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UI_DIR="${ROOT_DIR}/ui-tauri-react"
usage() {
cat <<'EOF'
check.sh - compatibility-focused project checks
Usage:
./check.sh installers # shell syntax + installer smoke tests
./check.sh rust # Rust runtime unit tests
./check.sh frontend # React/TypeScript production build
./check.sh all # run every check above
EOF
}
check_installers() {
echo "==> Checking installer shell syntax"
bash -n \
"${ROOT_DIR}/install.sh" \
"${ROOT_DIR}/install-ui.sh" \
"${ROOT_DIR}/install-rust-runtime.sh" \
"${ROOT_DIR}/setup-common.sh" \
"${ROOT_DIR}/setup-gnome.sh" \
"${ROOT_DIR}/setup-kde.sh" \
"${ROOT_DIR}/setup-niri.sh" \
"${ROOT_DIR}/uninstall.sh" \
"${ROOT_DIR}/tests/install-stdin-test.sh"
echo "==> Running installer smoke tests"
bash "${ROOT_DIR}/tests/install-stdin-test.sh"
}
check_rust() {
echo "==> Running Rust runtime unit tests"
cargo test --manifest-path "${UI_DIR}/src-tauri/Cargo.toml" --lib
}
check_frontend() {
echo "==> Building and type-checking frontend"
(cd "${UI_DIR}" && npm run vite:build)
}
case "${1:-all}" in
installers)
check_installers
;;
rust)
check_rust
;;
frontend)
check_frontend
;;
all)
check_installers
check_rust
check_frontend
;;
-h|--help|help)
usage
;;
*)
echo "ERROR: unknown check target: ${1:-}" >&2
echo >&2
usage >&2
exit 1
;;
esac
+138
View File
@@ -0,0 +1,138 @@
# Zenbook Duo UX8406M — Adaptation Plan
Machine: **ASUS Zenbook Duo UX8406MA** (UX8406M family)
OS: **Ubuntu, GNOME on Wayland**
Date: **2026-09-18**
This document captures the audit and implementation plan so work can be resumed if a session is interrupted.
---
## Goal
When the detachable keyboard is **off the pogo pins** (connected via Bluetooth or standalone USB), enable the second screen (`eDP-2`). When **docked on the pins** (USB HID bus `0003`), disable the second screen.
---
## Machine inventory (verified)
| Item | Value |
|------|-------|
| Model | `ASUS Zenbook Duo UX8406MA_UX8406MA` |
| Desktop | `ubuntu:GNOME`, Wayland |
| Primary display | `eDP-1` — backlight `intel_backlight` |
| Secondary display | `eDP-2` — backlight `card1-eDP-2-backlight` |
| ScreenPad WMI | `asus_screenpad` (exists; code uses eDP-2 backlight) |
| Touch top | `i2c-ELAN9008:00` → eDP-1 |
| Touch bottom | `i2c-ELAN9009:00` → eDP-2 |
| Keyboard (BT) | `HID_ID=0005:0B05:1B2D`, name `ASUS Zenbook Duo Keyboard` |
| Display control | `gdctl` via `mutter-common-bin` |
| User groups | `input` group already present |
Hardware paths match codebase expectations (`duo.rs`, `touchscreen.rs`, `sysfs.rs`, `probe.rs`).
---
## Root causes of install failure
1. **Missing build dependencies** — `libglib2.0-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, etc. All Rust binaries share the Tauri crate, so even daemons need GTK dev packages.
2. **Wrong install order** — `install.sh` runs `setup-gnome.sh` → `install-rust-runtime.sh` (cargo build) **before** `install-ui.sh` installs build prerequisites.
3. **Runtime removed** — `/usr/local/libexec/zenbook-duo/` missing; systemd units gone; no `~/.config/zenbook-duo/settings.json`.
4. **Historical runtime errors** (when previously installed):
- Session agent socket missing → display-mode policy failed
- Touchscreen unbind `No such device (os error 19)` on resume (race)
5. **Minor test bug** — `check.sh` greps for single-line `--version` hook; `zenbook-duo-session-agent.rs` uses multiline call.
---
## Dock / undock logic (code)
- `ConnectionType::Usb` (`HID_ID=0003:…`) → `keyboard_attached = true` → disable eDP-2
- `ConnectionType::Bluetooth` (`HID_ID=0005:…`) → `keyboard_attached = false` → enable eDP-2
- Detection: `hardware/sysfs.rs` → `detect_connection_type()` via `/sys/class/hidraw/*/device/uevent`
Key files:
- `runtime/probe.rs` — `keyboard_attached()`
- `runtime/policy.rs` — display/Wi-Fi/BT transitions
- `runtime/session_agent.rs` — `apply_dock_mode()` via `gdctl`
---
## Implementation checklist
### Phase 1 — Build environment
```bash
sudo apt update
sudo apt install -y \
build-essential pkg-config libssl-dev \
libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev librsvg2-dev \
libayatana-appindicator3-dev \
usbutils iio-sensor-proxy systemd mutter-common-bin
```
### Phase 2 — Codebase fixes (this session)
- [x] Install build prereqs from `setup-common.sh` **before** `install-rust-runtime.sh`
- [x] Fix `check.sh` smoke test for multiline `--version` hooks
- [x] Document product ID `1B2D` in README (alongside `1B2C`)
- [x] Harden touchscreen restore with retry on lifecycle resume
- [ ] Optional later: `asus_screenpad` fallback if eDP-2 backlight insufficient
- [ ] Optional later: split runtime crate from Tauri (daemon builds without GTK)
**Blocked on sudo:** package install and `./install.sh` require an interactive terminal password. There is also a broken sudoers line on this machine (`work ALL=NOPASSWD:/usr/bin/tee /sys/class/backlight/*/brightness` — wildcards not allowed).
### Phase 3 — Install and verify
```bash
cd /home/andrea/Documents/repo/zenbook
./install.sh
# Recommended: scale 1.25, USB media remap Yes
systemctl status zenbook-duo-rust-daemon.service
systemctl --user status zenbook-duo-session-agent.service
journalctl -u zenbook-duo-rust-daemon.service -f
```
After install, log out/in once if session agent fails to start.
### Phase 4 — Validation tests
| Test | Expected |
|------|----------|
| BT keyboard, undocked | 2 logical monitors in `gdctl show` |
| Keyboard on pogo pins (USB `0003:`) | 1 logical monitor |
| Attach/detach transition | Toggle within ~1s; journal shows status transition |
| Suspend/resume | Layout restored; no repeated touchscreen errors |
---
## Resume commands (if session lost)
```bash
cd /home/andrea/Documents/repo/zenbook
cat docs/adaptation-plan-ux8406m.md
./check.sh all
./install.sh
```
If build still fails on GTK:
```bash
sudo apt install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev build-essential pkg-config
./setup-gnome.sh
```
---
## Files touched in this adaptation
- `setup-common.sh` — build deps before Rust runtime
- `tests/install-stdin-test.sh` — relaxed `--version` grep
- `README.md` — product ID note
- `ui-tauri-react/src-tauri/src/runtime/daemon.rs` — touchscreen restore retry
- `docs/adaptation-plan-ux8406m.md` — this file
@@ -0,0 +1,141 @@
# Mutter bug report draft
## Where to file
File this in GNOME Mutter:
- https://gitlab.gnome.org/GNOME/mutter/-/work_items/new?type=Issue
This appears to be a Mutter issue because the observed behavior is window-management related on Wayland, not a behavior implemented by this project. This project only configures monitor layout through `gdctl` and does not move third-party windows between monitors in response to pointer focus changes.
## Suggested title
Window moved to lower monitor jumps back to primary upper monitor after clicking the primary monitor
## Report body
### Summary
On a dual-screen vertical layout under GNOME on Wayland, if I move a window to the lower monitor and then click on the upper primary monitor, the moved window is brought back to the upper monitor unexpectedly.
This looks like a Mutter/GNOME window-management issue. I initially suspected my monitor-layout helper, but after reviewing its code path, it only applies monitor topology through `gdctl` and does not contain any logic that moves arbitrary application windows in response to pointer clicks or focus changes.
### Environment
- Hardware: ASUS Zenbook Duo UX8406MA
- Monitors: two internal displays arranged vertically
- Layout: upper display is primary, lower display is positioned below it
- Session: GNOME on Wayland
- Distro: Ubuntu
- GNOME Shell version: `<fill in output of gnome-shell --version>`
- Mutter package version: `<fill in output of apt policy mutter mutter-common-bin | sed -n '1,12p'>`
- Kernel: `<fill in output of uname -r>`
- GPU: `<fill in output of lspci | grep -iE 'vga|3d|display'>`
### Steps to reproduce
1. Use a GNOME Wayland session with two displays in a vertical stack.
2. Set the upper display as the primary monitor.
3. Set the lower display below the primary monitor.
4. Open a normal application window.
5. Move that window to the lower monitor.
6. Click somewhere on the upper primary monitor.
### Actual result
The window that was moved to the lower monitor is brought back to the upper primary monitor.
### Expected result
Clicking the primary monitor should change focus only. It should not move a window from the lower monitor back to the primary monitor unless the user explicitly requested a move.
### Frequency
- `<always / often / intermittent>`
### Scope
- Affects Wayland-native apps: `<yes/no>`
- Affects Xwayland apps: `<yes/no>`
- Reproduces with GNOME extensions disabled: `<yes/no>`
- Reproduces after stopping local Zenbook helper services: `<yes/no>`
### Why I believe this belongs in Mutter
- The behavior is specifically about window placement after focus interaction across monitors.
- My local helper only applies monitor configuration via `gdctl set` when layout changes are requested or when hardware/lifecycle state changes.
- It does not contain code that repositions other apps' windows on pointer clicks.
- If the issue still reproduces after stopping local helper services, that further isolates it to GNOME/Mutter.
### Diagnostics
Please let me know if you want Mutter debug logs from a nested session, but here is the basic information I can provide immediately.
#### Display layout
```text
<paste output of: gdctl show>
```
#### GNOME and package versions
```text
gnome-shell --version
apt policy mutter mutter-common-bin gnome-shell
uname -a
```
#### GPU details
```text
lspci | grep -iE 'vga|3d|display'
```
#### Session details
```text
echo "$XDG_SESSION_TYPE"
echo "$XDG_CURRENT_DESKTOP"
loginctl show-session "$XDG_SESSION_ID" -p Type -p Desktop -p Name
```
#### Check whether helper services are involved
```text
systemctl --user stop zenbook-duo-session-agent.service
sudo systemctl stop zenbook-duo-rust-daemon.service zenbook-duo-rust-lifecycle.service
```
Then retry the reproduction and note whether the issue still happens.
#### Journal logs around the reproduction
```text
journalctl --user -b --no-pager | grep -iE 'gnome-shell|mutter|zenbook'
journalctl -b --no-pager | grep -iE 'gnome-shell|mutter|zenbook'
```
### Additional notes
- The lower monitor is layout-wise below the primary monitor, not to the side.
- The key symptom is not just focus transfer; the window is actually re-homed to the primary monitor.
- If needed, I can test whether this depends on maximized state, tiled state, or whether the app is Wayland-native vs Xwayland.
## Suggested duplicate search terms
Before filing, search Mutter issues for:
- `window jumps back to primary monitor`
- `window moved to second monitor returns to primary`
- `wayland vertical monitor layout focus moves window`
- `Zenbook Duo monitor click moves window`
## Local evidence from the Zenbook Duo project
Relevant code paths reviewed:
- [ui-tauri-react/src-tauri/src/runtime/session_agent.rs](ui-tauri-react/src-tauri/src/runtime/session_agent.rs) only applies GNOME display layout through `gdctl`, including `--below` placement for the second internal display.
- [ui-tauri-react/src-tauri/src/runtime/daemon.rs](ui-tauri-react/src-tauri/src/runtime/daemon.rs) only triggers display replays on lifecycle, lid, session registration, or hardware policy changes.
- [ui-tauri-react/src-tauri/src/lib.rs](ui-tauri-react/src-tauri/src/lib.rs) only focuses the Zenbook Duo app window itself from tray or single-instance flows.
These paths do not implement generic cross-monitor window moves for arbitrary applications.
@@ -0,0 +1,116 @@
# Rust Runtime Migration Plan
## Summary
Migrate the non-installer runtime from the current `duo.sh` + Python helper model to a Rust-first architecture with:
- a privileged system daemon as the single source of truth for hardware state and policy
- a small user-session agent for compositor/session-bound actions
- the existing Tauri app refactored into a client of the daemon over a Unix socket API
This is a one-shot cutover, not a staged parity rollout. Installer/setup scripts remain Bash, but they will install and wire up the new Rust binaries/services instead of `duo.sh` and Python helpers.
## Architecture and Key Changes
### 1. Replace `duo.sh` and Python helpers with Rust services
- Rebuild all runtime behavior currently owned by `duo.sh`: dock attach/detach handling, backlight persistence/control, brightness sync, Wi-Fi/Bluetooth state tracking, lock/resume handling, rotation handling, and USB remap lifecycle.
- Fold the three Python helpers into Rust:
- USB keyboard backlight control into the existing HID/rusb path
- Bluetooth hidraw backlight control into the existing ioctl HID path
- brightness key injection into a Rust uinput helper path
- Replace shell-managed state in `/tmp/duo*` with daemon-owned structured runtime state under a new Rust-controlled path; the shell-era temp-file contract is not preserved.
### 2. Introduce two Rust runtime processes
- **System daemon**: owns hardware watchers, state machine, policy, persistence, logging, and privileged operations.
- **User-session agent**: a minimal unprivileged process started per login session that performs GNOME/KDE/Niri display changes and other session-scoped actions on behalf of the daemon.
- The daemon and agent communicate over a local Unix socket contract; the daemon is authoritative and the agent is an executor for session-scoped operations only.
### 3. Refactor the Tauri app into a daemon client
- Move the UI off direct file reads, direct shell control, and direct runtime ownership.
- Replace `status`, control, logs, remap, and event flows with daemon-backed requests and event subscriptions over the Unix socket API.
- Keep the Tauri binary as the UI plus existing privileged helper modes only where still useful during migration; post-cutover, the daemon becomes the runtime authority.
- Profiles/settings remain user-facing concepts in the UI, but application of those settings goes through daemon APIs.
### 4. Preserve backend coverage in the Rust world
- Initial Rust cutover must fully support GNOME, KDE, and Niri.
- Backend-specific display logic currently split across shell helpers becomes Rust-managed backend adapters, with the session agent responsible for invoking the compositor-specific commands/tools inside the user session.
- The system daemon must not directly depend on shell-era display helper scripts after the cutover.
### 5. Installer/setup changes only at the integration boundary
- Keep installer/setup scripts in Bash, but switch them to installing/enabling:
- the Rust system daemon service
- the Rust user-session agent service
- the updated UI/client binary
- Remove installation of Python helpers and `duo.sh` from the target state.
- Preserve simple one-line install/update UX even though the runtime beneath it changes completely.
## Public Interfaces and Contracts
### Unix socket daemon API
Define a structured local API for:
- status snapshot
- control actions: backlight, orientation, display layout, profile activation, service reload/restart-style actions
- settings read/write needed by the UI
- remap control and status
- log/event streaming or polling
The API should be versioned from day one so the UI and daemon can detect mismatches cleanly.
### Session-agent contract
Define a narrow daemon-to-agent contract for:
- apply display layout
- set orientation / rotate modes
- launch session UI actions such as emoji picker
- report execution success/failure and session capability/backends
### Service model
Adopt:
- one systemd **system service** for the daemon
- one systemd **user service** for the session agent
- the Tauri app as an optional client, not a required runtime component
## Test Plan
### Functional parity scenarios
- Boot with keyboard attached and detached
- USB attach/detach transitions
- Bluetooth fallback behavior after detach
- Backlight set/cycle/persist across attach-detach and restart
- Brightness up/down behavior with compositor-native handling
- Wi-Fi/Bluetooth restore behavior across dock transitions
- Lock/unlock and suspend/resume transitions
- Rotation/orientation flows
- USB remap start/stop/pause behavior
- GNOME, KDE, and Niri display reconfiguration paths
### Integration scenarios
- UI connects to daemon, reads status, sends controls, and receives events
- Session agent registration and daemon-to-agent action execution
- Agent absent/unavailable behavior is surfaced clearly without daemon crash
- Service startup ordering and recovery when daemon or agent restarts independently
### Failure-mode coverage
- device disappearance during hotplug
- stale session agent socket
- daemon running without a logged-in agent
- permission/device-open failures
- backend command failures on GNOME/KDE/Niri
- UI/daemon API version mismatch
## Assumptions and Defaults
- Installer/setup scripts stay Bash and are explicitly out of scope for Rust migration except for changing what they install and enable.
- Migration includes replacing `duo.sh`, the three Python helpers, and shell-era runtime file/state ownership.
- This is a one-shot cutover, so the implementation must reach full GNOME/KDE/Niri runtime parity before switching installers/services to the Rust daemon by default.
- The new architecture is intentionally a clean break: no requirement to preserve `/usr/local/bin/duo`, `/tmp/duo*`, or the old CLI/state-file contract.
- The recommended execution order is: implement daemon core first, then session agent, then UI refactor to daemon API, then installer/service cutover, then parity validation across all three backends.
@@ -0,0 +1,619 @@
# Touchscreen Toggle Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add per-display touchscreen enable/disable to the Zenbook Duo Control Panel, persisted across reboots.
**Architecture:** sysfs unbind/bind via daemon IPC (daemon runs as root). Settings stored in existing DuoSettings model. UI toggles on Controls and Display pages.
**Tech Stack:** Rust (Tauri backend, daemon), React + TypeScript (frontend), serde JSON IPC
**Spec:** `docs/superpowers/specs/2026-03-22-touchscreen-toggle-design.md`
---
### Task 1: Hardware module — touchscreen detection and control
**Files:**
- Create: `ui-tauri-react/src-tauri/src/hardware/touchscreen.rs`
- Modify: `ui-tauri-react/src-tauri/src/hardware/mod.rs`
- [ ] **Step 1: Create `hardware/touchscreen.rs` with `TouchscreenDevice` struct**
```rust
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TouchscreenDevice {
pub name: String,
pub i2c_id: String,
pub connector: String,
pub enabled: bool,
}
```
- [ ] **Step 2: Implement `list_touchscreens()`**
Scans `/sys/bus/i2c/drivers/i2c_hid_acpi/` for ELAN touchscreen devices. Checks if bound (enabled) by testing if the device symlink exists under the driver directory.
```rust
/// Maps ELAN model number to display connector.
fn elan_to_connector(name: &str) -> Option<&'static str> {
if name.contains("ELAN9008") {
Some("eDP-1")
} else if name.contains("ELAN9009") {
Some("eDP-2")
} else {
None
}
}
/// Reads the device name from sysfs for an i2c device.
fn read_i2c_device_name(i2c_id: &str) -> Option<String> {
let path = format!("/sys/bus/i2c/devices/{}/name", i2c_id);
fs::read_to_string(&path).ok().map(|s| s.trim().to_string())
}
/// Checks if the i2c device is currently bound to its driver.
fn is_bound(i2c_id: &str) -> bool {
Path::new(&format!(
"/sys/bus/i2c/drivers/i2c_hid_acpi/{}",
i2c_id
))
.exists()
}
pub fn list_touchscreens() -> Vec<TouchscreenDevice> {
let mut devices = Vec::new();
let i2c_devices = match fs::read_dir("/sys/bus/i2c/devices") {
Ok(entries) => entries,
Err(_) => return devices,
};
for entry in i2c_devices.flatten() {
let i2c_id = entry.file_name().to_string_lossy().to_string();
if !i2c_id.starts_with("i2c-ELAN") {
continue;
}
let name = match read_i2c_device_name(&i2c_id) {
Some(n) => n,
None => continue,
};
let connector = match elan_to_connector(&name) {
Some(c) => c.to_string(),
None => continue,
};
devices.push(TouchscreenDevice {
name,
i2c_id: i2c_id.clone(),
connector,
enabled: is_bound(&i2c_id),
});
}
devices
}
```
- [ ] **Step 3: Implement `set_touchscreen_enabled()`**
```rust
pub fn set_touchscreen_enabled(i2c_id: &str, enabled: bool) -> Result<(), String> {
let path = if enabled {
"/sys/bus/i2c/drivers/i2c_hid_acpi/bind"
} else {
"/sys/bus/i2c/drivers/i2c_hid_acpi/unbind"
};
fs::write(path, i2c_id)
.map_err(|e| format!("Failed to {} touchscreen {}: {}",
if enabled { "bind" } else { "unbind" }, i2c_id, e))
}
```
- [ ] **Step 4: Register module in `hardware/mod.rs`**
Add `pub mod touchscreen;` after existing declarations.
- [ ] **Step 5: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 6: Commit**
```bash
git add ui-tauri-react/src-tauri/src/hardware/touchscreen.rs ui-tauri-react/src-tauri/src/hardware/mod.rs
git commit -m "feat: add touchscreen hardware detection and sysfs control"
```
---
### Task 2: IPC protocol — add touchscreen request/response variants
**Files:**
- Modify: `ui-tauri-react/src-tauri/src/ipc/protocol.rs`
- [ ] **Step 1: Add `ListTouchscreens` and `SetTouchscreenEnabled` to `DaemonRequest`**
Add to the `DaemonRequest` enum:
```rust
ListTouchscreens,
SetTouchscreenEnabled { connector: String, enabled: bool },
```
- [ ] **Step 2: Add `Touchscreens` to `DaemonResponse`**
Add to the `DaemonResponse` enum:
```rust
Touchscreens { devices: Vec<crate::hardware::touchscreen::TouchscreenDevice> },
```
- [ ] **Step 3: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src-tauri/src/ipc/protocol.rs
git commit -m "feat: add touchscreen IPC protocol variants"
```
---
### Task 3: Settings persistence — add `touchscreen_disabled` field
**Files:**
- Modify: `ui-tauri-react/src-tauri/src/models/settings.rs`
- [ ] **Step 1: Add field to `DuoSettings`**
Add to the `DuoSettings` struct:
```rust
#[serde(default)]
pub touchscreen_disabled: Vec<String>,
```
- [ ] **Step 2: Update `Default` implementation**
In the `Default` impl for `DuoSettings`, add:
```rust
touchscreen_disabled: Vec::new(),
```
- [ ] **Step 3: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src-tauri/src/models/settings.rs
git commit -m "feat: add touchscreen_disabled to settings model"
```
---
### Task 4: Daemon handler — process touchscreen requests
**Files:**
- Modify: `ui-tauri-react/src-tauri/src/runtime/daemon.rs`
- [ ] **Step 1: Add match arms for touchscreen requests in `handle_client`**
In the `match envelope.payload { ... }` block, add:
```rust
DaemonRequest::ListTouchscreens => {
let devices = hardware::touchscreen::list_touchscreens();
DaemonResponse::Touchscreens { devices }
}
DaemonRequest::SetTouchscreenEnabled { connector, enabled } => {
let devices = hardware::touchscreen::list_touchscreens();
match devices.iter().find(|d| d.connector == connector) {
Some(dev) => {
match hardware::touchscreen::set_touchscreen_enabled(&dev.i2c_id, enabled) {
Ok(()) => DaemonResponse::Ack,
Err(message) => DaemonResponse::Error { message },
}
}
None => DaemonResponse::Error {
message: format!("No touchscreen found for connector {}", connector),
},
}
}
```
- [ ] **Step 2: Add touchscreen restore to `handle_lifecycle`**
In the `Post | Thaw | Boot` arm of `handle_lifecycle`, insert **between** the write-guard block (where `guard.touch()` / `persist_state()` are called) and the `forward_session_command` call. Use a new read-lock scope:
```rust
// Restore touchscreen disabled state
{
let guard = state.read().await;
let disabled = guard.settings.touchscreen_disabled.clone();
drop(guard);
for connector in &disabled {
let devices = hardware::touchscreen::list_touchscreens();
if let Some(dev) = devices.iter().find(|d| &d.connector == connector) {
if let Err(e) = hardware::touchscreen::set_touchscreen_enabled(&dev.i2c_id, false) {
eprintln!("rust-daemon: failed to restore touchscreen disabled for {}: {}", connector, e);
}
}
}
}
```
- [ ] **Step 3: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src-tauri/src/runtime/daemon.rs
git commit -m "feat: handle touchscreen IPC requests and boot restore in daemon"
```
---
### Task 5: Tauri commands — expose touchscreen control to frontend
**Files:**
- Create: `ui-tauri-react/src-tauri/src/commands/touchscreen.rs`
- Modify: `ui-tauri-react/src-tauri/src/commands/mod.rs`
- Modify: `ui-tauri-react/src-tauri/src/lib.rs`
- [ ] **Step 1: Create `commands/touchscreen.rs`**
Follows the daemon-first-with-fallback pattern from `commands/display.rs`:
```rust
use crate::hardware::touchscreen::{self, TouchscreenDevice};
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::runtime::client;
#[tauri::command]
pub fn list_touchscreens() -> Result<Vec<TouchscreenDevice>, String> {
match client::request(DaemonRequest::ListTouchscreens) {
Ok(DaemonResponse::Touchscreens { devices }) => Ok(devices),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => Ok(touchscreen::list_touchscreens()),
Err(_) => Ok(touchscreen::list_touchscreens()),
}
}
#[tauri::command]
pub fn set_touchscreen_enabled(connector: String, enabled: bool) -> Result<(), String> {
let fallback = || {
let devices = touchscreen::list_touchscreens();
match devices.iter().find(|d| d.connector == connector) {
Some(dev) => touchscreen::set_touchscreen_enabled(&dev.i2c_id, enabled),
None => Err(format!("No touchscreen found for {}", connector)),
}
};
match client::request(DaemonRequest::SetTouchscreenEnabled {
connector: connector.clone(),
enabled,
}) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => fallback(),
Err(_) => fallback(),
}
}
```
- [ ] **Step 2: Register module in `commands/mod.rs`**
Add `pub mod touchscreen;` after existing declarations.
- [ ] **Step 3: Register commands in `lib.rs` invoke_handler**
Add to the `generate_handler!` macro:
```rust
commands::touchscreen::list_touchscreens,
commands::touchscreen::set_touchscreen_enabled,
```
- [ ] **Step 4: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 5: Commit**
```bash
git add ui-tauri-react/src-tauri/src/commands/touchscreen.rs ui-tauri-react/src-tauri/src/commands/mod.rs ui-tauri-react/src-tauri/src/lib.rs
git commit -m "feat: add Tauri commands for touchscreen control"
```
---
### Task 6: Frontend types and API
**Files:**
- Modify: `ui-tauri-react/src/types/duo.ts`
- Modify: `ui-tauri-react/src/lib/tauri.ts`
- [ ] **Step 1: Add `TouchscreenDevice` interface to `duo.ts`**
```typescript
export interface TouchscreenDevice {
name: string;
i2cId: string;
connector: string;
enabled: boolean;
}
```
- [ ] **Step 2: Add `touchscreenDisabled` to `DuoSettings` interface**
```typescript
touchscreenDisabled: string[];
```
- [ ] **Step 3: Add invoke functions to `tauri.ts`**
```typescript
export const listTouchscreens = () =>
invoke<TouchscreenDevice[]>("list_touchscreens");
export const setTouchscreenEnabled = (connector: string, enabled: boolean) =>
invoke<void>("set_touchscreen_enabled", { connector, enabled });
```
- [ ] **Step 4: Verify frontend compiles**
Run: `cd ui-tauri-react && npm run check` (or `npx tsc --noEmit`)
- [ ] **Step 5: Commit**
```bash
git add ui-tauri-react/src/types/duo.ts ui-tauri-react/src/lib/tauri.ts
git commit -m "feat: add touchscreen types and API functions"
```
---
### Task 7: Controls page — touchscreen toggle section
**Files:**
- Modify: `ui-tauri-react/src/pages/Controls.tsx`
- [ ] **Step 1: Add imports and state**
Add `useEffect` to the React import (line 1 currently only imports `useState`). Add to existing imports:
```typescript
import { useState, useEffect } from "react";
import { listTouchscreens, setTouchscreenEnabled } from "@/lib/tauri";
import { TouchscreenDevice } from "@/types/duo";
import { Switch } from "@/components/ui/switch";
import { IconHandFinger } from "@tabler/icons-react";
```
Add state inside the component:
```typescript
const [touchscreens, setTouchscreens] = useState<TouchscreenDevice[]>([]);
useEffect(() => {
listTouchscreens().then(setTouchscreens).catch(console.error);
}, []);
const handleTouchToggle = async (connector: string, enabled: boolean) => {
try {
await setTouchscreenEnabled(connector, enabled);
setTouchscreens((prev) =>
prev.map((ts) => (ts.connector === connector ? { ...ts, enabled } : ts))
);
} catch (e) {
console.error("Failed to toggle touchscreen:", e);
}
};
```
- [ ] **Step 2: Add touchscreen card section**
Add after the existing card sections (Service Control card). Follow the same glass-card pattern:
```tsx
{touchscreens.length > 0 && (
<div className="glass-card animate-stagger-in stagger-4 rounded-xl p-5">
<div className="mb-5 flex items-center gap-2.5">
<div className="flex size-7 items-center justify-center rounded-lg bg-purple-500/12 text-purple-500 dark:bg-purple-400/10 dark:text-purple-400">
<IconHandFinger className="size-3.5" stroke={1.75} />
</div>
<div>
<h3 className="text-[13px] font-semibold text-foreground">
Touchscreen
</h3>
<p className="text-[11px] text-muted-foreground">
Enable or disable touch input per display
</p>
</div>
</div>
<div className="space-y-3">
{touchscreens.map((ts) => (
<div key={ts.connector} className="flex items-center justify-between">
<span className="text-[13px]">
{ts.connector}
<span className="text-muted-foreground ml-2 text-[11px]">
{ts.name}
</span>
</span>
<Switch
checked={ts.enabled}
onCheckedChange={(checked) =>
handleTouchToggle(ts.connector, checked)
}
/>
</div>
))}
</div>
</div>
)}
```
- [ ] **Step 3: Verify frontend compiles and renders**
Run: `cd ui-tauri-react && npm run check`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src/pages/Controls.tsx
git commit -m "feat: add touchscreen toggle section to Controls page"
```
---
### Task 8: Display page — per-display touch toggle
**Files:**
- Modify: `ui-tauri-react/src/pages/DisplayLayout.tsx`
- [ ] **Step 1: Add imports and state**
Add to existing imports:
```typescript
import { listTouchscreens, setTouchscreenEnabled } from "@/lib/tauri";
import { TouchscreenDevice } from "@/types/duo";
import { Switch } from "@/components/ui/switch";
```
Add state inside the component:
```typescript
const [touchscreens, setTouchscreens] = useState<TouchscreenDevice[]>([]);
useEffect(() => {
listTouchscreens().then(setTouchscreens).catch(console.error);
}, []);
const handleTouchToggle = async (connector: string, enabled: boolean) => {
try {
await setTouchscreenEnabled(connector, enabled);
setTouchscreens((prev) =>
prev.map((ts) => (ts.connector === connector ? { ...ts, enabled } : ts))
);
} catch (e) {
console.error("Failed to toggle touchscreen:", e);
}
};
```
- [ ] **Step 2: Add touch toggle to per-display details**
In the "Connected Displays" section where each display's stats are shown, add a touch toggle for displays that have a mapped touchscreen. After the stats line (`{d.width}x{d.height} @ ...`), add:
```tsx
{(() => {
const ts = touchscreens.find((t) => t.connector === d.connector);
if (!ts) return null;
return (
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">Touch</span>
<Switch
checked={ts.enabled}
onCheckedChange={(checked) =>
handleTouchToggle(d.connector, checked)
}
/>
</div>
);
})()}
```
- [ ] **Step 3: Verify frontend compiles**
Run: `cd ui-tauri-react && npm run check`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src/pages/DisplayLayout.tsx
git commit -m "feat: add per-display touch toggle to Display page"
```
---
### Task 9: Settings sync — persist touchscreen state on toggle
**Files:**
- Modify: `ui-tauri-react/src/pages/Controls.tsx`
- Modify: `ui-tauri-react/src/pages/DisplayLayout.tsx`
- [ ] **Step 1: Update `handleTouchToggle` in Controls.tsx to persist settings**
After the `setTouchscreenEnabled` call succeeds, also update and save settings:
```typescript
const handleTouchToggle = async (connector: string, enabled: boolean) => {
try {
await setTouchscreenEnabled(connector, enabled);
setTouchscreens((prev) =>
prev.map((ts) => (ts.connector === connector ? { ...ts, enabled } : ts))
);
// Persist to settings
const settings = await loadSettings();
const disabled = settings.touchscreenDisabled ?? [];
settings.touchscreenDisabled = enabled
? disabled.filter((c) => c !== connector)
: [...disabled.filter((c) => c !== connector), connector];
await saveSettings(settings);
} catch (e) {
console.error("Failed to toggle touchscreen:", e);
}
};
```
Make sure `loadSettings` and `saveSettings` are imported from `@/lib/tauri`.
- [ ] **Step 2: Apply the same update to `handleTouchToggle` in DisplayLayout.tsx**
Same logic as step 1.
- [ ] **Step 3: Verify frontend compiles**
Run: `cd ui-tauri-react && npm run check`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src/pages/Controls.tsx ui-tauri-react/src/pages/DisplayLayout.tsx
git commit -m "feat: persist touchscreen toggle state to settings"
```
---
### Task 10: Manual testing
- [ ] **Step 1: Build the project**
Run: `cd ui-tauri-react && cargo build --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 2: Verify touchscreen detection**
With the daemon running, open the Control Panel. The Controls page should show a "Touchscreen" section with toggles for eDP-1 and eDP-2.
- [ ] **Step 3: Test toggle**
Toggle one touchscreen off. Verify:
- Touch input stops on that display
- The toggle shows the correct state after refresh
- The setting persists (check settings file)
- [ ] **Step 4: Test boot restore**
Restart the daemon. Verify the previously disabled touchscreen remains disabled.
- [ ] **Step 5: Test re-enable**
Toggle the touchscreen back on. Verify touch input works again.
@@ -0,0 +1,132 @@
# Touchscreen Toggle — Design Spec
Per-display touchscreen enable/disable from the Control Panel UI, with state persistence across reboots.
## Context
The ASUS Zenbook Duo has two ELAN touchscreens (ELAN9008 on eDP-1, ELAN9009 on eDP-2). When the keyboard is detached and both screens are active, users may want to disable touch on one screen (e.g., to avoid accidental input while using a stylus on the other). No touchscreen control exists in the project today.
## Mechanism
**sysfs unbind/bind** — compositor-agnostic, immediate, no reboot required.
- Disable: `echo "<i2c_id>" > /sys/bus/i2c/drivers/i2c_hid_acpi/unbind`
- Enable: `echo "<i2c_id>" > /sys/bus/i2c/drivers/i2c_hid_acpi/bind`
- Requires root — routed through the daemon (which runs as root).
## Backend
### New file: `src-tauri/src/hardware/touchscreen.rs`
**`list_touchscreens() -> Vec<TouchscreenDevice>`**
Scans `/sys/class/input/event*` for devices with touch capabilities. Groups by parent i2c device ID to deduplicate (each ELAN device registers multiple event nodes — touch, stylus, touchpad, etc.). Returns one entry per physical touchscreen:
```rust
struct TouchscreenDevice {
name: String, // e.g., "ELAN9008:00 04F3:425B"
i2c_id: String, // e.g., "i2c-ELAN9008:00"
connector: String, // e.g., "eDP-1"
enabled: bool,
}
```
**Display mapping:** Hardcoded by device name — "ELAN9008" maps to eDP-1 (top/main), "ELAN9009" maps to eDP-2 (bottom). This matches the ACPI topology and is consistent with how the rest of the codebase handles this hardware.
**`set_touchscreen_enabled(i2c_id: &str, enabled: bool) -> Result<()>`**
Writes the i2c_id to the appropriate sysfs bind/unbind file. Called by the daemon (which has root privileges).
### Daemon IPC (`ipc/protocol.rs`)
Add to `DaemonRequest`:
```rust
ListTouchscreens,
SetTouchscreenEnabled { connector: String, enabled: bool },
```
Add to `DaemonResponse`:
```rust
Touchscreens { devices: Vec<TouchscreenDevice> },
```
The daemon handles these requests using the `hardware/touchscreen.rs` functions directly (it runs as root).
### New file: `src-tauri/src/commands/touchscreen.rs`
Two Tauri commands that route through daemon IPC (matching the pattern used by `get_display_layout` and `apply_display_layout`):
- `list_touchscreens` — sends `ListTouchscreens` to daemon, returns devices
- `set_touchscreen_enabled(connector, enabled)` — sends `SetTouchscreenEnabled` to daemon, also updates settings
Register both in the `invoke_handler` macro in `lib.rs`.
## Settings Persistence
Add to existing settings model (`models/settings.rs`):
```rust
#[serde(default)]
touchscreen_disabled: Vec<String> // connectors with touch disabled, e.g., ["eDP-2"]
```
Uses `#[serde(default)]` to avoid breaking deserialization of existing settings files.
Also update the TypeScript `DuoSettings` interface in `types/duo.ts`:
```typescript
touchscreenDisabled: string[];
```
- Toggle off: add connector to list, save settings
- Toggle on: remove connector from list, save settings
## Boot Restore
In `daemon.rs`, the `handle_lifecycle` function already restores state on `Post`/`Thaw`/`Boot` phases (backlight, dock mode). Add touchscreen restore there:
- Read `touchscreen_disabled` from `state.settings`
- Unbind each listed touchscreen
This is the correct location because the daemon runs as root (can write to sysfs) and already handles boot/resume state restoration.
## Frontend
### Types (`types/duo.ts`)
```typescript
interface TouchscreenDevice {
connector: string;
name: string;
enabled: boolean;
}
```
### API (`lib/tauri.ts`)
```typescript
export const listTouchscreens = () =>
invoke<TouchscreenDevice[]>("list_touchscreens");
export const setTouchscreenEnabled = (connector: string, enabled: boolean) =>
invoke<void>("set_touchscreen_enabled", { connector, enabled });
```
### Controls page (`Controls.tsx`)
New "Touchscreen" section with a toggle per display. Fetches state via `listTouchscreens()` on mount, calls `setTouchscreenEnabled()` on toggle. Follows existing Switch component pattern.
### Display page (`DisplayLayout.tsx`)
Per-display touch toggle in the display details list, next to the scale selector. Only shown for displays that have a mapped touchscreen. Same data source and commands as Controls page.
## Setup Scripts
The setup scripts (`setup-gnome.sh`, `setup-kde.sh`, `setup-niri.sh`) do **not** need changes — the daemon already runs as root and handles the sysfs writes directly. No new sudoers entries or polkit policies are required.
## Scope Exclusions
- No auto-toggle based on keyboard attach/detach (screen is off when keyboard attached)
- No profile integration
- No compositor-specific implementations (sysfs is universal)
Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 B

+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="${BASH_SOURCE[0]:-${0}}"
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)"
TAURI_DIR="${SCRIPT_DIR}/ui-tauri-react/src-tauri"
BUILD_SCRIPT="${SCRIPT_DIR}/build-rust-runtime.sh"
RUNTIME_INSTALL_DIR="/usr/local/libexec/zenbook-duo"
RUNTIME_BINS=(
zenbook-duo-daemon
zenbook-duo-session-agent
zenbook-duo-lifecycle
zenbook-duo-usb-remap-helper
)
BUILD_ONLY=false
INSTALL_ONLY=false
usage() {
cat <<'EOF'
install-rust-runtime.sh - build and/or install Zenbook Duo background services
Usage:
./install-rust-runtime.sh # build, then install (default)
./install-rust-runtime.sh --build-only # compile binaries only
./install-rust-runtime.sh --install-only # install existing binaries + systemd units
Build output: ui-tauri-react/src-tauri/target/release/
Install path: /usr/local/libexec/zenbook-duo/
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--build-only)
BUILD_ONLY=true
shift
;;
--install-only)
INSTALL_ONLY=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ "${BUILD_ONLY}" = true ] && [ "${INSTALL_ONLY}" = true ]; then
echo "ERROR: Choose only one of --build-only or --install-only" >&2
exit 1
fi
SYSTEM_SERVICE_NAME="zenbook-duo-rust-daemon.service"
LIFECYCLE_SERVICE_NAME="zenbook-duo-rust-lifecycle.service"
USER_SERVICE_NAME="zenbook-duo-session-agent.service"
SYSTEM_SLEEP_HOOK_PATH="/usr/lib/systemd/system-sleep/zenbook-duo-rust-lifecycle"
SYSTEM_SLEEP_HOOK_DIR="$(dirname "${SYSTEM_SLEEP_HOOK_PATH}")"
TARGET_USER="${USER:-}"
if [ "${EUID}" = "0" ]; then
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
TARGET_USER="${SUDO_USER}"
else
echo "ERROR: install-rust-runtime.sh must be launched from a real user session." >&2
exit 1
fi
fi
TARGET_UID="$(id -u "${TARGET_USER}" 2>/dev/null || true)"
if [ -z "${TARGET_UID}" ]; then
echo "ERROR: failed to resolve target user '${TARGET_USER}'" >&2
exit 1
fi
TARGET_GID="$(id -g "${TARGET_USER}" 2>/dev/null || true)"
if [ -z "${TARGET_GID}" ]; then
echo "ERROR: failed to resolve primary group for '${TARGET_USER}'" >&2
exit 1
fi
TARGET_HOME="$(getent passwd "${TARGET_USER}" 2>/dev/null | cut -d: -f6)"
if [ -z "${TARGET_HOME}" ]; then
echo "ERROR: failed to resolve home directory for '${TARGET_USER}'" >&2
exit 1
fi
die() {
echo "ERROR: $*" >&2
exit 1
}
emit_systemctl_failure() {
local manager="$1"
local command_display="$2"
local output="${3:-}"
echo "ERROR: ${manager} systemd manager is unavailable; cannot run: ${command_display}" >&2
if [ -n "${output}" ]; then
printf '%s\n' "${output}" | sed 's/^/ systemctl: /' >&2
fi
}
run_system_systemctl() {
local output=""
if ! output="$(sudo systemctl --system "$@" 2>&1)"; then
emit_systemctl_failure "The system" "sudo systemctl --system $*" "${output}"
echo "Make sure this machine is booted with systemd and that 'sudo systemctl status' works, then rerun ./install.sh." >&2
exit 1
fi
if [ -n "${output}" ]; then
printf '%s\n' "${output}"
fi
}
run_user_systemctl() {
local runtime_dir="/run/user/${TARGET_UID}"
local bus_address="unix:path=${runtime_dir}/bus"
if [ "${TARGET_USER}" = "${USER:-}" ] && [ "${EUID}" != "0" ]; then
XDG_RUNTIME_DIR="${runtime_dir}" \
DBUS_SESSION_BUS_ADDRESS="${bus_address}" \
systemctl --user "$@"
return
fi
sudo -u "${TARGET_USER}" \
XDG_RUNTIME_DIR="${runtime_dir}" \
DBUS_SESSION_BUS_ADDRESS="${bus_address}" \
systemctl --user "$@"
}
run_user_systemctl_checked() {
local output=""
if ! output="$(run_user_systemctl "$@" 2>&1)"; then
emit_systemctl_failure "The ${TARGET_USER} user" "systemctl --user $*" "${output}"
echo "Run the installer from an active desktop login where 'systemctl --user status' works, then rerun ./install.sh." >&2
exit 1
fi
if [ -n "${output}" ]; then
printf '%s\n' "${output}"
fi
}
ensure_system_manager_available() {
local output=""
if ! output="$(sudo systemctl --system show --property=Version --value 2>&1)"; then
emit_systemctl_failure "The system" "sudo systemctl --system show --property=Version --value" "${output}"
echo "Make sure this machine is booted with systemd and that 'sudo systemctl status' works, then rerun ./install.sh." >&2
exit 1
fi
}
ensure_user_manager_available() {
local runtime_dir="/run/user/${TARGET_UID}"
local bus_path="${runtime_dir}/bus"
local output=""
if [ ! -d "${runtime_dir}" ]; then
die "The systemd user runtime directory is missing for ${TARGET_USER}: ${runtime_dir}. Log in as ${TARGET_USER} and rerun ./install.sh from that desktop session."
fi
if [ ! -S "${bus_path}" ]; then
die "The systemd user bus is missing for ${TARGET_USER}: ${bus_path}. Log in as ${TARGET_USER} and rerun ./install.sh from that desktop session."
fi
if ! output="$(run_user_systemctl show-environment 2>&1)"; then
emit_systemctl_failure "The ${TARGET_USER} user" "systemctl --user show-environment" "${output}"
echo "Run the installer from an active desktop login where 'systemctl --user status' works, then rerun ./install.sh." >&2
exit 1
fi
}
import_user_environment() {
run_user_systemctl import-environment DISPLAY WAYLAND_DISPLAY NIRI_SOCKET XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP DESKTOP_SESSION XDG_SESSION_TYPE >/dev/null 2>&1 || true
if command -v dbus-update-activation-environment >/dev/null 2>&1; then
if [ "${TARGET_USER}" = "${USER:-}" ] && [ "${EUID}" != "0" ]; then
XDG_RUNTIME_DIR="/run/user/${TARGET_UID}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${TARGET_UID}/bus" \
dbus-update-activation-environment --systemd DISPLAY WAYLAND_DISPLAY NIRI_SOCKET XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP DESKTOP_SESSION XDG_SESSION_TYPE >/dev/null 2>&1 || true
else
sudo -u "${TARGET_USER}" \
XDG_RUNTIME_DIR="/run/user/${TARGET_UID}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${TARGET_UID}/bus" \
dbus-update-activation-environment --systemd DISPLAY WAYLAND_DISPLAY NIRI_SOCKET XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP DESKTOP_SESSION XDG_SESSION_TYPE >/dev/null 2>&1 || true
fi
fi
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "ERROR: Missing required command: $1" >&2
exit 1
}
}
if [ "${BUILD_ONLY}" = true ]; then
exec "${BUILD_SCRIPT}"
fi
if [ "${INSTALL_ONLY}" = false ]; then
"${BUILD_SCRIPT}"
fi
for binary in "${RUNTIME_BINS[@]}"; do
if [ ! -x "${TAURI_DIR}/target/release/${binary}" ]; then
die "Missing built binary ${TAURI_DIR}/target/release/${binary}. Run ./build-rust-runtime.sh first."
fi
done
need_cmd systemctl
ensure_system_manager_available
ensure_user_manager_available
echo "Installing Rust runtime binaries..."
sudo mkdir -p "${RUNTIME_INSTALL_DIR}"
sudo install -m 0755 \
"${TAURI_DIR}/target/release/zenbook-duo-daemon" \
"${RUNTIME_INSTALL_DIR}/zenbook-duo-daemon"
sudo install -m 0755 \
"${TAURI_DIR}/target/release/zenbook-duo-session-agent" \
"${RUNTIME_INSTALL_DIR}/zenbook-duo-session-agent"
sudo install -m 0755 \
"${TAURI_DIR}/target/release/zenbook-duo-lifecycle" \
"${RUNTIME_INSTALL_DIR}/zenbook-duo-lifecycle"
sudo install -m 0755 \
"${TAURI_DIR}/target/release/zenbook-duo-usb-remap-helper" \
"${RUNTIME_INSTALL_DIR}/zenbook-duo-usb-remap-helper"
echo "Installed Rust runtime versions:"
for binary in \
zenbook-duo-daemon \
zenbook-duo-session-agent \
zenbook-duo-lifecycle \
zenbook-duo-usb-remap-helper; do
"${RUNTIME_INSTALL_DIR}/${binary}" --version || true
done
echo "Installing Rust runtime services..."
cat <<EOF | sudo tee "/etc/systemd/system/${SYSTEM_SERVICE_NAME}" >/dev/null
[Unit]
Description=Zenbook Duo Rust Daemon
After=network.target
[Service]
Type=simple
ExecStart=${RUNTIME_INSTALL_DIR}/zenbook-duo-daemon
Environment=ZENBOOK_DUO_HOME=${TARGET_HOME}
Environment=ZENBOOK_DUO_USER=${TARGET_USER}
Environment=ZENBOOK_DUO_UID=${TARGET_UID}
Environment=ZENBOOK_DUO_GID=${TARGET_GID}
Restart=on-failure
RestartSec=1
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF | sudo tee "/etc/systemd/system/${LIFECYCLE_SERVICE_NAME}" >/dev/null
[Unit]
Description=Zenbook Duo Rust Lifecycle Handler
DefaultDependencies=no
Before=shutdown.target
After=multi-user.target
[Service]
Type=oneshot
ExecStart=${RUNTIME_INSTALL_DIR}/zenbook-duo-lifecycle boot
ExecStop=${RUNTIME_INSTALL_DIR}/zenbook-duo-lifecycle shutdown
RemainAfterExit=yes
TimeoutStartSec=10
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF | sudo tee "/etc/systemd/user/${USER_SERVICE_NAME}" >/dev/null
[Unit]
Description=Zenbook Duo Session Agent
ConditionUser=!gdm
After=graphical-session.target
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStartPre=/bin/sh -c 'test -n "\${DISPLAY:-}\${WAYLAND_DISPLAY:-}\${NIRI_SOCKET:-}"'
ExecStart=${RUNTIME_INSTALL_DIR}/zenbook-duo-session-agent
Restart=on-failure
RestartSec=2
TimeoutStopSec=5
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus
[Install]
WantedBy=default.target
EOF
sudo mkdir -p "${SYSTEM_SLEEP_HOOK_DIR}"
sudo rm -f "${SYSTEM_SLEEP_HOOK_PATH}"
sudo ln -sfn "${RUNTIME_INSTALL_DIR}/zenbook-duo-lifecycle" "${SYSTEM_SLEEP_HOOK_PATH}"
sudo systemctl disable zenbook-duo.service 2>/dev/null || true
sudo systemctl stop zenbook-duo.service 2>/dev/null || true
sudo rm -f /usr/lib/systemd/system-sleep/duo
run_system_systemctl daemon-reload
run_system_systemctl enable "${SYSTEM_SERVICE_NAME}"
run_system_systemctl enable "${LIFECYCLE_SERVICE_NAME}"
run_system_systemctl restart "${SYSTEM_SERVICE_NAME}"
run_system_systemctl restart "${LIFECYCLE_SERVICE_NAME}"
ensure_user_manager_available
run_user_systemctl_checked daemon-reload
import_user_environment
run_user_systemctl disable zenbook-duo-user.service >/dev/null 2>&1 || true
run_user_systemctl stop zenbook-duo-user.service >/dev/null 2>&1 || true
run_user_systemctl_checked enable "${USER_SERVICE_NAME}"
run_user_systemctl_checked restart "${USER_SERVICE_NAME}"
echo "Rust runtime services installed."
Executable
+608
View File
@@ -0,0 +1,608 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_URL_DEFAULT="https://github.com/zakstam/zenbook-duo-linux.git"
BRANCH_DEFAULT=""
SCRIPT_PATH="${BASH_SOURCE[0]:-${0}}"
RUNNING_FROM_STDIN=false
SCRIPT_PATH_ABS=""
if [ -z "${SCRIPT_PATH}" ] || [ "${SCRIPT_PATH}" = "bash" ] || [ "${SCRIPT_PATH}" = "-" ]; then
RUNNING_FROM_STDIN=true
SCRIPT_DIR="$(pwd)"
else
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)"
SCRIPT_PATH_ABS="${SCRIPT_DIR}/$(basename "${SCRIPT_PATH}")"
fi
SCRIPT_VERSION="2026-04-26"
usage() {
cat <<'EOF_USAGE'
install-ui.sh - clone, build, and install the Zenbook Duo Control UI (Tauri)
Usage:
./install-ui.sh [--repo URL] [--branch BRANCH] [--dir PATH] [--keep-dir]
[--build-only | --install-only] [--skip-prereqs]
Examples:
./install-ui.sh
./install-ui.sh --dir "$HOME/src/zenbook-duo-linux"
./install-ui.sh --build-only --dir .
./install-ui.sh --install-only --dir .
Notes:
- The Control Panel can be launched from your app menu after install.
- Privileged steps use a graphical auth prompt when available, with a terminal fallback.
- Use --build-only to compile without installing, then --install-only to install artifacts.
EOF_USAGE
}
die() {
echo "ERROR: $*" >&2
exit 1
}
running_as_root() {
[ "${EUID:-$(id -u)}" -eq 0 ]
}
stop_running_app() {
if ! command -v pgrep >/dev/null 2>&1; then
return 1
fi
local pids=""
pids="$(pgrep -x zenbook-duo-control 2>/dev/null || true)"
if [ -z "$pids" ]; then
return 1
fi
echo "Stopping running zenbook-duo-control (pids: $pids)..."
kill $pids 2>/dev/null || true
local i=0
while [ "$i" -lt 25 ]; do
if ! pgrep -x zenbook-duo-control >/dev/null 2>&1; then
echo "Stopped."
return 0
fi
sleep 0.2
i=$((i + 1))
done
echo "Force killing zenbook-duo-control..."
pids="$(pgrep -x zenbook-duo-control 2>/dev/null || true)"
if [ -n "$pids" ]; then
kill -9 $pids 2>/dev/null || true
fi
return 0
}
start_app_background() {
if ! command -v zenbook-duo-control >/dev/null 2>&1; then
echo "WARN: zenbook-duo-control not found on PATH; can't relaunch automatically." >&2
return 1
fi
echo "Relaunching zenbook-duo-control..."
nohup zenbook-duo-control >/dev/null 2>&1 &
}
ensure_shortcut() {
return 0
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
root_install_prereqs_dnf() {
echo "Installing build prerequisites (dnf)..."
dnf install -y \
git \
curl \
nodejs \
npm \
gcc \
gcc-c++ \
make \
pkgconf-pkg-config \
openssl-devel \
webkit2gtk4.1-devel \
gtk3-devel \
librsvg2-devel \
rpm-build
echo "Installing AppIndicator dev package (best-effort)..."
dnf install -y libappindicator-gtk3-devel \
|| dnf install -y libappindicator3-devel \
|| dnf install -y ayatana-appindicator3-devel \
|| echo "WARN: Could not install an AppIndicator *-devel package. Tray integration may not build on some desktops." >&2
}
root_install_prereqs_apt() {
echo "Installing build prerequisites (apt)..."
apt update
apt install -y \
git \
curl \
nodejs \
npm \
build-essential \
pkg-config \
libssl-dev \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
dpkg-dev \
fakeroot
echo "Installing AppIndicator dev package (best-effort)..."
apt install -y libayatana-appindicator3-dev \
|| apt install -y libappindicator3-dev \
|| echo "WARN: Could not install an AppIndicator *-dev package. Tray integration may not build on some desktops." >&2
}
root_install_prereqs_pacman() {
echo "Installing build prerequisites (pacman)..."
pacman -S --needed --noconfirm \
git \
curl \
nodejs \
npm \
base-devel \
pkgconf \
openssl \
gtk3 \
webkit2gtk-4.1 \
librsvg \
desktop-file-utils
echo "Installing AppIndicator package (best-effort)..."
pacman -S --needed --noconfirm libayatana-appindicator \
|| echo "WARN: Could not install libayatana-appindicator. Tray integration may not build on some desktops." >&2
}
root_install_package_dnf() {
local pkg="${1:-}"
local built_vr="${2:-}"
local installed_vr="${3:-}"
[ -n "${pkg}" ] || die "--root-install-package dnf requires a package path"
if [ -n "$built_vr" ] && [ -n "$installed_vr" ] && [ "$built_vr" = "$installed_vr" ]; then
dnf reinstall -y "$pkg"
else
dnf install -y "$pkg"
fi
}
root_install_package_apt() {
local pkg="${1:-}"
[ -n "${pkg}" ] || die "--root-install-package apt requires a package path"
[ -f "${pkg}" ] || die "Package file not found: ${pkg}"
# Install local debs with dpkg to avoid apt/_apt permission issues in /tmp.
dpkg -i "${pkg}" || apt install -y -f
}
root_install_ui_direct() {
local built_binary="${1:-}"
local desktop_src="${2:-}"
local icon_src="${3:-}"
[ -f "${built_binary}" ] || die "No built UI binary found at ${built_binary}"
[ -f "${desktop_src}" ] || die "Desktop entry template missing at ${desktop_src}"
[ -f "${icon_src}" ] || die "Icon missing at ${icon_src}"
echo "Installing UI binary and desktop assets..."
install -Dm755 "${built_binary}" /usr/local/bin/zenbook-duo-control
install -Dm644 "${desktop_src}" /usr/share/applications/zenbook-duo-control.desktop
install -Dm644 "${icon_src}" /usr/share/pixmaps/zenbook-duo-control.png
update-desktop-database /usr/share/applications 2>/dev/null || true
gtk-update-icon-cache -q /usr/share/icons/hicolor 2>/dev/null || true
}
dispatch_root_helper() {
local action="${1:-}"
shift || true
running_as_root || die "Privileged helper requires root"
case "$action" in
--root-install-prereqs)
case "${1:-}" in
dnf)
root_install_prereqs_dnf
;;
apt)
root_install_prereqs_apt
;;
pacman)
root_install_prereqs_pacman
;;
*)
die "Unsupported package manager for --root-install-prereqs: ${1:-<empty>}"
;;
esac
;;
--root-install-package)
local pkg_mgr="${1:-}"
local pkg="${2:-}"
local built_vr="${3:-}"
local installed_vr="${4:-}"
case "$pkg_mgr" in
dnf)
root_install_package_dnf "$pkg" "$built_vr" "$installed_vr"
;;
apt)
root_install_package_apt "$pkg"
;;
*)
die "Unsupported package manager for --root-install-package: ${pkg_mgr:-<empty>}"
;;
esac
;;
--root-install-direct)
root_install_ui_direct "${1:-}" "${2:-}" "${3:-}"
;;
*)
die "Unknown privileged helper action: ${action:-<empty>}"
;;
esac
}
if [[ "${1:-}" == --root-install-* ]]; then
dispatch_root_helper "$@"
exit 0
fi
REPO_URL="$REPO_URL_DEFAULT"
BRANCH="$BRANCH_DEFAULT"
TARGET_DIR=""
KEEP_DIR=false
FORCE_CLONE=false
BUILD_ONLY=false
INSTALL_ONLY=false
SKIP_PREREQS=false
while [ "$#" -gt 0 ]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--build-only)
BUILD_ONLY=true
shift
;;
--install-only)
INSTALL_ONLY=true
shift
;;
--skip-prereqs)
SKIP_PREREQS=true
shift
;;
--repo)
[ "${2:-}" != "" ] || die "--repo requires a value"
REPO_URL="$2"
shift 2
;;
--branch)
[ "${2:-}" != "" ] || die "--branch requires a value"
BRANCH="$2"
shift 2
;;
--dir)
[ "${2:-}" != "" ] || die "--dir requires a value"
TARGET_DIR="$2"
shift 2
;;
--keep-dir)
KEEP_DIR=true
shift
;;
--clone)
FORCE_CLONE=true
shift
;;
*)
die "Unknown argument: $1 (use --help)"
;;
esac
done
if [ "${BUILD_ONLY}" = true ] && [ "${INSTALL_ONLY}" = true ]; then
die "Choose only one of --build-only or --install-only"
fi
echo "install-ui.sh v${SCRIPT_VERSION}"
export PAGER=cat
export SYSTEMD_PAGER=cat
export GIT_PAGER=cat
export LESS="${LESS:--FRX}"
PKG_MGR=""
if command -v dnf >/dev/null 2>&1; then
PKG_MGR="dnf"
elif command -v apt >/dev/null 2>&1; then
PKG_MGR="apt"
elif command -v pacman >/dev/null 2>&1; then
PKG_MGR="pacman"
fi
SUDO_KEEPALIVE_PID=""
PRIVILEGE_MODE=""
has_graphical_session() {
[ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${XDG_SESSION_TYPE:-}" ]
}
prepare_privilege_helper() {
if running_as_root; then
PRIVILEGE_MODE="root"
return 0
fi
if [ -z "${SCRIPT_PATH_ABS}" ]; then
echo "WARN: install-ui.sh is running from stdin; graphical elevation is unavailable in this mode." >&2
fi
if [ -n "${SCRIPT_PATH_ABS}" ] && command -v pkexec >/dev/null 2>&1 && has_graphical_session; then
PRIVILEGE_MODE="pkexec"
echo "Using graphical privilege prompt via pkexec for system install steps."
return 0
fi
if command -v sudo >/dev/null 2>&1; then
PRIVILEGE_MODE="sudo"
echo "Using terminal sudo fallback for system install steps."
sudo -v
(
while true; do
sleep 50
sudo -n true || exit 0
done
) >/dev/null 2>&1 &
SUDO_KEEPALIVE_PID="$!"
return 0
fi
die "Need either pkexec (graphical) or sudo (terminal) to perform system install steps"
}
run_root_helper() {
local action="$1"
shift || true
case "$PRIVILEGE_MODE" in
root)
dispatch_root_helper "$action" "$@"
;;
pkexec)
[ -n "$SCRIPT_PATH_ABS" ] || die "Graphical elevation requires running install-ui.sh from a file"
pkexec "$SCRIPT_PATH_ABS" "$action" "$@"
;;
sudo)
[ -n "$SCRIPT_PATH_ABS" ] || die "Terminal elevation requires running install-ui.sh from a file"
sudo -E "$SCRIPT_PATH_ABS" "$action" "$@"
;;
*)
die "Privilege helper is not initialized"
;;
esac
}
install_prereqs() {
if [ "$PKG_MGR" = "dnf" ] || [ "$PKG_MGR" = "apt" ] || [ "$PKG_MGR" = "pacman" ]; then
run_root_helper --root-install-prereqs "$PKG_MGR"
else
echo "WARN: Could not detect dnf/apt/pacman; skipping prereq installation." >&2
fi
}
ensure_rust() {
if command -v cargo >/dev/null 2>&1; then
return 0
fi
echo "Rust toolchain not found. Installing via rustup..."
need_cmd curl
curl https://sh.rustup.rs -sSf | sh -s -- -y
# shellcheck disable=SC1091
source "$HOME/.cargo/env"
}
ui_direct_paths() {
UI_DIRECT_BINARY="$PWD/src-tauri/target/release/zenbook-duo-control"
UI_DIRECT_DESKTOP="$PWD/src-tauri/linux/zenbook-duo-control.desktop"
UI_DIRECT_ICON="$PWD/src-tauri/icons/128x128.png"
}
build_ui_direct() {
ui_direct_paths
echo "Building UI binary directly for pacman-based systems..."
npx tauri build --no-bundle
[ -f "${UI_DIRECT_BINARY}" ] || die "No built UI binary found at ${UI_DIRECT_BINARY}"
}
install_ui_direct_artifacts() {
ui_direct_paths
[ -f "${UI_DIRECT_BINARY}" ] || die "No built UI binary found at ${UI_DIRECT_BINARY}"
[ -f "${UI_DIRECT_DESKTOP}" ] || die "Desktop entry template missing at ${UI_DIRECT_DESKTOP}"
[ -f "${UI_DIRECT_ICON}" ] || die "Icon missing at ${UI_DIRECT_ICON}"
local was_running=false
if pgrep -x zenbook-duo-control >/dev/null 2>&1; then
was_running=true
stop_running_app || true
fi
run_root_helper --root-install-direct "${UI_DIRECT_BINARY}" "${UI_DIRECT_DESKTOP}" "${UI_DIRECT_ICON}"
if [ "${was_running}" = true ]; then
start_app_background || true
fi
rm -f "$HOME/.local/share/applications/zenbook-duo-control.desktop" 2>/dev/null || true
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
}
install_ui_direct() {
build_ui_direct
install_ui_direct_artifacts
}
if [ "${INSTALL_ONLY}" = false ]; then
prepare_privilege_helper
if [ "${SKIP_PREREQS}" = false ]; then
install_prereqs
echo "Prerequisites step complete."
else
echo "Skipping prerequisite installation (--skip-prereqs)."
fi
ensure_rust
need_cmd git
need_cmd npm
need_cmd cargo
else
PKG_MGR=""
if command -v dnf >/dev/null 2>&1; then
PKG_MGR="dnf"
elif command -v apt >/dev/null 2>&1; then
PKG_MGR="apt"
elif command -v pacman >/dev/null 2>&1; then
PKG_MGR="pacman"
fi
prepare_privilege_helper
fi
if [ "${INSTALL_ONLY}" = false ]; then
echo "Toolchain looks good. Starting clone/build..."
else
echo "Installing previously built UI artifacts..."
fi
if [ -z "$TARGET_DIR" ]; then
if [ "$RUNNING_FROM_STDIN" = false ] && [ "$FORCE_CLONE" = false ] && [ -d "$SCRIPT_DIR/ui-tauri-react" ]; then
TARGET_DIR="$SCRIPT_DIR"
KEEP_DIR=true
echo "Using local checkout: $TARGET_DIR"
else
TARGET_DIR="$(mktemp -d -t zenbook-duo-linux-ui.XXXXXX)"
echo "Cloning into temp dir: $TARGET_DIR"
fi
else
mkdir -p "$TARGET_DIR"
if [ -e "$TARGET_DIR/.git" ]; then
echo "Using existing repo: $TARGET_DIR"
elif [ "$(ls -A "$TARGET_DIR" 2>/dev/null | wc -l | tr -d ' ')" != "0" ]; then
die "--dir points to a non-empty directory without a git repo: $TARGET_DIR"
fi
fi
cleanup() {
if [ -n "${SUDO_KEEPALIVE_PID}" ]; then
kill "${SUDO_KEEPALIVE_PID}" >/dev/null 2>&1 || true
fi
if [ "$KEEP_DIR" = false ] && [[ "$TARGET_DIR" == /tmp/* || "$TARGET_DIR" == /var/tmp/* || "$TARGET_DIR" == /private/tmp/* ]]; then
rm -rf "$TARGET_DIR"
fi
}
trap cleanup EXIT
if [ "$TARGET_DIR" = "$SCRIPT_DIR" ] && [ "$FORCE_CLONE" = false ]; then
:
elif [ ! -e "$TARGET_DIR/.git" ]; then
if [ -n "$BRANCH" ]; then
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$TARGET_DIR"
else
git clone --depth 1 "$REPO_URL" "$TARGET_DIR"
fi
else
(cd "$TARGET_DIR" && git pull --ff-only) || true
fi
UI_DIR="$TARGET_DIR/ui-tauri-react"
[ -d "$UI_DIR" ] || die "ui directory not found at $UI_DIR"
cd "$UI_DIR"
if [ "${INSTALL_ONLY}" = false ]; then
echo "Building UI in: $UI_DIR"
if [ -f package-lock.json ]; then
npm ci
else
npm install
fi
if [ "$PKG_MGR" = "dnf" ]; then
npm run build -- --bundles rpm
elif [ "$PKG_MGR" = "apt" ]; then
npm run build -- --bundles deb
elif [ "$PKG_MGR" = "pacman" ]; then
build_ui_direct
else
npm run build -- --bundles deb rpm
fi
fi
if [ "${BUILD_ONLY}" = true ]; then
echo ""
echo "Build complete. Bundles are under src-tauri/target/release/bundle/"
exit 0
fi
if [ "$PKG_MGR" = "pacman" ]; then
install_ui_direct_artifacts
echo ""
echo "Installed. You can launch 'Zenbook Duo Control' from your app menu,"
echo "or run: zenbook-duo-control"
exit 0
fi
RPM_GLOB="src-tauri/target/release/bundle/rpm/"'*.rpm'
DEB_GLOB="src-tauri/target/release/bundle/deb/"'*.deb'
if [ "$PKG_MGR" = "dnf" ]; then
PKG="$(ls -1t $RPM_GLOB 2>/dev/null | head -n 1 || true)"
[ -n "$PKG" ] || die "No rpm found after build (looked for $RPM_GLOB)"
echo "Installing rpm: $PKG"
WAS_RUNNING=false
if pgrep -x zenbook-duo-control >/dev/null 2>&1; then
WAS_RUNNING=true
stop_running_app || true
fi
BUILT_VR="$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$PKG" 2>/dev/null || true)"
INSTALLED_VR="$(rpm -q --qf '%{VERSION}-%{RELEASE}' zenbook-duo-control 2>/dev/null || true)"
run_root_helper --root-install-package dnf "$PWD/$PKG" "$BUILT_VR" "$INSTALLED_VR"
if [ "$WAS_RUNNING" = true ]; then
start_app_background || true
fi
rm -f "$HOME/.local/share/applications/zenbook-duo-control.desktop" 2>/dev/null || true
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
elif [ "$PKG_MGR" = "apt" ]; then
PKG="$(ls -1t $DEB_GLOB 2>/dev/null | head -n 1 || true)"
[ -n "$PKG" ] || die "No deb found after build (looked for $DEB_GLOB)"
echo "Installing deb: $PKG"
WAS_RUNNING=false
if pgrep -x zenbook-duo-control >/dev/null 2>&1; then
WAS_RUNNING=true
stop_running_app || true
fi
run_root_helper --root-install-package apt "$PWD/$PKG"
if [ "$WAS_RUNNING" = true ]; then
start_app_background || true
fi
rm -f "$HOME/.local/share/applications/zenbook-duo-control.desktop" 2>/dev/null || true
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
else
die "Unsupported system (expected dnf, apt, or pacman). Package is built under src-tauri/target/release/bundle/."
fi
echo ""
echo "Installed. You can launch 'Zenbook Duo Control' from your app menu,"
echo "or run: zenbook-duo-control"
Executable
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="${BASH_SOURCE[0]:-}"
if [ -n "${SCRIPT_PATH}" ] && [ "${SCRIPT_PATH}" != "bash" ] && [ "${SCRIPT_PATH}" != "-" ] && [ -f "${SCRIPT_PATH}" ]; then
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)"
else
SCRIPT_DIR="$(pwd)"
fi
REPO_URL="https://github.com/zakstam/zenbook-duo-linux.git"
ARCHIVE_URL="https://github.com/zakstam/zenbook-duo-linux/archive/refs/heads/main.tar.gz"
usage() {
cat <<'EOF'
install.sh - unified installer for Zenbook Duo Linux
Usage:
./install.sh [install-options...] [-- setup-script-args...]
Examples:
./install.sh
./install.sh --build-only
./install.sh --install-only
./install.sh --skip-ui
./install.sh --no-usb-media-remap
./install.sh -- --no-usb-media-remap
sudo -E ./install.sh
This script auto-detects GNOME, KDE Plasma, or Niri, runs the matching setup
script, then installs the Zenbook Duo Control UI unless --skip-ui is passed.
Use ./build.sh to compile runtime + UI without installing system services.
EOF
}
ensure_repo_checkout() {
if [ -f "${SCRIPT_DIR}/setup-gnome.sh" ] && [ -f "${SCRIPT_DIR}/setup-common.sh" ] && [ -f "${SCRIPT_DIR}/install-ui.sh" ]; then
printf '%s\n' "${SCRIPT_DIR}"
return 0
fi
local temp_dir
temp_dir="$(mktemp -d -t zenbook-duo-linux.XXXXXX)"
if command -v git >/dev/null 2>&1; then
echo "Downloading zenbook-duo-linux..." >&2
git clone --depth 1 "${REPO_URL}" "${temp_dir}" >/dev/null 2>&1
printf '%s\n' "${temp_dir}"
return 0
fi
if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then
echo "Downloading zenbook-duo-linux..." >&2
curl -fsSL "${ARCHIVE_URL}" | tar -xz -C "${temp_dir}" --strip-components=1
printf '%s\n' "${temp_dir}"
return 0
fi
echo "ERROR: Could not download the repository automatically." >&2
echo "Install one of these first, then try again: git or (curl and tar)." >&2
exit 1
}
lower() {
printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
}
contains_token() {
local haystack=" ${1:-} "
local needle="${2:-}"
[[ "${haystack}" == *" ${needle} "* ]]
}
pick_desktop() {
local current_desktop_raw="${XDG_CURRENT_DESKTOP:-}"
local desktop_session_raw="${DESKTOP_SESSION:-}"
local session_desktop_raw="${XDG_SESSION_DESKTOP:-}"
local current_desktop
local desktop_session
local session_desktop
current_desktop="$(lower "${current_desktop_raw}")"
desktop_session="$(lower "${desktop_session_raw}")"
session_desktop="$(lower "${session_desktop_raw}")"
local detected=()
for value in "${current_desktop//:/ }" "${desktop_session}" "${session_desktop}"; do
[ -n "${value}" ] || continue
if contains_token "${value}" "gnome"; then
detected+=("gnome")
fi
if contains_token "${value}" "kde" || contains_token "${value}" "plasma"; then
detected+=("kde")
fi
if contains_token "${value}" "niri"; then
detected+=("niri")
fi
done
local unique=()
local item
for item in "${detected[@]:-}"; do
local seen=false
local existing
for existing in "${unique[@]:-}"; do
if [ "${existing}" = "${item}" ]; then
seen=true
break
fi
done
if [ "${seen}" = false ]; then
unique+=("${item}")
fi
done
if [ "${#unique[@]}" -eq 1 ]; then
printf '%s\n' "${unique[0]}"
return 0
fi
echo "ERROR: Could not confidently detect a supported desktop session." >&2
echo "Detected environment values:" >&2
echo " XDG_CURRENT_DESKTOP=${current_desktop_raw:-<empty>}" >&2
echo " DESKTOP_SESSION=${desktop_session_raw:-<empty>}" >&2
echo " XDG_SESSION_DESKTOP=${session_desktop_raw:-<empty>}" >&2
echo >&2
echo "Run one of these manually instead:" >&2
echo " ./setup-gnome.sh" >&2
echo " ./setup-kde.sh" >&2
echo " ./setup-niri.sh" >&2
return 1
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
INSTALL_UI=true
BUILD_ONLY=false
INSTALL_ONLY=false
SETUP_ARGS=()
while [ "$#" -gt 0 ]; do
case "$1" in
--skip-ui)
INSTALL_UI=false
shift
;;
--build-only)
BUILD_ONLY=true
shift
;;
--install-only)
INSTALL_ONLY=true
shift
;;
--)
shift
SETUP_ARGS+=("$@")
break
;;
*)
SETUP_ARGS+=("$1")
shift
;;
esac
done
if [ "${BUILD_ONLY}" = true ] && [ "${INSTALL_ONLY}" = true ]; then
echo "ERROR: Choose only one of --build-only or --install-only" >&2
exit 1
fi
if [ "${BUILD_ONLY}" = true ]; then
export DUO_RUNTIME_MODE="build-only"
elif [ "${INSTALL_ONLY}" = true ]; then
export DUO_RUNTIME_MODE="install-only"
fi
desktop="$(pick_desktop)"
repo_dir="$(ensure_repo_checkout)"
repo_dir_is_temp=false
if [ "${repo_dir}" != "${SCRIPT_DIR}" ] && [[ "${repo_dir}" == /tmp/* || "${repo_dir}" == /var/tmp/* || "${repo_dir}" == /private/tmp/* ]]; then
repo_dir_is_temp=true
fi
cleanup() {
if [ "${repo_dir_is_temp}" = true ]; then
rm -rf "${repo_dir}"
fi
}
trap cleanup EXIT
case "${desktop}" in
gnome)
setup_script="${repo_dir}/setup-gnome.sh"
;;
kde)
setup_script="${repo_dir}/setup-kde.sh"
;;
niri)
setup_script="${repo_dir}/setup-niri.sh"
;;
*)
echo "ERROR: Unsupported desktop target: ${desktop}" >&2
exit 1
;;
esac
echo "Detected desktop: ${desktop}"
echo "Running $(basename "${setup_script}")..."
if [ -r /dev/tty ]; then
"${setup_script}" "${SETUP_ARGS[@]}" </dev/tty
else
"${setup_script}" "${SETUP_ARGS[@]}"
fi
if [ "${INSTALL_UI}" = true ]; then
UI_ARGS=()
if [ "${repo_dir}" = "${SCRIPT_DIR}" ] && [ -e "${repo_dir}/.git" ]; then
UI_ARGS=(--dir "${repo_dir}")
elif [ "${repo_dir}" != "${SCRIPT_DIR}" ]; then
UI_ARGS=(--dir "${repo_dir}")
fi
if [ "${BUILD_ONLY}" = true ]; then
UI_ARGS+=(--build-only --skip-prereqs)
elif [ "${INSTALL_ONLY}" = true ]; then
UI_ARGS+=(--install-only --skip-prereqs)
fi
echo "Running install-ui.sh ${UI_ARGS[*]}..."
if [ -r /dev/tty ]; then
"${repo_dir}/install-ui.sh" "${UI_ARGS[@]}" </dev/tty
else
"${repo_dir}/install-ui.sh" "${UI_ARGS[@]}"
fi
else
echo "Skipping UI install (--skip-ui)."
fi
echo
echo "If this is a fresh install, you may need to log out and back in."
echo "If anything looks stale after updating, run: systemctl --user restart zenbook-duo-session-agent.service"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "zenbook",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env bash
# Shared setup flow for Zenbook Duo desktop backends.
# Desktop-specific wrappers declare only their backend dependency deltas and then
# call run_duo_setup.
DUO_COMMON_DNF_PACKAGES=(usbutils iio-sensor-proxy systemd)
DUO_COMMON_APT_PACKAGES=(usbutils iio-sensor-proxy systemd)
DUO_COMMON_PACMAN_PACKAGES=(usbutils iio-sensor-proxy systemd)
DUO_COMMON_MANUAL_DEPENDENCIES_HINT="usbutils, iio-sensor-proxy, systemd"
# Rust runtime and UI share one Tauri crate; build these before install-rust-runtime.sh.
DUO_COMMON_DNF_BUILD_PACKAGES=(
gcc gcc-c++ make pkgconf-pkg-config openssl-devel
gtk3-devel webkit2gtk4.1-devel librsvg2-devel
)
DUO_COMMON_APT_BUILD_PACKAGES=(
build-essential pkg-config libssl-dev
libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev librsvg2-dev
)
DUO_COMMON_PACMAN_BUILD_PACKAGES=(
base-devel pkgconf openssl gtk3 webkit2gtk-4.1 librsvg
)
# Keep these defaults aligned with DuoSettings::default in the Rust runtime and
# DEFAULT_DUO_SETTINGS in the frontend.
DUO_DEFAULT_BACKLIGHT=0
DUO_DEFAULT_SCALE=1.66
DUO_DEFAULT_USB_MEDIA_REMAP_ENABLED=true
DUO_DEFAULT_START_ON_BOOT_MINIMIZED=false
DUO_INSTALLER_MARKS_SETUP_COMPLETED=true
DUO_SUDOERS_DROPIN_PATH="/etc/sudoers.d/zenbook-duo"
build_duo_setup_package_lists() {
DNF_DESKTOP_PACKAGES=("${DNF_DESKTOP_PACKAGES[@]:-}")
APT_DESKTOP_PACKAGES=("${APT_DESKTOP_PACKAGES[@]:-}")
PACMAN_DESKTOP_PACKAGES=("${PACMAN_DESKTOP_PACKAGES[@]:-}")
DNF_PACKAGES=("${DUO_COMMON_DNF_PACKAGES[@]}" "${DNF_DESKTOP_PACKAGES[@]}")
APT_PACKAGES=("${DUO_COMMON_APT_PACKAGES[@]}" "${APT_DESKTOP_PACKAGES[@]}")
PACMAN_PACKAGES=("${DUO_COMMON_PACMAN_PACKAGES[@]}" "${PACMAN_DESKTOP_PACKAGES[@]}")
if [ -n "${MANUAL_DESKTOP_DEPENDENCIES_HINT:-}" ]; then
MANUAL_DEPENDENCIES_HINT="${DUO_COMMON_MANUAL_DEPENDENCIES_HINT}, ${MANUAL_DESKTOP_DEPENDENCIES_HINT}"
else
MANUAL_DEPENDENCIES_HINT="${DUO_COMMON_MANUAL_DEPENDENCIES_HINT}"
fi
}
run_duo_setup() {
: "${SETUP_SCRIPT_NAME:?SETUP_SCRIPT_NAME is required}"
DEFAULT_BACKLIGHT="${DEFAULT_BACKLIGHT:-${DUO_DEFAULT_BACKLIGHT}}"
DEFAULT_SCALE="${DEFAULT_SCALE:-${DUO_DEFAULT_SCALE}}"
USB_MEDIA_REMAP_ENABLED="${USB_MEDIA_REMAP_ENABLED:-${DUO_DEFAULT_USB_MEDIA_REMAP_ENABLED}}"
START_ON_BOOT_MINIMIZED="${START_ON_BOOT_MINIMIZED:-${DUO_DEFAULT_START_ON_BOOT_MINIMIZED}}"
build_duo_setup_package_lists
parse_duo_setup_args "$@"
resolve_duo_target_user
migrate_duo_sudoers
prompt_duo_defaults
install_duo_dependencies
install_duo_build_dependencies
configure_duo_sudoers
configure_duo_input_access
write_duo_settings_defaults
install_duo_rust_runtime
echo "Install complete."
}
parse_duo_setup_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--usb-media-remap)
USB_MEDIA_REMAP_ENABLED=true
shift
;;
--no-usb-media-remap)
USB_MEDIA_REMAP_ENABLED=false
shift
;;
*)
# Unknown flag/arg - ignore to keep backwards compatibility.
shift
;;
esac
done
}
resolve_duo_target_user() {
TARGET_USER="${USER:-}"
if [ -z "${TARGET_USER}" ]; then
TARGET_USER="$(id -un 2>/dev/null || true)"
fi
if [ "${EUID}" = "0" ]; then
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
TARGET_USER="${SUDO_USER}"
else
echo "ERROR: ${SETUP_SCRIPT_NAME} must be run from a real user session."
echo "Run: ./${SETUP_SCRIPT_NAME}"
exit 1
fi
fi
TARGET_UID="$(id -u "${TARGET_USER}" 2>/dev/null || true)"
TARGET_HOME="$(getent passwd "${TARGET_USER}" 2>/dev/null | cut -d: -f6)"
if [ -z "${TARGET_UID}" ] || [ -z "${TARGET_HOME}" ]; then
echo "ERROR: failed to resolve TARGET_USER=${TARGET_USER}"
exit 1
fi
}
duo_prompt() {
local prompt="$1"
local reply_var="$2"
local value=""
if [ -r /dev/tty ]; then
read -r -p "${prompt}" value </dev/tty
else
read -r -p "${prompt}" value
fi
printf -v "${reply_var}" '%s' "${value}"
}
prompt_duo_defaults() {
local input=""
duo_prompt "What would you like to use for the default keyboard backlight brightness [0-3] (default: ${DEFAULT_BACKLIGHT})? " input
DEFAULT_BACKLIGHT="${input:-${DEFAULT_BACKLIGHT}}"
duo_prompt "What would you like to use for monitor scale (1 = 100%, 1.5 = 150%, 1.66 = 166%, 2=200%) (default: ${DEFAULT_SCALE})? " input
DEFAULT_SCALE="${input:-${DEFAULT_SCALE}}"
local remap_answer=""
local remap_prompt="Enable USB Media Remap by default? [Y/n] "
if [ "${USB_MEDIA_REMAP_ENABLED}" != true ]; then
remap_prompt="Enable USB Media Remap by default? [y/N] "
fi
duo_prompt "${remap_prompt}" remap_answer
case "${remap_answer}" in
[yY]|[yY][eE][sS])
USB_MEDIA_REMAP_ENABLED=true
;;
[nN]|[nN][oO])
USB_MEDIA_REMAP_ENABLED=false
;;
esac
}
install_duo_dependencies() {
if command -v dnf >/dev/null 2>&1; then
sudo dnf install -y "${DNF_PACKAGES[@]}"
elif command -v apt >/dev/null 2>&1; then
sudo apt install -y "${APT_PACKAGES[@]}"
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -S --needed --noconfirm "${PACMAN_PACKAGES[@]}"
else
echo "Unsupported package manager. Please install dependencies manually:"
echo " ${MANUAL_DEPENDENCIES_HINT}"
exit 1
fi
}
install_duo_build_dependencies() {
echo "Installing Rust/Tauri build dependencies..."
if command -v dnf >/dev/null 2>&1; then
sudo dnf install -y "${DUO_COMMON_DNF_BUILD_PACKAGES[@]}"
sudo dnf install -y libappindicator-gtk3-devel \
|| sudo dnf install -y libappindicator3-devel \
|| sudo dnf install -y ayatana-appindicator3-devel \
|| echo "WARN: Could not install an AppIndicator *-devel package." >&2
elif command -v apt >/dev/null 2>&1; then
sudo apt install -y "${DUO_COMMON_APT_BUILD_PACKAGES[@]}"
sudo apt install -y libayatana-appindicator3-dev \
|| sudo apt install -y libappindicator3-dev \
|| echo "WARN: Could not install an AppIndicator *-dev package." >&2
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -S --needed --noconfirm "${DUO_COMMON_PACMAN_BUILD_PACKAGES[@]}"
sudo pacman -S --needed --noconfirm libayatana-appindicator \
|| echo "WARN: Could not install libayatana-appindicator." >&2
else
echo "WARN: Could not install build dependencies automatically." >&2
echo "Install GTK/WebKit dev packages manually before the Rust runtime build." >&2
fi
}
remove_legacy_duo_sudoers_entry() {
if sudo grep -q "/usr/bin/tee /sys/class/backlight/.*/brightness" /etc/sudoers 2>/dev/null; then
sudo sed -i '\|/usr/bin/tee /sys/class/backlight/.*/brightness|d' /etc/sudoers
fi
}
write_duo_sudoers_dropin() {
if [ -z "${TARGET_USER}" ]; then
return 0
fi
local backlight_entries=()
local backlight_dir
for backlight_dir in /sys/class/backlight/*; do
[ -e "${backlight_dir}/brightness" ] || continue
backlight_entries+=("${TARGET_USER} ALL=NOPASSWD:/usr/bin/tee ${backlight_dir}/brightness")
done
if [ "${#backlight_entries[@]}" -eq 0 ]; then
echo "WARN: No backlight brightness paths found; skipping sudoers drop-in." >&2
return 0
fi
{
echo "# Managed by Zenbook Duo installer."
printf '%s\n' "${backlight_entries[@]}"
} | sudo tee "${DUO_SUDOERS_DROPIN_PATH}" >/dev/null
sudo chmod 0440 "${DUO_SUDOERS_DROPIN_PATH}"
}
migrate_duo_sudoers() {
remove_legacy_duo_sudoers_entry
}
configure_duo_sudoers() {
write_duo_sudoers_dropin
}
configure_duo_input_access() {
if [ -n "${TARGET_USER}" ] && ! groups "${TARGET_USER}" | grep -q '\binput\b'; then
sudo usermod -aG input "${TARGET_USER}"
echo "Added ${TARGET_USER} to input group (logout/login required for full effect)"
fi
echo 'SUBSYSTEM=="input", ATTRS{name}=="*ASUS Zenbook Duo Keyboard", TAG+="uaccess", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/90-zenbook-duo-keyboard.rules >/dev/null
# Do not install a hwdb remap for KEYBOARD_KEY_7003*: it overrides the USB Fn layer.
sudo rm -f /etc/udev/hwdb.d/90-zenbook-duo-keyboard.hwdb
sudo systemd-hwdb update
sudo udevadm trigger
}
write_duo_settings_defaults() {
local python3=""
python3="$(command -v python3 || true)"
[ -n "${python3}" ] || return 0
[ -n "${TARGET_HOME}" ] || return 0
local config_dir="${TARGET_HOME}/.config/zenbook-duo"
local settings_file="${config_dir}/settings.json"
local start_on_boot_minimized="${START_ON_BOOT_MINIMIZED:-${DUO_DEFAULT_START_ON_BOOT_MINIMIZED}}"
local python_cmd=("${python3}")
if [ "${TARGET_USER}" != "${USER:-}" ] || [ "${EUID}" = "0" ]; then
sudo -u "${TARGET_USER}" mkdir -p "${config_dir}" 2>/dev/null || true
python_cmd=(sudo -u "${TARGET_USER}" "${python3}")
else
mkdir -p "${config_dir}" 2>/dev/null || true
fi
"${python_cmd[@]}" - "${settings_file}" "${DEFAULT_BACKLIGHT}" "${DEFAULT_SCALE}" "${USB_MEDIA_REMAP_ENABLED}" "${start_on_boot_minimized}" "${DUO_INSTALLER_MARKS_SETUP_COMPLETED}" <<'PY'
import json
import sys
from pathlib import Path
settings_file = Path(sys.argv[1])
default_backlight = int(sys.argv[2])
default_scale = float(sys.argv[3])
usb_media_remap_enabled = sys.argv[4].strip().lower() in ("1", "true", "yes", "y", "on")
start_on_boot_minimized = sys.argv[5].strip().lower() in ("1", "true", "yes", "y", "on")
setup_completed = sys.argv[6].strip().lower() in ("1", "true", "yes", "y", "on")
settings_file.parent.mkdir(parents=True, exist_ok=True)
data = {}
if settings_file.exists():
try:
loaded = json.loads(settings_file.read_text())
if isinstance(loaded, dict):
data = loaded
except Exception:
data = {}
data.setdefault("autoDualScreen", True)
data.setdefault("syncBrightness", True)
data.setdefault("theme", "system")
data.setdefault("invertSensorRotation", False)
data["defaultBacklight"] = default_backlight
data["defaultScale"] = default_scale
data["usbMediaRemapEnabled"] = usb_media_remap_enabled
data["startOnBootMinimized"] = start_on_boot_minimized
data["setupCompleted"] = setup_completed
settings_file.write_text(json.dumps(data, indent=2) + "\n")
PY
}
install_duo_rust_runtime() {
local args=()
case "${DUO_RUNTIME_MODE:-}" in
build-only)
args+=(--build-only)
echo "Building Rust runtime..."
;;
install-only)
args+=(--install-only)
echo "Installing Rust runtime..."
;;
*)
echo "Building and installing Rust runtime..."
;;
esac
"${DUO_SETUP_DIR}/install-rust-runtime.sh" "${args[@]}"
}
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Installation script for ASUS Zenbook Duo Linux dual-screen management (GNOME).
# Installs dependencies, configures sudoers and udev rules, and installs the
# Rust runtime services.
set -euo pipefail
DUO_SETUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)"
SETUP_SCRIPT_NAME="setup-gnome.sh"
DNF_DESKTOP_PACKAGES=(mutter)
APT_DESKTOP_PACKAGES=(mutter-common-bin)
PACMAN_DESKTOP_PACKAGES=(mutter)
MANUAL_DESKTOP_DEPENDENCIES_HINT="mutter/gdctl"
# shellcheck source=setup-common.sh
source "${DUO_SETUP_DIR}/setup-common.sh"
run_duo_setup "$@"
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Installation script for ASUS Zenbook Duo Linux dual-screen management (KDE).
# Installs dependencies, configures sudoers and udev rules, and installs the
# Rust runtime services.
set -euo pipefail
DUO_SETUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)"
SETUP_SCRIPT_NAME="setup-kde.sh"
DNF_DESKTOP_PACKAGES=(kscreen)
APT_DESKTOP_PACKAGES=(kscreen)
PACMAN_DESKTOP_PACKAGES=(kscreen)
MANUAL_DESKTOP_DEPENDENCIES_HINT="kscreen/kscreen-doctor"
# shellcheck source=setup-common.sh
source "${DUO_SETUP_DIR}/setup-common.sh"
run_duo_setup "$@"
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Installation script for ASUS Zenbook Duo Linux dual-screen management (Niri).
# Installs dependencies, configures sudoers and udev rules, and installs the
# Rust runtime services.
set -euo pipefail
DUO_SETUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)"
SETUP_SCRIPT_NAME="setup-niri.sh"
DNF_DESKTOP_PACKAGES=(niri)
APT_DESKTOP_PACKAGES=(niri)
PACMAN_DESKTOP_PACKAGES=(niri)
MANUAL_DESKTOP_DEPENDENCIES_HINT="niri"
# shellcheck source=setup-common.sh
source "${DUO_SETUP_DIR}/setup-common.sh"
run_duo_setup "$@"
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
output="$(cat "${ROOT_DIR}/install.sh" | bash -s -- --help 2>&1)" || {
echo "FAIL: piped install.sh --help should succeed" >&2
exit 1
}
if [[ "${output}" != *"install.sh - unified installer for Zenbook Duo Linux"* ]]; then
echo "FAIL: help output missing expected banner" >&2
exit 1
fi
empty_array_output="$(bash --noprofile --norc -c 'set -euo pipefail; BASH_SOURCE=(); source <(head -n 9 "'"${ROOT_DIR}"'/install.sh"); printf "%s\n" "${SCRIPT_DIR}"' 2>&1)" || {
echo "FAIL: init should survive an empty BASH_SOURCE array" >&2
exit 1
}
if [[ "${empty_array_output}" != "$(pwd)" ]]; then
echo "FAIL: empty-array fallback should use current working directory" >&2
exit 1
fi
temp_root="$(mktemp -d)"
trap 'rm -rf "${temp_root}"' EXIT
fake_bin="${temp_root}/bin"
mkdir -p "${fake_bin}"
cat > "${fake_bin}/git" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" != "clone" ]]; then
echo "unexpected git args: $*" >&2
exit 1
fi
target="${@: -1}"
mkdir -p "${target}"
touch "${target}/setup-common.sh" "${target}/setup-gnome.sh" "${target}/setup-kde.sh" "${target}/setup-niri.sh" "${target}/install-ui.sh"
EOF
chmod +x "${fake_bin}/git"
checkout_output="$(bash --noprofile --norc -c '
set -euo pipefail
export PATH="'"${fake_bin}"':/usr/bin:/bin"
source <(sed -n "1,128p" "'"${ROOT_DIR}"'/install.sh")
SCRIPT_DIR="'"${temp_root}"'/missing-checkout"
ensure_repo_checkout
' 2>/dev/null)" || {
echo "FAIL: ensure_repo_checkout should succeed with fake git" >&2
exit 1
}
if [[ "${checkout_output}" == *"Downloading zenbook-duo-linux..."* ]]; then
echo "FAIL: ensure_repo_checkout should not mix status logs into stdout" >&2
exit 1
fi
if [[ ! -f "${checkout_output}/setup-niri.sh" ]]; then
echo "FAIL: ensure_repo_checkout should print the checkout path" >&2
exit 1
fi
desktop_output="$(bash --noprofile --norc -c '
set -euo pipefail
source <(sed -n "1,128p" "'"${ROOT_DIR}"'/install.sh")
XDG_CURRENT_DESKTOP=GNOME DESKTOP_SESSION= XDG_SESSION_DESKTOP= pick_desktop
XDG_CURRENT_DESKTOP="KDE Plasma" DESKTOP_SESSION= XDG_SESSION_DESKTOP= pick_desktop
XDG_CURRENT_DESKTOP= DESKTOP_SESSION= XDG_SESSION_DESKTOP=niri pick_desktop
' 2>/dev/null)" || {
echo "FAIL: pick_desktop should detect all supported desktops" >&2
exit 1
}
if [[ "${desktop_output}" != $'gnome\nkde\nniri' ]]; then
echo "FAIL: pick_desktop should map GNOME, KDE, and Niri consistently" >&2
exit 1
fi
assert_setup_packages() {
local label="$1"
local dnf_desktop="$2"
local apt_desktop="$3"
local pacman_desktop="$4"
local manual_hint="$5"
local output=""
output="$(bash --noprofile --norc -c '
set -euo pipefail
source "$1"
DNF_DESKTOP_PACKAGES=("$2")
APT_DESKTOP_PACKAGES=("$3")
PACMAN_DESKTOP_PACKAGES=("$4")
MANUAL_DESKTOP_DEPENDENCIES_HINT="$5"
build_duo_setup_package_lists
printf "dnf:%s\napt:%s\npacman:%s\nhint:%s\n" \
"${DNF_PACKAGES[*]}" \
"${APT_PACKAGES[*]}" \
"${PACMAN_PACKAGES[*]}" \
"${MANUAL_DEPENDENCIES_HINT}"
' _ "${ROOT_DIR}/setup-common.sh" "${dnf_desktop}" "${apt_desktop}" "${pacman_desktop}" "${manual_hint}")" || {
echo "FAIL: setup package matrix should build for ${label}" >&2
exit 1
}
local expected=$'dnf:usbutils iio-sensor-proxy systemd '"${dnf_desktop}"$'\napt:usbutils iio-sensor-proxy systemd '"${apt_desktop}"$'\npacman:usbutils iio-sensor-proxy systemd '"${pacman_desktop}"$'\nhint:usbutils, iio-sensor-proxy, systemd, '"${manual_hint}"
if [[ "${output}" != "${expected}" ]]; then
echo "FAIL: setup package matrix mismatch for ${label}" >&2
echo "Got:" >&2
echo "${output}" >&2
exit 1
fi
}
assert_setup_packages gnome mutter mutter-common-bin mutter mutter/gdctl
assert_setup_packages kde kscreen kscreen kscreen kscreen/kscreen-doctor
assert_setup_packages niri niri niri niri niri
if ! grep -q 'WantedBy=default.target' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: user service should be enabled from default.target" >&2
exit 1
fi
if grep -q 'Wants=graphical-session.target' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: user service should not pull in graphical-session.target" >&2
exit 1
fi
if ! grep -q 'After=graphical-session.target' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: user service should still order itself after graphical-session.target" >&2
exit 1
fi
if ! grep -q 'StartLimitIntervalSec=0' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: user service should keep retrying while the graphical session environment is not ready" >&2
exit 1
fi
if ! grep -Fq 'ExecStartPre=/bin/sh -c '\''test -n "\${DISPLAY:-}\${WAYLAND_DISPLAY:-}\${NIRI_SOCKET:-}"'\''' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: user service should wait for graphical session variables before starting" >&2
exit 1
fi
if ! grep -q 'RestartSec=2' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: user service should retry startup at a bounded cadence" >&2
exit 1
fi
if ! grep -q 'import-environment DISPLAY WAYLAND_DISPLAY NIRI_SOCKET XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP DESKTOP_SESSION XDG_SESSION_TYPE' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: installer should import graphical session environment for the user manager" >&2
exit 1
fi
if ! grep -q 'ensure_system_manager_available' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should preflight the system service manager before building" >&2
exit 1
fi
if ! grep -q 'run_system_systemctl daemon-reload' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should wrap required system daemon reloads with clear errors" >&2
exit 1
fi
if ! grep -q 'ensure_user_manager_available' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should preflight the target user service manager" >&2
exit 1
fi
if ! grep -q 'run_user_systemctl_checked daemon-reload' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should hard-fail required user daemon reloads with clear errors" >&2
exit 1
fi
if ! grep -Fq 'local bus_address="unix:path=${runtime_dir}/bus"' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should avoid stale user bus environment when calling systemctl --user" >&2
exit 1
fi
if ! grep -Fq 'DBUS_SESSION_BUS_ADDRESS="${bus_address}"' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should pass the stable user bus to systemctl --user" >&2
exit 1
fi
for service_name in \
'SYSTEM_SERVICE_NAME="zenbook-duo-rust-daemon.service"' \
'LIFECYCLE_SERVICE_NAME="zenbook-duo-rust-lifecycle.service"' \
'USER_SERVICE_NAME="zenbook-duo-session-agent.service"'; do
if ! grep -q "${service_name}" "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should keep service name ${service_name}" >&2
exit 1
fi
done
if ! grep -q 'Installed Rust runtime versions:' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should print installed runtime versions" >&2
exit 1
fi
if ! grep -Fq '"${RUNTIME_INSTALL_DIR}/${binary}" --version' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: runtime installer should call installed binaries with --version" >&2
exit 1
fi
if ! grep -q 'install_duo_build_dependencies' "${ROOT_DIR}/setup-common.sh"; then
echo "FAIL: setup-common.sh should install build dependencies before the Rust runtime" >&2
exit 1
fi
if ! grep -q 'DUO_SUDOERS_DROPIN_PATH="/etc/sudoers.d/zenbook-duo"' "${ROOT_DIR}/setup-common.sh"; then
echo "FAIL: setup-common.sh should write sudoers rules into /etc/sudoers.d" >&2
exit 1
fi
if grep -Fq 'ALL=NOPASSWD:/usr/bin/tee /sys/class/backlight/*/brightness' "${ROOT_DIR}/setup-common.sh"; then
echo "FAIL: setup-common.sh should not write wildcard sudoers command arguments" >&2
exit 1
fi
if ! grep -A20 'run_duo_setup()' "${ROOT_DIR}/setup-common.sh" | grep -q 'install_duo_build_dependencies'; then
echo "FAIL: run_duo_setup should call install_duo_build_dependencies before install_duo_rust_runtime" >&2
exit 1
fi
for binary_name in \
zenbook-duo-daemon \
zenbook-duo-session-agent \
zenbook-duo-lifecycle \
zenbook-duo-usb-remap-helper \
usb-media-remap; do
binary_path="src/bin/${binary_name}.rs"
binary_file="${ROOT_DIR}/ui-tauri-react/src-tauri/${binary_path}"
if ! grep -Fq 'print_and_exit_if_requested' "${binary_file}" \
|| ! grep -Fq "\"${binary_name}\"" "${binary_file}"; then
echo "FAIL: runtime binary ${binary_path} should support --version" >&2
exit 1
fi
done
if ! grep -q 'SYSTEM_SLEEP_HOOK_PATH="/usr/lib/systemd/system-sleep/zenbook-duo-rust-lifecycle"' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: installer should install the Rust lifecycle sleep hook" >&2
exit 1
fi
if ! grep -q 'ln -sfn "${RUNTIME_INSTALL_DIR}/zenbook-duo-lifecycle" "${SYSTEM_SLEEP_HOOK_PATH}"' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: lifecycle binary should be the single system-sleep entrypoint" >&2
exit 1
fi
if ! grep -q 'run_user_systemctl_best_effort daemon-reload' "${ROOT_DIR}/uninstall.sh"; then
echo "FAIL: uninstaller should not fail cleanup when the user manager daemon reload is unavailable" >&2
exit 1
fi
if ! grep -q 'run_user_systemctl_best_effort stop zenbook-duo-session-agent.service' "${ROOT_DIR}/uninstall.sh"; then
echo "FAIL: uninstaller should treat user service cleanup as best-effort" >&2
exit 1
fi
if grep -q 'ExecStart=.*\(pre\|post\|thaw\|hibernate\)' "${ROOT_DIR}/install-rust-runtime.sh"; then
echo "FAIL: lifecycle service should not duplicate suspend/resume handling" >&2
exit 1
fi
if ! grep -q 'command -v pacman' "${ROOT_DIR}/setup-common.sh"; then
echo "FAIL: shared setup helper should support pacman-based systems" >&2
exit 1
fi
for common_packages in \
'DUO_COMMON_DNF_PACKAGES=(usbutils iio-sensor-proxy systemd)' \
'DUO_COMMON_APT_PACKAGES=(usbutils iio-sensor-proxy systemd)' \
'DUO_COMMON_PACMAN_PACKAGES=(usbutils iio-sensor-proxy systemd)'; do
if ! grep -q "${common_packages}" "${ROOT_DIR}/setup-common.sh"; then
echo "FAIL: shared setup helper should centralize common dependency list ${common_packages}" >&2
exit 1
fi
done
remap_default_output="$(bash --noprofile --norc -c '
set -euo pipefail
source "'"${ROOT_DIR}"'/setup-common.sh"
duo_prompt() { printf -v "$2" ""; }
DEFAULT_BACKLIGHT=0
DEFAULT_SCALE=1.66
USB_MEDIA_REMAP_ENABLED=false
prompt_duo_defaults
printf "%s\n" "${USB_MEDIA_REMAP_ENABLED}"
DEFAULT_BACKLIGHT=0
DEFAULT_SCALE=1.66
USB_MEDIA_REMAP_ENABLED=true
prompt_duo_defaults
printf "%s\n" "${USB_MEDIA_REMAP_ENABLED}"
' 2>&1)" || {
echo "FAIL: prompt_duo_defaults should be callable with mocked prompts" >&2
exit 1
}
if [[ "${remap_default_output}" != $'false\ntrue' ]]; then
echo "FAIL: empty USB remap prompt answer should preserve the current default" >&2
printf 'Got:\n%s\n' "${remap_default_output}" >&2
exit 1
fi
cat > "${temp_root}/settings-defaults-test.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source "$1"
temp_home="$2/home"
mkdir -p "${temp_home}"
TARGET_HOME="${temp_home}"
TARGET_USER="${USER:-$(id -un)}"
DEFAULT_BACKLIGHT="${DUO_DEFAULT_BACKLIGHT}"
DEFAULT_SCALE="${DUO_DEFAULT_SCALE}"
USB_MEDIA_REMAP_ENABLED="${DUO_DEFAULT_USB_MEDIA_REMAP_ENABLED}"
write_duo_settings_defaults
python3 - "${temp_home}/.config/zenbook-duo/settings.json" <<'PY'
import json
import sys
from pathlib import Path
settings = json.loads(Path(sys.argv[1]).read_text())
expected = {
"defaultBacklight": 0,
"defaultScale": 1.66,
"usbMediaRemapEnabled": True,
"startOnBootMinimized": False,
"setupCompleted": True,
"autoDualScreen": True,
"syncBrightness": True,
"theme": "system",
}
for key, value in expected.items():
if settings.get(key) != value:
raise SystemExit(f"{key} expected {value!r}, got {settings.get(key)!r}")
PY
EOF
bash "${temp_root}/settings-defaults-test.sh" "${ROOT_DIR}/setup-common.sh" "${temp_root}" || {
echo "FAIL: installer-written settings defaults should match the compatibility contract" >&2
exit 1
}
if ! grep -q 'pub const DEFAULT_BACKLIGHT_LEVEL: u8 = 0;' "${ROOT_DIR}/ui-tauri-react/src-tauri/src/models/settings.rs"; then
echo "FAIL: Rust settings default backlight should match installer defaults" >&2
exit 1
fi
if ! grep -q '0o666' "${ROOT_DIR}/ui-tauri-react/src-tauri/src/runtime/daemon.rs"; then
echo "FAIL: daemon socket should be accessible across local user sessions" >&2
exit 1
fi
if ! grep -q 'defaultBacklight: 0,' "${ROOT_DIR}/ui-tauri-react/src/lib/defaults.ts"; then
echo "FAIL: frontend settings default backlight should match installer defaults" >&2
exit 1
fi
if ! grep -q 'startOnBootMinimized: false,' "${ROOT_DIR}/ui-tauri-react/src/lib/defaults.ts"; then
echo "FAIL: frontend settings default startup preference should match installer defaults" >&2
exit 1
fi
for setup_script in setup-gnome.sh setup-kde.sh setup-niri.sh; do
expected_name="${setup_script}"
if ! grep -q "SETUP_SCRIPT_NAME=\"${expected_name}\"" "${ROOT_DIR}/${setup_script}"; then
echo "FAIL: ${setup_script} should declare its setup script name for shared errors" >&2
exit 1
fi
if ! grep -q 'source "${DUO_SETUP_DIR}/setup-common.sh"' "${ROOT_DIR}/${setup_script}"; then
echo "FAIL: ${setup_script} should delegate to setup-common.sh" >&2
exit 1
fi
if ! grep -q 'DNF_DESKTOP_PACKAGES=' "${ROOT_DIR}/${setup_script}"; then
echo "FAIL: ${setup_script} should declare only desktop-specific dnf dependencies" >&2
exit 1
fi
if ! grep -q 'APT_DESKTOP_PACKAGES=' "${ROOT_DIR}/${setup_script}"; then
echo "FAIL: ${setup_script} should declare only desktop-specific apt dependencies" >&2
exit 1
fi
if ! grep -q 'PACMAN_DESKTOP_PACKAGES=' "${ROOT_DIR}/${setup_script}"; then
echo "FAIL: ${setup_script} should declare only desktop-specific pacman dependencies" >&2
exit 1
fi
if grep -q '^DNF_PACKAGES=' "${ROOT_DIR}/${setup_script}" || grep -q '^APT_PACKAGES=' "${ROOT_DIR}/${setup_script}" || grep -q '^PACMAN_PACKAGES=' "${ROOT_DIR}/${setup_script}"; then
echo "FAIL: ${setup_script} should not duplicate common package arrays" >&2
exit 1
fi
done
if ! grep -q 'PKG_MGR="pacman"' "${ROOT_DIR}/install-ui.sh"; then
echo "FAIL: install-ui.sh should detect pacman-based systems" >&2
exit 1
fi
if ! grep -q 'install_ui_direct' "${ROOT_DIR}/install-ui.sh"; then
echo "FAIL: install-ui.sh should have a direct-install path for pacman systems" >&2
exit 1
fi
if ! grep -q 'command -v pkexec' "${ROOT_DIR}/install-ui.sh"; then
echo "FAIL: install-ui.sh should prefer pkexec when a graphical auth prompt is available" >&2
exit 1
fi
if ! grep -q -- '--root-install-prereqs' "${ROOT_DIR}/install-ui.sh"; then
echo "FAIL: install-ui.sh should expose a root-only prereq helper entrypoint" >&2
exit 1
fi
if ! grep -q -- '--root-install-package' "${ROOT_DIR}/install-ui.sh"; then
echo "FAIL: install-ui.sh should expose a root-only package install helper entrypoint" >&2
exit 1
fi
if ! grep -q -- '--root-install-direct' "${ROOT_DIR}/install-ui.sh"; then
echo "FAIL: install-ui.sh should expose a root-only direct install helper entrypoint" >&2
exit 1
fi
if ! grep -q 'sudo pacman -Rns --noconfirm zenbook-duo-control' "${ROOT_DIR}/uninstall.sh"; then
echo "FAIL: uninstall.sh should try pacman removal when available" >&2
exit 1
fi
echo "PASS"
@@ -0,0 +1,7 @@
[build]
# Optional: set this to a sensible value for your machine.
# jobs = 8
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+37
View File
@@ -0,0 +1,37 @@
# Zenbook Duo Control UI
## Build commands
- `npm run build`: full Tauri release build with configured bundles.
- `npm run build:local`: release build without packaging bundles. Faster for local verification.
- `npm run build:frontend`: frontend-only production build.
- `npm run build:rust`: Rust-only release build for the Tauri backend.
## Faster builds
The biggest speed win during iteration is to avoid full packaging unless you need `.deb` or `.rpm`.
- Use `npm run build:local` for local release checks.
- Use `npm run build:frontend` when changing only the UI.
- Use `npm run build:rust` when changing only Rust code.
- Use `npm run dev` during active development.
Cargo already uses multiple cores by default. You can cap or tune it explicitly when useful:
```bash
CARGO_BUILD_JOBS=8 npm run build:local
```
If `sccache` is installed, you can enable Rust compile caching:
```bash
RUSTC_WRAPPER=sccache npm run build:local
```
If `mold` is installed on Linux, you can reduce link time with a local Cargo config. An example is provided in `.cargo/config.toml.example`.
To set up the recommended local tooling automatically:
```bash
./scripts/setup-fast-build.sh
```
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-mira",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src-tauri/target']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// shadcn-style modules intentionally export helpers next to components.
'react-refresh/only-export-components': 'off',
// Existing memoized views are valid; don't fail lint when React Compiler skips them.
'react-hooks/preserve-manual-memoization': 'off',
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zenbook Duo Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9590
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "ui-tauri-react",
"private": true,
"version": "0.3.7",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"build:local": "tauri build --no-bundle",
"build:frontend": "vite build",
"build:rust": "cargo build --manifest-path src-tauri/Cargo.toml --release",
"vite:dev": "vite",
"vite:build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-resizable-panels": "^4.7.5",
"recharts": "^3.8.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@tauri-apps/cli": "^2.10.1",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1"
}
}
+7030
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
EXAMPLE_CONFIG="$ROOT_DIR/.cargo/config.toml.example"
LOCAL_CONFIG="$ROOT_DIR/.cargo/config.toml"
have_cmd() {
command -v "$1" >/dev/null 2>&1
}
install_with_manager() {
if have_cmd pacman; then
sudo pacman -S --needed clang mold sccache
return
fi
if have_cmd apt-get; then
sudo apt-get update
sudo apt-get install -y clang mold sccache
return
fi
if have_cmd dnf; then
sudo dnf install -y clang mold sccache
return
fi
if have_cmd zypper; then
sudo zypper install -y clang mold sccache
return
fi
echo "Unsupported package manager. Install these manually: clang mold sccache" >&2
exit 1
}
echo "Checking build tools..."
missing=0
for tool in clang mold sccache; do
if have_cmd "$tool"; then
echo " found: $tool"
else
echo " missing: $tool"
missing=1
fi
done
if [[ "$missing" -eq 1 ]]; then
echo
echo "Installing missing tools..."
install_with_manager
else
echo
echo "All recommended tools are already installed."
fi
mkdir -p "$(dirname "$LOCAL_CONFIG")"
if [[ -f "$LOCAL_CONFIG" ]]; then
echo
echo "Keeping existing Cargo config at $LOCAL_CONFIG"
else
cp "$EXAMPLE_CONFIG" "$LOCAL_CONFIG"
echo
echo "Wrote $LOCAL_CONFIG from template."
fi
cat <<'EOF'
Recommended usage:
CARGO_BUILD_JOBS=8 RUSTC_WRAPPER=sccache npm run build:local
Adjust the jobs value for your machine.
EOF
+5505
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
[package]
name = "zenbook-duo-control"
version = "0.3.7"
edition = "2021"
description = "Zenbook Duo Linux Control Panel"
default-run = "zenbook-duo-control"
[lib]
name = "zenbook_duo_control_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "zenbook-duo-control"
path = "src/main.rs"
[[bin]]
name = "usb-media-remap"
path = "src/bin/usb-media-remap.rs"
[[bin]]
name = "zenbook-duo-daemon"
path = "src/bin/zenbook-duo-daemon.rs"
[[bin]]
name = "zenbook-duo-session-agent"
path = "src/bin/zenbook-duo-session-agent.rs"
[[bin]]
name = "zenbook-duo-lifecycle"
path = "src/bin/zenbook-duo-lifecycle.rs"
[[bin]]
name = "zenbook-duo-usb-remap-helper"
path = "src/bin/zenbook-duo-usb-remap-helper.rs"
[build-dependencies]
tauri-build = { version = "2.5", features = [] }
[dependencies]
tauri = { version = "2.10", features = ["tray-icon"] }
tauri-plugin-shell = "2.3"
tauri-plugin-single-instance = "2.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
notify = "7"
rusb = "0.9"
zbus = "5"
nix = { version = "0.29", features = ["ioctl", "fs", "user", "signal", "process"] }
dirs = "6"
chrono = { version = "0.4", features = ["serde"] }
log = "0.4"
env_logger = "0.11"
futures-util = "0.3"
libc = "0.2"
evdev = "0.12"
signal-hook = "0.3"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/tauri/refs/heads/dev/crates/tauri-utils/schema.json",
"identifier": "default",
"description": "Default permissions for the Zenbook Duo Control app",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-start-dragging",
"shell:allow-spawn",
"shell:allow-execute"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default permissions for the Zenbook Duo Control app","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-start-dragging","shell:allow-spawn","shell:allow-execute"]}}
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Zenbook Duo Control
Comment=Zenbook Duo Linux Control Panel
Exec=zenbook-duo-control
Icon=zenbook-duo-control
Terminal=false
Categories=Utility;Settings;
StartupNotify=true
@@ -0,0 +1,9 @@
fn main() {
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested("usb-media-remap");
if let Err(err) = zenbook_duo_control_lib::usb_media_remap_helper::run_from_env() {
zenbook_duo_control_lib::usb_media_remap_helper::log_error(&err);
eprintln!("{err}");
std::process::exit(1);
}
}
@@ -0,0 +1,10 @@
#[tokio::main]
async fn main() {
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested("zenbook-duo-daemon");
env_logger::init();
if let Err(err) = zenbook_duo_control_lib::runtime::daemon::run().await {
eprintln!("{err}");
std::process::exit(1);
}
}
@@ -0,0 +1,59 @@
use zenbook_duo_control_lib::hardware::{hid, sysfs};
use zenbook_duo_control_lib::ipc::protocol::{DaemonRequest, DaemonResponse, LifecyclePhase};
use zenbook_duo_control_lib::runtime::client;
fn main() {
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested("zenbook-duo-lifecycle");
env_logger::init();
let Some(raw_phase) = std::env::args().nth(1) else {
eprintln!("usage: zenbook-duo-lifecycle <pre|post|hibernate|thaw|boot|shutdown>");
std::process::exit(2);
};
let phase = match parse_phase(&raw_phase) {
Ok(phase) => phase,
Err(message) => {
eprintln!("{message}");
std::process::exit(2);
}
};
let result = match client::request(DaemonRequest::HandleLifecycle {
phase: phase.clone(),
}) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => fallback_lifecycle(&phase)
.map_err(|fallback| format!("{message}; fallback failed: {fallback}")),
Ok(_) => fallback_lifecycle(&phase),
Err(_) => fallback_lifecycle(&phase),
};
if let Err(err) = result {
eprintln!("{err}");
std::process::exit(1);
}
}
fn parse_phase(raw: &str) -> Result<LifecyclePhase, String> {
match raw {
"pre" => Ok(LifecyclePhase::Pre),
"post" => Ok(LifecyclePhase::Post),
"hibernate" => Ok(LifecyclePhase::Hibernate),
"thaw" => Ok(LifecyclePhase::Thaw),
"boot" => Ok(LifecyclePhase::Boot),
"shutdown" => Ok(LifecyclePhase::Shutdown),
_ => Err(format!("unsupported lifecycle phase: {raw}")),
}
}
fn fallback_lifecycle(phase: &LifecyclePhase) -> Result<(), String> {
match phase {
LifecyclePhase::Pre | LifecyclePhase::Hibernate | LifecyclePhase::Shutdown => {
hid::set_backlight(0)
}
LifecyclePhase::Post | LifecyclePhase::Thaw | LifecyclePhase::Boot => {
hid::set_backlight(sysfs::read_backlight_level())
}
}
}
@@ -0,0 +1,12 @@
#[tokio::main]
async fn main() {
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested(
"zenbook-duo-session-agent",
);
env_logger::init();
if let Err(err) = zenbook_duo_control_lib::runtime::session_agent::run().await {
eprintln!("{err}");
std::process::exit(1);
}
}
@@ -0,0 +1,15 @@
fn main() {
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested(
"zenbook-duo-usb-remap-helper",
);
env_logger::init();
match zenbook_duo_control_lib::usb_media_remap_helper::run_from_env() {
Ok(()) => std::process::exit(0),
Err(err) => {
zenbook_duo_control_lib::usb_media_remap_helper::log_error(&err);
eprintln!("{err}");
std::process::exit(1);
}
}
}
@@ -0,0 +1,86 @@
use crate::hardware::{hid, sysfs};
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::runtime::client;
#[tauri::command]
pub fn get_backlight() -> u8 {
sysfs::read_backlight_level()
}
#[tauri::command]
pub fn set_backlight(level: u8) -> Result<(), String> {
set_backlight_daemon_first(level)
}
fn daemon_ack_or_transport_fallback(
response: Result<DaemonResponse, String>,
action: &str,
fallback: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
match response {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(other) => Err(format!(
"Unexpected daemon response while {action}: {other:?}"
)),
Err(_) => fallback(),
}
}
pub fn set_backlight_daemon_first(level: u8) -> Result<(), String> {
daemon_ack_or_transport_fallback(
client::request(DaemonRequest::SetBacklight { level }),
"setting backlight",
|| hid::set_backlight(level),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daemon_ack_mapping_surfaces_error_and_unexpected_response() {
assert!(
daemon_ack_or_transport_fallback(Ok(DaemonResponse::Ack), "testing", || {
Err("fallback should not run".into())
})
.is_ok()
);
assert_eq!(
daemon_ack_or_transport_fallback(
Ok(DaemonResponse::Error {
message: "daemon rejected".into(),
}),
"testing",
|| Ok(()),
)
.expect_err("daemon error should fail"),
"daemon rejected"
);
assert!(
daemon_ack_or_transport_fallback(Ok(DaemonResponse::Pong), "testing", || Ok(()))
.expect_err("unexpected response should fail")
.contains("Unexpected daemon response")
);
}
#[test]
fn daemon_ack_mapping_falls_back_only_on_transport_failure() {
assert!(
daemon_ack_or_transport_fallback(Err("socket missing".into()), "testing", || {
Ok(())
})
.is_ok()
);
assert_eq!(
daemon_ack_or_transport_fallback(Err("socket missing".into()), "testing", || {
Err("fallback failed".into())
})
.expect_err("fallback failure should propagate"),
"fallback failed"
);
}
}
@@ -0,0 +1,552 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::io::Write;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvdevDevice {
pub event_path: String,
pub name: String,
pub phys: Option<String>,
pub bustype: Option<String>,
pub vendor: Option<String>,
pub product: Option<String>,
pub cap_ev: Option<String>,
pub cap_key: Option<String>,
pub cap_abs: Option<String>,
pub cap_msc: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvdevEvent {
pub ts_sec: i64,
pub ts_usec: i64,
pub type_code: u16,
pub code: u16,
pub value: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvdevEventMulti {
pub event_path: String,
pub ts_sec: i64,
pub ts_usec: i64,
pub type_code: u16,
pub code: u16,
pub value: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HidDevice {
pub id: String,
pub driver: Option<String>,
pub hid_id: Option<String>,
pub hid_name: Option<String>,
pub hid_phys: Option<String>,
pub hidraw_nodes: Vec<String>,
pub input_event_nodes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportDescriptor {
pub len: usize,
pub hex: String,
pub report_ids: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HidrawSample {
pub ts_ms: u128,
pub hex: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HidrawCapture {
pub hidraw_path: String,
pub samples: Vec<HidrawSample>,
pub stderr: Option<String>,
}
fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
fn is_event_node_name(name: &str) -> bool {
if !name.starts_with("event") {
return false;
}
name["event".len()..].chars().all(|c| c.is_ascii_digit())
}
#[tauri::command]
pub fn diag_list_evdev() -> Result<Vec<EvdevDevice>, String> {
let base = Path::new("/sys/class/input");
let mut out: Vec<EvdevDevice> = Vec::new();
let entries =
fs::read_dir(base).map_err(|e| format!("Failed to read /sys/class/input: {e}"))?;
for ent in entries.flatten() {
let file_name = ent.file_name();
let file_name = file_name.to_string_lossy().to_string();
if !is_event_node_name(&file_name) {
continue;
}
let sys = ent.path();
let dev = sys.join("device");
let name = read_trimmed(dev.join("name")).unwrap_or_else(|| "(unknown)".into());
let phys = read_trimmed(dev.join("phys"));
let bustype = read_trimmed(dev.join("id/bustype"));
let vendor = read_trimmed(dev.join("id/vendor"));
let product = read_trimmed(dev.join("id/product"));
let cap_ev = read_trimmed(dev.join("capabilities/ev"));
let cap_key = read_trimmed(dev.join("capabilities/key"));
let cap_abs = read_trimmed(dev.join("capabilities/abs"));
let cap_msc = read_trimmed(dev.join("capabilities/msc"));
out.push(EvdevDevice {
event_path: format!("/dev/input/{file_name}"),
name,
phys,
bustype,
vendor,
product,
cap_ev,
cap_key,
cap_abs,
cap_msc,
});
}
out.sort_by(|a, b| a.event_path.cmp(&b.event_path));
Ok(out)
}
fn validate_dev_input_event(path: &str) -> Result<(), String> {
if !path.starts_with("/dev/input/event") {
return Err("Only /dev/input/event* paths are allowed".into());
}
let suffix = &path["/dev/input/event".len()..];
if suffix.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
return Err("Invalid event node path".into());
}
Ok(())
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct InputEvent {
time: libc::timeval,
type_: u16,
code: u16,
value: i32,
}
#[tauri::command]
pub fn diag_capture_evdev(event_path: String, seconds: u32) -> Result<Vec<EvdevEvent>, String> {
validate_dev_input_event(&event_path)?;
let seconds = seconds.clamp(1, 30);
let file =
fs::File::open(&event_path).map_err(|e| format!("Failed to open {event_path}: {e}"))?;
let fd = file.as_raw_fd();
// Non-blocking reads.
let flags = nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_GETFL)
.map_err(|e| format!("fcntl(F_GETFL) failed: {e}"))?;
let mut oflags = nix::fcntl::OFlag::from_bits_truncate(flags);
oflags.insert(nix::fcntl::OFlag::O_NONBLOCK);
nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_SETFL(oflags))
.map_err(|e| format!("fcntl(F_SETFL) failed: {e}"))?;
let start = Instant::now();
let deadline = start + Duration::from_secs(seconds as u64);
let mut out: Vec<EvdevEvent> = Vec::new();
let mut buf = vec![0u8; 4096];
while Instant::now() < deadline {
match nix::unistd::read(fd, &mut buf) {
Ok(0) => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(n) => {
let mut offset = 0usize;
while offset + std::mem::size_of::<InputEvent>() <= n {
let ptr = unsafe { buf.as_ptr().add(offset) as *const InputEvent };
let ev = unsafe { *ptr };
out.push(EvdevEvent {
ts_sec: ev.time.tv_sec as i64,
ts_usec: ev.time.tv_usec as i64,
type_code: ev.type_,
code: ev.code,
value: ev.value,
});
offset += std::mem::size_of::<InputEvent>();
}
}
Err(err) => {
if err == nix::errno::Errno::EAGAIN {
std::thread::sleep(Duration::from_millis(10));
continue;
}
return Err(format!("Failed to read {event_path}: {err}"));
}
}
}
Ok(out)
}
#[tauri::command]
pub fn diag_capture_evdev_multi(
event_paths: Vec<String>,
seconds: u32,
) -> Result<Vec<EvdevEventMulti>, String> {
if event_paths.is_empty() {
return Ok(Vec::new());
}
let seconds = seconds.clamp(1, 30);
for p in &event_paths {
validate_dev_input_event(p)?;
}
let deadline = Instant::now() + Duration::from_secs(seconds as u64);
let out: Arc<Mutex<Vec<EvdevEventMulti>>> = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
for path in event_paths {
let out = out.clone();
handles.push(std::thread::spawn(move || {
let file = match fs::File::open(&path) {
Ok(f) => f,
Err(_) => return,
};
let fd = file.as_raw_fd();
let flags = match nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_GETFL) {
Ok(v) => v,
Err(_) => return,
};
let mut oflags = nix::fcntl::OFlag::from_bits_truncate(flags);
oflags.insert(nix::fcntl::OFlag::O_NONBLOCK);
let _ = nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_SETFL(oflags));
let mut buf = vec![0u8; 4096];
while Instant::now() < deadline {
match nix::unistd::read(fd, &mut buf) {
Ok(0) => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(n) => {
let mut offset = 0usize;
while offset + std::mem::size_of::<InputEvent>() <= n {
let ptr = unsafe { buf.as_ptr().add(offset) as *const InputEvent };
let ev = unsafe { *ptr };
let mut guard = out.lock().unwrap();
guard.push(EvdevEventMulti {
event_path: path.clone(),
ts_sec: ev.time.tv_sec as i64,
ts_usec: ev.time.tv_usec as i64,
type_code: ev.type_,
code: ev.code,
value: ev.value,
});
drop(guard);
offset += std::mem::size_of::<InputEvent>();
}
}
Err(err) => {
if err == nix::errno::Errno::EAGAIN {
std::thread::sleep(Duration::from_millis(10));
continue;
}
return;
}
}
}
}));
}
for h in handles {
let _ = h.join();
}
let mut v = out.lock().unwrap().clone();
v.sort_by(|a, b| {
(a.ts_sec, a.ts_usec, a.event_path.clone()).cmp(&(
b.ts_sec,
b.ts_usec,
b.event_path.clone(),
))
});
Ok(v)
}
fn parse_hid_uevent(contents: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for line in contents.lines() {
if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
map
}
fn parse_hid_id(hid_id: &str) -> Option<(String, String, String)> {
// HID_ID=0003:00000B05:00001B2C
let parts: Vec<&str> = hid_id.split(':').collect();
if parts.len() != 3 {
return None;
}
Some((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
))
}
fn collect_hidraw_nodes(dev_path: &Path) -> Vec<String> {
let mut out = Vec::new();
let hidraw_dir = dev_path.join("hidraw");
if let Ok(entries) = fs::read_dir(hidraw_dir) {
for ent in entries.flatten() {
let name = ent.file_name().to_string_lossy().to_string();
if name.starts_with("hidraw") {
out.push(format!("/dev/{name}"));
}
}
}
out.sort();
out
}
fn collect_input_event_nodes(dev_path: &Path) -> Vec<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
let input_dir = dev_path.join("input");
let Ok(entries) = fs::read_dir(input_dir) else {
return Vec::new();
};
for ent in entries.flatten() {
let p = ent.path();
// /sys/bus/hid/devices/.../input/inputNN
if let Ok(sub) = fs::read_dir(&p) {
for sub_ent in sub.flatten() {
let name = sub_ent.file_name().to_string_lossy().to_string();
if is_event_node_name(&name) {
out.insert(format!("/dev/input/{name}"));
}
}
}
}
out.into_iter().collect()
}
#[tauri::command]
pub fn diag_list_hid(vid: String, pid: String) -> Result<Vec<HidDevice>, String> {
let base = Path::new("/sys/bus/hid/devices");
let entries =
fs::read_dir(base).map_err(|e| format!("Failed to read /sys/bus/hid/devices: {e}"))?;
let want_vid = vid.trim().to_ascii_lowercase();
let want_pid = pid.trim().to_ascii_lowercase();
let mut out: Vec<HidDevice> = Vec::new();
for ent in entries.flatten() {
let id = ent.file_name().to_string_lossy().to_string();
let dev_path = ent.path();
let uevent = match fs::read_to_string(dev_path.join("uevent")) {
Ok(s) => s,
Err(_) => continue,
};
let map = parse_hid_uevent(&uevent);
let hid_id = map.get("HID_ID").cloned();
let Some(hid_id_val) = hid_id.clone() else {
continue;
};
let Some((_bus, v, p)) = parse_hid_id(&hid_id_val) else {
continue;
};
if v.trim_start_matches('0').to_ascii_lowercase() != want_vid.trim_start_matches('0')
|| p.trim_start_matches('0').to_ascii_lowercase() != want_pid.trim_start_matches('0')
{
continue;
}
out.push(HidDevice {
id,
driver: map.get("DRIVER").cloned(),
hid_id,
hid_name: map.get("HID_NAME").cloned(),
hid_phys: map.get("HID_PHYS").cloned(),
hidraw_nodes: collect_hidraw_nodes(&dev_path),
input_event_nodes: collect_input_event_nodes(&dev_path),
});
}
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
fn validate_hid_device_id(id: &str) -> Result<(), String> {
// Prevent path traversal; allow only sane sysfs names.
if id.is_empty() {
return Err("Empty HID device id".into());
}
if id.contains('/') || id.contains("..") {
return Err("Invalid HID device id".into());
}
Ok(())
}
#[tauri::command]
pub fn diag_read_report_descriptor(hid_device_id: String) -> Result<ReportDescriptor, String> {
validate_hid_device_id(&hid_device_id)?;
let path = PathBuf::from("/sys/bus/hid/devices")
.join(&hid_device_id)
.join("report_descriptor");
let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
// Trim trailing zeros (sysfs often exposes a 4096-byte padded blob).
let mut end = bytes.len();
while end > 0 && bytes[end - 1] == 0 {
end -= 1;
}
let bytes = &bytes[..end];
let mut ids: BTreeSet<u8> = BTreeSet::new();
let mut i = 0usize;
while i + 1 < bytes.len() {
if bytes[i] == 0x85 {
ids.insert(bytes[i + 1]);
i += 2;
continue;
}
i += 1;
}
Ok(ReportDescriptor {
len: bytes.len(),
hex: bytes.iter().map(|b| format!("{b:02x}")).collect::<String>(),
report_ids: ids.into_iter().collect(),
})
}
fn validate_hidraw(path: &str) -> Result<(), String> {
if !path.starts_with("/dev/hidraw") {
return Err("Only /dev/hidraw* paths are allowed".into());
}
let suffix = &path["/dev/hidraw".len()..];
if suffix.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
return Err("Invalid hidraw path".into());
}
Ok(())
}
#[tauri::command]
pub fn diag_capture_hidraw_pkexec(
hidraw_path: String,
seconds: u32,
) -> Result<HidrawCapture, String> {
validate_hidraw(&hidraw_path)?;
let seconds = seconds.clamp(1, 15);
// Use pkexec to run a bounded python reader for root-only hidraw nodes.
let script = r#"
import sys, os, select, time, json, binascii, errno
path = sys.argv[1]
seconds = float(sys.argv[2])
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
start = time.time()
last = None
while time.time() - start < seconds:
r, _, _ = select.select([fd], [], [], 0.2)
if not r:
continue
try:
data = os.read(fd, 64)
except OSError as e:
if e.errno in (errno.EIO, errno.EAGAIN):
continue
raise
if not data:
continue
if data != last:
out = {"tsMs": int((time.time() - start) * 1000), "hex": binascii.hexlify(data).decode()}
print(json.dumps(out), flush=True)
last = data
os.close(fd)
"#;
let mut child = Command::new("pkexec")
.arg("/usr/bin/python3")
.arg("-")
.arg(&hidraw_path)
.arg(seconds.to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn pkexec/python3: {e}"))?;
{
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Failed to open stdin for pkexec child".to_string())?;
stdin
.write_all(script.as_bytes())
.map_err(|e| format!("Failed to write python script: {e}"))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for pkexec: {e}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout);
let mut samples: Vec<HidrawSample> = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<HidrawSample>(line) {
Ok(s) => samples.push(s),
Err(_) => {
// Ignore parse errors; keep going.
}
}
}
Ok(HidrawCapture {
hidraw_path,
samples,
stderr: if stderr.is_empty() {
None
} else {
Some(stderr)
},
})
}
@@ -0,0 +1,133 @@
use crate::hardware::display_layout;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::models::{DisplayLayout, Orientation};
use crate::runtime::client;
fn daemon_display_layout_or_transport_fallback(
response: Result<DaemonResponse, String>,
fallback: impl FnOnce() -> Result<DisplayLayout, String>,
) -> Result<DisplayLayout, String> {
match response {
Ok(DaemonResponse::DisplayLayout { layout }) => {
Ok(display_layout::normalize_display_layout(layout))
}
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(other) => Err(format!(
"Unexpected daemon response while reading display layout: {other:?}"
)),
Err(_) => fallback(),
}
}
fn daemon_ack_or_transport_fallback(
response: Result<DaemonResponse, String>,
action: &str,
fallback: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
match response {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(other) => Err(format!(
"Unexpected daemon response while {action}: {other:?}"
)),
Err(_) => fallback(),
}
}
#[tauri::command]
pub fn get_display_layout() -> Result<DisplayLayout, String> {
daemon_display_layout_or_transport_fallback(
client::request(DaemonRequest::GetDisplayLayout),
display_layout::get_display_layout,
)
}
#[tauri::command]
pub fn apply_display_layout(layout: DisplayLayout) -> Result<(), String> {
let normalized = display_layout::normalize_display_layout(layout);
daemon_ack_or_transport_fallback(
client::request(DaemonRequest::ApplyDisplayLayout {
layout: normalized.clone(),
}),
"applying display layout",
|| display_layout::apply_display_layout(&normalized),
)
}
#[tauri::command]
pub fn set_orientation(orientation: Orientation) -> Result<(), String> {
daemon_ack_or_transport_fallback(
client::request(DaemonRequest::SetOrientation {
orientation: orientation.clone(),
}),
"setting orientation",
|| display_layout::set_orientation(&orientation),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_layout() -> DisplayLayout {
DisplayLayout { displays: vec![] }
}
#[test]
fn display_layout_mapping_surfaces_daemon_failures() {
assert_eq!(
daemon_display_layout_or_transport_fallback(
Ok(DaemonResponse::Error {
message: "daemon rejected".into(),
}),
|| Ok(empty_layout()),
)
.expect_err("daemon error should fail"),
"daemon rejected"
);
assert!(
daemon_display_layout_or_transport_fallback(Ok(DaemonResponse::Pong), || {
Ok(empty_layout())
})
.expect_err("unexpected response should fail")
.contains("Unexpected daemon response")
);
}
#[test]
fn display_layout_mapping_falls_back_on_transport_failure() {
assert!(
daemon_display_layout_or_transport_fallback(Err("socket missing".into()), || {
Ok(empty_layout())
})
.is_ok()
);
}
#[test]
fn display_ack_mapping_surfaces_error_and_falls_back_only_on_transport_failure() {
assert_eq!(
daemon_ack_or_transport_fallback(
Ok(DaemonResponse::Error {
message: "daemon rejected".into(),
}),
"testing",
|| Ok(()),
)
.expect_err("daemon error should fail"),
"daemon rejected"
);
assert!(
daemon_ack_or_transport_fallback(Ok(DaemonResponse::Pong), "testing", || Ok(()))
.expect_err("unexpected response should fail")
.contains("Unexpected daemon response")
);
assert!(
daemon_ack_or_transport_fallback(Err("socket missing".into()), "testing", || {
Ok(())
})
.is_ok()
);
}
}
@@ -0,0 +1,39 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::models::HardwareEvent;
use crate::runtime::client;
pub type EventBuffer = Arc<Mutex<VecDeque<HardwareEvent>>>;
pub fn create_event_buffer() -> EventBuffer {
Arc::new(Mutex::new(VecDeque::with_capacity(500)))
}
pub fn push_event(buffer: &EventBuffer, event: HardwareEvent) {
let mut buf = buffer.lock().unwrap();
if buf.len() >= 500 {
buf.pop_front();
}
buf.push_back(event);
}
#[tauri::command]
pub fn get_recent_events(count: usize, state: tauri::State<'_, EventBuffer>) -> Vec<HardwareEvent> {
if let Ok(DaemonResponse::Events { events }) =
client::request(DaemonRequest::GetRecentEvents { limit: count })
{
return events;
}
let buf = state.lock().unwrap();
buf.iter()
.rev()
.take(count)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
@@ -0,0 +1,22 @@
use crate::hardware::sysfs;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::runtime::client;
use crate::runtime::logger;
#[tauri::command]
pub fn read_log(lines: usize) -> Vec<String> {
match client::request(DaemonRequest::TailLogs { lines }) {
Ok(DaemonResponse::Logs { lines }) => lines,
_ => sysfs::read_log_lines(lines),
}
}
#[tauri::command]
pub fn clear_log() -> Result<(), String> {
match client::request(DaemonRequest::ClearLogs) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => logger::clear().or_else(|_| sysfs::clear_log()),
Err(_) => logger::clear().or_else(|_| sysfs::clear_log()),
}
}
@@ -0,0 +1,12 @@
pub mod backlight;
pub mod diagnostics;
pub mod display;
pub mod events;
pub mod logs;
pub mod profiles;
pub mod service;
pub mod settings;
pub mod status;
pub mod theme;
pub mod touchscreen;
pub mod usb_media_remap;
@@ -0,0 +1,235 @@
use std::{fs, path::PathBuf};
use crate::hardware::display_layout;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::models::{Profile, ProfileList};
use crate::runtime::client;
fn profiles_path() -> PathBuf {
let config_dir = crate::commands::settings::config_base_dir().join("zenbook-duo");
let _ = fs::create_dir_all(&config_dir);
config_dir.join("profiles.json")
}
fn load_profile_list() -> ProfileList {
let path = profiles_path();
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| ProfileList {
profiles: Profile::default_profiles(),
})
}
fn save_profile_list(list: &ProfileList) -> Result<(), String> {
let path = profiles_path();
let json = serde_json::to_string_pretty(list).map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, json).map_err(|e| format!("Write error: {e}"))
}
#[tauri::command]
pub fn list_profiles() -> Vec<Profile> {
load_profile_list().profiles
}
#[tauri::command]
pub fn save_profile(profile: Profile) -> Result<(), String> {
let mut list = load_profile_list();
if let Some(existing) = list.profiles.iter_mut().find(|p| p.id == profile.id) {
*existing = profile;
} else {
list.profiles.push(profile);
}
save_profile_list(&list)
}
#[tauri::command]
pub fn delete_profile(id: String) -> Result<(), String> {
let mut list = load_profile_list();
list.profiles.retain(|p| p.id != id);
save_profile_list(&list)
}
fn daemon_response_result(
response: Result<DaemonResponse, String>,
action: &str,
fallback: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
match response {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(format!("{action} failed: {message}")),
Ok(other) => Err(format!(
"{action} failed: unexpected daemon response {other:?}"
)),
Err(_) => fallback().map_err(|message| format!("{action} failed: {message}")),
}
}
#[tauri::command]
pub fn activate_profile(id: String) -> Result<(), String> {
let list = load_profile_list();
let profile = list
.profiles
.iter()
.find(|p| p.id == id)
.ok_or_else(|| format!("Profile '{id}' not found"))?
.clone();
daemon_response_result(
client::request(DaemonRequest::SetBacklight {
level: profile.backlight_level,
}),
"Set profile backlight",
|| crate::commands::backlight::set_backlight_daemon_first(profile.backlight_level),
)?;
daemon_response_result(
client::request(DaemonRequest::SetOrientation {
orientation: profile.orientation.clone(),
}),
"Set profile orientation",
|| display_layout::set_orientation(&profile.orientation),
)?;
if let Some(ref layout) = profile.display_layout {
daemon_response_result(
client::request(DaemonRequest::ApplyDisplayLayout {
layout: layout.clone(),
}),
"Apply profile display layout",
|| display_layout::apply_display_layout(layout),
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Orientation;
use std::env;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestHome {
path: PathBuf,
previous_home: Option<String>,
}
impl TestHome {
fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
let path = env::temp_dir().join(format!(
"zenbook-duo-profiles-test-{}-{unique}",
std::process::id()
));
fs::create_dir_all(&path).expect("create test home");
let previous_home = env::var("ZENBOOK_DUO_HOME").ok();
env::set_var("ZENBOOK_DUO_HOME", &path);
Self {
path,
previous_home,
}
}
}
impl Drop for TestHome {
fn drop(&mut self) {
if let Some(previous_home) = &self.previous_home {
env::set_var("ZENBOOK_DUO_HOME", previous_home);
} else {
env::remove_var("ZENBOOK_DUO_HOME");
}
let _ = fs::remove_dir_all(&self.path);
}
}
fn test_profile(id: &str) -> Profile {
Profile {
id: id.into(),
name: "Test".into(),
backlight_level: 2,
scale: 1.5,
orientation: Orientation::Normal,
dual_screen_enabled: true,
display_layout: None,
}
}
#[test]
fn profile_storage_roundtrip_uses_zenbook_duo_home() {
let _guard = crate::commands::settings::test_env_lock()
.lock()
.expect("profiles env lock");
let home = TestHome::new();
let profile = test_profile("custom");
save_profile(profile).expect("save profile");
let profile_path = home.path.join(".config/zenbook-duo/profiles.json");
assert!(profile_path.is_file());
assert!(list_profiles().iter().any(|profile| profile.id == "custom"));
delete_profile("custom".into()).expect("delete profile");
assert!(!list_profiles().iter().any(|profile| profile.id == "custom"));
}
#[test]
fn profile_storage_loads_defaults_when_no_file_exists() {
let _guard = crate::commands::settings::test_env_lock()
.lock()
.expect("profiles env lock");
let _home = TestHome::new();
let profiles = list_profiles();
assert!(profiles.iter().any(|profile| profile.id == "docked"));
assert!(profiles.iter().any(|profile| profile.id == "tablet"));
assert!(profiles.iter().any(|profile| profile.id == "presentation"));
}
#[test]
fn profile_activation_surfaces_daemon_errors_and_unexpected_responses() {
let daemon_error = daemon_response_result(
Ok(DaemonResponse::Error {
message: "no session".into(),
}),
"Set profile orientation",
|| Ok(()),
);
assert_eq!(
daemon_error.expect_err("daemon error should fail"),
"Set profile orientation failed: no session"
);
let unexpected =
daemon_response_result(Ok(DaemonResponse::Pong), "Set profile backlight", || Ok(()));
assert!(unexpected
.expect_err("unexpected response should fail")
.contains("unexpected daemon response"));
}
#[test]
fn profile_activation_uses_fallback_only_for_daemon_transport_failure() {
let fallback_error = daemon_response_result(
Err("daemon unavailable".into()),
"Apply profile display layout",
|| Err("local apply failed".into()),
);
assert_eq!(
fallback_error.expect_err("fallback error should fail"),
"Apply profile display layout failed: local apply failed"
);
let fallback_success = daemon_response_result(
Err("daemon unavailable".into()),
"Set profile backlight",
|| Ok(()),
);
assert!(fallback_success.is_ok());
}
}
@@ -0,0 +1,138 @@
use std::process::Command;
use crate::hardware::sysfs;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse, PROTOCOL_VERSION};
use crate::models::VersionInfo;
use crate::runtime::client;
const LEGACY_DAEMON_RESTART_ERROR: &str = "Service restart not yet owned by rust-daemon";
#[tauri::command]
pub fn is_service_active() -> bool {
match client::request(DaemonRequest::GetStatus) {
Ok(DaemonResponse::Status { .. }) => true,
_ => sysfs::is_service_active(),
}
}
#[tauri::command]
pub fn get_version_info() -> VersionInfo {
let app_version = env!("CARGO_PKG_VERSION");
match client::request(DaemonRequest::GetVersion) {
Ok(DaemonResponse::Version { version }) => {
VersionInfo::from_daemon(app_version, PROTOCOL_VERSION, version)
}
Ok(DaemonResponse::Error { message }) => parse_daemon_protocol_version(&message)
.map(|daemon_protocol_version| {
VersionInfo::protocol_mismatch(
app_version,
PROTOCOL_VERSION,
daemon_protocol_version,
)
})
.unwrap_or_else(|| VersionInfo::unavailable(app_version, PROTOCOL_VERSION)),
_ => VersionInfo::unavailable(app_version, PROTOCOL_VERSION),
}
}
fn parse_daemon_protocol_version(message: &str) -> Option<u32> {
message
.strip_prefix("Protocol mismatch: expected ")?
.split_once(',')?
.0
.parse()
.ok()
}
#[tauri::command]
pub fn restart_service() -> Result<(), String> {
match client::request(DaemonRequest::RestartService) {
Ok(DaemonResponse::Ack) => return Ok(()),
Ok(DaemonResponse::Error { message }) if message != LEGACY_DAEMON_RESTART_ERROR => {
return Err(message);
}
Ok(DaemonResponse::Error { .. }) => {}
Ok(_) => return Err("Unexpected daemon response while restarting service".into()),
Err(_) => {}
}
let mut errors = Vec::new();
if let Err(message) = restart_system_unit("zenbook-duo-rust-daemon.service") {
errors.push(message);
}
if let Err(message) = restart_user_unit("zenbook-duo-session-agent.service") {
errors.push(message);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
fn restart_user_unit(unit: &str) -> Result<(), String> {
let output = Command::new("systemctl")
.args(["--user", "restart", unit])
.output()
.map_err(|e| format!("Failed to restart {unit}: {e}"))?;
if output.status.success() || unit_not_found(&output) {
Ok(())
} else {
Err(format!(
"Failed to restart {unit}: {}",
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
fn restart_system_unit(unit: &str) -> Result<(), String> {
let output = Command::new("systemctl")
.args(["restart", unit])
.output()
.map_err(|e| format!("Failed to restart {unit}: {e}"))?;
if output.status.success() || unit_not_found(&output) {
return Ok(());
}
if command_exists("pkexec") {
let elevated = Command::new("pkexec")
.args(["systemctl", "restart", unit])
.output()
.map_err(|e| format!("Failed to restart {unit} with pkexec: {e}"))?;
if elevated.status.success() || unit_not_found(&elevated) {
return Ok(());
}
return Err(format!(
"Failed to restart {unit} with pkexec: {}",
String::from_utf8_lossy(&elevated.stderr).trim()
));
}
Err(format!(
"Failed to restart {unit}: {}",
String::from_utf8_lossy(&output.stderr).trim()
))
}
fn command_exists(program: &str) -> bool {
Command::new("sh")
.args(["-c", &format!("command -v {program} >/dev/null 2>&1")])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn unit_not_found(output: &std::process::Output) -> bool {
let stderr = String::from_utf8_lossy(&output.stderr);
stderr.contains("not loaded")
|| stderr.contains("could not be found")
|| stderr.contains("Unit ")
}
@@ -0,0 +1,238 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::models::DuoSettings;
use crate::runtime::client;
const AUTOSTART_FILE_NAME: &str = "zenbook-duo-control.desktop";
#[cfg(test)]
pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
pub(crate) fn config_base_dir() -> PathBuf {
if let Ok(home_override) = env::var("ZENBOOK_DUO_HOME") {
PathBuf::from(home_override).join(".config")
} else {
dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))
}
}
fn settings_path() -> PathBuf {
let config_dir = config_base_dir().join("zenbook-duo");
let _ = fs::create_dir_all(&config_dir);
config_dir.join("settings.json")
}
fn autostart_path() -> PathBuf {
config_base_dir()
.join("autostart")
.join(AUTOSTART_FILE_NAME)
}
fn autostart_enabled() -> bool {
autostart_path().is_file()
}
fn desktop_exec_path() -> String {
let executable = env::current_exe().unwrap_or_else(|_| PathBuf::from("zenbook-duo-control"));
let executable = executable.to_string_lossy();
let escaped = executable.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\" --start-minimized")
}
fn write_autostart_entry() -> Result<(), String> {
let path = autostart_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Create autostart dir error: {e}"))?;
}
let entry = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=Zenbook Duo Control\n\
Comment=Start Zenbook Duo Control hidden in the system tray\n\
Exec={}\n\
Icon=zenbook-duo-control\n\
Terminal=false\n\
Categories=Utility;Settings;\n\
NoDisplay=true\n\
X-GNOME-Autostart-enabled=true\n",
desktop_exec_path()
);
fs::write(&path, entry).map_err(|e| format!("Write autostart entry error: {e}"))
}
fn remove_autostart_entry() -> Result<(), String> {
match fs::remove_file(autostart_path()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Remove autostart entry error: {e}")),
}
}
fn sync_autostart_entry(settings: &DuoSettings) -> Result<(), String> {
if settings.start_on_boot_minimized {
write_autostart_entry()
} else {
remove_autostart_entry()
}
}
pub fn load_settings_local() -> DuoSettings {
let path = settings_path();
let raw = match fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return DuoSettings::default(), // no settings file => show setup
};
// Merge defaults + file contents.
// Important behavior: when upgrading an existing install, we don't want to force the setup
// screen to appear just because `setupCompleted` is a new field.
let mut settings: DuoSettings = serde_json::from_str(&raw).unwrap_or_default();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if v.get("setupCompleted").is_none() {
settings.setup_completed = true;
}
}
settings
}
pub fn save_settings_local(settings: DuoSettings) -> Result<(), String> {
let path = settings_path();
let json =
serde_json::to_string_pretty(&settings).map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, json).map_err(|e| format!("Write error: {e}"))
}
#[tauri::command]
pub fn load_settings() -> DuoSettings {
let mut settings = load_settings_local();
settings.start_on_boot_minimized = autostart_enabled();
settings
}
fn save_settings_daemon_result(response: Result<DaemonResponse, String>) -> Result<(), String> {
match response {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(other) => Err(format!(
"Unexpected daemon response while saving settings: {other:?}"
)),
Err(_) => Ok(()),
}
}
#[tauri::command]
pub fn save_settings(settings: DuoSettings) -> Result<(), String> {
sync_autostart_entry(&settings)?;
save_settings_local(settings.clone())?;
save_settings_daemon_result(client::request(DaemonRequest::SaveSettings { settings }))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestHome {
path: PathBuf,
previous_home: Option<String>,
}
impl TestHome {
fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
let path = env::temp_dir().join(format!(
"zenbook-duo-settings-test-{}-{unique}",
std::process::id()
));
fs::create_dir_all(&path).expect("create test home");
let previous_home = env::var("ZENBOOK_DUO_HOME").ok();
env::set_var("ZENBOOK_DUO_HOME", &path);
Self {
path,
previous_home,
}
}
}
impl Drop for TestHome {
fn drop(&mut self) {
if let Some(previous_home) = &self.previous_home {
env::set_var("ZENBOOK_DUO_HOME", previous_home);
} else {
env::remove_var("ZENBOOK_DUO_HOME");
}
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn save_settings_daemon_result_surfaces_error_and_unexpected_response() {
assert!(save_settings_daemon_result(Ok(DaemonResponse::Ack)).is_ok());
assert_eq!(
save_settings_daemon_result(Ok(DaemonResponse::Error {
message: "daemon rejected".into(),
}))
.expect_err("daemon error should fail"),
"daemon rejected"
);
assert!(save_settings_daemon_result(Ok(DaemonResponse::Pong))
.expect_err("unexpected response should fail")
.contains("Unexpected daemon response"));
}
#[test]
fn save_settings_daemon_result_allows_transport_failure_after_local_save() {
assert!(save_settings_daemon_result(Err("socket missing".into())).is_ok());
}
#[test]
fn local_settings_roundtrip_preserves_issue_17_switch_fields() {
let _guard = test_env_lock().lock().expect("settings env lock");
let _home = TestHome::new();
let mut settings = DuoSettings::default();
settings.start_on_boot_minimized = true;
settings.invert_sensor_rotation = true;
settings.setup_completed = true;
save_settings_local(settings).expect("save settings");
let loaded = load_settings_local();
assert!(loaded.start_on_boot_minimized);
assert!(loaded.invert_sensor_rotation);
assert!(loaded.setup_completed);
}
#[test]
fn load_settings_uses_autostart_file_as_start_on_boot_source_of_truth() {
let _guard = test_env_lock().lock().expect("settings env lock");
let _home = TestHome::new();
let mut settings = DuoSettings::default();
settings.start_on_boot_minimized = true;
settings.invert_sensor_rotation = true;
save_settings_local(settings).expect("save settings");
assert!(!load_settings().start_on_boot_minimized);
let path = autostart_path();
fs::create_dir_all(path.parent().expect("autostart parent")).expect("create autostart dir");
fs::write(&path, "[Desktop Entry]\nType=Application\n").expect("write autostart file");
let loaded = load_settings();
assert!(loaded.start_on_boot_minimized);
assert!(loaded.invert_sensor_rotation);
}
}
@@ -0,0 +1,14 @@
use crate::hardware::sysfs;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::models::DuoStatus;
use crate::runtime::client;
#[tauri::command]
pub fn get_status() -> Result<DuoStatus, String> {
match client::request(DaemonRequest::GetStatus) {
Ok(DaemonResponse::Status { status }) => Ok(status),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => Err("Unexpected daemon response while reading status".into()),
Err(_) => Ok(sysfs::get_full_status()),
}
}
@@ -0,0 +1,108 @@
use std::process::Command;
use crate::models::ThemePreference;
#[tauri::command]
pub fn get_system_theme() -> Option<ThemePreference> {
system_theme_from_portal().or_else(system_theme_from_gsettings)
}
fn system_theme_from_portal() -> Option<ThemePreference> {
let output = Command::new("gdbus")
.args([
"call",
"--session",
"--dest",
"org.freedesktop.portal.Desktop",
"--object-path",
"/org/freedesktop/portal/desktop",
"--method",
"org.freedesktop.portal.Settings.Read",
"org.freedesktop.appearance",
"color-scheme",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_portal_color_scheme(&stdout)
}
fn system_theme_from_gsettings() -> Option<ThemePreference> {
let output = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_gsettings_color_scheme(&stdout)
}
fn parse_portal_color_scheme(value: &str) -> Option<ThemePreference> {
if value.contains("uint32 1") {
Some(ThemePreference::Dark)
} else if value.contains("uint32 2") || value.contains("uint32 0") {
Some(ThemePreference::Light)
} else {
None
}
}
fn parse_gsettings_color_scheme(value: &str) -> Option<ThemePreference> {
let normalized = value.trim().trim_matches('\'').trim_matches('"');
if normalized.contains("prefer-dark") {
Some(ThemePreference::Dark)
} else if normalized.contains("prefer-light") || normalized.contains("default") {
Some(ThemePreference::Light)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_portal_color_scheme() {
assert_eq!(
parse_portal_color_scheme("(<<uint32 1>>,)"),
Some(ThemePreference::Dark)
);
assert_eq!(
parse_portal_color_scheme("(<<uint32 0>>,)"),
Some(ThemePreference::Light)
);
assert_eq!(
parse_portal_color_scheme("(<<uint32 2>>,)"),
Some(ThemePreference::Light)
);
assert_eq!(parse_portal_color_scheme(""), None);
}
#[test]
fn parses_gsettings_color_scheme() {
assert_eq!(
parse_gsettings_color_scheme("'prefer-dark'"),
Some(ThemePreference::Dark)
);
assert_eq!(
parse_gsettings_color_scheme("'prefer-light'"),
Some(ThemePreference::Light)
);
assert_eq!(
parse_gsettings_color_scheme("'default'"),
Some(ThemePreference::Light)
);
assert_eq!(parse_gsettings_color_scheme("''"), None);
}
}
@@ -0,0 +1,33 @@
use crate::hardware::touchscreen::{self, TouchscreenDevice};
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::runtime::client;
#[tauri::command]
pub fn list_touchscreens() -> Result<Vec<TouchscreenDevice>, String> {
match client::request(DaemonRequest::ListTouchscreens) {
Ok(DaemonResponse::Touchscreens { devices }) => Ok(devices),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => Ok(touchscreen::list_touchscreens()),
Err(_) => Ok(touchscreen::list_touchscreens()),
}
}
#[tauri::command]
pub fn set_touchscreen_enabled(connector: String, enabled: bool) -> Result<(), String> {
let fallback = || {
let devices = touchscreen::list_touchscreens();
match devices.iter().find(|d| d.connector == connector) {
Some(dev) => touchscreen::set_touchscreen_enabled(&dev.i2c_id, enabled),
None => Err(format!("No touchscreen found for {}", connector)),
}
};
match client::request(DaemonRequest::SetTouchscreenEnabled {
connector: connector.clone(),
enabled,
}) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => fallback(),
Err(_) => fallback(),
}
}
@@ -0,0 +1,444 @@
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::process::Command;
use std::time::Duration;
use nix::unistd::{Uid, User};
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
use crate::runtime::client;
use crate::runtime::paths;
const HELPER_BINARY_NAME: &str = "zenbook-duo-usb-remap-helper";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsbMediaRemapStatus {
pub running: bool,
pub pid: Option<u32>,
pub paused: bool,
}
#[tauri::command]
pub fn usb_media_remap_status() -> UsbMediaRemapStatus {
daemon_first_status()
}
#[tauri::command]
pub async fn usb_media_remap_start() -> Result<(), String> {
daemon_first_start()
}
#[tauri::command]
pub async fn usb_media_remap_stop() -> Result<(), String> {
daemon_first_stop()
}
pub fn get_status() -> UsbMediaRemapStatus {
let pid_path = pid_path();
let pid = read_pid(&pid_path).or_else(|| recover_running_helper_pid(&pid_path));
if let Some(pid) = pid {
if is_pid_running(pid) {
return UsbMediaRemapStatus {
running: true,
pid: Some(pid),
paused: std::path::Path::new(&pause_file_path()).exists(),
};
}
let _ = fs::remove_file(&pid_path);
}
UsbMediaRemapStatus {
running: false,
pid: None,
paused: false,
}
}
pub fn start_remap() -> Result<(), String> {
if get_status().running {
return Ok(());
}
// `pkexec` can take a few seconds before the auth prompt appears, and the user then needs
// time to enter their password. Treat this as part of "startup" so the UI doesn't show a
// spurious timeout error before authentication is even possible.
const START_TIMEOUT_SECS: u64 = 90;
let pid_path = pid_path();
ensure_duo_dir_for_pid(&pid_path)?;
let helper_path = helper_binary_path()?;
let user = current_username().map_err(log_error)?;
let (mut cmd, launcher_name) = if running_as_root() {
(
Command::new(&helper_path),
helper_path.display().to_string(),
)
} else {
let mut cmd = Command::new("pkexec");
cmd.arg(&helper_path);
(cmd, "pkexec".to_string())
};
cmd.arg("--pid-file").arg(&pid_path).arg("--user").arg(user);
start_remap_spawn_and_wait(cmd, Duration::from_secs(START_TIMEOUT_SECS), &launcher_name)
}
pub fn stop_remap() -> Result<(), String> {
// Clean up pause file on stop.
let _ = fs::remove_file(pause_file_path());
let pid_files = running_pid_files();
if pid_files.is_empty() {
// Nothing to stop.
return Ok(());
}
let helper_path = helper_binary_path()?;
for pid_path in pid_files {
ensure_duo_dir_for_pid(&pid_path)?;
let (mut cmd, launcher_name) = if running_as_root() {
(
Command::new(&helper_path),
helper_path.display().to_string(),
)
} else {
let mut cmd = Command::new("pkexec");
cmd.arg(&helper_path);
(cmd, "pkexec".to_string())
};
cmd.arg("--stop").arg("--pid-file").arg(&pid_path);
let status = cmd
.status()
.map_err(|e| log_error(format!("Failed to stop remapper ({launcher_name}): {e}")))?;
if !status.success() {
return Err(log_error(format!(
"Failed to stop remapper ({launcher_name} exited with {status})"
)));
}
}
// Wait briefly for pid-file removal / process exit so the UI status doesn't bounce.
for _ in 0..30 {
if !get_status().running {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Ok(())
}
fn start_remap_spawn_and_wait(
mut cmd: Command,
timeout: Duration,
launcher_name: &str,
) -> Result<(), String> {
let mut child = cmd
.spawn()
.map_err(|e| log_error(format!("Failed to start remapper ({launcher_name}): {e}")))?;
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if get_status().running {
// Ensure we reap the pkexec child process when it eventually exits (avoid zombies).
std::thread::spawn(move || {
let _ = child.wait();
});
return Ok(());
}
if let Ok(Some(status)) = child.try_wait() {
return Err(log_error(format!(
"Remapper failed to start ({launcher_name} exited with {status})"
)));
}
std::thread::sleep(Duration::from_millis(100));
}
// Timeout elapsed. Keep pkexec running in the background (it might still be waiting for auth).
std::thread::spawn(move || {
let _ = child.wait();
});
Err(log_error(format!(
"Timed out waiting for remapper to start (waited {}s). If an authentication prompt appeared, complete it and try again.",
timeout.as_secs()
)))
}
fn read_pid(path: &str) -> Option<u32> {
fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
}
fn is_pid_running(pid: u32) -> bool {
let res = unsafe { libc::kill(pid as i32, 0) };
if res == 0 {
return true;
}
let err = std::io::Error::last_os_error();
matches!(err.raw_os_error(), Some(code) if code == libc::EPERM)
}
fn current_username() -> Result<String, String> {
if let Ok(user) = std::env::var("SUDO_USER") {
if !user.is_empty() && user != "root" {
return Ok(user);
}
}
if let Ok(user) = std::env::var("ZENBOOK_DUO_USER") {
if !user.is_empty() {
return Ok(user);
}
}
let user = User::from_uid(Uid::current())
.map_err(|e| format!("Failed to read current user: {e}"))?
.ok_or_else(|| "Failed to resolve current user".to_string())?;
Ok(user.name)
}
fn pid_path() -> String {
runtime_dir_for_target_user()
.join("usb_media_remap.pid")
.to_string_lossy()
.into_owned()
}
pub fn pause_file_path() -> String {
runtime_dir_for_target_user()
.join("usb_media_remap.paused")
.to_string_lossy()
.into_owned()
}
pub fn toggle_pause() -> Result<(), String> {
let path = pause_file_path();
if std::path::Path::new(&path).exists() {
fs::remove_file(&path).map_err(|e| format!("Failed to remove pause file: {e}"))?;
} else {
ensure_duo_dir_for_pid(&pid_path())?;
fs::write(&path, "").map_err(|e| format!("Failed to create pause file: {e}"))?;
}
Ok(())
}
#[tauri::command]
pub fn usb_media_remap_toggle_pause() -> Result<(), String> {
daemon_first_toggle_pause()
}
pub fn daemon_first_status() -> UsbMediaRemapStatus {
match client::request(DaemonRequest::UsbMediaRemapStatus) {
Ok(DaemonResponse::UsbMediaRemapStatus { status }) => status,
_ => get_status(),
}
}
pub fn daemon_first_start() -> Result<(), String> {
let result = match client::request(DaemonRequest::UsbMediaRemapStart) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => Err("Unexpected daemon response while starting USB remap".into()),
Err(_) => start_remap(),
};
if result.is_ok() {
let _ = send_desktop_notification("USB Media Remap enabled");
}
result
}
pub fn daemon_first_stop() -> Result<(), String> {
let result = match client::request(DaemonRequest::UsbMediaRemapStop) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => Err("Unexpected daemon response while stopping USB remap".into()),
Err(_) => stop_remap(),
};
if result.is_ok() {
let _ = send_desktop_notification("USB Media Remap disabled");
}
result
}
pub fn daemon_first_toggle_pause() -> Result<(), String> {
let was_paused = daemon_first_status().paused;
let result = match client::request(DaemonRequest::UsbMediaRemapTogglePause) {
Ok(DaemonResponse::Ack) => Ok(()),
Ok(DaemonResponse::Error { message }) => Err(message),
Ok(_) => Err("Unexpected daemon response while toggling USB remap pause".into()),
Err(_) => toggle_pause(),
};
if result.is_ok() {
let msg = if was_paused {
"USB Media Remap resumed"
} else {
"USB Media Remap paused"
};
let _ = send_desktop_notification(msg);
}
result
}
fn running_pid_files() -> Vec<String> {
let p1 = pid_path();
if get_status().running {
return vec![p1];
}
Vec::new()
}
fn runtime_dir_for_target_user() -> std::path::PathBuf {
paths::user_runtime_dir(target_uid())
}
fn target_uid() -> u32 {
std::env::var("SUDO_UID")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.or_else(|| {
std::env::var("ZENBOOK_DUO_UID")
.ok()
.and_then(|value| value.parse::<u32>().ok())
})
.unwrap_or_else(|| Uid::current().as_raw())
}
fn send_desktop_notification(message: &str) -> Result<(), String> {
let user = current_username()?;
let uid = target_uid();
let runtime_dir = format!("/run/user/{uid}");
let bus_address = format!("unix:path={runtime_dir}/bus");
let mut cmd = if running_as_root() {
let mut cmd = Command::new("runuser");
cmd.args(["-u", &user, "--", "env"]);
cmd
} else {
Command::new("env")
};
let status = cmd
.args([
&format!("XDG_RUNTIME_DIR={runtime_dir}"),
&format!("DBUS_SESSION_BUS_ADDRESS={bus_address}"),
"notify-send",
"-a",
"Zenbook Duo Control",
"-i",
"input-keyboard",
message,
])
.status()
.map_err(|e| format!("Failed to launch desktop notification: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("notify-send exited with {status}"))
}
}
fn running_as_root() -> bool {
Uid::current().is_root()
}
fn helper_binary_path() -> Result<std::path::PathBuf, String> {
let current_exe = std::env::current_exe()
.map_err(|e| log_error(format!("Failed to find current exe: {e}")))?;
let sibling = current_exe.with_file_name(HELPER_BINARY_NAME);
if sibling.exists() {
return Ok(sibling);
}
Err(log_error(format!(
"Failed to find {} next to {}",
HELPER_BINARY_NAME,
current_exe.display()
)))
}
fn recover_running_helper_pid(pid_path: &str) -> Option<u32> {
let pid = find_running_helper_pid(pid_path)?;
let _ = ensure_duo_dir_for_pid(pid_path);
let _ = fs::write(pid_path, pid.to_string());
Some(pid)
}
fn find_running_helper_pid(pid_path: &str) -> Option<u32> {
let proc_dir = fs::read_dir("/proc").ok()?;
for entry in proc_dir.flatten() {
let name = entry.file_name();
let Ok(pid) = name.to_string_lossy().parse::<u32>() else {
continue;
};
let cmdline = fs::read(entry.path().join("cmdline")).ok()?;
if cmdline.is_empty() {
continue;
}
let args = cmdline
.split(|b| *b == 0)
.filter(|part| !part.is_empty())
.map(|part| String::from_utf8_lossy(part).into_owned())
.collect::<Vec<_>>();
if !helper_args_match_pid_file(&args, pid_path) {
continue;
}
if is_pid_running(pid) {
return Some(pid);
}
}
None
}
fn helper_args_match_pid_file(args: &[String], pid_path: &str) -> bool {
let Some(exe) = args.first() else {
return false;
};
if !exe.ends_with(HELPER_BINARY_NAME) {
return false;
}
args.windows(2)
.any(|window| window[0] == "--pid-file" && window[1] == pid_path)
}
fn ensure_duo_dir_for_pid(pid_file: &str) -> Result<(), String> {
let dir = std::path::Path::new(pid_file)
.parent()
.ok_or_else(|| format!("Invalid pid file path: {pid_file}"))?;
crate::runtime::runtime_dir::ensure_dir_owned_like_parent(dir)
}
fn log_error<T: Into<String>>(message: T) -> String {
let message = message.into();
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let log_path = std::path::Path::new(&pid_path())
.parent()
.map(|p| p.join("duo.log"))
.unwrap_or_else(|| std::env::temp_dir().join("zenbook-duo-usb-remap.log"));
let _ = fs::create_dir_all(
log_path
.parent()
.unwrap_or_else(|| std::path::Path::new("/tmp")),
);
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) {
let _ = writeln!(file, "{} - USB-REMAP - ERROR: {}", timestamp, message);
}
message
}
@@ -0,0 +1,71 @@
use crate::ipc::protocol::SessionBackend;
use crate::models::{DisplayLayout, Orientation};
/// Interface implemented by each compositor Adapter.
///
/// Callers use the public display-layout functions in the parent Module; only
/// this seam needs to know which compositor command syntax or parser shape is
/// active.
pub(super) trait CompositorDisplayAdapter {
fn layout(&self) -> Result<DisplayLayout, String>;
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String>;
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String>;
}
struct GnomeAdapter;
struct KdeAdapter;
struct NiriAdapter;
impl CompositorDisplayAdapter for GnomeAdapter {
fn layout(&self) -> Result<DisplayLayout, String> {
super::gnome::get_gnome_display_layout()
}
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
super::gnome::apply_gnome_display_layout(layout)
}
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String> {
super::gnome::set_gnome_orientation(orientation)
}
}
impl CompositorDisplayAdapter for KdeAdapter {
fn layout(&self) -> Result<DisplayLayout, String> {
super::kde::get_kde_display_layout()
}
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
super::kde::apply_kde_display_layout(layout)
}
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String> {
super::kde::set_kde_orientation(orientation)
}
}
impl CompositorDisplayAdapter for NiriAdapter {
fn layout(&self) -> Result<DisplayLayout, String> {
super::niri::get_niri_display_layout()
}
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
super::niri::apply_niri_display_layout(layout)
}
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String> {
super::niri::set_niri_orientation(orientation)
}
}
pub(super) fn with_display_adapter<T>(
backend: SessionBackend,
f: impl FnOnce(&dyn CompositorDisplayAdapter) -> Result<T, String>,
) -> Result<T, String> {
match backend {
SessionBackend::Gnome => f(&GnomeAdapter),
SessionBackend::Kde => f(&KdeAdapter),
SessionBackend::Niri => f(&NiriAdapter),
SessionBackend::Unknown => Err("Unsupported session backend for display layout".into()),
}
}
@@ -0,0 +1,505 @@
use super::*;
pub(super) fn get_gnome_display_layout() -> Result<DisplayLayout, String> {
let output = compositor::command_output("gdctl", &["show"])?;
if !output.status.success() {
return Err(format!(
"gdctl show failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_gdctl_output(&stdout)
}
pub(super) fn parse_gdctl_output(output: &str) -> Result<DisplayLayout, String> {
// gdctl output is a tree with box-drawing characters.
// We parse two parts:
// - "Monitors" section for connectors and current/preferred mode
// - "Logical monitors" section for position/scale/transform/primary
#[derive(Debug, Clone, Default)]
struct LogicalProps {
x: i32,
y: i32,
scale: f64,
transform: u32,
primary: bool,
}
fn extract_connector_from_monitor_header(line: &str) -> Option<String> {
// Examples:
// "├──Monitor eDP-1 (Built-in display)"
// "└──Monitor HDMI-1 (… )"
// "Monitor eDP-1 (… )"
let idx = line.find("Monitor ")?;
let rest = &line[idx + "Monitor ".len()..];
let connector = rest.split_whitespace().next().unwrap_or("").trim();
if connector.is_empty() {
None
} else {
Some(connector.to_string())
}
}
fn extract_mode_from_line(line: &str) -> Option<DisplayMode> {
// Finds the first token like "2880x1800@120.000" (optionally suffixed with "Hz").
let token = line
.split_whitespace()
.find(|t| t.contains('x') && t.contains('@'))?;
// Tokens in gdctl output often have tree prefixes (e.g. "└──2880x1800@120.000").
let token = token.trim_start_matches(|c: char| !c.is_ascii_digit());
let backend_mode_id = token
.trim_end_matches("Hz")
.trim_end_matches(|c: char| !(c.is_ascii_digit() || c == '.'))
.trim()
.to_string();
let (res, rate) = backend_mode_id.split_once('@')?;
let (w, h) = res.split_once('x')?;
let width: u32 = w.trim().parse().ok()?;
let height: u32 = h.trim().parse().ok()?;
let refresh_rate: f64 = rate.trim().parse().ok()?;
Some(make_display_mode_with_backend_id(
width,
height,
refresh_rate,
Some(backend_mode_id),
))
}
fn extract_i32_pair(line: &str) -> Option<(i32, i32)> {
// Extracts first two integers (supports negative).
let nums: Vec<i32> = line
.split(|c: char| !c.is_ascii_digit() && c != '-')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() >= 2 {
Some((nums[0], nums[1]))
} else {
None
}
}
fn parse_transform(s: &str) -> u32 {
// gdctl prints e.g. "normal" or "90".
match s.trim() {
"normal" => 0,
"90" => 90,
"180" => 180,
"270" => 270,
_ => 0,
}
}
let mut monitor_order: Vec<String> = Vec::new();
let mut monitor_modes: std::collections::HashMap<String, Vec<DisplayMode>> =
std::collections::HashMap::new();
let mut monitor_current_mode: std::collections::HashMap<String, DisplayMode> =
std::collections::HashMap::new();
let mut in_monitors = false;
let mut in_logical = false;
let mut current_monitor: Option<String> = None;
let mut current_logical = LogicalProps {
x: 0,
y: 0,
scale: 1.0,
transform: 0,
primary: false,
};
let mut in_logical_block = false;
let mut connector_to_logical: std::collections::HashMap<String, LogicalProps> =
std::collections::HashMap::new();
for raw in output.lines() {
let line = raw.trim();
if line.starts_with("Monitors:") {
in_monitors = true;
in_logical = false;
current_monitor = None;
continue;
}
if line.starts_with("Logical monitors:") {
in_logical = true;
in_monitors = false;
current_monitor = None;
in_logical_block = false;
continue;
}
if in_monitors {
if let Some(connector) = extract_connector_from_monitor_header(line) {
current_monitor = Some(connector.clone());
if !monitor_order.contains(&connector) {
monitor_order.push(connector);
}
continue;
}
if let Some(ref connector) = current_monitor {
if let Some(mode) = extract_mode_from_line(line) {
monitor_modes
.entry(connector.clone())
.or_default()
.push(mode.clone());
if line.contains("Current mode")
|| line.contains("current mode")
|| !monitor_current_mode.contains_key(connector)
{
monitor_current_mode.insert(connector.clone(), mode);
}
}
}
}
if in_logical {
if line.contains("Logical monitor #") {
// Start a new logical monitor block.
current_logical = LogicalProps {
x: 0,
y: 0,
scale: 1.0,
transform: 0,
primary: false,
};
in_logical_block = true;
continue;
}
if !in_logical_block {
continue;
}
if line.contains("Position:") {
if let Some((x, y)) = extract_i32_pair(line) {
current_logical.x = x;
current_logical.y = y;
}
continue;
}
if line.contains("Scale:") {
if let Some(scale) = line.split_whitespace().last().and_then(|s| s.parse().ok()) {
current_logical.scale = scale;
}
continue;
}
if line.contains("Transform:") {
if let Some(t) = line.split_whitespace().last() {
current_logical.transform = parse_transform(t);
}
continue;
}
if line.contains("Primary:") {
current_logical.primary = line.contains("yes");
continue;
}
// Monitor list item under this logical monitor.
// Example: "└──eDP-1 (Built-in display)"
if line.contains('(') {
let token = line.split_whitespace().next().unwrap_or("");
let connector = token
.trim()
.trim_start_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '-'));
// Avoid picking up tree labels like "Monitors:".
if !connector.is_empty() && connector.contains('-') && !connector.ends_with(':') {
connector_to_logical.insert(connector.to_string(), current_logical.clone());
}
}
}
}
let mut displays: Vec<DisplayInfo> = Vec::new();
let mut missing_logical: Vec<usize> = Vec::new();
for connector in monitor_order {
let had_logical = connector_to_logical.contains_key(&connector);
if !had_logical && !is_internal_connector(&connector) {
continue;
}
let current_mode = monitor_current_mode
.get(&connector)
.cloned()
.or_else(|| {
monitor_modes
.get(&connector)
.and_then(|modes| modes.first())
.cloned()
})
.unwrap_or_else(|| make_display_mode(0, 0, 60.0));
let available_modes = dedupe_modes(
monitor_modes
.remove(&connector)
.filter(|modes| !modes.is_empty())
.unwrap_or_else(|| vec![current_mode.clone()]),
);
let logical = connector_to_logical
.get(&connector)
.cloned()
.unwrap_or_default();
let idx = displays.len();
displays.push(DisplayInfo {
connector,
width: current_mode.width,
height: current_mode.height,
refresh_rate: current_mode.refresh_rate,
scale: if logical.scale == 0.0 {
1.0
} else {
logical.scale
},
x: logical.x,
y: logical.y,
transform: logical.transform,
primary: logical.primary,
current_mode,
available_modes,
refresh_policy: RefreshPolicy::Fixed,
supports_dynamic_refresh: false,
});
if !had_logical {
missing_logical.push(idx);
}
}
// If gdctl doesn't report a logical-monitor position for a physical monitor
// (e.g. an internal panel that's currently disabled), place it below the primary
// as a sensible default for UI editing.
if !displays.is_empty() && !missing_logical.is_empty() {
let primary_idx = displays.iter().position(|d| d.primary).unwrap_or(0);
let anchor = displays[primary_idx].clone();
let mut next_y = anchor.y + anchor.height as i32;
for &i in &missing_logical {
if i == primary_idx {
continue;
}
displays[i].x = anchor.x;
displays[i].y = next_y;
displays[i].scale = anchor.scale;
displays[i].transform = anchor.transform;
next_y += displays[i].height as i32;
}
}
Ok(DisplayLayout { displays })
}
pub(super) fn gnome_mode_arg(
display: &DisplayInfo,
current_layout: Option<&DisplayLayout>,
) -> String {
if let Some(mode) = display.current_mode.backend_mode_id.as_ref() {
return mode.clone();
}
if let Some(mode) = display
.available_modes
.iter()
.find(|mode| mode.mode_id == display.current_mode.mode_id)
.and_then(|mode| mode.backend_mode_id.as_ref())
{
return mode.clone();
}
if let Some(mode) = current_layout
.and_then(|layout| {
layout
.displays
.iter()
.find(|d| d.connector == display.connector)
})
.and_then(|current| {
current.available_modes.iter().find(|mode| {
mode.mode_id == display.current_mode.mode_id
|| (mode.width == display.current_mode.width
&& mode.height == display.current_mode.height
&& (mode.refresh_rate - display.current_mode.refresh_rate).abs() < 0.001)
})
})
.and_then(|mode| mode.backend_mode_id.as_ref())
{
return mode.clone();
}
display.current_mode.mode_id.clone()
}
pub(super) fn apply_gnome_display_layout(layout: &DisplayLayout) -> Result<(), String> {
if layout.displays.is_empty() {
return Err("No displays in layout".into());
}
let current_layout = get_gnome_display_layout().ok();
// gdctl rejects negative logical monitor positions.
// Normalize the layout so the smallest x/y becomes 0.
let mut min_x: i32 = 0;
let mut min_y: i32 = 0;
for d in &layout.displays {
min_x = min_x.min(d.x);
min_y = min_y.min(d.y);
}
let shift_x = if min_x < 0 { -min_x } else { 0 };
let shift_y = if min_y < 0 { -min_y } else { 0 };
fn transform_arg(t: u32) -> Option<&'static str> {
match t {
0 => Some("normal"),
90 => Some("90"),
180 => Some("180"),
270 => Some("270"),
_ => None,
}
}
// Build gdctl set command in logical-monitor mode.
// Keep the transform semantics aligned with `gdctl set --help`.
let mut args: Vec<String> = vec!["set".into(), "--layout-mode".into(), "logical".into()];
let mut primary_used = false;
for display in &layout.displays {
if display.refresh_policy == RefreshPolicy::Dynamic {
return Err(format!(
"Dynamic refresh is not supported for {} on GNOME",
display.connector
));
}
args.push("--logical-monitor".into());
if display.primary && !primary_used {
args.push("--primary".into());
primary_used = true;
}
args.push("--scale".into());
args.push(format!("{:.6}", display.scale.max(0.1)));
args.push("--monitor".into());
args.push(display.connector.clone());
args.push("--mode".into());
args.push(gnome_mode_arg(display, current_layout.as_ref()));
args.push("--x".into());
args.push((display.x + shift_x).to_string());
args.push("--y".into());
args.push((display.y + shift_y).to_string());
if let Some(t) = transform_arg(display.transform) {
if t != "normal" {
args.push("--transform".into());
args.push(t.into());
}
}
}
// If caller didn't set a primary at all, make the first display primary.
if !primary_used {
if let Some(pos) = args.iter().position(|a| a == "--logical-monitor") {
args.insert(pos + 1, "--primary".into());
}
}
let output = compositor::command_output("gdctl", &args)?;
if !output.status.success() {
return Err(format!(
"gdctl set failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
fn gnome_scale() -> Result<f64, String> {
let layout = get_display_layout()?;
layout
.displays
.iter()
.find(|display| display.primary)
.or_else(|| layout.displays.first())
.map(|display| display.scale.max(0.1))
.ok_or_else(|| "No GNOME displays available".to_string())
}
fn gnome_logical_monitor_count() -> Result<usize, String> {
let output = compositor::command_output("gdctl", &["show"])?;
if !output.status.success() {
return Err(format!(
"gdctl show failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| line.contains("Logical monitor #"))
.count())
}
pub(super) fn set_gnome_orientation(orientation: &Orientation) -> Result<(), String> {
let scale = format!("{:.6}", gnome_scale()?);
let transform = match orientation {
Orientation::Normal => None,
Orientation::Left => Some("90"),
Orientation::Right => Some("270"),
Orientation::Inverted => Some("180"),
};
let logical_count = gnome_logical_monitor_count().unwrap_or(1);
let mut args = vec![
"set".to_string(),
"--logical-monitor".to_string(),
"--primary".to_string(),
"--scale".to_string(),
scale.clone(),
"--monitor".to_string(),
PRIMARY_INTERNAL_CONNECTOR.to_string(),
];
if let Some(transform) = transform {
args.push("--transform".to_string());
args.push(transform.to_string());
}
if logical_count > 1 {
args.push("--logical-monitor".to_string());
args.push("--scale".to_string());
args.push(scale);
args.push("--monitor".to_string());
args.push(SECONDARY_INTERNAL_CONNECTOR.to_string());
match orientation {
Orientation::Left => {
args.extend(["--left-of", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
}
Orientation::Right => {
args.extend(["--right-of", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
}
Orientation::Inverted => {
args.extend(["--above", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
}
Orientation::Normal => {
args.extend(["--below", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
}
}
if let Some(transform) = transform {
args.push("--transform".to_string());
args.push(transform.to_string());
}
}
run_command("gdctl", &args)
}
@@ -0,0 +1,241 @@
use super::*;
pub(super) fn get_kde_display_layout() -> Result<DisplayLayout, String> {
let value = compositor::kscreen_json()?;
let outputs = compositor::kde_outputs_from_value(&value)?;
fn parse_kde_mode(value: &serde_json::Value) -> Option<DisplayMode> {
let width = value
.get("size")
.and_then(|size| size.get("width"))
.and_then(|v| v.as_u64())
.or_else(|| value.get("width").and_then(|v| v.as_u64()))? as u32;
let height = value
.get("size")
.and_then(|size| size.get("height"))
.and_then(|v| v.as_u64())
.or_else(|| value.get("height").and_then(|v| v.as_u64()))? as u32;
let refresh_rate = value
.get("refreshRate")
.and_then(|v| v.as_f64())
.map(|rate| rate / 1000.0)?;
Some(make_display_mode(width, height, refresh_rate))
}
let mut displays = Vec::new();
for output in outputs {
if !output
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
continue;
}
let connector = output
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing KDE output name".to_string())?;
let size = output
.get("size")
.and_then(|v| v.as_object())
.ok_or_else(|| format!("Missing KDE size for {connector}"))?;
let pos = output
.get("pos")
.and_then(|v| v.as_object())
.ok_or_else(|| format!("Missing KDE position for {connector}"))?;
let rotation = output
.get("rotation")
.and_then(|v| v.as_str())
.unwrap_or("none");
let scale = output.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
let current_mode = output
.get("currentMode")
.and_then(parse_kde_mode)
.unwrap_or_else(|| {
make_display_mode(
size.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
size.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
output
.get("currentMode")
.and_then(|mode| mode.get("refreshRate"))
.and_then(|v| v.as_f64())
.map(|rate| rate / 1000.0)
.unwrap_or(60.0),
)
});
let available_modes = dedupe_modes(
output
.get("modes")
.and_then(|v| v.as_array())
.map(|modes| modes.iter().filter_map(parse_kde_mode).collect())
.unwrap_or_else(|| vec![current_mode.clone()]),
);
displays.push(DisplayInfo {
connector: connector.to_string(),
width: current_mode.width,
height: current_mode.height,
refresh_rate: current_mode.refresh_rate,
scale,
x: pos.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
y: pos.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
transform: match rotation {
"right" => 90,
"left" => 270,
"inverted" => 180,
_ => 0,
},
primary: output.get("priority").and_then(|v| v.as_i64()).unwrap_or(0) == 1,
current_mode,
available_modes,
refresh_policy: RefreshPolicy::Fixed,
supports_dynamic_refresh: false,
});
}
Ok(DisplayLayout { displays })
}
pub(super) fn apply_kde_display_layout(layout: &DisplayLayout) -> Result<(), String> {
if layout.displays.is_empty() {
return Err("No displays in layout".into());
}
let mut args: Vec<String> = Vec::new();
let available_outputs = kde_output_names()?;
for connector in omitted_output_names(layout, &available_outputs) {
args.push(format!("output.{connector}.disable"));
}
for display in &layout.displays {
if display.refresh_policy == RefreshPolicy::Dynamic {
return Err(format!(
"Dynamic refresh is not supported for {} on KDE",
display.connector
));
}
args.push(format!("output.{}.enable", display.connector));
args.push(format!(
"output.{}.mode.{}",
display.connector, display.current_mode.mode_id
));
args.push(format!(
"output.{}.position.{},{}",
display.connector, display.x, display.y
));
args.push(format!(
"output.{}.rotation.{}",
display.connector,
match display.transform {
90 => "right",
180 => "inverted",
270 => "left",
_ => "none",
}
));
args.push(format!(
"output.{}.scale.{:.6}",
display.connector,
display.scale.max(0.1)
));
if display.primary {
args.push(format!("output.{}.priority.1", display.connector));
}
}
run_command("kscreen-doctor", &args)
}
fn kde_output_logical_size(name: &str) -> Result<(i64, i64), String> {
compositor::kde_output_logical_size_from_value(&compositor::kscreen_json()?, name)
}
fn kde_enabled_output_count() -> Result<usize, String> {
compositor::kde_enabled_output_count_from_value(&compositor::kscreen_json()?)
}
fn kde_rotation_token(orientation: &Orientation) -> &'static str {
match orientation {
Orientation::Left => "right",
Orientation::Right => "left",
Orientation::Inverted => "inverted",
Orientation::Normal => "none",
}
}
fn rotated_secondary_position(
orientation: &Orientation,
primary_size: Result<(i64, i64), String>,
) -> Result<(i64, i64), String> {
let (width, height) = primary_size?;
let (rot_w, rot_h) = match orientation {
Orientation::Left | Orientation::Right => (height, width),
Orientation::Normal | Orientation::Inverted => (width, height),
};
Ok(match orientation {
Orientation::Left => (-rot_w, 0),
Orientation::Right => (rot_w, 0),
Orientation::Inverted => (0, -rot_h),
Orientation::Normal => (0, rot_h),
})
}
pub(super) fn set_kde_orientation(orientation: &Orientation) -> Result<(), String> {
let token = kde_rotation_token(orientation);
let enabled_count = kde_enabled_output_count().unwrap_or(1);
if enabled_count <= 1 {
let args = vec![
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.position.0,0"),
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.rotation.{token}"),
];
return run_command("kscreen-doctor", &args);
}
let (pos_x, pos_y) = rotated_secondary_position(
orientation,
kde_output_logical_size(PRIMARY_INTERNAL_CONNECTOR),
)?;
let args = vec![
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.enable"),
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.rotation.{token}"),
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.rotation.{token}"),
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.position.0,0"),
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.position.{pos_x},{pos_y}"),
];
run_command("kscreen-doctor", &args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_primary_geometry_is_an_error_for_secondary_positioning() {
let result =
rotated_secondary_position(&Orientation::Normal, Err("missing geometry".into()));
assert_eq!(
result.expect_err("missing geometry should fail"),
"missing geometry"
);
}
#[test]
fn secondary_position_uses_rotated_primary_geometry() {
assert_eq!(
rotated_secondary_position(&Orientation::Left, Ok((1200, 800))).expect("position"),
(-800, 0)
);
assert_eq!(
rotated_secondary_position(&Orientation::Normal, Ok((1200, 800))).expect("position"),
(0, 800)
);
}
}
@@ -0,0 +1,485 @@
use crate::hardware::duo::{
is_internal_connector, is_primary_internal_connector, PRIMARY_INTERNAL_CONNECTOR,
SECONDARY_INTERNAL_CONNECTOR,
};
mod adapters;
mod gnome;
mod kde;
mod niri;
use crate::ipc::protocol::SessionBackend;
use crate::models::{DisplayInfo, DisplayLayout, DisplayMode, Orientation, RefreshPolicy};
use crate::runtime::{compositor, session};
use std::collections::HashSet;
use std::thread;
use std::time::Duration;
fn format_refresh_rate(refresh_rate: f64) -> String {
let rounded = (refresh_rate * 1000.0).round() / 1000.0;
let mut value = format!("{rounded:.3}");
while value.contains('.') && value.ends_with('0') {
value.pop();
}
if value.ends_with('.') {
value.pop();
}
value
}
fn mode_id(width: u32, height: u32, refresh_rate: f64) -> String {
format!("{width}x{height}@{}", format_refresh_rate(refresh_rate))
}
fn make_display_mode(width: u32, height: u32, refresh_rate: f64) -> DisplayMode {
make_display_mode_with_backend_id(width, height, refresh_rate, None)
}
fn make_display_mode_with_backend_id(
width: u32,
height: u32,
refresh_rate: f64,
backend_mode_id: Option<String>,
) -> DisplayMode {
DisplayMode {
mode_id: mode_id(width, height, refresh_rate),
backend_mode_id,
width,
height,
refresh_rate,
}
}
fn dedupe_modes(modes: Vec<DisplayMode>) -> Vec<DisplayMode> {
let mut seen = HashSet::new();
let mut deduped = Vec::new();
for mode in modes {
if seen.insert(mode.mode_id.clone()) {
deduped.push(mode);
}
}
deduped
}
fn omitted_output_names(layout: &DisplayLayout, available_outputs: &[String]) -> Vec<String> {
let desired = layout
.displays
.iter()
.map(|display| display.connector.as_str())
.collect::<HashSet<_>>();
available_outputs
.iter()
.filter(|name| !desired.contains(name.as_str()))
.cloned()
.collect()
}
fn kde_output_names() -> Result<Vec<String>, String> {
compositor::kde_output_names_from_value(&compositor::kscreen_json()?)
}
fn niri_output_names() -> Result<Vec<String>, String> {
compositor::niri_output_names_from_value(&compositor::niri_outputs_json()?)
}
fn stacked_logical_height(display: &DisplayInfo) -> i32 {
let rotated = display.transform == 90 || display.transform == 270;
let physical_height = if rotated {
display.width
} else {
display.height
};
let scale = display.scale.max(0.1);
(physical_height as f64 / scale).ceil() as i32
}
pub fn normalize_display_layout(layout: DisplayLayout) -> DisplayLayout {
let Some(top_display) = layout
.displays
.iter()
.find(|display| is_primary_internal_connector(&display.connector))
.cloned()
else {
return layout;
};
let top_logical_height = stacked_logical_height(&top_display);
let shift_x = top_display.x;
let shift_y = top_display.y;
let displays = layout
.displays
.into_iter()
.map(|display| {
if is_primary_internal_connector(&display.connector) {
return DisplayInfo {
x: 0,
y: 0,
..display
};
}
if display.connector == SECONDARY_INTERNAL_CONNECTOR {
return DisplayInfo {
x: 0,
y: top_logical_height,
..display
};
}
DisplayInfo {
x: display.x - shift_x,
y: display.y - shift_y,
..display
}
})
.collect();
DisplayLayout { displays }
}
/// Get the current display layout through the selected compositor Adapter.
pub fn get_display_layout() -> Result<DisplayLayout, String> {
let layout = adapters::with_display_adapter(detect_backend(), |adapter| adapter.layout())?;
Ok(normalize_display_layout(layout))
}
/// Apply a display layout through the selected compositor Adapter.
pub fn apply_display_layout(layout: &DisplayLayout) -> Result<(), String> {
let normalized = normalize_display_layout(layout.clone());
adapters::with_display_adapter(detect_backend(), |adapter| {
adapter.apply_layout(&normalized)
})
}
fn detect_backend() -> SessionBackend {
session::detect_backend_from_env()
}
fn run_command<S: AsRef<str>>(program: &str, args: &[S]) -> Result<(), String> {
compositor::run_command(program, args)
}
fn run_niri_command(args: &[&str]) -> Result<(), String> {
compositor::run_niri_command(args)
}
fn run_niri_position_command(connector: &str, x: i32, y: i32) -> Result<(), String> {
let x_arg = x.to_string();
let y_arg = y.to_string();
run_niri_command(&[
"msg", "output", connector, "position", "set", "--", &x_arg, &y_arg,
])
}
/// Set screen orientation through the selected compositor Adapter.
pub fn set_orientation(orientation: &Orientation) -> Result<(), String> {
adapters::with_display_adapter(detect_backend(), |adapter| {
adapter.set_orientation(orientation)
})
.map_err(|message| {
if message == "Unsupported session backend for display layout" {
"Unsupported session backend for orientation control".to_string()
} else {
message
}
})
}
#[cfg(test)]
mod tests {
use super::adapters::CompositorDisplayAdapter;
use super::*;
fn test_display(connector: &str) -> DisplayInfo {
let mode = make_display_mode(2880, 1800, 120.0);
DisplayInfo {
connector: connector.to_string(),
width: mode.width,
height: mode.height,
refresh_rate: mode.refresh_rate,
scale: 1.66,
x: 0,
y: 0,
transform: 0,
primary: is_primary_internal_connector(connector),
current_mode: mode.clone(),
available_modes: vec![mode],
refresh_policy: RefreshPolicy::Fixed,
supports_dynamic_refresh: false,
}
}
struct FakeAdapter;
impl adapters::CompositorDisplayAdapter for FakeAdapter {
fn layout(&self) -> Result<DisplayLayout, String> {
Ok(DisplayLayout {
displays: vec![test_display(PRIMARY_INTERNAL_CONNECTOR)],
})
}
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
if layout.displays.is_empty() {
Err("empty layout".into())
} else {
Ok(())
}
}
fn set_orientation(&self, _orientation: &Orientation) -> Result<(), String> {
Ok(())
}
}
#[test]
fn display_adapter_interface_covers_layout_apply_and_orientation() {
let adapter = FakeAdapter;
assert_eq!(adapter.layout().expect("layout").displays.len(), 1);
assert!(adapter
.apply_layout(&DisplayLayout {
displays: vec![test_display(PRIMARY_INTERNAL_CONNECTOR)],
})
.is_ok());
assert!(adapter.set_orientation(&Orientation::Left).is_ok());
}
#[test]
fn finds_all_outputs_omitted_from_requested_layout() {
let layout = DisplayLayout {
displays: vec![test_display(PRIMARY_INTERNAL_CONNECTOR)],
};
let available = vec![
PRIMARY_INTERNAL_CONNECTOR.to_string(),
SECONDARY_INTERNAL_CONNECTOR.to_string(),
"HDMI-A-1".to_string(),
];
assert_eq!(
omitted_output_names(&layout, &available),
vec![SECONDARY_INTERNAL_CONNECTOR, "HDMI-A-1"]
);
}
#[test]
fn external_only_layout_omits_internal_outputs_for_clamshell() {
let layout = DisplayLayout {
displays: vec![test_display("HDMI-A-1")],
};
let available = vec![
PRIMARY_INTERNAL_CONNECTOR.to_string(),
SECONDARY_INTERNAL_CONNECTOR.to_string(),
"HDMI-A-1".to_string(),
];
assert_eq!(
omitted_output_names(&layout, &available),
vec![PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR]
);
}
#[test]
fn parses_kde_output_names_for_disable_replay() {
let value = serde_json::json!({
"outputs": [
{ "name": "eDP-1", "enabled": true },
{ "name": "eDP-2", "enabled": true }
]
});
assert_eq!(
compositor::kde_output_names_from_value(&value).expect("names should parse"),
vec!["eDP-1".to_string(), "eDP-2".to_string()]
);
}
#[test]
fn parses_niri_output_names_for_disable_replay() {
let value = serde_json::json!({
"eDP-1": { "name": "eDP-1" },
"eDP-2": { "name": "eDP-2" }
});
assert_eq!(
compositor::niri_output_names_from_value(&value).expect("names should parse"),
vec!["eDP-1".to_string(), "eDP-2".to_string()]
);
}
#[test]
fn formats_mode_ids_without_trailing_zeroes() {
assert_eq!(mode_id(2880, 1800, 120.0), "2880x1800@120");
assert_eq!(mode_id(2880, 1800, 60.001), "2880x1800@60.001");
}
#[test]
fn parses_gdctl_modes_into_current_and_available_modes() {
let output = r#"Monitors:
├──Monitor eDP-1 (Built-in display)
│ ├──Current mode
│ │ └──2880x1800@120.000
│ └──Preferences
│ ├──2880x1800@120.000
│ └──2880x1800@60.000
Logical monitors:
└──Logical monitor #1
├──Position: 0, 0
├──Scale: 1.750000
├──Transform: normal
├──Primary: yes
└──eDP-1 (Built-in display)
"#;
let layout = gnome::parse_gdctl_output(output).expect("gdctl output should parse");
let display = layout.displays.first().expect("display should exist");
assert_eq!(display.current_mode.mode_id, "2880x1800@120");
assert_eq!(
display.current_mode.backend_mode_id.as_deref(),
Some("2880x1800@120.000")
);
assert_eq!(display.available_modes.len(), 2);
assert_eq!(display.available_modes[1].mode_id, "2880x1800@60");
assert_eq!(
display.available_modes[1].backend_mode_id.as_deref(),
Some("2880x1800@60.000")
);
assert_eq!(display.refresh_policy, RefreshPolicy::Fixed);
}
#[test]
fn gnome_parser_skips_non_logical_external_monitors_but_keeps_internal_panels() {
let output = r#"Monitors:
├──Monitor eDP-1 (Built-in display)
│ └──Current mode
│ └──2880x1800@120.000
├──Monitor eDP-2 (Built-in display)
│ └──Preferences
│ └──2880x1800@120.000
└──Monitor HDMI-A-1 (External display)
└──Preferences
└──1920x1080@60.000
Logical monitors:
└──Logical monitor #1
├──Position: 0, 0
├──Scale: 1.000000
├──Transform: normal
├──Primary: yes
└──eDP-1 (Built-in display)
"#;
let layout = gnome::parse_gdctl_output(output).expect("gdctl output should parse");
let connectors = layout
.displays
.iter()
.map(|display| display.connector.as_str())
.collect::<Vec<_>>();
assert_eq!(connectors, vec!["eDP-1", "eDP-2"]);
assert_eq!(layout.displays[1].y, 1800);
}
#[test]
fn gnome_parser_keeps_external_monitors_that_have_logical_entries() {
let output = r#"Monitors:
├──Monitor eDP-1 (Built-in display)
│ └──Current mode
│ └──2880x1800@120.000
└──Monitor HDMI-A-1 (External display)
└──Current mode
└──1920x1080@60.000
Logical monitors:
├──Logical monitor #1
│ ├──Position: 0, 0
│ ├──Scale: 1.000000
│ ├──Transform: normal
│ ├──Primary: yes
│ └──eDP-1 (Built-in display)
└──Logical monitor #2
├──Position: 2880, 0
├──Scale: 1.000000
├──Transform: normal
├──Primary: no
└──HDMI-A-1 (External display)
"#;
let layout = gnome::parse_gdctl_output(output).expect("gdctl output should parse");
let external = layout
.displays
.iter()
.find(|display| display.connector == "HDMI-A-1")
.expect("active external should be included");
assert_eq!(external.x, 2880);
assert!(!external.primary);
}
#[test]
fn gnome_mode_arg_prefers_backend_token_and_falls_back_to_current_modes() {
let mut display = test_display(PRIMARY_INTERNAL_CONNECTOR);
display.current_mode.backend_mode_id = Some("2880x1800@120.000".into());
assert_eq!(gnome::gnome_mode_arg(&display, None), "2880x1800@120.000");
display.current_mode.backend_mode_id = None;
display.available_modes[0].backend_mode_id = None;
let current_layout = DisplayLayout {
displays: vec![DisplayInfo {
current_mode: make_display_mode_with_backend_id(
2880,
1800,
120.0,
Some("2880x1800@120.000".into()),
),
available_modes: vec![make_display_mode_with_backend_id(
2880,
1800,
120.0,
Some("2880x1800@120.000".into()),
)],
..test_display(PRIMARY_INTERNAL_CONNECTOR)
}],
};
assert_eq!(
gnome::gnome_mode_arg(&display, Some(&current_layout)),
"2880x1800@120.000"
);
}
#[test]
fn parses_niri_transform_from_logical_object() {
let value = serde_json::json!({
"logical": { "transform": "Normal" }
});
assert_eq!(niri::parse_niri_transform(&value), 0);
let value = serde_json::json!({
"logical": { "transform": "90" }
});
assert_eq!(niri::parse_niri_transform(&value), 90);
}
#[test]
fn falls_back_to_top_level_niri_transform() {
let value = serde_json::json!({
"transform": "270"
});
assert_eq!(niri::parse_niri_transform(&value), 270);
}
#[test]
fn resolves_niri_current_mode_from_index() {
let value = serde_json::json!({
"current_mode": 1,
"modes": [
{ "width": 1920, "height": 1200, "refresh_rate": 60000 },
{ "width": 2880, "height": 1800, "refresh_rate": 120000 }
]
});
let mode = niri::resolve_niri_current_mode(&value).expect("mode should resolve");
assert_eq!(mode.get("width").and_then(|v| v.as_u64()), Some(2880));
assert_eq!(mode.get("height").and_then(|v| v.as_u64()), Some(1800));
}
}
@@ -0,0 +1,242 @@
use super::*;
pub(super) fn get_niri_display_layout() -> Result<DisplayLayout, String> {
let value = compositor::niri_outputs_json()?;
let outputs = compositor::niri_outputs_from_value(&value)?;
fn parse_niri_mode(value: &serde_json::Value) -> Option<DisplayMode> {
Some(make_display_mode(
value.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
value.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
value
.get("refresh_rate")
.and_then(|v| v.as_f64())
.map(|rate| rate / 1000.0)?,
))
}
let mut displays = Vec::new();
for output in outputs {
if output
.get("current_mode")
.is_some_and(|value| value.is_null())
{
continue;
}
let connector = output
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing niri output name".to_string())?;
let logical = output
.get("logical")
.and_then(|v| v.as_object())
.ok_or_else(|| format!("Missing niri logical geometry for {connector}"))?;
let current_mode = resolve_niri_current_mode(&output)
.and_then(|value| parse_niri_mode(&value))
.ok_or_else(|| format!("Missing niri current mode for {connector}"))?;
let available_modes = dedupe_modes(
output
.get("modes")
.and_then(|v| v.as_array())
.map(|modes| modes.iter().filter_map(parse_niri_mode).collect())
.unwrap_or_else(|| vec![current_mode.clone()]),
);
// Niri reports VRR support on the Duo panels, but enabling it can hard-freeze the
// machine on this hardware. Keep the control disabled until there is a known-safe path.
let supports_dynamic_refresh = false;
let refresh_policy = RefreshPolicy::Fixed;
displays.push(DisplayInfo {
connector: connector.to_string(),
width: current_mode.width,
height: current_mode.height,
refresh_rate: current_mode.refresh_rate,
scale: logical.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0),
x: logical.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
y: logical.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
transform: parse_niri_transform(&output),
primary: is_primary_internal_connector(connector),
current_mode,
available_modes,
refresh_policy,
supports_dynamic_refresh,
});
}
Ok(DisplayLayout { displays })
}
pub(super) fn resolve_niri_current_mode(output: &serde_json::Value) -> Option<serde_json::Value> {
if let Some(mode) = output.get("current_mode").and_then(|v| v.as_object()) {
return Some(serde_json::Value::Object(mode.clone()));
}
let index = output.get("current_mode").and_then(|v| v.as_u64())? as usize;
output
.get("modes")
.and_then(|v| v.as_array())
.and_then(|modes| modes.get(index))
.cloned()
}
pub(super) fn parse_niri_transform(output: &serde_json::Value) -> u32 {
output
.get("logical")
.and_then(|value| value.get("transform"))
.or_else(|| output.get("transform"))
.and_then(|value| value.as_str())
.map(|value| value.to_ascii_lowercase())
.map(|value| match value.as_str() {
"90" => 90,
"180" => 180,
"270" => 270,
"flipped" | "inverted" => 180,
_ => 0,
})
.unwrap_or(0)
}
pub(super) fn apply_niri_display_layout(layout: &DisplayLayout) -> Result<(), String> {
if layout.displays.is_empty() {
return Err("No displays in layout".into());
}
let available_outputs = niri_output_names()?;
for connector in omitted_output_names(layout, &available_outputs) {
run_niri_command(&["msg", "output", &connector, "off"])?;
}
for display in &layout.displays {
run_niri_command(&["msg", "output", &display.connector, "on"])?;
match display.refresh_policy {
RefreshPolicy::Dynamic => {
return Err(format!(
"Dynamic refresh is disabled on Niri because it is unstable on this hardware ({})",
display.connector
));
}
RefreshPolicy::Fixed => {
run_niri_command(&["msg", "output", &display.connector, "vrr", "off"])?;
}
}
run_niri_command(&[
"msg",
"output",
&display.connector,
"mode",
&display.current_mode.mode_id,
])?;
run_niri_command(&[
"msg",
"output",
&display.connector,
"transform",
match display.transform {
90 => "90",
180 => "180",
270 => "270",
_ => "normal",
},
])?;
run_niri_command(&[
"msg",
"output",
&display.connector,
"scale",
&format!("{:.6}", display.scale.max(0.1)),
])?;
run_niri_position_command(&display.connector, display.x, display.y)?;
}
Ok(())
}
fn niri_output_logical_size(name: &str) -> Result<(i64, i64), String> {
compositor::niri_output_logical_size_from_value(&compositor::niri_outputs_json()?, name)
}
fn niri_enabled_output_count() -> Result<usize, String> {
compositor::niri_enabled_output_count_from_value(&compositor::niri_outputs_json()?)
}
fn niri_transform_token(orientation: &Orientation) -> &'static str {
match orientation {
Orientation::Left => "90",
Orientation::Right => "270",
Orientation::Inverted => "180",
Orientation::Normal => "normal",
}
}
fn secondary_position(
orientation: &Orientation,
primary_size: Result<(i64, i64), String>,
) -> Result<(i64, i64), String> {
let (width, height) = primary_size?;
Ok(match orientation {
Orientation::Left => (-width, 0),
Orientation::Right => (width, 0),
Orientation::Inverted => (0, -height),
Orientation::Normal => (0, height),
})
}
pub(super) fn set_niri_orientation(orientation: &Orientation) -> Result<(), String> {
let token = niri_transform_token(orientation);
run_niri_command(&[
"msg",
"output",
PRIMARY_INTERNAL_CONNECTOR,
"transform",
token,
])?;
run_niri_position_command(PRIMARY_INTERNAL_CONNECTOR, 0, 0)?;
if niri_enabled_output_count().unwrap_or(1) <= 1 {
return Ok(());
}
run_niri_command(&[
"msg",
"output",
SECONDARY_INTERNAL_CONNECTOR,
"transform",
token,
])?;
thread::sleep(Duration::from_millis(300));
let (pos_x, pos_y) = secondary_position(
orientation,
niri_output_logical_size(PRIMARY_INTERNAL_CONNECTOR),
)?;
run_niri_position_command(SECONDARY_INTERNAL_CONNECTOR, pos_x as i32, pos_y as i32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_primary_geometry_is_an_error_for_secondary_positioning() {
let result = secondary_position(&Orientation::Normal, Err("missing geometry".into()));
assert_eq!(
result.expect_err("missing geometry should fail"),
"missing geometry"
);
}
#[test]
fn secondary_position_uses_primary_geometry() {
assert_eq!(
secondary_position(&Orientation::Right, Ok((1200, 800))).expect("position"),
(1200, 0)
);
assert_eq!(
secondary_position(&Orientation::Normal, Ok((1200, 800))).expect("position"),
(0, 800)
);
}
}
@@ -0,0 +1,51 @@
pub const PRIMARY_INTERNAL_CONNECTOR: &str = "eDP-1";
pub const SECONDARY_INTERNAL_CONNECTOR: &str = "eDP-2";
pub const INTERNAL_CONNECTORS: [&str; 2] =
[PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR];
pub fn is_internal_connector(connector: &str) -> bool {
INTERNAL_CONNECTORS.contains(&connector)
}
pub fn is_primary_internal_connector(connector: &str) -> bool {
connector == PRIMARY_INTERNAL_CONNECTOR
}
pub fn is_secondary_internal_connector(connector: &str) -> bool {
connector == SECONDARY_INTERNAL_CONNECTOR
}
pub fn connector_for_elan_name(name: &str) -> Option<&'static str> {
if name.contains("ELAN9008") {
Some(PRIMARY_INTERNAL_CONNECTOR)
} else if name.contains("ELAN9009") {
Some(SECONDARY_INTERNAL_CONNECTOR)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifies_internal_connectors() {
assert!(is_internal_connector(PRIMARY_INTERNAL_CONNECTOR));
assert!(is_internal_connector(SECONDARY_INTERNAL_CONNECTOR));
assert!(!is_internal_connector("HDMI-A-1"));
}
#[test]
fn maps_elan_touchscreens_to_internal_connectors() {
assert_eq!(
connector_for_elan_name("ELAN9008:00"),
Some(PRIMARY_INTERNAL_CONNECTOR)
);
assert_eq!(
connector_for_elan_name("ELAN9009:00"),
Some(SECONDARY_INTERNAL_CONNECTOR)
);
assert_eq!(connector_for_elan_name("ELAN0000:00"), None);
}
}
@@ -0,0 +1,153 @@
use std::fs;
use std::os::unix::io::AsRawFd;
use std::path::Path;
use rusb::UsbContext;
/// USB HID SET_REPORT for keyboard backlight control using rusb.
///
/// Protocol:
/// Report ID: 0x5A
/// Data: [0x5A, 0xBA, 0xC5, 0xC4, level, 0x00 x 11]
/// wValue: 0x035A, wIndex: 4, wLength: 16
pub fn set_backlight_usb(level: u8) -> Result<(), String> {
let level = level.min(3);
let context = rusb::Context::new().map_err(|e| format!("USB context error: {e}"))?;
let devices = context
.devices()
.map_err(|e| format!("USB device list error: {e}"))?;
for device in devices.iter() {
let desc: rusb::DeviceDescriptor = match device.device_descriptor() {
Ok(d) => d,
Err(_) => continue,
};
// ASUS Zenbook Duo keyboard: vendor 0x0B05
if desc.vendor_id() != 0x0B05 {
continue;
}
let handle: rusb::DeviceHandle<rusb::Context> = match device.open() {
Ok(h) => h,
Err(_) => continue,
};
// Check if this is the keyboard by reading product string
if let Ok(product) = handle.read_product_string_ascii(&desc) {
if !product.contains("Zenbook Duo Keyboard") && !product.contains("ASUS_DUO") {
continue;
}
} else {
continue;
}
// Detach kernel driver if needed
let interface = 4u8;
let _ = handle.set_auto_detach_kernel_driver(true);
let _ = handle.claim_interface(interface as u8);
// Build the HID SET_REPORT payload
let mut data = [0u8; 16];
data[0] = 0x5A; // Report ID
data[1] = 0xBA;
data[2] = 0xC5;
data[3] = 0xC4;
data[4] = level;
// HID SET_REPORT: bmRequestType=0x21, bRequest=0x09
// wValue = 0x0300 | report_id = 0x035A
// wIndex = interface number
let request_type = 0x21; // Host-to-device, class, interface
let request = 0x09; // SET_REPORT
let value = 0x035A; // Feature report, ID 0x5A
let index = interface as u16;
let timeout = std::time::Duration::from_secs(2);
handle
.write_control(request_type, request, value, index, &data, timeout)
.map_err(|e| format!("USB write error: {e}"))?;
return Ok(());
}
Err("Zenbook Duo keyboard not found via USB".into())
}
/// Bluetooth HID Feature Report for keyboard backlight using ioctl HIDIOCSFEATURE.
pub fn set_backlight_bluetooth(level: u8) -> Result<(), String> {
let level = level.min(3);
// Find the hidraw device for the Zenbook keyboard
let hidraw_path = find_bt_hidraw()?;
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&hidraw_path)
.map_err(|e| format!("Failed to open {hidraw_path}: {e}"))?;
let fd = file.as_raw_fd();
// Build the same payload
let mut data = [0u8; 16];
data[0] = 0x5A;
data[1] = 0xBA;
data[2] = 0xC5;
data[3] = 0xC4;
data[4] = level;
// HIDIOCSFEATURE = 0xC0104806 + len
// This is _IOC(_IOC_WRITE|_IOC_READ, 'H', 0x06, len)
// For 16 bytes: 0xC0104806
let hidiocsfeature: libc::c_ulong = 0xC010_4806;
let ret = unsafe { libc::ioctl(fd, hidiocsfeature, data.as_mut_ptr()) };
if ret < 0 {
return Err(format!(
"ioctl HIDIOCSFEATURE failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
fn find_bt_hidraw() -> Result<String, String> {
let hidraw_dir = Path::new("/sys/class/hidraw");
if let Ok(entries) = fs::read_dir(hidraw_dir) {
for entry in entries.flatten() {
let uevent_path = entry.path().join("device/uevent");
if let Ok(contents) = fs::read_to_string(&uevent_path) {
if (contents.contains("Zenbook Duo Keyboard") || contents.contains("ASUS_DUO"))
&& contents.contains("0005:")
{
let name = entry.file_name();
return Ok(format!("/dev/{}", name.to_string_lossy()));
}
}
}
}
Err("Bluetooth hidraw device not found".into())
}
/// Set backlight, trying USB first then Bluetooth.
pub fn set_backlight(level: u8) -> Result<(), String> {
// Try USB first
let usb_err = match set_backlight_usb(level) {
Ok(()) => return Ok(()),
Err(e) => e,
};
// Try Bluetooth
let bt_err = match set_backlight_bluetooth(level) {
Ok(()) => return Ok(()),
Err(e) => e,
};
Err(format!(
"Failed to set keyboard backlight natively (usb: {usb_err}; bt: {bt_err})"
))
}
@@ -0,0 +1,5 @@
pub mod display_layout;
pub mod duo;
pub mod hid;
pub mod sysfs;
pub mod touchscreen;
@@ -0,0 +1,341 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::models::{ConnectionType, DuoStatus, Orientation};
use crate::runtime::{paths, state::RuntimeState};
const BACKLIGHT_ROOT: &str = "/sys/class/backlight";
const POGO_PIN_USB_PORT: &str = "-6/input";
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct KeyboardPresence {
saw_pogo_usb: bool,
saw_other_usb: bool,
saw_bluetooth: bool,
}
fn load_runtime_state() -> Option<RuntimeState> {
let path = paths::state_file_path();
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
fn backlight_dirs_from(root: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = fs::read_dir(root)
.ok()
.into_iter()
.flat_map(|entries| entries.flatten())
.map(|entry| entry.path())
.filter(|path| path.join("brightness").exists())
.collect();
dirs.sort();
dirs
}
fn backlight_name(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
}
fn is_secondary_backlight_name(name: &str) -> bool {
name.contains("edp-2") || name.contains("edp2")
}
fn primary_backlight_dir_from(root: &Path) -> Option<PathBuf> {
let dirs = backlight_dirs_from(root);
dirs.iter()
.find(|path| backlight_name(path) == "intel_backlight")
.or_else(|| {
dirs.iter()
.find(|path| backlight_name(path).contains("edp-1"))
})
.or_else(|| {
dirs.iter()
.find(|path| !is_secondary_backlight_name(&backlight_name(path)))
})
.cloned()
}
fn secondary_backlight_dir_from(root: &Path) -> Option<PathBuf> {
backlight_dirs_from(root)
.into_iter()
.find(|path| is_secondary_backlight_name(&backlight_name(path)))
}
pub fn primary_backlight_dir() -> Option<PathBuf> {
primary_backlight_dir_from(Path::new(BACKLIGHT_ROOT))
}
pub fn secondary_backlight_dir() -> Option<PathBuf> {
secondary_backlight_dir_from(Path::new(BACKLIGHT_ROOT))
}
fn read_backlight_value(path: &Path) -> Option<u32> {
fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse().ok())
}
pub fn read_backlight_level() -> u8 {
load_runtime_state()
.map(|state| state.status.backlight_level)
.unwrap_or(0)
}
pub fn read_display_brightness() -> u32 {
primary_backlight_dir()
.and_then(|dir| read_backlight_value(&dir.join("brightness")))
.unwrap_or(0)
}
pub fn read_max_brightness() -> u32 {
primary_backlight_dir()
.and_then(|dir| read_backlight_value(&dir.join("max_brightness")))
.unwrap_or(1)
}
pub fn detect_connection_type() -> ConnectionType {
let hidraw_dir = Path::new("/sys/class/hidraw");
if !hidraw_dir.exists() {
return ConnectionType::None;
}
let presence = detect_keyboard_presence(hidraw_dir);
// The keyboard can expose both paired Bluetooth and docked USB HID interfaces at the
// same time. Prefer USB so docked behavior, including the media remap helper, stays active.
classify_connection_type(
presence.saw_pogo_usb || presence.saw_other_usb,
presence.saw_bluetooth,
)
}
pub fn keyboard_attached() -> bool {
let hidraw_dir = Path::new("/sys/class/hidraw");
if !hidraw_dir.exists() {
return false;
}
detect_keyboard_presence(hidraw_dir).saw_pogo_usb
}
fn detect_keyboard_presence(hidraw_dir: &Path) -> KeyboardPresence {
let mut presence = KeyboardPresence::default();
if let Ok(entries) = fs::read_dir(hidraw_dir) {
for entry in entries.flatten() {
let uevent_path = entry.path().join("device/uevent");
let Ok(contents) = fs::read_to_string(&uevent_path) else {
continue;
};
update_keyboard_presence(&mut presence, &contents);
}
}
presence
}
fn update_keyboard_presence(presence: &mut KeyboardPresence, contents: &str) {
if !(contents.contains("Zenbook Duo Keyboard") || contents.contains("ASUS_DUO")) {
return;
}
if contents.contains("HID_ID=0005:") {
presence.saw_bluetooth = true;
return;
}
if is_pogo_pin_usb_keyboard_uevent(contents) {
presence.saw_pogo_usb = true;
} else {
presence.saw_other_usb = true;
}
}
fn is_pogo_pin_usb_keyboard_uevent(contents: &str) -> bool {
contents.contains("HID_ID=0003:") && contents.contains(POGO_PIN_USB_PORT)
}
fn classify_connection_type(saw_usb: bool, saw_bluetooth: bool) -> ConnectionType {
if saw_usb {
ConnectionType::Usb
} else if saw_bluetooth {
ConnectionType::Bluetooth
} else {
ConnectionType::None
}
}
pub fn is_service_active() -> bool {
is_unit_active_system("zenbook-duo-rust-daemon.service")
&& is_unit_active_user("zenbook-duo-session-agent.service")
}
fn is_unit_active_user(unit: &str) -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-active", unit])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
.unwrap_or(false)
}
fn is_unit_active_system(unit: &str) -> bool {
std::process::Command::new("systemctl")
.args(["is-active", unit])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
.unwrap_or(false)
}
pub fn get_full_status() -> DuoStatus {
if let Some(mut status) = load_runtime_state().map(|state| state.status) {
status.display_brightness = read_display_brightness();
status.max_brightness = read_max_brightness();
status.connection_type = detect_connection_type();
status.service_active = is_service_active();
return status;
}
DuoStatus {
keyboard_attached: false,
connection_type: detect_connection_type(),
monitor_count: 0,
wifi_enabled: false,
bluetooth_enabled: false,
backlight_level: read_backlight_level(),
display_brightness: read_display_brightness(),
max_brightness: read_max_brightness(),
service_active: is_service_active(),
orientation: Orientation::Normal,
}
}
pub fn clear_log() -> Result<(), String> {
let runtime_path = paths::log_file_path();
if let Some(parent) = runtime_path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(runtime_path, "").map_err(|e| e.to_string())
}
pub fn read_log_lines(count: usize) -> Vec<String> {
let runtime_path = paths::log_file_path();
let contents = fs::read_to_string(&runtime_path).unwrap_or_default();
contents
.lines()
.rev()
.take(count)
.map(String::from)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_usb_when_both_transport_interfaces_are_visible() {
assert_eq!(classify_connection_type(true, true), ConnectionType::Usb);
}
#[test]
fn returns_bluetooth_when_only_bluetooth_is_visible() {
assert_eq!(
classify_connection_type(false, true),
ConnectionType::Bluetooth
);
}
#[test]
fn returns_none_when_no_transport_is_visible() {
assert_eq!(classify_connection_type(false, false), ConnectionType::None);
}
#[test]
fn marks_port_six_usb_keyboard_as_pogo_pin_attached() {
let mut presence = KeyboardPresence::default();
update_keyboard_presence(
&mut presence,
"HID_ID=0003:00000B05:00001B2C\nHID_NAME=Primax Electronics Ltd. ASUS Zenbook Duo Keyboard\nHID_PHYS=usb-0000:00:14.0-6/input5\n",
);
assert!(presence.saw_pogo_usb);
assert!(!presence.saw_other_usb);
assert!(!presence.saw_bluetooth);
}
#[test]
fn keeps_cable_only_usb_keyboard_detached() {
let mut presence = KeyboardPresence::default();
update_keyboard_presence(
&mut presence,
"HID_ID=0003:00000B05:00001B2C\nHID_NAME=Primax Electronics Ltd. ASUS Zenbook Duo Keyboard\nHID_PHYS=usb-0000:00:14.0-4/input5\n",
);
assert!(!presence.saw_pogo_usb);
assert!(presence.saw_other_usb);
assert!(!presence.saw_bluetooth);
}
#[test]
fn records_bluetooth_keyboard_without_marking_usb() {
let mut presence = KeyboardPresence::default();
update_keyboard_presence(
&mut presence,
"HID_ID=0005:00000B05:00001B2D\nHID_NAME=ASUS Zenbook Duo Keyboard\n",
);
assert!(!presence.saw_pogo_usb);
assert!(!presence.saw_other_usb);
assert!(presence.saw_bluetooth);
}
#[test]
fn discovers_primary_and_secondary_backlight_devices() {
let root = unique_temp_dir("backlight");
create_backlight(&root, "card1-eDP-2-backlight");
create_backlight(&root, "intel_backlight");
assert_eq!(
primary_backlight_dir_from(&root)
.and_then(|path| path.file_name().map(|name| name.to_owned())),
Some(std::ffi::OsString::from("intel_backlight"))
);
assert_eq!(
secondary_backlight_dir_from(&root)
.and_then(|path| path.file_name().map(|name| name.to_owned())),
Some(std::ffi::OsString::from("card1-eDP-2-backlight"))
);
fs::remove_dir_all(root).expect("remove temp dir");
}
fn create_backlight(root: &Path, name: &str) {
let dir = root.join(name);
fs::create_dir_all(&dir).expect("create backlight dir");
fs::write(dir.join("brightness"), "100").expect("write brightness");
fs::write(dir.join("max_brightness"), "400").expect("write max brightness");
}
fn unique_temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock before unix epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"zenbook-duo-sysfs-{label}-{}-{nanos}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}
@@ -0,0 +1,93 @@
use crate::hardware::duo;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TouchscreenDevice {
pub name: String,
pub i2c_id: String,
pub connector: String,
pub enabled: bool,
}
/// Maps ELAN model number to display connector.
fn elan_to_connector(name: &str) -> Option<&'static str> {
duo::connector_for_elan_name(name)
}
/// Reads the device name from sysfs for an i2c device.
fn read_i2c_device_name(i2c_id: &str) -> Option<String> {
let path = format!("/sys/bus/i2c/devices/{}/name", i2c_id);
fs::read_to_string(&path).ok().map(|s| s.trim().to_string())
}
/// Checks if the i2c device is currently bound to its driver.
fn is_bound(i2c_id: &str) -> bool {
Path::new(&format!("/sys/bus/i2c/drivers/i2c_hid_acpi/{}", i2c_id)).exists()
}
pub fn list_touchscreens() -> Vec<TouchscreenDevice> {
let mut devices = Vec::new();
let i2c_devices = match fs::read_dir("/sys/bus/i2c/devices") {
Ok(entries) => entries,
Err(_) => return devices,
};
for entry in i2c_devices.flatten() {
let i2c_id = entry.file_name().to_string_lossy().to_string();
if !i2c_id.starts_with("i2c-ELAN") {
continue;
}
let name = match read_i2c_device_name(&i2c_id) {
Some(n) => n,
None => continue,
};
let connector = match elan_to_connector(&name) {
Some(c) => c.to_string(),
None => continue,
};
devices.push(TouchscreenDevice {
name,
i2c_id: i2c_id.clone(),
connector,
enabled: is_bound(&i2c_id),
});
}
devices
}
pub fn set_touchscreen_enabled(i2c_id: &str, enabled: bool) -> Result<(), String> {
let path = if enabled {
"/sys/bus/i2c/drivers/i2c_hid_acpi/bind"
} else {
"/sys/bus/i2c/drivers/i2c_hid_acpi/unbind"
};
fs::write(path, i2c_id).map_err(|e| {
format!(
"Failed to {} touchscreen {}: {}",
if enabled { "bind" } else { "unbind" },
i2c_id,
e
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hardware::duo::{PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR};
#[test]
fn maps_known_elan_panels_to_duo_connectors() {
assert_eq!(
elan_to_connector("ELAN9008:00"),
Some(PRIMARY_INTERNAL_CONNECTOR)
);
assert_eq!(
elan_to_connector("ELAN9009:00"),
Some(SECONDARY_INTERNAL_CONNECTOR)
);
assert_eq!(elan_to_connector("ELAN1234:00"), None);
}
}
+1
View File
@@ -0,0 +1 @@
pub mod protocol;
@@ -0,0 +1,175 @@
use serde::{Deserialize, Serialize};
use crate::commands::usb_media_remap::UsbMediaRemapStatus;
use crate::models::{
DaemonVersionInfo, DisplayLayout, DuoSettings, DuoStatus, HardwareEvent, Orientation,
};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Envelope<T> {
pub protocol_version: u32,
pub payload: T,
}
impl<T> Envelope<T> {
pub fn new(payload: T) -> Self {
Self {
protocol_version: PROTOCOL_VERSION,
payload,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DaemonRequest {
Ping,
HandleLifecycle {
phase: LifecyclePhase,
},
GetStatus,
GetVersion,
GetDisplayLayout,
GetSettings,
SaveSettings {
settings: DuoSettings,
},
SetBacklight {
level: u8,
},
SetOrientation {
orientation: Orientation,
},
ApplyDisplayLayout {
layout: DisplayLayout,
},
UsbMediaRemapStatus,
UsbMediaRemapStart,
UsbMediaRemapStop,
UsbMediaRemapTogglePause,
RestartService,
RegisterSessionAgent {
session_id: String,
backend: SessionBackend,
socket_path: String,
},
AppendLog {
line: String,
},
TailLogs {
lines: usize,
},
ClearLogs,
GetRecentEvents {
limit: usize,
},
ListTouchscreens,
SetTouchscreenEnabled {
connector: String,
enabled: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DaemonResponse {
Pong,
Ack,
Status {
status: DuoStatus,
},
Version {
version: DaemonVersionInfo,
},
DisplayLayout {
layout: DisplayLayout,
},
Settings {
settings: DuoSettings,
},
UsbMediaRemapStatus {
status: UsbMediaRemapStatus,
},
Logs {
lines: Vec<String>,
},
Events {
events: Vec<HardwareEvent>,
},
Touchscreens {
devices: Vec<crate::hardware::touchscreen::TouchscreenDevice>,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecyclePhase {
Pre,
Post,
Hibernate,
Thaw,
Boot,
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DaemonEvent {
StatusChanged {
status: DuoStatus,
},
HardwareEvent {
event: HardwareEvent,
},
SessionAgentChanged {
connected: bool,
backend: Option<SessionBackend>,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionBackend {
Gnome,
Kde,
Niri,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionCommand {
GetDisplayLayout,
SetDockMode {
attached: bool,
scale: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
layout: Option<DisplayLayout>,
},
ApplyDisplayLayout {
layout: DisplayLayout,
},
SetOrientation {
orientation: Orientation,
},
ShowNotification {
title: String,
message: String,
urgent: bool,
},
OpenEmojiPicker,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionResponse {
Ack,
DisplayLayout { layout: DisplayLayout },
Error { message: String },
}
+222
View File
@@ -0,0 +1,222 @@
pub mod commands;
pub mod hardware;
pub mod ipc;
pub mod models;
pub mod runtime;
pub mod usb_media_remap_helper;
mod watchers;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
use commands::events::create_event_buffer;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
let event_buffer = create_event_buffer();
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
if argv.iter().any(|arg| arg == "--start-minimized") {
return;
}
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_shell::init())
.manage(event_buffer.clone())
.setup(move |app| {
let handle = app.handle().clone();
let start_minimized = std::env::args().any(|arg| arg == "--start-minimized");
// Build tray menu
build_tray(&handle)?;
// Start background watchers
watchers::start_all_watchers(&handle, event_buffer.clone());
if !start_minimized {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
Ok(())
})
.on_window_event(|window, event| {
// Minimize to tray on close instead of quitting
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
commands::status::get_status,
commands::backlight::get_backlight,
commands::backlight::set_backlight,
commands::display::get_display_layout,
commands::display::apply_display_layout,
commands::display::set_orientation,
commands::service::is_service_active,
commands::service::get_version_info,
commands::service::restart_service,
commands::settings::load_settings,
commands::settings::save_settings,
commands::theme::get_system_theme,
commands::logs::read_log,
commands::logs::clear_log,
commands::profiles::list_profiles,
commands::profiles::save_profile,
commands::profiles::delete_profile,
commands::profiles::activate_profile,
commands::events::get_recent_events,
commands::diagnostics::diag_list_evdev,
commands::diagnostics::diag_capture_evdev,
commands::diagnostics::diag_capture_evdev_multi,
commands::diagnostics::diag_list_hid,
commands::diagnostics::diag_read_report_descriptor,
commands::diagnostics::diag_capture_hidraw_pkexec,
commands::usb_media_remap::usb_media_remap_status,
commands::usb_media_remap::usb_media_remap_start,
commands::usb_media_remap::usb_media_remap_stop,
commands::usb_media_remap::usb_media_remap_toggle_pause,
commands::touchscreen::list_touchscreens,
commands::touchscreen::set_touchscreen_enabled,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn build_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
let separator1 = PredefinedMenuItem::separator(app)?;
let profile_docked = MenuItem::with_id(app, "profile_docked", "Docked", true, None::<&str>)?;
let profile_tablet = MenuItem::with_id(app, "profile_tablet", "Tablet", true, None::<&str>)?;
let profile_presentation = MenuItem::with_id(
app,
"profile_presentation",
"Presentation",
true,
None::<&str>,
)?;
let profiles_submenu = Submenu::with_items(
app,
"Profiles",
true,
&[&profile_docked, &profile_tablet, &profile_presentation],
)?;
let bl_0 = MenuItem::with_id(app, "bl_0", "Backlight Off", true, None::<&str>)?;
let bl_1 = MenuItem::with_id(app, "bl_1", "Backlight Low", true, None::<&str>)?;
let bl_2 = MenuItem::with_id(app, "bl_2", "Backlight Medium", true, None::<&str>)?;
let bl_3 = MenuItem::with_id(app, "bl_3", "Backlight High", true, None::<&str>)?;
let backlight_submenu =
Submenu::with_items(app, "Backlight", true, &[&bl_0, &bl_1, &bl_2, &bl_3])?;
let separator2 = PredefinedMenuItem::separator(app)?;
let usb_media_remap = MenuItem::with_id(
app,
"usb_media_remap",
"Toggle USB Media Remap",
true,
None::<&str>,
)?;
let usb_media_remap_pause = MenuItem::with_id(
app,
"usb_media_remap_pause",
"Pause/Resume Remap",
true,
None::<&str>,
)?;
let separator3 = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&show,
&separator1,
&profiles_submenu,
&backlight_submenu,
&separator2,
&usb_media_remap,
&usb_media_remap_pause,
&separator3,
&quit,
],
)?;
let tray_builder = TrayIconBuilder::new()
.menu(&menu)
.tooltip("Zenbook Duo Control")
.on_menu_event(move |app, event| {
let id = event.id().as_ref();
match id {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
"profile_docked" | "profile_tablet" | "profile_presentation" => {
let profile_id = id.strip_prefix("profile_").unwrap_or(id);
let _ = commands::profiles::activate_profile(profile_id.to_string());
}
id if id.starts_with("bl_") => {
if let Ok(level) = id[3..].parse::<u8>() {
let _ = commands::backlight::set_backlight_daemon_first(level);
}
}
"usb_media_remap" => {
let status = commands::usb_media_remap::daemon_first_status();
if status.running {
let _ = commands::usb_media_remap::daemon_first_stop();
} else {
let _ = commands::usb_media_remap::daemon_first_start();
}
}
"usb_media_remap_pause" => {
let _ = commands::usb_media_remap::daemon_first_toggle_pause();
}
_ => {}
}
});
let tray_builder = if let Some(icon) = app.default_window_icon() {
tray_builder.icon(icon.clone())
} else {
tray_builder
};
let _tray = tray_builder
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}
+29
View File
@@ -0,0 +1,29 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// The app also acts as a small privileged helper when invoked via pkexec.
// This avoids needing to bundle a separate binary alongside the main app.
if std::env::args().any(|a| a == "--usb-media-remap-helper") {
match zenbook_duo_control_lib::usb_media_remap_helper::run_from_env() {
Ok(()) => std::process::exit(0),
Err(err) => {
zenbook_duo_control_lib::usb_media_remap_helper::log_error(&err);
eprintln!("{err}");
std::process::exit(1);
}
}
}
if std::env::args().any(|a| a == "--toggle-remap-pause") {
match zenbook_duo_control_lib::commands::usb_media_remap::toggle_pause() {
Ok(()) => std::process::exit(0),
Err(err) => {
eprintln!("{err}");
std::process::exit(1);
}
}
}
zenbook_duo_control_lib::run()
}
@@ -0,0 +1,78 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HardwareEvent {
pub timestamp: DateTime<Utc>,
pub category: EventCategory,
pub severity: EventSeverity,
pub message: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum EventCategory {
Usb,
Display,
Keyboard,
Network,
Rotation,
Bluetooth,
Service,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum EventSeverity {
Info,
Warning,
Error,
}
impl HardwareEvent {
pub fn info(
category: EventCategory,
message: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
timestamp: Utc::now(),
category,
severity: EventSeverity::Info,
message: message.into(),
source: source.into(),
}
}
#[allow(dead_code)]
pub fn warning(
category: EventCategory,
message: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
timestamp: Utc::now(),
category,
severity: EventSeverity::Warning,
message: message.into(),
source: source.into(),
}
}
#[allow(dead_code)]
pub fn error(
category: EventCategory,
message: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
timestamp: Utc::now(),
category,
severity: EventSeverity::Error,
message: message.into(),
source: source.into(),
}
}
}
@@ -0,0 +1,11 @@
pub mod event;
pub mod profile;
pub mod settings;
pub mod status;
pub mod version;
pub use event::*;
pub use profile::*;
pub use settings::*;
pub use status::*;
pub use version::*;
@@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
use super::{DisplayLayout, Orientation};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub id: String,
pub name: String,
pub backlight_level: u8,
pub scale: f64,
pub orientation: Orientation,
pub dual_screen_enabled: bool,
pub display_layout: Option<DisplayLayout>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ProfileList {
pub profiles: Vec<Profile>,
}
impl Profile {
pub fn default_profiles() -> Vec<Profile> {
vec![
Profile {
id: "docked".into(),
name: "Docked".into(),
backlight_level: 3,
scale: 1.66,
orientation: Orientation::Normal,
dual_screen_enabled: false,
display_layout: None,
},
Profile {
id: "tablet".into(),
name: "Tablet".into(),
backlight_level: 0,
scale: 1.66,
orientation: Orientation::Normal,
dual_screen_enabled: true,
display_layout: None,
},
Profile {
id: "presentation".into(),
name: "Presentation".into(),
backlight_level: 3,
scale: 1.66,
orientation: Orientation::Normal,
dual_screen_enabled: true,
display_layout: None,
},
]
}
}
@@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
use super::DisplayLayout;
pub const DEFAULT_BACKLIGHT_LEVEL: u8 = 0;
pub const DEFAULT_SCALE_FACTOR: f64 = 1.66;
pub const DEFAULT_USB_MEDIA_REMAP_ENABLED: bool = true;
pub const DEFAULT_START_ON_BOOT_MINIMIZED: bool = false;
pub const DEFAULT_INVERT_SENSOR_ROTATION: bool = false;
pub const DEFAULT_SETUP_COMPLETED: bool = false;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DuoSettings {
#[serde(default = "default_backlight")]
pub default_backlight: u8,
#[serde(default = "default_scale")]
pub default_scale: f64,
#[serde(default)]
pub auto_dual_screen: bool,
#[serde(default)]
pub sync_brightness: bool,
#[serde(default)]
pub theme: ThemePreference,
#[serde(default = "default_usb_media_remap_enabled")]
pub usb_media_remap_enabled: bool,
#[serde(default = "default_start_on_boot_minimized")]
pub start_on_boot_minimized: bool,
#[serde(default = "default_invert_sensor_rotation")]
pub invert_sensor_rotation: bool,
#[serde(default)]
pub setup_completed: bool,
#[serde(default)]
pub touchscreen_disabled: Vec<String>,
#[serde(default)]
pub saved_display_layout: Option<DisplayLayout>,
}
impl Default for DuoSettings {
fn default() -> Self {
Self {
default_backlight: default_backlight(),
default_scale: default_scale(),
auto_dual_screen: true,
sync_brightness: true,
theme: ThemePreference::System,
usb_media_remap_enabled: default_usb_media_remap_enabled(),
start_on_boot_minimized: default_start_on_boot_minimized(),
invert_sensor_rotation: default_invert_sensor_rotation(),
setup_completed: DEFAULT_SETUP_COMPLETED,
touchscreen_disabled: Vec::new(),
saved_display_layout: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ThemePreference {
#[default]
System,
Light,
Dark,
}
fn default_backlight() -> u8 {
DEFAULT_BACKLIGHT_LEVEL
}
fn default_scale() -> f64 {
DEFAULT_SCALE_FACTOR
}
fn default_usb_media_remap_enabled() -> bool {
DEFAULT_USB_MEDIA_REMAP_ENABLED
}
fn default_start_on_boot_minimized() -> bool {
DEFAULT_START_ON_BOOT_MINIMIZED
}
fn default_invert_sensor_rotation() -> bool {
DEFAULT_INVERT_SENSOR_ROTATION
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_installer_and_frontend_contract() {
let settings = DuoSettings::default();
assert_eq!(settings.default_backlight, DEFAULT_BACKLIGHT_LEVEL);
assert_eq!(settings.default_scale, DEFAULT_SCALE_FACTOR);
assert_eq!(
settings.usb_media_remap_enabled,
DEFAULT_USB_MEDIA_REMAP_ENABLED
);
assert_eq!(
settings.start_on_boot_minimized,
DEFAULT_START_ON_BOOT_MINIMIZED
);
assert_eq!(
settings.invert_sensor_rotation,
DEFAULT_INVERT_SENSOR_ROTATION
);
assert_eq!(settings.setup_completed, DEFAULT_SETUP_COMPLETED);
assert!(settings.auto_dual_screen);
assert!(settings.sync_brightness);
assert_eq!(settings.theme, ThemePreference::System);
}
}
@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DuoStatus {
pub keyboard_attached: bool,
pub connection_type: ConnectionType,
pub monitor_count: u32,
pub wifi_enabled: bool,
pub bluetooth_enabled: bool,
pub backlight_level: u8,
pub display_brightness: u32,
pub max_brightness: u32,
pub service_active: bool,
pub orientation: Orientation,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionType {
Usb,
Bluetooth,
#[default]
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Orientation {
#[default]
Normal,
Left,
Right,
Inverted,
}
impl Orientation {
pub fn as_duo_arg(&self) -> &str {
match self {
Orientation::Normal => "normal",
Orientation::Left => "left-up",
Orientation::Right => "right-up",
Orientation::Inverted => "bottom-up",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum RefreshPolicy {
#[default]
Fixed,
Dynamic,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DisplayMode {
pub mode_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_mode_id: Option<String>,
pub width: u32,
pub height: u32,
pub refresh_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisplayInfo {
pub connector: String,
pub width: u32,
pub height: u32,
pub refresh_rate: f64,
pub scale: f64,
pub x: i32,
pub y: i32,
pub transform: u32,
pub primary: bool,
pub current_mode: DisplayMode,
pub available_modes: Vec<DisplayMode>,
pub refresh_policy: RefreshPolicy,
pub supports_dynamic_refresh: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisplayLayout {
pub displays: Vec<DisplayInfo>,
}
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DaemonVersionInfo {
pub version: String,
pub protocol_version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VersionInfo {
pub app_version: String,
pub app_protocol_version: u32,
pub daemon_version: Option<String>,
pub daemon_protocol_version: Option<u32>,
pub service_available: bool,
}
impl VersionInfo {
pub fn from_daemon(
app_version: impl Into<String>,
app_protocol_version: u32,
daemon: DaemonVersionInfo,
) -> Self {
Self {
app_version: app_version.into(),
app_protocol_version,
daemon_version: Some(daemon.version),
daemon_protocol_version: Some(daemon.protocol_version),
service_available: true,
}
}
pub fn protocol_mismatch(
app_version: impl Into<String>,
app_protocol_version: u32,
daemon_protocol_version: u32,
) -> Self {
Self {
app_version: app_version.into(),
app_protocol_version,
daemon_version: None,
daemon_protocol_version: Some(daemon_protocol_version),
service_available: true,
}
}
pub fn unavailable(app_version: impl Into<String>, app_protocol_version: u32) -> Self {
Self {
app_version: app_version.into(),
app_protocol_version,
daemon_version: None,
daemon_protocol_version: None,
service_available: false,
}
}
}
@@ -0,0 +1,308 @@
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use crate::models::{ConnectionType, EventCategory, HardwareEvent};
use crate::runtime::{logger, state::RuntimeState};
const HIDRAW_ROOT: &str = "/sys/class/hidraw";
const REPORT_ID: u8 = 0x5a;
const REPORT_RELEASE: u8 = 0x00;
const REPORT_BACKLIGHT_CYCLE: u8 = 0xc7;
const REPORT_BRIGHTNESS_DOWN: u8 = 0x10;
const REPORT_BRIGHTNESS_UP: u8 = 0x20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BluetoothHotkeyAction {
BacklightCycle,
BrightnessDown,
BrightnessUp,
}
pub(crate) fn start(state: Arc<RwLock<RuntimeState>>) {
match std::thread::Builder::new()
.name("zenbook-duo-bt-hotkeys".into())
.spawn(move || watch_loop(state))
{
Ok(_) => {}
Err(err) => {
log::warn!("failed to spawn Bluetooth hotkey watcher: {err}");
let _ = logger::append_line(format!(
"rust-daemon: failed to spawn Bluetooth hotkey watcher: {err}"
));
}
}
}
fn watch_loop(state: Arc<RwLock<RuntimeState>>) {
loop {
let Some(path) = find_bluetooth_keyboard_hidraw() else {
std::thread::sleep(Duration::from_secs(5));
continue;
};
match fs::OpenOptions::new().read(true).open(&path) {
Ok(file) => {
let _ = logger::append_line(format!(
"rust-daemon: Bluetooth hotkey watcher opened {}",
path.display()
));
if let Err(err) = watch_device(file, state.clone()) {
log::warn!("Bluetooth hotkey watcher lost {}: {err}", path.display());
let _ = logger::append_line(format!(
"rust-daemon: Bluetooth hotkey watcher lost {}: {err}",
path.display()
));
}
}
Err(err) => {
log::warn!(
"failed to open Bluetooth hotkey hidraw {}: {err}",
path.display()
);
}
}
std::thread::sleep(Duration::from_secs(2));
}
}
fn watch_device(mut file: fs::File, state: Arc<RwLock<RuntimeState>>) -> Result<(), String> {
let mut active_action: Option<BluetoothHotkeyAction> = None;
let mut buffer = [0_u8; 64];
loop {
match file.read(&mut buffer) {
Ok(0) => return Err("hidraw read returned EOF".into()),
Ok(count) => {
let report = &buffer[..count];
if is_release_report(report) {
active_action = None;
continue;
}
let Some(action) = parse_hotkey_report(report) else {
continue;
};
if active_action == Some(action) {
continue;
}
active_action = Some(action);
if let Err(err) = handle_action(action, &state) {
log::warn!("failed to handle Bluetooth hotkey {action:?}: {err}");
let _ = logger::append_line(format!(
"rust-daemon: failed to handle Bluetooth hotkey {action:?}: {err}"
));
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err.to_string()),
}
}
}
fn find_bluetooth_keyboard_hidraw() -> Option<PathBuf> {
find_bluetooth_keyboard_hidraw_from(Path::new(HIDRAW_ROOT))
}
fn find_bluetooth_keyboard_hidraw_from(root: &Path) -> Option<PathBuf> {
let mut devices = fs::read_dir(root)
.ok()?
.flatten()
.filter_map(|entry| {
let contents = fs::read_to_string(entry.path().join("device/uevent")).ok()?;
if !is_bluetooth_keyboard_uevent(&contents) {
return None;
}
Some(Path::new("/dev").join(entry.file_name()))
})
.collect::<Vec<_>>();
devices.sort();
devices.into_iter().next()
}
fn is_bluetooth_keyboard_uevent(contents: &str) -> bool {
(contents.contains("Zenbook Duo Keyboard") || contents.contains("ASUS_DUO"))
&& contents.contains("HID_ID=0005:")
}
fn parse_hotkey_report(report: &[u8]) -> Option<BluetoothHotkeyAction> {
if report.len() < 2 || report[0] != REPORT_ID {
return None;
}
match report[1] {
REPORT_BACKLIGHT_CYCLE => Some(BluetoothHotkeyAction::BacklightCycle),
REPORT_BRIGHTNESS_DOWN => Some(BluetoothHotkeyAction::BrightnessDown),
REPORT_BRIGHTNESS_UP => Some(BluetoothHotkeyAction::BrightnessUp),
_ => None,
}
}
fn is_release_report(report: &[u8]) -> bool {
report.len() >= 2 && report[0] == REPORT_ID && report[1] == REPORT_RELEASE
}
fn handle_action(
action: BluetoothHotkeyAction,
state: &Arc<RwLock<RuntimeState>>,
) -> Result<(), String> {
if !matches!(
crate::hardware::sysfs::detect_connection_type(),
ConnectionType::Bluetooth
) {
return Ok(());
}
match action {
BluetoothHotkeyAction::BacklightCycle => cycle_backlight(state),
BluetoothHotkeyAction::BrightnessDown | BluetoothHotkeyAction::BrightnessUp => {
step_brightness(action, state)
}
}
}
fn cycle_backlight(state: &Arc<RwLock<RuntimeState>>) -> Result<(), String> {
let current = state.blocking_read().status.backlight_level;
let next = (current + 1) % 4;
crate::hardware::hid::set_backlight(next)?;
let mut guard = state.blocking_write();
guard.status.backlight_level = next;
guard.push_recent_event(HardwareEvent::info(
EventCategory::Keyboard,
format!("Backlight set to {next}"),
"rust-daemon",
));
guard.touch();
guard.save()?;
let _ = logger::append_line(format!(
"rust-daemon: Bluetooth hotkey cycled backlight -> {next}"
));
Ok(())
}
fn step_brightness(
action: BluetoothHotkeyAction,
state: &Arc<RwLock<RuntimeState>>,
) -> Result<(), String> {
let primary = crate::hardware::sysfs::primary_backlight_dir()
.ok_or_else(|| "no primary backlight device found".to_string())?;
let max = read_brightness_value(&primary.join("max_brightness"))?;
let current = read_brightness_value(&primary.join("brightness"))?;
let next = next_brightness_value(current, max, action);
fs::write(primary.join("brightness"), next.to_string())
.map_err(|e| format!("Failed to write primary brightness: {e}"))?;
let sync_secondary = state.blocking_read().settings.sync_brightness;
if sync_secondary {
if let Some(secondary) = crate::hardware::sysfs::secondary_backlight_dir() {
let secondary_max = read_brightness_value(&secondary.join("max_brightness"))?;
let mirrored = next.min(secondary_max);
fs::write(secondary.join("brightness"), mirrored.to_string())
.map_err(|e| format!("Failed to write secondary brightness: {e}"))?;
}
}
let mut guard = state.blocking_write();
guard.status.display_brightness = next;
guard.push_recent_event(HardwareEvent::info(
EventCategory::Display,
format!("Display brightness set to {next}"),
"rust-daemon",
));
guard.touch();
guard.save()?;
let _ = logger::append_line(format!(
"rust-daemon: Bluetooth hotkey adjusted brightness -> {next}"
));
Ok(())
}
fn read_brightness_value(path: &Path) -> Result<u32, String> {
fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?
.trim()
.parse::<u32>()
.map_err(|e| format!("Invalid brightness value in {}: {e}", path.display()))
}
fn next_brightness_value(current: u32, max: u32, action: BluetoothHotkeyAction) -> u32 {
let step = (max / 20).max(1);
match action {
BluetoothHotkeyAction::BrightnessUp => current.saturating_add(step).min(max),
BluetoothHotkeyAction::BrightnessDown => current.saturating_sub(step),
BluetoothHotkeyAction::BacklightCycle => current,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_captured_bluetooth_hotkey_reports() {
assert_eq!(
parse_hotkey_report(&[0x5a, 0xc7, 0x00, 0x00, 0x00, 0x00]),
Some(BluetoothHotkeyAction::BacklightCycle)
);
assert_eq!(
parse_hotkey_report(&[0x5a, 0x10, 0x00, 0x00, 0x00, 0x00]),
Some(BluetoothHotkeyAction::BrightnessDown)
);
assert_eq!(
parse_hotkey_report(&[0x5a, 0x20, 0x00, 0x00, 0x00, 0x00]),
Some(BluetoothHotkeyAction::BrightnessUp)
);
}
#[test]
fn ignores_release_and_unrelated_reports() {
assert_eq!(
parse_hotkey_report(&[0x5a, 0x00, 0x00, 0x00, 0x00, 0x00]),
None
);
assert_eq!(parse_hotkey_report(&[0x59, 0xc7, 0x00]), None);
assert_eq!(parse_hotkey_report(&[0x5a]), None);
}
#[test]
fn identifies_bluetooth_keyboard_hidraw_uevents() {
assert!(is_bluetooth_keyboard_uevent(
"HID_ID=0005:00000B05:00001B2C\nHID_NAME=ASUS Zenbook Duo Keyboard\n"
));
assert!(!is_bluetooth_keyboard_uevent(
"HID_ID=0003:00000B05:00001B2C\nHID_NAME=ASUS Zenbook Duo Keyboard\n"
));
assert!(!is_bluetooth_keyboard_uevent(
"HID_ID=0005:00001234:00005678\nHID_NAME=Other Keyboard\n"
));
}
#[test]
fn brightness_steps_use_five_percent_chunks() {
assert_eq!(
next_brightness_value(200, 400, BluetoothHotkeyAction::BrightnessUp),
220
);
assert_eq!(
next_brightness_value(200, 400, BluetoothHotkeyAction::BrightnessDown),
180
);
assert_eq!(
next_brightness_value(395, 400, BluetoothHotkeyAction::BrightnessUp),
400
);
assert_eq!(
next_brightness_value(5, 400, BluetoothHotkeyAction::BrightnessDown),
0
);
}
}
@@ -0,0 +1,39 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
use crate::ipc::protocol::{DaemonRequest, DaemonResponse, Envelope};
use crate::runtime::paths;
pub fn request(request: DaemonRequest) -> Result<DaemonResponse, String> {
let mut stream = UnixStream::connect(paths::daemon_socket_path())
.map_err(|e| format!("Failed to connect to daemon socket: {e}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.map_err(|e| format!("Failed to set daemon read timeout: {e}"))?;
stream
.set_write_timeout(Some(Duration::from_secs(3)))
.map_err(|e| format!("Failed to set daemon write timeout: {e}"))?;
let line = serde_json::to_string(&Envelope::new(request))
.map_err(|e| format!("Failed to encode daemon request: {e}"))?;
stream
.write_all(line.as_bytes())
.map_err(|e| format!("Failed to write daemon request: {e}"))?;
stream
.write_all(b"\n")
.map_err(|e| format!("Failed to terminate daemon request: {e}"))?;
let mut reader = BufReader::new(stream);
let mut reply = String::new();
reader
.read_line(&mut reply)
.map_err(|e| format!("Failed to read daemon response: {e}"))?;
if reply.trim().is_empty() {
return Err("Daemon returned an empty response".into());
}
let envelope: Envelope<DaemonResponse> = serde_json::from_str(reply.trim_end())
.map_err(|e| format!("Invalid daemon response JSON: {e}"))?;
Ok(envelope.payload)
}
@@ -0,0 +1,211 @@
use std::process::{Command, Output};
use serde_json::Value;
use crate::runtime::session;
pub fn command_output<S: AsRef<str>>(program: &str, args: &[S]) -> Result<Output, String> {
Command::new(program)
.args(args.iter().map(|arg| arg.as_ref()))
.output()
.map_err(|e| format!("Failed to run {program}: {e}"))
}
pub fn command_succeeds<S: AsRef<str>>(program: &str, args: &[S]) -> bool {
command_output(program, args)
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn run_command<S: AsRef<str>>(program: &str, args: &[S]) -> Result<(), String> {
let output = command_output(program, args)?;
ensure_success(output)
}
pub fn niri_command_output(args: &[&str]) -> Result<Output, String> {
session::build_niri_command(args)
.output()
.map_err(|e| format!("Failed to run niri msg: {e}"))
}
pub fn niri_command_succeeds(args: &[&str]) -> bool {
niri_command_output(args)
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn run_niri_command(args: &[&str]) -> Result<(), String> {
let output = niri_command_output(args)?;
ensure_success(output)
}
pub fn kscreen_json() -> Result<Value, String> {
let output = command_output("kscreen-doctor", &["-j"])?;
if !output.status.success() {
return Err(stderr_trimmed(&output));
}
serde_json::from_slice(&output.stdout).map_err(|e| format!("Invalid kscreen JSON: {e}"))
}
pub fn niri_outputs_json() -> Result<Value, String> {
let output = niri_command_output(&["msg", "--json", "outputs"])?;
if !output.status.success() {
return Err(stderr_trimmed(&output));
}
serde_json::from_slice(&output.stdout).map_err(|e| format!("Invalid niri JSON: {e}"))
}
pub fn kde_outputs_from_value(value: &Value) -> Result<Vec<Value>, String> {
value
.get("outputs")
.and_then(|v| v.as_array())
.cloned()
.ok_or_else(|| "Missing KDE outputs array".to_string())
}
pub fn kde_output_names_from_value(value: &Value) -> Result<Vec<String>, String> {
Ok(kde_outputs_from_value(value)?
.iter()
.filter_map(|output| output.get("name").and_then(|v| v.as_str()))
.map(ToString::to_string)
.collect())
}
pub fn niri_outputs_from_value(value: &Value) -> Result<Vec<Value>, String> {
if let Some(arr) = value.as_array() {
Ok(arr.clone())
} else if let Some(obj) = value.as_object() {
Ok(obj.values().cloned().collect())
} else {
Err("Unexpected niri outputs shape".into())
}
}
pub fn niri_output_names_from_value(value: &Value) -> Result<Vec<String>, String> {
Ok(niri_outputs_from_value(value)?
.iter()
.filter_map(|output| output.get("name").and_then(|v| v.as_str()))
.map(ToString::to_string)
.collect())
}
pub fn kde_output_logical_size_from_value(value: &Value, name: &str) -> Result<(i64, i64), String> {
for output in kde_outputs_from_value(value)? {
if output.get("name").and_then(|v| v.as_str()) == Some(name) {
let size = output
.get("size")
.and_then(|v| v.as_object())
.ok_or_else(|| "Missing KDE output size".to_string())?;
let scale = output.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
let width = size.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
let height = size.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
return Ok((
(width as f64 / scale).round() as i64,
(height as f64 / scale).round() as i64,
));
}
}
Err(format!("KDE output {name} not found"))
}
pub fn kde_enabled_output_count_from_value(value: &Value) -> Result<usize, String> {
Ok(kde_outputs_from_value(value)?
.iter()
.filter(|output| {
output
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
})
.count())
}
pub fn niri_output_logical_size_from_value(
value: &Value,
name: &str,
) -> Result<(i64, i64), String> {
for output in niri_outputs_from_value(value)? {
if output.get("name").and_then(|v| v.as_str()) == Some(name) {
let logical = output
.get("logical")
.and_then(|v| v.as_object())
.ok_or_else(|| "Missing niri logical size".to_string())?;
let width = logical.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
let height = logical.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
return Ok((width, height));
}
}
Err(format!("Niri output {name} not found"))
}
pub fn niri_enabled_output_count_from_value(value: &Value) -> Result<usize, String> {
Ok(niri_outputs_from_value(value)?
.iter()
.filter(|output| {
!output
.get("current_mode")
.is_some_and(|value| value.is_null())
})
.count())
}
fn ensure_success(output: Output) -> Result<(), String> {
if output.status.success() {
Ok(())
} else {
Err(stderr_trimmed(&output))
}
}
fn stderr_trimmed(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_kde_output_names_and_logical_size() {
let value = serde_json::json!({
"outputs": [
{ "name": "eDP-1", "enabled": true, "size": { "width": 2880, "height": 1800 }, "scale": 1.5 },
{ "name": "eDP-2", "enabled": false, "size": { "width": 2880, "height": 1800 }, "scale": 2.0 }
]
});
assert_eq!(
kde_output_names_from_value(&value).expect("names should parse"),
vec!["eDP-1".to_string(), "eDP-2".to_string()]
);
assert_eq!(
kde_output_logical_size_from_value(&value, "eDP-1"),
Ok((1920, 1200))
);
assert_eq!(kde_enabled_output_count_from_value(&value), Ok(1));
}
#[test]
fn parses_niri_array_and_object_output_shapes() {
let array_value = serde_json::json!([
{ "name": "eDP-1", "current_mode": { "width": 2880 }, "logical": { "width": 1440, "height": 900 } },
{ "name": "eDP-2", "current_mode": null, "logical": { "width": 1440, "height": 900 } }
]);
let object_value = serde_json::json!({
"eDP-1": { "name": "eDP-1", "current_mode": { "width": 2880 }, "logical": { "width": 1440, "height": 900 } },
"eDP-2": { "name": "eDP-2", "current_mode": null, "logical": { "width": 1440, "height": 900 } }
});
assert_eq!(
niri_output_names_from_value(&object_value).expect("names should parse"),
vec!["eDP-1".to_string(), "eDP-2".to_string()]
);
assert_eq!(
niri_output_logical_size_from_value(&array_value, "eDP-1"),
Ok((1440, 900))
);
assert_eq!(niri_enabled_output_count_from_value(&array_value), Ok(1));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
use super::*;
pub(crate) struct NotificationSink;
impl NotificationSink {
pub(crate) async fn runtime_error(
state: &Arc<RwLock<RuntimeState>>,
title: &str,
message: &str,
) {
notify_runtime_error(state, title, message).await;
}
}
pub(crate) async fn notify_runtime_error(
state: &Arc<RwLock<RuntimeState>>,
title: &str,
message: &str,
) {
if !should_notify_runtime_error(title, message) {
return;
}
if !should_emit_runtime_notification(state, title, message).await {
return;
}
if let Err(err) = send_runtime_notification_via_session(state, title, message).await {
log::warn!("failed to send runtime notification via session agent: {err}");
if let Err(fallback_err) = send_runtime_notification_direct(title, message) {
log::warn!("failed to send runtime notification directly: {fallback_err}");
}
}
}
pub(super) fn should_notify_runtime_error(title: &str, message: &str) -> bool {
if title != "Zenbook Duo Runtime Error" {
return true;
}
!runtime_error_is_display_session_deferral(message)
}
fn runtime_error_is_display_session_deferral(message: &str) -> bool {
[
"Lifecycle dock refresh skipped: ",
"Display-mode policy action failed: ",
"Session registration dock replay skipped: ",
"Lid state display update failed: ",
]
.iter()
.any(|prefix| {
message
.strip_prefix(prefix)
.is_some_and(is_display_session_deferral)
})
}
async fn should_emit_runtime_notification(
state: &Arc<RwLock<RuntimeState>>,
title: &str,
message: &str,
) -> bool {
let key = format!("{title}\n{message}");
let now = Utc::now();
let mut guard = state.write().await;
if let Some(last) = &guard.last_runtime_notification {
if last.key == key && now - last.emitted_at < ChronoDuration::seconds(30) {
return false;
}
}
guard.last_runtime_notification = Some(crate::runtime::state::RuntimeNotificationState {
key,
emitted_at: now,
});
persist_state(&guard);
true
}
async fn send_runtime_notification_via_session(
state: &Arc<RwLock<RuntimeState>>,
title: &str,
message: &str,
) -> Result<(), String> {
match session_bridge::request_session(
state.clone(),
SessionCommand::ShowNotification {
title: title.to_string(),
message: message.to_string(),
urgent: true,
},
false,
)
.await?
{
SessionResponse::Ack => Ok(()),
SessionResponse::Error { message } => Err(message),
SessionResponse::DisplayLayout { .. } => {
Err("Unexpected display-layout response for notification request".into())
}
}
}
pub(super) fn send_runtime_notification_direct(title: &str, message: &str) -> Result<(), String> {
let target_user = std::env::var("ZENBOOK_DUO_USER")
.map_err(|_| "ZENBOOK_DUO_USER is not set for runtime notifications".to_string())?;
let target_uid = std::env::var("ZENBOOK_DUO_UID")
.map_err(|_| "ZENBOOK_DUO_UID is not set for runtime notifications".to_string())?;
let runtime_dir = format!("/run/user/{target_uid}");
let bus_address = format!("unix:path={runtime_dir}/bus");
let status = Command::new("sudo")
.args(["-u", &target_user, "env"])
.arg(format!("XDG_RUNTIME_DIR={runtime_dir}"))
.arg(format!("DBUS_SESSION_BUS_ADDRESS={bus_address}"))
.args([
"notify-send",
"-a",
"Zenbook Duo Control",
"-u",
"critical",
"-i",
"dialog-error",
title,
message,
])
.status()
.map_err(|e| format!("Failed to launch runtime notification: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("Runtime notification exited with {status}"))
}
}
@@ -0,0 +1,188 @@
use super::*;
pub(crate) struct SessionBridge;
impl SessionBridge {
pub(crate) async fn request(
state: Arc<RwLock<RuntimeState>>,
command: SessionCommand,
disconnect_on_failure: bool,
) -> Result<SessionResponse, String> {
request_session(state, command, disconnect_on_failure).await
}
}
pub(super) async fn request_session(
state: Arc<RwLock<RuntimeState>>,
command: SessionCommand,
disconnect_on_failure: bool,
) -> Result<SessionResponse, String> {
let socket_path = {
let guard = state.read().await;
guard
.session_agent
.socket_path
.clone()
.ok_or_else(|| "No session agent registered".to_string())?
};
let stream = match timeout(Duration::from_secs(3), UnixStream::connect(&socket_path)).await {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
if disconnect_on_failure {
mark_session_agent_disconnected(
&state,
&format!("Failed to connect to session agent: {e}"),
)
.await;
}
return Err(format!("Failed to connect to session agent: {e}"));
}
Err(_) => {
if disconnect_on_failure {
mark_session_agent_disconnected(&state, "Timed out connecting to session agent")
.await;
}
return Err("Timed out connecting to session agent".to_string());
}
};
let (reader, mut writer) = stream.into_split();
let line = serde_json::to_string(&Envelope::new(command))
.map_err(|e| format!("Failed to encode session command: {e}"))?;
match timeout(Duration::from_secs(3), writer.write_all(line.as_bytes())).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if disconnect_on_failure {
mark_session_agent_disconnected(
&state,
&format!("Failed to write session command: {e}"),
)
.await;
}
return Err(format!("Failed to write session command: {e}"));
}
Err(_) => {
return handle_session_write_timeout(
&state,
"Timed out writing session command",
disconnect_on_failure,
)
.await;
}
}
match timeout(Duration::from_secs(3), writer.write_all(b"\n")).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if disconnect_on_failure {
mark_session_agent_disconnected(
&state,
&format!("Failed to terminate session command: {e}"),
)
.await;
}
return Err(format!("Failed to terminate session command: {e}"));
}
Err(_) => {
return handle_session_write_timeout(
&state,
"Timed out terminating session command",
disconnect_on_failure,
)
.await;
}
}
let mut lines = BufReader::new(reader).lines();
let reply = match timeout(Duration::from_secs(3), lines.next_line()).await {
Err(_) => {
if disconnect_on_failure {
mark_session_agent_disconnected(&state, "Timed out waiting for session response")
.await;
}
return Err("Timed out waiting for session response".to_string());
}
Ok(Ok(Some(reply))) => reply,
Ok(Ok(None)) => {
if disconnect_on_failure {
mark_session_agent_disconnected(&state, "Session agent closed before replying")
.await;
}
return Err("Session agent closed before replying".to_string());
}
Ok(Err(e)) => {
if disconnect_on_failure {
mark_session_agent_disconnected(
&state,
&format!("Failed to read session response: {e}"),
)
.await;
}
return Err(format!("Failed to read session response: {e}"));
}
};
let envelope: Envelope<SessionResponse> =
serde_json::from_str(&reply).map_err(|e| format!("Invalid session response JSON: {e}"))?;
Ok(envelope.payload)
}
pub(super) async fn handle_session_write_timeout<T>(
state: &Arc<RwLock<RuntimeState>>,
reason: &str,
disconnect_on_failure: bool,
) -> Result<T, String> {
if disconnect_on_failure {
mark_session_agent_disconnected(state, reason).await;
}
Err(reason.to_string())
}
async fn mark_session_agent_disconnected(state: &Arc<RwLock<RuntimeState>>, reason: &str) {
let mut guard = state.write().await;
let was_connected = guard.session_agent.connected || guard.status.service_active;
guard.session_agent = Default::default();
guard.status.service_active = false;
if was_connected {
guard.push_recent_event(HardwareEvent::warning(
EventCategory::Service,
format!("Session agent disconnected: {reason}"),
"rust-daemon",
));
}
guard.touch();
persist_state(&guard);
let _ = logger::append_line(format!(
"rust-daemon: session agent disconnected: {}",
reason
));
drop(guard);
if was_connected {
if let Err(err) =
ServiceController::queue_target_user_unit_restart("zenbook-duo-session-agent.service")
{
log::warn!("failed to queue session agent restart: {err}");
let _ = logger::append_line(format!(
"rust-daemon: failed to queue session agent restart: {}",
err
));
} else {
let _ = logger::append_line("rust-daemon: queued session agent restart");
}
}
if should_notify_session_agent_disconnect(reason) {
if let Err(err) = notification_sink::send_runtime_notification_direct(
"Zenbook Duo Runtime Error",
&format!("Session agent disconnected: {reason}"),
) {
log::warn!("failed to send direct disconnect notification: {err}");
}
}
}
pub(super) fn should_notify_session_agent_disconnect(reason: &str) -> bool {
!is_display_session_deferral(reason)
}
@@ -0,0 +1,80 @@
use std::ffi::OsStr;
use std::process::{Command, Output};
/// Narrow Interface for host process execution.
///
/// Production code uses [`ProcessCommandRunner`]; tests use [`FakeCommandRunner`]
/// so Modules can verify behavior without invoking Linux desktop/system services.
pub trait CommandRunner {
fn output(&self, program: &str, args: &[&str]) -> Result<Output, String>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ProcessCommandRunner;
impl CommandRunner for ProcessCommandRunner {
fn output(&self, program: &str, args: &[&str]) -> Result<Output, String> {
Command::new(program)
.args(args.iter().map(OsStr::new))
.output()
.map_err(|e| format!("Failed to run {program}: {e}"))
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use std::collections::VecDeque;
use std::os::unix::process::ExitStatusExt;
use std::process::{ExitStatus, Output};
use std::sync::Mutex;
#[derive(Debug)]
pub struct FakeCommandRunner {
calls: Mutex<Vec<(String, Vec<String>)>>,
outputs: Mutex<VecDeque<Result<Output, String>>>,
}
impl FakeCommandRunner {
pub fn new(outputs: impl IntoIterator<Item = Result<Output, String>>) -> Self {
Self {
calls: Mutex::new(Vec::new()),
outputs: Mutex::new(outputs.into_iter().collect()),
}
}
pub fn success(stdout: &str) -> Output {
Output {
status: ExitStatus::from_raw(0),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
}
}
pub fn failure(stderr: &str) -> Output {
Output {
status: ExitStatus::from_raw(1 << 8),
stdout: Vec::new(),
stderr: stderr.as_bytes().to_vec(),
}
}
pub fn calls(&self) -> Vec<(String, Vec<String>)> {
self.calls.lock().expect("calls lock").clone()
}
}
impl CommandRunner for FakeCommandRunner {
fn output(&self, program: &str, args: &[&str]) -> Result<Output, String> {
self.calls.lock().expect("calls lock").push((
program.to_string(),
args.iter().map(|arg| arg.to_string()).collect(),
));
self.outputs
.lock()
.expect("outputs lock")
.pop_front()
.unwrap_or_else(|| Err(format!("No fake output for {program}")))
}
}
}
@@ -0,0 +1,31 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use crate::runtime::paths;
fn ensure_log_parent() -> Result<(), String> {
let path = paths::log_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create log dir: {e}"))?;
}
Ok(())
}
pub fn append_line(message: impl AsRef<str>) -> Result<(), String> {
ensure_log_parent()?;
let path = paths::log_file_path();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Failed to open {}: {e}", path.display()))?;
writeln!(file, "{}", message.as_ref())
.map_err(|e| format!("Failed to write {}: {e}", path.display()))
}
pub fn clear() -> Result<(), String> {
ensure_log_parent()?;
let path = paths::log_file_path();
fs::write(&path, "").map_err(|e| format!("Failed to clear {}: {e}", path.display()))
}
@@ -0,0 +1,148 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::models::{EventCategory, HardwareEvent};
use crate::runtime::{logger, state::RuntimeState};
pub fn start(state: Arc<RwLock<RuntimeState>>) {
tokio::spawn(async move {
if let Err(err) = watch_logind(state.clone()).await {
log::warn!("logind runtime watcher failed: {err}");
crate::runtime::daemon::notify_runtime_error(
&state,
"Zenbook Duo Runtime Error",
&format!("Logind watcher failed: {err}"),
)
.await;
let _ = logger::append_line(format!("rust-daemon: logind watcher failed: {err}"));
}
});
}
async fn watch_logind(state: Arc<RwLock<RuntimeState>>) -> Result<(), zbus::Error> {
let connection = zbus::Connection::system().await?;
let manager = zbus::fdo::PropertiesProxy::builder(&connection)
.destination("org.freedesktop.login1")?
.path("/org/freedesktop/login1")?
.build()
.await?;
let login_manager_interface = "org.freedesktop.login1.Manager"
.try_into()
.expect("login1 manager interface name should be valid");
if let Ok(value) = manager.get(login_manager_interface, "LidClosed").await {
if let Ok(lid_closed) = value.downcast_ref::<bool>() {
sync_initial_lid_closed_state(&state, lid_closed).await;
}
}
let mut stream = manager.receive_properties_changed().await?;
use futures_util::StreamExt;
while let Some(signal) = stream.next().await {
let args = signal.args().ok();
if let Some(args) = args {
if args.changed_properties().contains_key("LockedHint") {
let locked = args
.changed_properties()
.get("LockedHint")
.and_then(|value| value.downcast_ref::<bool>().ok());
let message = match locked {
Some(true) => "Screen locked",
Some(false) => "Screen unlocked",
None => "Screen lock state changed",
};
let mut guard = state.write().await;
guard.push_recent_event(HardwareEvent::info(
EventCategory::Display,
message,
"rust-daemon",
));
guard.touch();
if let Err(err) = guard.save() {
log::warn!("failed to save logind runtime state: {err}");
let _ = logger::append_line(format!(
"rust-daemon: failed to persist logind state: {err}"
));
}
let _ = logger::append_line(format!("rust-daemon: {}", message));
}
if let Some(lid_closed) = args
.changed_properties()
.get("LidClosed")
.and_then(|value| value.downcast_ref::<bool>().ok())
{
if let Err(err) =
crate::runtime::daemon::DisplayReplayPolicy::handle_lid_closed_change(
&state,
lid_closed,
)
.await
{
if crate::runtime::daemon::is_display_session_deferral(&err) {
let _ = logger::append_line(format!(
"rust-daemon: lid state display update deferred: {err}"
));
continue;
}
log::warn!("failed to handle lid state change: {err}");
crate::runtime::daemon::notify_runtime_error(
&state,
"Zenbook Duo Runtime Error",
&format!("Lid state display update failed: {err}"),
)
.await;
let _ = logger::append_line(format!(
"rust-daemon: lid state display update failed: {err}"
));
}
}
}
}
Ok(())
}
async fn sync_initial_lid_closed_state(state: &Arc<RwLock<RuntimeState>>, lid_closed: bool) {
if let Err(err) =
crate::runtime::daemon::DisplayReplayPolicy::handle_lid_closed_change(state, lid_closed)
.await
{
if crate::runtime::daemon::is_display_session_deferral(&err) {
let _ = logger::append_line(format!(
"rust-daemon: initial lid state display update deferred: {err}"
));
return;
}
log::warn!("failed to sync initial lid state: {err}");
crate::runtime::daemon::notify_runtime_error(
state,
"Zenbook Duo Runtime Error",
&format!("Initial lid state display update failed: {err}"),
)
.await;
let _ = logger::append_line(format!(
"rust-daemon: initial lid state display update failed: {err}"
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn initial_lid_closed_true_records_runtime_state_even_when_display_defers() {
let state = Arc::new(RwLock::new(RuntimeState::default()));
sync_initial_lid_closed_state(&state, true).await;
assert!(state.read().await.lid_closed);
}
}

Some files were not shown because too many files have changed in this diff Show More