initial commit
|
After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 975 B |
|
After Width: | Height: | Size: 1019 B |
@@ -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?
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,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
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -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
|
||||||
@@ -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 ."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 975 B |
|
After Width: | Height: | Size: 1019 B |
@@ -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."
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "zenbook",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -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[@]}"
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]}}
|
||||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
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(¤t_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||