feat: dzgui 7 alpha

This commit is contained in:
aclist 2025-12-08 17:32:25 +09:00
parent 1acdbd58c5
commit 2e5b771137
150 changed files with 10359 additions and 0 deletions

24
CHANGELOG.md Normal file
View File

@ -0,0 +1,24 @@
## Added
- Add pyproject.toml file
- requests, packaging.version, psutil
- Lockfile
- Changelog text wrapping and formatting
- Decouple UI components into modules
- Choose from kilometer or miles distance display
- More granular haversine calculation
- Parse XDG data dirs with fallbacks
- Dedicated mods page
- Dedicated thanks page
- Rearrange menus/breadcrumbs
- Documentation ships with source
- Open filepicker when generating system log
## Changed
- Reduce padding on keys button
- Add icon to keys button
- Add icons to API buttons
- Boldface breadcrumbs
## Unreleased
- Setup wizard
- Integrate add/connect widgets into main menu

34
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,34 @@
# Preamble
This project is free and open source software (FOSS) that is improved upon through the collaborative efforts of its community.
To foster a collegial and productive atmosphere, all interactions with this project and its members shall be subject to these standards.
"Members" refers to all project maintainers, contributors, developers, and end-users.
Bear in mind that members may come from different countries, age groups, and backgrounds, and have differing experience levels.
# Standards
- Do not engage in antagonistic, offensive, discriminatory, threatening, or inflammatory behavior or trolling towards other members under any circumstances.
- Engage in professional communication and stay on topic. Keep banter and jokes to a minimum, as they are liable to be misconstrued.
- Do not post offensive or inflammatory off-site content, or content violating the above standards or the terms of service of this site.
- Do not post spam. The site operator implements anti-spam measures that may lead to your account being revoked.
- While this project relates to the DayZ computer game, it is not a forum for non-technical discussion about the game, or a place to post unsolicited links.
- Do not use images, emoji reactions, or other non-verbal gags as justification for skirting the above standards.
# Off-site content
From time to time, it may be necessary to include off-site reference material or links. Use discretion when selecting this content and provide
the minimum necessary for a technical discussion of the matter. Do not willfully include content that violates the above standards.
# Enforcement
Violations of the above standards will be subject to moderation decisions.
Project maintainers reserve the right to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are
not aligned with these standards, and will communicate reasons for moderation decisions when appropriate.
Depending on the nature of the violation, or if the violation is not corrected after a warning has been issued, the user may be permanently barred from further interaction with the project.
# Technical support
This project is free software, but this does not imply a guarantee of free, unlimited tech support and troubleshooting in perpetuity.
The software is provided as-is, and no warranty is made as to its fitness for a given purpose or that it is free from defects.
Project members volunteer their time. Do not disparage other members when they attempt to assist you.
Be aware that if you cannot provide enough information when filing a ticket, this may lead to the ticket being closed as non-actionable.

View File

@ -1,3 +1,6 @@
For additional licenses to submodules, see docs/licenses.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007

28
README.md Normal file
View File

@ -0,0 +1,28 @@
## What this is
DZGUI allows you to connect to both official and modded/community DayZ servers on Linux and provides a graphical interface for doing so.
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
- Search for and display server metadata in a table (server name, player count, ping, queue size, current gametime, distance, IP)
- Add/delete/manage favorite servers by IP or ID
- Quick-connect to favorite/recent servers
- Find and prepare mods being requested by servers (choose from manual or automatic installation)
- Bulk delete/update local mods
- Concatenate launch options to pass to Steam
- Connect to mod-enabled LAN servers
## Setup and usage
Refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html)
- [Mirror (Codeberg)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png)
## Attribution
Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/)
# TODO: mention a2s/dayzquery

674
docs/LICENSE Normal file
View File

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

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -a

22
docs/README.md Normal file
View File

@ -0,0 +1,22 @@
## Dependencies:
```
pip install -r requirements.txt
```
## Setup:
1. Modify `source/conf.py`.
2. Add .rst pages to `source` tree.
## Build
```
$ mkdir build
$ sphinx-build -M html source build -a
```
## Publish
Export contents of `build/html` to host.
N.B. For GitHub, touch `.nojekyll` file on the root of the branch.

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

2
docs/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
sphinx-rtd-theme
sphinx-copybutton

60
docs/source/bugs.rst Normal file
View File

@ -0,0 +1,60 @@
Submitting a ticket
==========================
Tickets are tracked at the `DZGUI issue tracker <https://github.com/aclist/dztui/issues>`_.
You will need to `register for a GitHub account <https://github.com/signup>`_ in order to submit one.
If have checked the :doc:`kb` and `closed issues <https://github.com/aclist/dztui/issues?q=is%3Aissue+is%3Aclosed>`_ area and did not find a solution, three ticket types are provided for your
convenience:
- **Bug report**: for unexpected, breaking behavior in the application
- **Feature request**: to propose new functionality
- **Troubleshooting**: if all else fails, you can request help here
Reports by example
--------------------------
Below are some examples of how to format a constructive report that will ensure your problem can be resolved in a timely fashion.
A non-actionable report
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Subject: add button
hi, you must add a button to show how long I have to wait to join a full server, the app is useless without this...do your job, guys!!! ;-)
This report exhibits a number of problems:
- Vague title and summary: in addition to being unclear, this will slow down filing the incoming report.
- Problem space is poorly defined: it is not clear where the issue lies, where the feature is supposed to be implemented, nor why.
- Lack of context: the feature may already exist, but there is not enough information to go on. Alternatively, the feature may be technically impossible to implement, but there is not enough
information to go on.
- Incorrect report type: this is a feature request filed as a bug report.
- Unprofessional tone: the message is disrespectful towards project members and inserts the user's subjective opinion.
An actionable report
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Subject: Version 5.5.0-beta.2 crashes when selecting multiple table rows via Ctrl-click
This occurs specifically on version 5.5.0-beta.2 if the setting ALLOW_MULTI is set to ON. I was able to reproduce it using the following steps:
Distribution: Fedora
- Ensure that ALLOW_MULTI is set to ON
- Open the application and immediately navigate to the Server Browser
- Uses the Ctrl-click binding to select multiple rows in the table
- After >1 row is selected, the application crashes to the desktop with the following traceback (see attached dump)
Thanks for looking into this.
This report is actionable because it is objective, specific, and detailed:
- The subject is clearly defined: specific details ensure it will be filed in a timely manner.
- Scope is explicit: the specific version, settings, and conditions under which the problem occur were given.
- Ample context: the user gave steps to reproduce the problem and clarified each one.
- Courteous tone: the user interacted with project members in a professional manner.
- Supporting information: the user provided a crash dump to expedite resolution of the issue.

40
docs/source/conf.py Normal file
View File

@ -0,0 +1,40 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'DZGUI'
author = 'aclist'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = []
templates_path = ['_templates']
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
#html_theme = 'alabaster'
extensions = [
'sphinx_rtd_theme',
'sphinx_copybutton',
"sphinx.ext.autosectionlabel",
"sphinx.ext.githubpages"
]
html_theme = "sphinx_rtd_theme"
html_static_path = ['_static']
html_show_sphinx = False
html_show_copyright = False
html_show_sourcelink = False
today_fmt = "%Y-%m-%d"
release = "5.8.x"
version = "5.8.x"

View File

@ -0,0 +1,12 @@
Developers
===========
This project is open source under the GPLv3 license. You can find the source code on
`GitHub <https://github.com/aclist/dztui>`_. If you wish to contribute to
development, you will find further help in the
`CONTRIBUTING.md <https://github.com/aclist/dztui/blob/main/CONTRIBUTING.md>`_
file.
All contributors are expected to act in accordance with the project's
`code of conduct <https://github.com/aclist/dztui/blob/main/CODE_OF_CONDUCT.md>`_,
and follow the project's standards and guidelines.

BIN
docs/source/images/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
docs/source/images/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/source/images/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
docs/source/images/04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

BIN
docs/source/images/05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

BIN
docs/source/images/06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

BIN
docs/source/images/07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

BIN
docs/source/images/08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
docs/source/images/09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

69
docs/source/index.rst Normal file
View File

@ -0,0 +1,69 @@
.. DZGUI documentation master file, created by
sphinx-quickstart on Thu Dec 12 13:12:14 2024.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
DZGUI documentation (|release|)
=================================
Last update: |today|
What this is
----------------
A DayZ server browser/mod manager for Linux. Used to list official and community server details and quick connect to preferred servers by staging mods and concatenating launch options automatically.
Source code:
- `GitHub <https://www.github.com/aclist/dztui>`_
- `Codeberg (mirror) <https://codeberg.org/aclist/dztui>`_
Installation
-------------
- Step 1: :doc:`installation`
- Step 2: :doc:`setup`
Usage
------
See: :doc:`usage`.
Uninstallation
---------------
See: :doc:`uninstallation`.
Troubleshooting
----------------
See: :doc:`kb` or :ref:`Submitting a ticket`.
.. toctree::
:maxdepth: 3
:hidden:
self
.. toctree::
:caption: Setup
:maxdepth: 3
:hidden:
installation
setup
usage
uninstallation
.. toctree::
:caption: Advanced usage
:maxdepth: 3
:hidden:
steam
locale
.. toctree::
:caption: Help
:maxdepth: 3
:hidden:
kb
bugs
development

View File

@ -0,0 +1,64 @@
Installation
==================
Dependencies
----------------------
If not already installed, the below can be found in your systems package manager.
If any dependencies are missing when the application starts, it will warn you, so you need not take any preemptive measures here.
- curl
- jq
- PyGObject (python-gobject)
- steam
- wmctrl or xdotool
- zenity
.. note::
All dependencies are installed out of the box on Steam Deck.
Downloading
------------------
Automatic installation
^^^^^^^^^^^^^^^^^^^^^^^
Invoke the command below from a terminal:
.. code:: console
curl -s "https://codeberg.org/aclist/dztui/raw/branch/dzgui/install.sh" | bash
Manual installation
^^^^^^^^^^^^^^^^^^^^
Clone the repository:
.. code:: console
git clone https://codeberg.org/aclist/dztui.git
Set the executable bit on ``dzgui.sh``:
.. code:: console
chmod +x dzgui.sh
Launch the script:
.. code:: console
./dzgui.sh
nixOS installation
^^^^^^^^^^^^^^^^^^^
Two packages are independently maintained by other contributors. Each takes a slightly different approach:
- https://github.com/lelgenio/dzgui-nix
- https://github.com/jiriks74/dzgui.flake
Next steps
---------------
Proceed to the next section for setup instructions.

77
docs/source/kb.rst Normal file
View File

@ -0,0 +1,77 @@
Knowledge Base
==================
.. _DZG-001:
DZG-001: Periodically getting dropped from servers, or servers appear unreachable
------------------------------------------------------------------------------------
It is a longstanding issue in DayZ that the game opens a large number of connections when querying servers and/or while connected to servers.
This can result in excess traffic on the user's PC which, depending on how much headroom your network has, can lead to getting dropped, unresponsiveness, or a timeout.
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. If it does, your wireless router settings do not have enough headroom for max simultaneous connections. Use a wired connection, or update your network settings to a more permissive setup.
Bohemia has acknowledged this issue going back 10+ years and stated that DayZ has a heavy impact on the network, but there is as yet no proposed solution on the DayZ side.
This issue is frequently seen on Steam Deck, due to the tendency for users to use it in an untethered Wi-Fi setup.
.. _DZG-002:
DZG-002: Some servers appear locked in the official DayZ client, and are unreachable in DZGUI
-----------------------------------------------------------------------------------------------
This is a variant of :ref:`DZG-001`.
.. _DZG-003:
DZG-003: On Steam Deck, DayZ becomes unresponsive/sluggish over time
---------------------------------------------------------------------------------------------
When DayZ is open for 1+ hours, a gradual loss in performance and FPS may occur on the Steam Deck.
A solution that seems to work for most users is to install `Cryo Utilities <https://github.com/CryoByte33/steam-deck-utilities>`_, a third-party performance management application.
.. _DZG-004:
DZG-004: On Steam Deck, some mods in the Workshop show a black screen when DZGUI attempts to open them
---------------------------------------------------------------------------------------------------------
This is a bug in the Steam client that is being tracked at Valve's Steam for Linux issue tracker here: https://github.com/ValveSoftware/steam-for-linux/issues/9598.
To resolve this issue, manually intervene in the Steam client by selecting a different context (e.g., Store, Library), waiting for it to load, then navigating back to the Workshop context. This should
clear the blockage and allow the contents to render.
.. _DZG-005:
DZG-005: Rendering problems with objects in the Winter Chernarus v2 mod
--------------------------------------------------------------------------
This mod has LOD (level of detail) bugs that may cause objects near the player, such as leaves, to render incorrectly, or cause distant trees to pop in abruptly. This is an acknowledged issue with the mod itself, not with DayZ or DZGUI.
There is no user-side fix for this issue; it is a problem solely on the mod side.
.. _DZG-006:
DZG-006: After moving DayZ to another drive, DZGUI fails to locate it on initial setup
----------------------------------------------------------------------------------------
If you recently moved the location of DayZ using Steam's internal dialogs, it may take some time for this information to update internally on Steam's side.
Steam stores the location of installed games in a unified file, and DZGUI checks this file during initial setup to determine where Steam claims DayZ is installed.
If you recently moved DayZ to a different drive or partition but did not restart Steam, this information may be out of date, causing Steam to report the wrong location.
Try restarting the Steam client and starting the DZGUI initial setup again.
.. _DZG-007:
DZG-007: Why do I need a Steam Web API key? Is it safe?
----------------------------------------------------------
In order to provide a server browser showing a searchable list of all available servers, DZGUI utilizes the Steam Web API.
Actual connections and queries to individual servers are performed directly between the computer and the DayZ server.
DZGUI gets its server information directly from the most authoritative source: Steam. It does this by letting the user be solely in control of their own API key and the application in an authenticated way. Users explicitly get permission to use a Web API key instead of scraping DayZ server info from third-party sites.
Everything that happens between DZGUI and the Steam Web API endpoint takes place solely on the user's computer, using a GET request (fetch server list), and no information gets sent back to the developer. DZGUI does not scrape third party DayZ APIs without permission.
There is some misconception that a Steam Web API key could be used to gain information about a user's account or control their account. Not only is this not possible, but the Web API key is used solely by the user on their own computer and is protected by Steam Guard.
A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model.
You are responsible for the creation, storage, management, and revocation of your Web API key.

12
docs/source/locale.rst Normal file
View File

@ -0,0 +1,12 @@
Locale support
==================
For internationalization purposes, DZGUI will inherit the default locale setting on the system when displaying numbers.
This is used for thousands separators in long numbers and decimal separators in fractional numbers.
If you wish to use a specific regional numbering preference while retaining a different base system language (e.g., English language with German-style numbering), pass the desired locale as a variable before launching DZGUI:
``LC_ALL=de_DE.UTF-8 ./dzgui.sh``
If you intend to use this frequently, you could wrap the above in a script or alias.

53
docs/source/setup.rst Normal file
View File

@ -0,0 +1,53 @@
Setup
=========
DayZ license
-----------------
Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton
Experimental for the latest version).
As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one.
API keys
-------------
You will need at a minimum a :ref:`Steam Web API key<steam_api>` in order to browse the server index.
.. _steam_api:
Steam Web API key
^^^^^^^^^^^^^^^^^^
**This step is required**.
Register for a `Steam Web API key <https://steamcommunity.com/dev/apikey>`_ (free) using your Steam account. You will be asked for a unique URL for your app when registering.
Since this key is for a personal use application and does not actually call back anywhere, set a generic local identifier here like "127.0.0.1" or some other name that is meaningful to you.
Once configured, you can insert this key in the app when launching it for the first time.
.. note::
If you are confused about this requirement, please refer to DZGUI Knowledge Base article :ref:`DZG-007<DZG-007>` for additional information.
Battlemetrics API key
^^^^^^^^^^^^^^^^^^^^^^
**This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP.
Register for an API key at `BattleMetrics <https://www.battlemetrics.com/account/register?after=%2Fdevelopers>`_ (free).
From the ``Personal Access Tokens area``, select ``New Token``.
Give the token any name in the field at the top.
Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``.
Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app.
.. tip::
Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``.
Next steps
--------------
Proceed to the next section for usage instructions.

90
docs/source/steam.rst Normal file
View File

@ -0,0 +1,90 @@
Steam integration
====================
DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments.
First, launch Steam in the **Large** (default) view.
.. attention::
Steam Deck users: Gaming Mode has limited support for configuration of custom games. You will need to switch to **Desktop Mode** in order to add a non-Steam game.
.. figure:: images/01.png
:scale: 70%
:alt: Adding a non-Steam game
Select **Add a Game** > **Add a Non-Steam Game** from the bottom left corner of the Steam client.
Navigate to ``$HOME/.local/share/applications`` and select ``dzgui.desktop``.
Select **Add Selected Programs** to add a shortcut to DZGUI.
Artwork
^^^^^^^^^^
DZGUI ships with custom artwork that provides various cover/splash images to allow it to integrate with the rest of your games library.
.. figure:: images/02.png
:scale: 70%
:alt: Adding a non-Steam game
From the main library view, navigate to the apps details page and right-click the blank image header at the top.
.. figure:: images/03.png
:scale: 70%
:alt: Adding a non-Steam game
Select **Set Custom Background**
Select to display **All Files** from the **File type** dropdown
Navigate to ``$HOME/.local/share/dzgui`` and select ``hero.png``.
.. figure:: images/04.png
:scale: 70%
:alt: Adding a non-Steam game
Next, right-click the image background and select **Set Custom Logo**.
Navigate to ``$HOME/.local/share/dzgui`` and select ``logo.png``.
.. figure:: images/05.png
:scale: 70%
:alt: Adding a non-Steam game
Notice that this removes the redundant app name that occluded the image, as seen above.
.. figure:: images/06.png
:scale: 70%
:alt: Adding a non-Steam game
Next, navigate to your Library index (looks like a bookshelf of cover art) and find the DZGUI app.
Right-click its cover and select **Manage** > **Set custom artwork**.
Navigate to ``$HOME/.local/share/dzgui`` and select ``grid.png``.
.. figure:: images/07.png
:scale: 70%
:alt: Adding a non-Steam game
The final result will create box art looking like the above.
.. figure:: images/08.png
:scale: 100%
:alt: Adding a non-Steam game
Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the games
icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view.
Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side.
Steam shows a collection of box art based on categories like "Play Next", "Recent Games", etc. Look for a **downward-pointing caret icon** and click it, then select the **Recent Games** category.
.. figure:: images/09.png
:scale: 100%
:alt: Adding a non-Steam game
If DZGUI was the last item played, it will be shown with a landscape, rather than portrait, orientation cover, which is initially blank.
To customize this cover, right click it and select **Manage** > **Set custom artwork**, navigate to ``$HOME/.local/share/dzgui``, then select ``hero.png``.

View File

@ -0,0 +1,12 @@
Uninstallation
==================
To uninstall DZGUI and its associated files, invoke ``./dzgui.sh`` with the ``-u`` or ``--uninstall`` flag.
Follow the prompts to choose from:
- **Full uninstall** -- removes the application and all user-defined configuration files
- **Partial uninstall** -- removes the application, but keeps user-defined configuration files
.. tip::
If you intend to reinstall DZGUI again, keeping your configuration files is recommended.

25
docs/source/usage.rst Normal file
View File

@ -0,0 +1,25 @@
Usage
=========
It is always advised to have Steam running in the background. DZGUI is meant to run "on top of" Steam.
DZGUI can be launched in one of three ways.
From a terminal
------------------
Invoke the command ``./dzgui.sh`` from wherever you downloaded the DZGUI script to.
Launching from a terminal may give more verbose information in the event of a crash, and can be a good way of troubleshooting problems.
From the shortcut shipped with the application
--------------------------------------------------
On most distributions, if you are using a desktop environment based on the Freedesktop specification, shortcuts will be installed for you.
- One shortcut is located under the ``Games`` category of your systems applications list/start menu.
- The other is accessed via the ``DZGUI`` desktop icon (Steam Deck only)
From Steam
--------------
See :doc:`steam`.

0
dzgui/__init__.py Normal file
View File

0
dzgui/api/__init__.py Normal file
View File

40
dzgui/api/bm.py Normal file
View File

@ -0,0 +1,40 @@
import logging
import requests
from pathlib import Path
from dzgui.api.servers import Record
from dzgui.config.query import lookup
from dzgui.const.endpoints import BM_SERVERS
from dzgui.const.enum import Preferences
from typing import Union
logger = logging.getLogger(__name__)
def get_attributes(config: Path, uid: int) -> str:
# TODO: handle if key is not set
# TODO: tests for malformed IDs/values
key = lookup(config, Preferences.BM)
hdr = {"Authorization": "Bearer " + key}
payload: dict[str, str] = {
"filter[game]": "dayz",
"sort": "-players",
"filter[ids][whitelist]": str(uid)
}
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
res.raise_for_status()
j = res.json()["data"][0]["attributes"]
return j
def map_id_to_record(config: Path, uid: int) -> Record | None:
try:
record = get_attributes(config, uid)
ip = record["ip"]
port = record["port"]
qport = record["portQuery"]
return Record(ip, port, qport)
except Exception as e:
logger.warn(e)
return None

170
dzgui/api/mods.py Normal file
View File

@ -0,0 +1,170 @@
import dayzquery
import hashlib
import logging
import re
import shlex
from dataclasses import dataclass
from pathlib import Path
import dzgui.api.pefile as PeFile
from dzgui.api.servers import Record, get_rules
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
LIBRARYFOLDERS_PATH,
WORKSHOP_PATH,
)
from dzgui.util.strings import checkmark
from dzgui.config.query import enum_to_key, lookup
from dzgui.const.enum import Preferences
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class ModMeta:
protocol: str
published_id: str
name: str
timestamp: str
def get_local_mod_ids(steam_path: Path) -> list[int]:
workshop_path = get_local_mod_path(steam_path)
mods = get_local_mods(workshop_path)
return [int(mod.name) for mod in mods]
def get_local_mod_path(steam_path: Path) -> Path:
p = PeFile.get_app_path(steam_path / LIBRARYFOLDERS_PATH, APPID_DAYZ)
workshop_path = p / WORKSHOP_PATH
return workshop_path
def get_local_mods(workshop_path: Path) -> list[Path]:
mods = [file for file in workshop_path.iterdir() if file.is_dir()]
return mods
# TODO: TEST: mock bad meta files with fixtures and remove them
def parse_meta(file: Path) -> ModMeta:
mod = file / "meta.cpp"
if mod.exists() is False:
return None
with open(file / "meta.cpp", "r") as f:
st = f.read()
lex = shlex.shlex(st)
lex.whitespace += "=;"
v = []
while True:
tok = lex.get_token()
if not tok:
break
if tok == "protocol" or tok == "publishedid":
ntok = lex.get_token()
elif tok == "timestamp":
# some malformed .NET tick conversions result in numbers < 0
ntok = lex.get_token()
if ntok == "-":
ntok += str(lex.get_token())
elif tok == "name":
ntok = lex.get_token().split('"')[1]
v.append(ntok)
meta = ModMeta(*v)
return meta
def get_mod_size(path: Path) -> float:
s = 0
for f in path.rglob("*"):
s += f.stat().st_size
size = round(s / (1024 * 1024), 3)
return size
def get_delimited_mods(steam_path: Path) -> list[Any]:
workshop_path = get_local_mod_path(steam_path)
mods = get_local_mods(workshop_path)
clean = []
for mod in mods:
mod_dir = mod.name
symlink = _hash(mod_dir)
# NOTE: malformed .cpp files could break this
# mention that mods may be downloading
meta = parse_meta(mod)
if meta is None:
continue
size = get_mod_size(mod)
clean.append([meta.name, symlink, mod_dir, size])
clean.sort(key=lambda row: row[0])
return clean
def get_missing_mods(local: list, remote: list) -> list:
return [mod for mod in remote if mod not in local]
def get_server_modlist(server: Record, steam: Path) -> list:
try:
rules = dayzquery.dayz_rules((server.ip, server.qport))
except Exception as e:
raise e
remote_mods = [[mod.name, mod.workshop_id] for mod in rules.mods]
remote_mods.sort(key=lambda row: row[0])
local_mods = get_local_mod_ids(steam)
for mod in remote_mods:
if mod[1] in local_mods:
mod.append(checkmark)
else:
mod.append("")
return remote_mods
def _hash(uid: str) -> str:
md5 = hashlib.md5()
md5.update(uid.encode("ascii"))
return "@" + md5.hexdigest()[:8]
def remove_stale_signatures(config: Path, versions: Path) -> None:
if versions.is_file() is False:
logger.warning("No mod signatures file found")
return
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
ids = get_local_mod_ids(steam_path)
with open(versions, "r") as f:
lines = f.readlines()
for line in lines:
uid = int(line.split(",")[0])
if uid not in ids:
lines.remove(line)
with open(versions, "w") as f:
for line in lines:
f.write(line)
def find_stale_mods(config: Path) -> list[int]:
steam = lookup(config, Preferences.DEFAULT)
steam_path = Path(steam)
local = get_local_mod_ids(steam_path)
servers = lookup(config, Preferences.IP_LIST)
all_mods = []
for server in servers:
split = server.split(":")
ip = split[0]
gport = split[1]
qport = split[2]
mods = get_rules(ip, qport)
all_mods += mods
stale = []
for mod in local:
if mod not in all_mods:
stale.append(mod)
return stale

465
dzgui/api/pefile.py Normal file
View File

@ -0,0 +1,465 @@
import json
import struct
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from shlex import shlex
from typing import BinaryIO, Union
from packaging.version import Version
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP,
DAYZ_BINARY,
LIBRARYFOLDERS_PATH,
)
from dzgui.api.steam import vdf2json
# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
endian = "<"
IMAGE_DIRECTORY_ENTRY = 2
VERSION_RESOURCE = 16
RESOURCE_NODE = ".rsrc"
PE32_x86 = "0x10b"
PE32_x64 = "0x20b"
VS_VERSION_INFO_MAGIC = "0xfeef04bd0000"
VS_VERSION_INFO_ID = "VS_VERSION_INFO"
class VersionMatch(Enum):
LOCAL_OLDER = 1
LOCAL_NEWER = 2
SAME_VERSION = 3
class u8:
fmt = "B"
class u16:
fmt = "H"
class u32:
fmt = "L"
class u64:
fmt = "Q"
class i8:
fmt = "b"
class i16:
fmt = "h"
class i32:
fmt = "l"
class i64:
fmt = "q"
class PackedData:
@classmethod
def unpack(cls, data: BinaryIO):
r = []
for key, value in cls.__annotations__.items():
if value == str:
f = data.read(8).rstrip(b"\x00\x00").decode()
else:
fmt = endian + (value.fmt)
size = struct.calcsize(fmt)
f = struct.unpack(fmt, data.read(size))[0]
r.append(f)
return cls(*r)
@dataclass(slots=True, frozen=True)
class COFF_FILE_HDR(PackedData):
machine_type: u16
number_of_sections: u16
timestamp: u32
pointer_to_symbol_table: u32
number_of_symbols: u32
size_of_optional_header: u16
characteristics: u16
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_X86(PackedData):
magic: u16
major_linker_ver: u8
minor_linker_ver: u8
size_of_code: u32
size_of_initialized_data: u32
size_of_uninitialized_data: u32
address_of_entry_point: u32
base_of_code: u32
base_of_data: u32
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_X64(PackedData):
magic: u16
major_linker_ver: u8
minor_linker_ver: u8
size_of_code: u32
size_of_initialized_data: u32
size_of_uninitialized_data: u32
address_of_entry_point: u32
base_of_code: u32
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_WIN_X86(PackedData):
image_base: u32
section_alignment: u32
file_alignment: u32
major_operating_system_version: u16
minor_operating_system_version: u16
major_image_version: u16
minor_image_version: u16
major_subsystem_version: u16
minor_subsystem_version: u16
win32_version_value: u32
size_of_image: u32
size_of_headers: u32
checksum: u32
subsystem: u16
dll_characteristics: u16
size_of_stack_reserve: u32
size_of_stack_commit: u32
size_of_heap_reserve: u32
size_of_heap_commit: u32
loader_flags: u32
number_of_rva_and_sizes: u32
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_WIN_X64(PackedData):
image_base: u64
section_alignment: u32
file_alignment: u32
major_operating_system_version: u16
minor_operating_system_version: u16
major_image_version: u16
minor_image_version: u16
major_subsystem_version: u16
minor_subsystem_version: u16
win32_version_value: u32
size_of_image: u32
size_of_headers: u32
checksum: u32
subsystem: u16
dll_characteristics: u16
size_of_stack_reserve: u64
size_of_stack_commit: u64
size_of_heap_reserve: u64
size_of_heap_commit: u64
loader_flags: u32
number_of_rva_and_sizes: u32
@dataclass(slots=True, frozen=True)
class DATA_DIR(PackedData):
virtual_address: u32
size: u32
@dataclass(slots=True, frozen=True)
class SECTION_HDR(PackedData):
name: str
virtual_size: u32
virtual_address: u32
size_of_raw_data: u32
pointer_to_raw_data: u32
pointer_to_relocations: u32
pointer_to_line_numbers: u32
number_of_relocations: u16
number_of_line_numbers: u16
characteristics: u32
@dataclass(slots=True, frozen=True)
class RESOURCE_DIRECTORY_TABLE(PackedData):
characteristics: u32
timestamp: u32
major_version: u16
minor_version: u16
number_of_name_entries: u16
number_of_id_entries: u16
@dataclass(slots=True, frozen=True)
class RESOURCE_DIRECTORY_ENTRY(PackedData):
"""
This field is either a string identifying a data leaf
(if the high bit is set) or an ID to another nested directory
(if the high bit is clear). The outermost level is always a
directory. If it is a name, the lower 31 bits are the offset from the
beginning of the resource section's raw data to the name
(the name consists of 16 bits length and trailing wide characters,
in Unicode, not 0-terminated).
"""
name_or_id: u32
data_or_subdir: u32
@dataclass(slots=True, frozen=True)
class RESOURCE_DATA_ENTRY(PackedData):
data_rva: u32
size: u32
codepage: u32
reserved: u32
@dataclass(slots=True, frozen=True)
class VS_VERSION_INFO_HDR(PackedData):
size: u16
value_length: u16
value_type: u16
@dataclass(slots=True, frozen=True)
class DayZVersion:
major: int
minor: int
patch: int
@dataclass(slots=True, frozen=True)
class FileVersion:
major: int
minor: int
build: int
revision: int
@dataclass(slots=True, frozen=True)
class Result:
local: Union[str, None]
remote: str
build: str
path: Union[Path, None]
match: VersionMatch
error: Union[Exception, None]
class PeFileError(Exception):
"""Expected contents missing from headers or resource nodes"""
pass
class AppNotInstalledError(Exception):
"""App not present in user's libraryfolders"""
pass
class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk"""
pass
class VDFLoadError(Exception):
"""Malformed VDF or JSON conversion"""
pass
def parse_version_number(data: BinaryIO) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
return FileVersion(major, minor, build, revision)
def seek_to_hex(address: str, data: BinaryIO) -> None:
data.seek(int(address, base=16))
def seek_to_pe_stub(data: BinaryIO) -> None:
MAGIC = "0x3c"
seek_to_hex(MAGIC, data)
e_lfanew = hex(struct.unpack("<h", data.read(2))[0])
seek_to_hex(e_lfanew, data)
pe_stub = data.read(4).rstrip(b"\x00\x00\x00\x00").decode()
if pe_stub != "PE":
raise PeFileError("missing PE header data")
def get_dayz_version(file: Path) -> DayZVersion:
try:
version = get_version(file)
except Exception as e:
# TODO: log the exception
raise e
patch = str(version.build) + str(version.revision)
dz_vers = DayZVersion(version.major, version.minor, int(patch))
return dz_vers
def dayz_version_to_str(v: DayZVersion) -> str:
return ".".join(str(el) for el in [v.major, v.minor, v.patch])
def dayz_version_from_str(v: str) -> DayZVersion:
vers = v.split(".")
assert len(vers) == 3
return DayZVersion(*[int(el) for el in vers])
def get_version(file: Path) -> FileVersion:
with open(file, "rb") as f:
seek_to_pe_stub(f)
COFF = COFF_FILE_HDR.unpack(f)
pos = f.tell()
blob = f.read()
magic = hex(struct.unpack("<H", (blob[0:2]))[0])
f.seek(pos)
if magic == PE32_x86:
OPTIONAL_HDR_X86.unpack(f)
OBJW = OPTIONAL_HDR_WIN_X86.unpack(f)
else:
OPTIONAL_HDR_X64.unpack(f)
OBJW = OPTIONAL_HDR_WIN_X64.unpack(f)
if OBJW.number_of_rva_and_sizes < 1:
raise PeFileError("no data resource directory")
data_dirs = []
for rva in range(OBJW.number_of_rva_and_sizes):
data_dir = DATA_DIR.unpack(f)
data_dirs.append(data_dir)
res_dir = data_dirs[IMAGE_DIRECTORY_ENTRY]
dir_va = res_dir.virtual_address
for section in range(COFF.number_of_sections):
hdr = SECTION_HDR.unpack(f)
if hdr.name == RESOURCE_NODE:
va = hdr.virtual_address
ptr = hdr.pointer_to_raw_data
offset = dir_va - va + ptr
seek_to_hex(hex(offset), f)
break
if hdr.name != RESOURCE_NODE:
raise PeFileError("no root resource node found")
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = table.number_of_name_entries + table.number_of_id_entries
for entry in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
if entry.name_or_id == VERSION_RESOURCE:
while entry.data_or_subdir & (1 << 31):
shift = entry.data_or_subdir & ~(1 << 31)
seek_to_hex(hex(offset + shift), f)
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = (
table.number_of_name_entries
+ table.number_of_id_entries
)
for entry in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
break
if entry.name_or_id > VERSION_RESOURCE:
raise PeFileError("no version info node found")
seek_to_hex(hex(offset + entry.data_or_subdir), f)
data = RESOURCE_DATA_ENTRY.unpack(f)
# https://stackoverflow.com/questions/2170843/va-virtual-address-rva-relative-virtual-address
offset = data.data_rva - hdr.virtual_address + hdr.pointer_to_raw_data
seek_to_hex(hex(offset), f)
hdr = VS_VERSION_INFO_HDR.unpack(f)
# https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo
byte_len = len(VS_VERSION_INFO_ID.encode("utf-16le"))
label = f.read(byte_len).decode("utf-16le")
if label != VS_VERSION_INFO_ID:
raise PeFileError(f"header identifier != '{VS_VERSION_INFO_ID}'")
f.read(32 - byte_len)
identifier = hex(struct.unpack("<Q", f.read(8))[0])
if identifier != VS_VERSION_INFO_MAGIC:
raise PeFileError(
f"{VS_VERSION_INFO_ID} address != '{VS_VERSION_INFO_MAGIC}'"
)
try:
version = parse_version_number(f)
except Exception as e:
raise PeFileError(e)
return version
def get_nested_app_path(steam_path: Path, appid: int) -> Path:
return get_pefile_path(steam_path, appid).parent
def get_pefile_path(steam_path: Path, appid: int) -> Path:
identifier = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP}
name = identifier[appid]
binary = DAYZ_BINARY
app_path = get_app_path(steam_path / LIBRARYFOLDERS_PATH, appid)
pe_path = app_path / f"steamapps/common/{name}/{binary}"
return pe_path
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
try:
j = json.loads(vdf2json(folders_path))
except Exception:
raise VDFLoadError("Failed to parse libraryfolders")
for obj in j["libraryfolders"]:
if str(appid) in j["libraryfolders"][obj]["apps"]:
app_path = j["libraryfolders"][obj]["path"]
break
if app_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid {appid}"
)
app_path = Path(app_path)
if app_path.exists() is False:
raise AppMovedError(
f"Path '{app_path}' specified in libraryfolders does not exist"
)
return app_path
# TODO: tests
def compare_versions(local: DayZVersion, remote: DayZVersion) -> VersionMatch:
local_str = Version(dayz_version_to_str(local))
remote_str = Version(dayz_version_to_str(remote))
if local_str == remote_str:
return VersionMatch.SAME_VERSION
if local_str < remote_str:
return VersionMatch.LOCAL_OLDER
if local_str > remote_str:
return VersionMatch.LOCAL_NEWER

62
dzgui/api/probe.py Normal file
View File

@ -0,0 +1,62 @@
import logging
import requests
from typing import Union, TYPE_CHECKING
from dzgui.const.constants import APPID_DAYZ, REQUEST_TIMEOUT
from dzgui.const import endpoints
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from requests import Response
def test_steam_api(key: str) -> bool:
appid = APPID_DAYZ
payload: dict[str, Union[int, str]] = {
"filter": r"\appid" + fr"\{appid}",
"limit": 10,
"key": key,
}
try:
res = requests.get(endpoints.STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
return is_remote_up(res)
except Exception as e:
logger.critical(e)
# TODO: pass exception text into dialog
return False
# TODO: consolidate is_remote_up and exception logic into one function
def test_ipdb() -> bool:
try:
res = requests.get(endpoints.DB_IP, timeout=REQUEST_TIMEOUT)
return is_remote_up(res)
except Exception as e:
logger.critical(e)
return False
def test_bm_api(key: str) -> bool:
payload: dict[str, str] = {
"filter[game]": "dayz",
}
hdr = {"Authorization": "Bearer " + key}
try:
res = requests.get(
endpoints.BM_SERVERS,
params=payload,
headers=hdr,
timeout=REQUEST_TIMEOUT
)
return is_remote_up(res)
except Exception as e:
logger.critical(e)
return False
def is_remote_up(res: "Response") -> bool:
if res.status_code == 200:
return True
else:
return False

444
dzgui/api/servers.py Normal file
View File

@ -0,0 +1,444 @@
import ipaddress
import json
import logging
import math
import os
import re
import requests
import socket
import subprocess
import typing # noqa
from dataclasses import dataclass
from typing import Union
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.util import strings
import a2s
import dayzquery
logger = logging.getLogger(__name__)
# TODO: confirm that patches from testing are incorporated here
params = [
r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa
r"\map\namalsk\empty\1",
r"\map\namalsk\noplayers\1",
r"\map\chernarusplus\empty\1",
r"\map\chernarusplus\noplayers\1",
r"\map\sakhal\empty\1",
r"\map\sakhal\noplayers\1",
r"\map\enoch\empty\1",
r"\map\enoch\noplayers\1",
]
class BmAPIError(Exception):
pass
class BmIdError(Exception):
pass
class InvalidIpError(Exception):
pass
def get_netmask() -> str:
hostname = os.uname()[1]
i = socket.gethostbyname(hostname)
netmask = i.rsplit(".", 1)[0]
return netmask
def test_ip(suffix: int, port: int) -> dict | None:
netmask = get_netmask()
hostname = f"{netmask}.{str(suffix)}"
ping = ["ping", "-c1", "-i", "0.1", "-w", "1"]
output = subprocess.run(ping + [hostname], capture_output=True)
if output.returncode == 0:
return query_direct(hostname, port)
return None
def sanitize(name: str) -> str:
name = re.sub(r"\r", r"", name)
name = re.sub(r"\n", r"", name)
name = re.sub(r"\x01", r"", name)
name = re.sub(r"\ufeff", r"", name)
name = re.sub(r"(^!\s*)", r"-", name)
name = re.sub(r"(^-\s*)", r"", name)
name = re.sub(r"(^-)", r"", name)
name = re.sub(r"(^\s*)", r"", name)
name = re.sub(r"\t", "", name)
return name
def parse_json(json: list) -> list:
"""
Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case
sanitization failed, discard malformed rows rather than
aborting outright.
"""
rows = []
for row in json:
try:
name = sanitize(row["name"])
if name == "":
continue
except KeyError:
continue
malformed = False
for key in [
"map",
"gametype",
"players",
"max_players",
"addr",
"gameport",
]:
try:
row[key]
except KeyError:
malformed = True
break
if malformed is True:
continue
try:
r = row["gametype"].split(",")
except KeyError:
continue
if "no3rd" in r:
view = strings.filter_1pp
else:
view = strings.filter_3pp
if "external" in r:
provider = strings.filter_unoffiical
else:
provider = strings.filter_official
if "mod" in r:
modded = True
else:
modded = False
try:
r = row["gametype"].split("lqs")
queue = r[1].split(",")[0]
except IndexError:
queue = 0
try:
test_time = re.search(r"[0-9]{2}:[0-9]{2}", row["gametype"])
if test_time:
time = test_time.group(0)
else:
time = strings.unknown
except AttributeError:
time = strings.unknown
try:
ip = row["addr"].split(":")[0] + ":" + str(row["gameport"])
qport = row["addr"].split(":")[1]
except IndexError:
continue
try:
ping = row["ping"]
except KeyError:
ping = 9999
mapname = row["map"].lower()
players = row["players"]
max_players = row["max_players"]
raw = [
name,
mapname,
view,
time,
int(players),
int(max_players),
int(queue),
ip,
int(qport),
ping,
provider,
modded,
]
rows.append(raw)
return rows
def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None:
try:
info = a2s.info((ip, qport), TIMEOUT)
name = info.server_name
mapname = info.map_name
address = ip + ":" + str(qport)
gameport = str(info.port)
players = info.player_count
max_players = info.max_players
keywords = info.keywords
try:
ping = info.ping
ping = math.floor(info.ping * 1000)
except AttributeError:
ping = 9999
res = {}
res["name"] = name
res["map"] = mapname
res["gametype"] = keywords
res["players"] = players
res["max_players"] = max_players
res["addr"] = address
res["gameport"] = gameport
res["ping"] = ping
return res
except TimeoutError:
return None
except KeyError:
return None
@dataclass(slots=True, frozen=True)
class Res:
status: int
parsed: bool
json: Union[str, None]
@dataclass(slots=True, frozen=True)
class Ping:
addr: str
iteration: int
ping: int
@dataclass(slots=True, frozen=True)
class Details:
data: Union[list, None]
description: str
success: bool
@dataclass(slots=True, frozen=True)
class Prereqs:
password: bool
gameport: int
appid: Union[int, None]
version: Union[str, None]
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
# TODO: refactor this
def get_prereqs(ip: str, qport: int) -> Prereqs:
try:
info = a2s.info((ip, qport))
except TimeoutError:
return Prereqs(False, 0, None, None)
gameport = getattr(info, "port", 0)
is_password = getattr(info, "password_protected", False)
appid = getattr(info, "game_id", None)
version = getattr(info, "version", None)
return Prereqs(is_password, gameport, appid, version)
def details(ip: str, qport: int) -> Details:
default_str = strings.none_provided
try:
info = a2s.info((ip, qport))
except TimeoutError:
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, int(qport)))
except TimeoutError:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",")
except AttributeError:
return Details(None, default_str, False)
battleye = strings.disabled
if "battleye" in keywords:
battleye = strings.enabled
day_accel = 0.0
night_accel = 0.0
for keyword in keywords:
if "etm" in keyword:
day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}")
if "entm" in keywords:
night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}")
try:
password = info.password_protected
if password is False:
password = strings.disabled
else:
password = strings.enabled
except AttributeError:
password = "-"
try:
vac = info.vac_enabled
if vac is False:
vac = strings.disabled
else:
vac = strings.enabled
except AttributeError:
vac = strings.null
try:
version = info.version
except AttributeError:
version = strings.null
try:
dlc = rules.dlc_flags
if dlc == 0:
dlc = strings.none
if dlc == 2:
dlc = strings.dlc_frostline
except AttributeError:
dlc = strings.unspecified
try:
platform = rules.platform
if platform == "win":
platform = strings.windows
if platform == "?":
platform = strings.linux
except AttributeError:
platform = strings.unspecified
try:
description = rules.description.strip()
if description == "":
description = default_str
except AttributeError:
description = default_str
rows = [
["Battleye", battleye],
["Daytime acceleration", f"{day_accel}x"],
["DLC", dlc],
["Night-time acceleration", f"{night_accel}x"],
["Password", password],
["Platform", platform],
["Valve Anti-Cheat", vac],
["Version", version],
]
return Details(rows, description, True)
def ping(iteration: int, row: list) -> Ping:
addr = row[7]
qport = row[8]
res = None
if row[9] != 9999:
return Ping(addr, iteration, row[9])
try:
ip = addr.split(":")[0]
except IndexError:
ping = 9999
try:
res = query_direct(ip, qport, 0.5)
except Exception:
pass
if res is None:
ping = 9999
else:
ping = res["ping"]
return Ping(addr, iteration, ping)
def query_api(key: str, appid: int, param: str) -> Res:
LIMIT = 10000
payload: dict[str, Union[int, str]] = {
"filter": r"\appid" + fr"\{appid}" + param,
"limit": LIMIT,
"key": key,
}
try:
res = requests.get(STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
parsed = True
data = res.json()
except Exception as e:
status = res.status_code
parsed = False
data = None
finally:
return Res(status, parsed, data)
def validate_ip(addr: str) -> Record:
fields = addr.split(":")
if len(fields) != 2:
raise InvalidIpError("Address must be formatted as IP:Queryport")
ip = fields[0]
port = fields[1]
try:
int(port)
except ValueError:
raise InvalidIpError(f"'{port}' is not a valid port")
if int(port) > 65535 or int(port) < 0:
raise InvalidIpError(f"'{port}' is not a valid port")
try:
ipaddress.ip_address(ip)
except ValueError:
raise InvalidIpError(f"'{ip}' is not a valid IP")
ip = addr.split(":")[0]
qport = int(addr.split(":")[1])
record = Record(ip, 0, qport)
return record
def get_rules(ip: str, qport: int) -> list[int]:
try:
mods = dayzquery.dayz_rules((ip, int(qport))).mods
return [mod for mod in mods]
except Exception as e:
logger.critical(e)
return []

137
dzgui/api/steam.py Normal file
View File

@ -0,0 +1,137 @@
import json
import logging
import subprocess
from shlex import shlex
from pathlib import Path
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(__name__)
def query_defunct():
pass
# TODO: unimplemented
#cf.
#!/usr/bin/env bash
#query_defunct(){
# readarray -t modlist <<< "$@"
# local max=${#modlist[@]}
# concat(){
# for ((i=0;i<$max;i++)); do
# echo "publishedfileids[$i]=${modlist[$i]}&"
# done | awk '{print}' ORS=''
# }
# payload(){
# echo -e "itemcount=${max}&$(concat)"
# }
# post(){
# curl -s \
# -X POST \
# -H "Content-Type:application/x-www-form-urlencoded"\
# -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json'
# }
# post | jq
# return
# local result=$(post | jq -r '
# .[].publishedfiledetails[]
# | select(.result==1)
# | select(.filename|contains("screenshot")|not)
# | "\(.file_size) \(.publishedfileid)"')
# <<< "$result" awk '{print $2}'
#}
#
#query_defunct "3576065083"
def concat_mods(mods: list[str]) -> str:
for mod in mods:
mods[mod] = "@" + mod
return ";".join(mods)
# TEST: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture
def connect(addr: str, appid: int, name: str, mods: list):
# TODO: get name from configs
# TODO: concat_mods(mods):
# @<mod>;@<mod>;
params = [
f"-connect={addr}",
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}"
]
# TODO: get steam launch command from configs
# args = concat_bash_args()
#
proc = subprocess.Popen([*args, "-applaunch", appid, *params])
# check proc.returncode
def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf"
try:
vdf = vdf2json(resolved_path)
j = json.loads(vdf)
for user in j["users"]:
if j["users"][user]["MostRecent"] == "1":
return user
return None
except Exception as e:
logger.warn(e)
return None
def vdf2json(path: Path) -> str:
def _istr(indent, string):
return (indent * " ") + string
jbuf = "{\n"
indent = 1
with open(path, "r") as f:
st = f.read()
lex = shlex(st)
while True:
tok = lex.get_token()
if not tok:
return jbuf + "}\n"
if tok == "}":
indent -= 1
jbuf += _istr(indent, "}")
ntok = lex.get_token()
lex.push_token(ntok)
if ntok and ntok != "}":
jbuf += ","
jbuf += "\n"
else:
ntok = lex.get_token()
if ntok == "{":
jbuf += _istr(indent, tok + ": {\n")
indent += 1
else:
jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token()
lex.push_token(ntok)
if ntok != "}":
jbuf += ","
jbuf += "\n"
def gen_shortcut() -> None:
# TODO:
"""
during setup, prompt user to select matching user id from loginusers
show user account name, select outer steam id
steam/userdata/<steamid_32>/config/shortcuts.vdf
"""
#STEAMID_MAGIC = 76561197960265728
# STEAMID_64 - STEAMID_MAGIC = STEAMID32
# or get right-most 32 bits
#STEAMID_64 & 0xFFFFFFFF
pass

0
dzgui/config/__init__.py Normal file
View File

72
dzgui/config/convert.py Normal file
View File

@ -0,0 +1,72 @@
import json
import shlex
from pathlib import Path
"""
Convert legacy dztuirc to config.json
"""
class UnsupportedKey(Exception):
pass
def str2bool(string: str) -> bool:
s = string.lower()
match s:
case "":
return False
case "0":
return False
case "1" | "2":
return True
case "false":
return False
case "true":
return True
case _:
raise UnsupportedKey(f"The key {s} is not a valid config key.")
def rc2json(file: Path) -> str:
with open(file, "r") as f:
lex = shlex.shlex(f.read())
lex.whitespace += "="
d = {}
ips: list[str] = []
toggles = ["auto_install", "debug", "fullscreen"]
deprecated = ["staging_dir", "src_path", "steam_path"]
while True:
tok = lex.get_token()
ntok = lex.get_token()
if ntok is not None:
ntok = ntok.strip('""')
if tok in deprecated:
continue
elif tok in toggles:
ntok = str2bool(ntok)
elif tok == "preferred_client":
tok = "client"
elif tok == "api_key":
tok = "bm_api"
elif tok == "ip_list":
while True:
ntok = lex.get_token().strip('""')
if ntok == ")":
break
ips.append(ntok)
continue
if not tok:
break
d[tok] = ntok
d["ip_list"] = ips
d["use_miles"] = False
return json.dumps(d, indent=2)

View File

@ -0,0 +1,42 @@
import shutil
import textwrap
from pathlib import Path
def write_desktop_file(share_path: Path) -> None:
template = f"""\
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Exec={share_path}/launch.sh
Name=DZGUI
Comment=dzgui
Icon={share_path}/dzgui
Categories=Game"""
file = share_path / "dzgui.desktop"
file.write_text(textwrap.dedent(template))
def write_shim_file(share_path: Path) -> None:
# FIXME: expects sys.prefix to be a venv
file = share_path / "launch.sh"
template=f"""\
source {venv}/bin/activate
python3.13 dzgui
"""
try:
file.write_text(textwrap.dedent(template))
f.chmod(f.stat().st_mode | stat.S_IEXEC)
except OSError:
logger.critical(e)
return
clear_old_path()
write_desktop_file(share_path)
def clear_old_path() -> None:
home = Path.home()
path = home / ".local/share/dzgui"
shutil.rmtree(path)

97
dzgui/config/ipdb.py Normal file
View File

@ -0,0 +1,97 @@
import gzip
import logging
import re
import requests
import shutil
from pathlib import Path
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import DB_IP
logger = logging.getLogger(__name__)
def find_download_url(url: str) -> str | None:
res = requests.get(url, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
reg = r'ref=[\'"]([^\'">]+.csv.gz)'
urls = re.findall(reg, res.text)
if len(urls) < 1:
return None
return str(urls[0])
def get_ipdb(ips_path: Path) -> None:
url = find_download_url(DB_IP)
if url is None:
return
date = find_date(url)
month_file = ips_path.parent / ".month"
if month_file.exists() is True:
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
return
# TODO: log additional output
logger.info(f"Fetching IPDB for {date} from {url}")
try:
tmp = serialize(url)
except Exception as e:
logger.critical(e)
return
logger.info(f"Extracting {tmp}")
unzip(tmp, ips_path)
logger.info("Stripping IPv6 records")
try:
strip_ipv6(ips_path)
except Exception as e:
logger.critical(e)
return
with open(month_file, "w") as f:
f.write(date)
logger.info(f"Wrote {date} to {month_file}")
def find_date(url: str) -> str:
date = re.sub(r"(dbip-city-lite-)(.*)(.csv.gz)", r"\2", url)
date = date.split("/")[-1]
return date
def unzip(tmp: Path, ips_path: Path) -> None:
with gzip.open(tmp, "rb") as f:
with open(ips_path, "wb") as outfile:
shutil.copyfileobj(f, outfile)
tmp.unlink()
def serialize(url: str) -> Path:
with requests.get(url, stream=True) as r:
r.raise_for_status()
filename = "ips.csv.gz"
tmp = Path("/tmp") / filename
with open(tmp, "wb") as f:
shutil.copyfileobj(r.raw, f)
return tmp
# TODO: optimize this function
# consider using grep
# cf. grep -vE "^[a-z0-9]{4}:" | grep -v "::" > "$ip_file"
def strip_ipv6(path: Path) -> None:
ips = []
with open(path, "r") as f:
s = f.read()
reg = r"^\d{1,3}\..*"
ips = re.findall(reg, s, re.MULTILINE)
assert ips[0].split(",")[0] == "0.0.0.0"
assert ips[-1].split(",")[0] == "224.0.0.0"
with open(path, "w") as f:
for ip in ips:
f.write(ip + "\n")

60
dzgui/config/query.py Normal file
View File

@ -0,0 +1,60 @@
from typing import Any
from pathlib import Path
from dzgui.util._json import read_json
from dzgui.const.enum import Preferences
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
class ConfigFileError(Exception):
pass
def lookup(path: Path, enum: Preferences) -> Any:
if path.is_file() is False:
raise ConfigFileError("Not a valid file")
key = enum_to_key(enum)
config = get_config(path)
try:
return config[key]
except KeyError:
return None
def get_config(path: Path) -> dict:
# TODO: is this being called twice?
try:
json = read_json(path)
except Exception as e:
raise e
return json
def get_favorites(path: Path) -> list[str]:
try:
conf = get_config(path)
except Exception as e:
pass
return conf["ip_list"]
def is_in_favs(record: str, path: Path) -> bool:
favs = get_favorites(path)
if record in favs:
return True
return False
def enum_to_key(enum: Preferences) -> str:
return enum.dict["key"]
def get_client_index(client: str) -> int:
if client == STEAM_CMD:
return 0
elif client == FLATPAK_RUN_CMD:
return 1
else:
return 2

10
dzgui/config/uninstall.py Normal file
View File

@ -0,0 +1,10 @@
import shutil
from pathlib import Path
def uninstall(path: Path) -> None
# TODO: use xdg object?
# partial/full (keep config?)
# -u removes state, log
# XDG_SHARE_HOME/dzgui
# XDG_STATE_HOME/dzgui
# XDG_DATA_HOME/dzgui

25
dzgui/config/update.py Normal file
View File

@ -0,0 +1,25 @@
from pathlib import Path
from dzgui.const.enum import Preferences
from dzgui.config.query import get_config, enum_to_key
from dzgui.util._json import write_json
# TEST: parametrize writing config values and checking config output in test
def toggle_config(path: Path, key: Preferences) -> None:
real_key = enum_to_key(key)
try:
conf = get_config(path)
cur_val = conf[real_key]
conf[real_key] = not cur_val
write_json(conf, path)
except Exception as e:
raise e
def write_config(path: Path, key: Preferences, value: str) -> None:
real_key = enum_to_key(key)
try:
conf = get_config(path)
conf[real_key] = value
write_json(conf, path)
except Exception as e:
raise e

12
dzgui/config/userprefs.py Normal file
View File

@ -0,0 +1,12 @@
from dataclasses import dataclass
from dzgui.config.xdg import Xdg
from dzgui.util.ip import Coords
@dataclass(slots=True, frozen=True)
class UserPrefs:
is_steam_deck: bool
is_game_mode: bool
coords: Coords | None
version: str
allow_updates: bool
paths: Xdg

90
dzgui/config/xdg.py Normal file
View File

@ -0,0 +1,90 @@
import os
from pathlib import Path
from dataclasses import dataclass
from dzgui.const.constants import APP_NAME_LOWER, DEBUG_LOG, SYSTEM_LOG
@dataclass
class Xdg:
config: Path
columns: Path
notes: Path
resolution: Path
history: Path
version: Path
system: Path
debug: Path
ips: Path
def is_writeable(path_str: str) -> bool:
if path_str is None:
return False
path = Path(path_str)
if not path.exists():
try:
path.mkdir(parents=True)
except OSError:
return False
path.unlink()
return True
try:
file = Path(path / "temp")
with open(file, "w") as f:
f.write("")
except Exception:
return False
file.unlink()
return True
def get_xdg_paths() -> dict:
home = Path.home()
xdg_paths = {
"XDG_CONFIG_HOME": home / ".config",
"XDG_STATE_HOME": home / ".local" / "state",
"XDG_DATA_HOME": home / ".local" / "share",
"XDG_CACHE_HOME": home / ".cache",
}
resolved_paths = {}
for path in xdg_paths:
rp = os.environ.get(path)
resolved_paths[path] = Path(rp) / APP_NAME_LOWER if is_writeable(rp) else xdg_paths[path] / APP_NAME_LOWER
return resolved_paths
def parse_filepaths(xdg: dict) -> Xdg:
# cache = xdg["XDG_CACHE_HOME"]
# data = xdg["XDG_DATA_HOME"]
config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"]
config = config / "config.json"
system = state / "logs" / SYSTEM_LOG
debug = state / "logs" / DEBUG_LOG
columns = state / "dzg.cols.json"
notes = state / "dzg.notes.json"
resolution = state / "dzg.res.json"
history = state / "dzg.history"
versions = state / "dzg.versions"
ips = state / "ips.csv"
return Xdg(
config,
columns,
notes,
resolution,
history,
versions,
system,
debug,
ips,
)

53
dzgui/const/constants.py Normal file
View File

@ -0,0 +1,53 @@
APPID_DAYZ = 221100
APPID_DAYZ_EXP = 1024020
UDP_PORT = 27016
VM_FILE = "/proc/sys/vm/max_map_count"
MIN_COUNT = 1048576
REQUEST_TIMEOUT = 3
APPNAME_DAYZ = "DayZ"
APPNAME_DAYZ_EXP = "DayZ Experimental"
DAYZ_BINARY = "DayZ_x64.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
STEAM_CMD = "steam"
FLATPAK_CMD = "flatpak"
FLATPAK_APPID = "com.valvesoftware.Steam"
FLATPAK_RUN_CMD = "flatpak run " + FLATPAK_APPID
FLATPAK_SANDBOX = "flatpak-spawn --host flatpak run " + FLATPAK_APPID
AUTHOR = "aclist"
STABLE_REPO = "stable"
BETA_REPO = "testing"
APP_NAME = "DZGUI"
APP_NAME_LOWER = "dzgui"
APP_NAME_ABBR = "dzg"
HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C"
VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic"
INPUT_KEYBOARD = "input-keyboard-symbolic"
HELP_BUBBLE = "help-about-symbolic"
WEB_BROWSER = "web-browser-symbolic"
SEPARATOR = "SEPARATOR"
NO_EXPAND = False
NO_FILL = False
EXPAND = True
FILL = True
SCROLL_INCREMENT = 50
WINDOW_DEFAULT_X = 1400
WINDOW_DEFAULT_Y = 800
LEGACY_CONFIG_PATH = ".config/dztui/dztuirc"
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"

23
dzgui/const/endpoints.py Normal file
View File

@ -0,0 +1,23 @@
# internal
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
DB_IP = "https://db-ip.com/db/download/ip-to-city-lite"
IP_ECHO = "https://ipecho.net/plain"
COORDS_API = "http://ip-api.com/json"
# steam URIs
WORKSHOP = "steam://url/CommunityFilePage/"
OPEN_URL = "steam://openurl/"
# user facing
GITHUB_PAGES = "https://aclist.github.io/dzgui/index"
CODEBERG_PAGES = "https://aclist.codeberg.page"
GITHUB_ISSUES = "https://github.com/aclist/dzgui/issues"
FORUM = "https://old.reddit.com/r/dzgui"
SPONSORS = "https://github.com/sponsors/aclist"
STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey"
BM_API_SETUP = "https://www.battlemetrics.com/developers"
BM_BROWSE = "https://www.battlemetrics.com/servers/dayz"

525
dzgui/const/enum.py Normal file
View File

@ -0,0 +1,525 @@
from enum import Enum
from typing import Self
from dzgui.util import strings
class Port(Enum):
DEFAULT = 1
CUSTOM = 2
class Popup(Enum):
WAIT = 1
NOTIFY = 2
CONFIRM = 3
ENTRY = 4
RETURN = 5
MODLIST = 6
DETAILS = 7
QUIT = 8
class Command(Enum):
INTERACTIVE = 1
ONESHOT = 2
HELP = 3
TOGGLE = 4
THANKS = 5
class VAdjustment(Enum):
UP = 1
DOWN = 2
TOP = 3
BOTTOM = 4
class CursorPosition(Enum):
UP = 1
DOWN = 2
TOP = 3
BOTTOM = 4
class FilterMode(Enum):
KEYWORD = 1
MAP = 2
INITIAL = 3
TOGGLE_OFF = 4
TOGGLE_ON = 5
class EnumWithAttrs(Enum):
def __new__(cls, *args, **kwargs) -> Self:
value = len(cls.__members__) + 1
obj = object.__new__(cls)
obj._value_ = value
return obj
def __init__(self, d: dict):
self.dict = d
class Preferences(EnumWithAttrs):
STEAM = {"key": "steam_api",}
BM = {"key": "bm_api",}
CLIENT = {"key": "client",}
WINDOW = {"key": "fullscreen",}
DIST = {"key": "use_miles",}
NAME = {"key": "name",}
INSTALL = {"key": "auto_install",}
DEBUG = {"key": "debug",}
DEFAULT = {"key": "default_steam_path",}
FAV_LBL = {"key": "fav_label",}
FAV_SRV = {"key": "fav_server",}
IP_LIST = {"key": "ip_list",}
BRANCH = {"key": "branch",}
class NotebookPage(EnumWithAttrs):
CHANGELOG = {"crumbs": strings.crumbs.changelog,}
HISTORY = {"crumbs": strings.crumbs.history,}
KEYS = {"crumbs": strings.crumbs.keys,}
LAN = {"crumbs": strings.crumbs.lan,}
LOG = {"crumbs": strings.crumbs.log,}
MAIN = {"crumbs": strings.crumbs.main,}
MODS = {"crumbs": strings.crumbs.mods,}
MY_SERVERS = {"crumbs": strings.crumbs.my_servers,}
OPTIONS = {"crumbs": strings.crumbs.options,}
SERVERS = {"crumbs": strings.crumbs.servers,}
THANKS = {"crumbs": strings.crumbs.thanks,}
class RowType(EnumWithAttrs):
@classmethod
def str2rowtype(cls, string: str) -> "RowType":
for member in cls:
if string == member.dict["label"]:
return member
return RowType.DYNAMIC
# specialized behavior
# TODO: deprecated
DYNAMIC = {
"label": None,
"tooltip": None,
}
RESOLVE_IP = {
"label": "Resolve IP",
"tooltip": None,
"wait_msg": "Resolving remote IP",
}
HIGHLIGHT = {
"label": "Highlight stale",
"tooltip": None,
"wait_msg": "Looking for stale mods",
}
# pages
SERVER_BROWSER = {
"label": "Server browser",
"tooltip": "Used to browse the global server list",
"type": "server",
}
SAVED_SERVERS = {
"label": "My saved servers",
"tooltip": "Browse your saved servers. Unreachable servers will be excluded",
"type": "server",
}
RECENT_SERVERS = {
"label": "Recent servers",
"tooltip": "Shows the last 10 servers you connected to (includes attempts)",
"type": "server",
}
SCAN_LAN = {
"label": "Scan LAN servers",
"tooltip": "Search for servers on your local network",
"type": "server",
}
LIST_MODS = {
"label": "Mods",
"tooltip": "Browse a list of locally-installed mods",
"quad_label": "Mods",
"type": "mods",
}
CHANGELOG = {
"label": "View changelog",
"tooltip": "Opens the DZGUI changelog",
}
OPTIONS = {"label": "Options", "tooltip": None}
KEYBINDINGS = {"label": "Keybindings", "tooltip": None}
SHOW_LOG = {
"label": "Show debug log",
"tooltip": "Read the DZGUI log generated since startup",
"quad_label": "Debug log",
}
# interactive dialogs
CONN_BY_IP = {
"label": "Connect by IP",
"tooltip": "Connect to a server by IP",
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
"link_label": None,
"type": Command.INTERACTIVE,
}
CONN_BY_ID = {
"label": "Connect by ID",
"tooltip": "Connect to a server by Battlemetrics ID",
"prompt": "Enter server ID",
"link_label": "Open Battlemetrics",
"type": Command.INTERACTIVE,
}
SEPARATOR = {
"label" : "SEPARATOR",
"tooltip": "",
}
ADD_BY_IP = {
"label": "Add server by IP",
"tooltip": "Add a server by IP",
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
"link_label": None,
"type": Command.INTERACTIVE,
}
ADD_BY_ID = {
"label": "Add server by ID",
"tooltip": "Add a server by Battlemetrics ID",
"prompt": "Enter server ID",
"link_label": "Open Battlemetrics",
"type": Command.INTERACTIVE,
}
CHNG_FAV = {
"label": "Change favorite server",
"tooltip": "Update your quick-connect server",
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
"link_label": None,
"alt": None,
"default": "unset",
"val": "fav_label",
"type": Command.INTERACTIVE,
}
CHNG_PLAYER = {
"label": "Change player name",
"tooltip": "Update your in-game name (required by some servers)",
"prompt": "Enter new nickname",
"link_label": None,
"alt": None,
"default": None,
"val": "name",
"type": Command.INTERACTIVE,
}
CHNG_STEAM_API = {
"label": "Change Steam API key",
"tooltip": "Can be used if you revoked an old API key",
"prompt": "Enter new API key",
"link_label": "Open Steam API page",
"type": Command.INTERACTIVE,
}
CHNG_BM_API = {
"label": "Change Battlemetrics API key",
"tooltip": "Can be used if you revoked an old API key",
"link_label": "Open Battlemetrics API page",
"prompt": "Enter new API key",
"type": Command.INTERACTIVE,
}
# oneshot commands
QUICK_CONNECT = {
"label": "Quick-connect to favorite server",
"tooltip": "Connect to your favorite server",
"wait_msg": "Working",
"default": "unset",
"alt": None,
"val": "fav_label",
"type": Command.ONESHOT,
}
FORCE_UPDATE = {
"label": "Force update local mods",
"tooltip": "Synchronize local mods with remote versions (experimental)",
"wait_msg": "Updating mods",
"type": Command.ONESHOT,
}
DUMP_LOG = {
"label": "Output system info to log file",
"tooltip": "Dump diagnostic data for troubleshooting",
"wait_msg": "Generating log",
"type": Command.ONESHOT,
}
HANDSHAKE = {
"label": "Handshake",
"tooltip": None,
"wait_msg": "Waiting for DayZ",
"type": Command.ONESHOT,
}
HANDSHAKE_EXP = {
"label": "Handshake_EXP",
"tooltip": None,
"wait_msg": "Waiting for DayZ",
"type": Command.ONESHOT,
}
DELETE_SELECTED = {
"label": "Delete selected mods",
"tooltip": None,
"wait_msg": "Deleting mods",
"type": Command.ONESHOT,
}
# help pages
DOCS = {
"label": "Documentation/help files (GitHub) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
"type": Command.HELP,
}
DOCS_FALLBACK = {
"label": "Documentation/help files (Codeberg mirror) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
"type": Command.HELP,
}
BUGS = {
"label": "Report a bug (GitHub) ⧉",
"tooltip": "Open the DZGUI issue tracker in a browser",
"type": Command.HELP,
}
FORUM = {
"label": "DZGUI Subreddit ⧉",
"tooltip": "Open the DZGUI discussion forum in a browser",
"type": Command.HELP,
}
SPONSOR = {
"label": "Sponsor (GitHub) ⧉",
"tooltip": "Sponsor development of the DZGUI project",
"type": Command.HELP,
}
THANKS = {
"label": "Special thanks",
"tooltip": "A list of contributors, testers, and sponsors",
"type": Command.THANKS,
}
# TODO: rename to ContextItem
class ContextMenu(EnumWithAttrs):
ADD_SERVER = {"label": strings.add}
REMOVE_SERVER = {"label": strings.remove}
COPY_NAME = {"label": strings.copy_name}
COPY_CLIPBOARD = {"label": strings.copy_ip}
ADD_NOTE = {"label": strings.add_note}
SHOW_MODS = {"label": strings.show_mods}
SHOW_DETAILS = {"label": strings.show_details}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history}
OPEN_WORKSHOP = {"label": strings.open_workshop}
DELETE_MOD = {"label": strings.delete_mod}
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
class ContextMenuGroup(Enum):
"""
Groupings of context menu items
"""
MOD = (
ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD
)
LOG = (
ContextMenu.COPY_LOG_CLIPBOARD,
)
SERVER_BROWSER = (
ContextMenu.ADD_SERVER,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
)
SCAN_LAN = (
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
)
SAVED = (
ContextMenu.REMOVE_SERVER,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
)
RECENT = (
ContextMenu.ADD_SERVER,
ContextMenu.REMOVE_HISTORY,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
)
MAIN_MENU_ROWS = (
RowType.SERVER_BROWSER,
RowType.SAVED_SERVERS,
RowType.RECENT_SERVERS,
RowType.SCAN_LAN,
RowType.SEPARATOR,
RowType.QUICK_CONNECT,
RowType.CONN_BY_IP,
RowType.CONN_BY_ID,
RowType.SEPARATOR,
RowType.ADD_BY_IP,
RowType.ADD_BY_ID,
RowType.CHNG_FAV,
)
HELP_MENU_ROWS = (
RowType.CHANGELOG,
RowType.SHOW_LOG,
RowType.DUMP_LOG,
RowType.SEPARATOR,
RowType.DOCS,
RowType.DOCS_FALLBACK,
RowType.BUGS,
RowType.FORUM,
RowType.SPONSOR,
RowType.SEPARATOR,
RowType.THANKS,
)
#class WindowContext(EnumWithAttrs):
# # TODO: deprecated
# @classmethod
# def row2con(cls, row: RowType) -> "WindowContext":
# m = WindowContext.MAIN_MENU
# for member in cls:
# if row in member.dict["rows"]:
# m = member
# elif row in member.dict["called_by"]:
# m = member
# else:
# continue
# return m
#
# # outer menu pages
# # TODO: some deprecated keys in here
# #MAIN_MENU = {
# # RowType.SERVER_BROWSER,
# # RowType.SAVED_SERVERS,
# # RowType.RECENT_SERVERS,
# # RowType.SCAN_LAN,
# # RowType.SEPARATOR,
# # RowType.QUICK_CONNECT,
# # RowType.CONN_BY_IP,
# # RowType.CONN_BY_ID,
# # RowType.SEPARATOR,
# # RowType.ADD_BY_IP,
# # RowType.ADD_BY_ID,
# # RowType.CHNG_FAV,
# # }
# #MODS = {
# # "label": "Mods",
# # "rows": [],
# # "called_by": [],
# #}
# HELP = {
# "label": "Help",
# "rows": [
# RowType.CHANGELOG,
# RowType.SHOW_LOG,
# RowType.DUMP_LOG,
# RowType.SEPARATOR,
# RowType.DOCS,
# RowType.DOCS_FALLBACK,
# RowType.BUGS,
# RowType.FORUM,
# RowType.SPONSOR,
# RowType.SEPARATOR,
# RowType.THANKS,
# ],
# "called_by": [],
# }
#
# ## inner server contexts
# #TABLE_API = {
# # "label": "",
# # "rows": [],
# # "called_by": [RowType.SERVER_BROWSER],
# #}
# #TABLE_SERVER = {
# # "label": "",
# # "rows": [],
# # "called_by": [
# # RowType.SAVED_SERVERS,
# # RowType.RECENT_SERVERS,
# # RowType.SCAN_LAN,
# # ],
# #}
# #TABLE_MODS = {
# # "label": "",
# # "rows": [],
# # "called_by": [
# # RowType.LIST_MODS,
# # ],
# #}
# #TABLE_LOG = {
# # "label": "",
# # "rows": [],
# # "called_by": [RowType.SHOW_LOG],
# #}
class ModButton(EnumWithAttrs):
SELECT_ALL = {
"label": strings.mod_panel.select_all,
"tooltip": strings.mod_panel.select_all_tooltip,
}
UNSELECT_ALL = {
"label": strings.mod_panel.unselect_all,
"tooltip": strings.mod_panel.unselect_all_tooltip
}
HIGHLIGHT_STALE = {
"label": strings.mod_panel.highlight_stale,
"tooltip": strings.mod_panel.highlight_stale_tooltip,
}
UNHIGHLIGHT_STALE = {
"label": strings.mod_panel.unhighlight_stale,
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
}
DELETE_SELECTED = {
"label": strings.mod_panel.delete_selected,
"tooltip": strings.mod_panel.delete_selected_tooltip,
}
SELECT_STALE = {
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
}
class ButtonType(EnumWithAttrs):
MAIN_MENU = {
"label": strings.buttons.main_label,
"tooltip": strings.buttons.main_tooltip,
"opens": NotebookPage.MAIN,
}
MODS = {
"label": strings.buttons.mods_label,
"tooltip": strings.buttons.mods_tooltip,
"opens": NotebookPage.MODS,
}
OPTIONS = {
"label": strings.buttons.options_label,
"tooltip": strings.buttons.options_tooltip,
"opens": NotebookPage.OPTIONS
}
HELP = {
"label": strings.buttons.help_label,
"tooltip": strings.buttons.help_tooltip,
"opens": NotebookPage.MAIN,
}
EXIT = {
"label": strings.buttons.exit_label,
"tooltip": strings.buttons.exit_tooltip,
"opens": None,
}

15
dzgui/const/update.py Normal file
View File

@ -0,0 +1,15 @@
"""
This file is intended to be patched by package maintainers
repackaging DZGUI for use in different distributions.
If DZGUI is going to be installed globally via a package manager
or other means and will reside in an immutable location, or needs to be
explicitly bound to a certain version, set the flag below to False.
This will disable the following:
- Version update checks at startup
- Ability to toggle between Stable/Testing branches in the Options menu
"""
ALLOW_UPDATES = True

545
dzgui/controllers/mc.py Normal file
View File

@ -0,0 +1,545 @@
import logging
import os
import locale
import shutil
import threading
import textwrap
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, TYPE_CHECKING
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, GObject
import dzgui.api.pefile as PeFile
from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.api.mods import (
get_delimited_mods,
get_local_mod_path,
find_stale_mods,
_hash,
remove_stale_signatures
)
from dzgui.const.enum import (
Preferences,
Popup,
MAIN_MENU_ROWS,
HELP_MENU_ROWS,
RowType,
NotebookPage,
ButtonType,
ContextMenu,
)
from dzgui.const.constants import LIBRARYFOLDERS_PATH, APPID_DAYZ, APPID_DAYZ_EXP, HEX_RED
from dzgui.controllers.model import ModelManager
from dzgui.config import update
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
from dzgui.util.diag import write_diagnostic
from dzgui.util import localize, cooldown, strings
from dzgui.util._json import read_json, write_json
from dzgui.util.open_links import open_workshop_page
from dzgui.util.format import pluralize, format_mods
from dzgui.views.dialogs.filepicker import FilePicker
from dzgui.views.dialogs.generic import GenericDialog
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from dzgui.views.components.buttonbox import ContextualButton
from dzgui.views.base import AppNavigation
from dzgui.const.enum import ContextMenu
from dzgui.views.base import OuterWindow
class Controller:
def __init__(self) -> None:
# TODO: main controller delegates to model manager
self.map_store = Gtk.ListStore(str)
self.row_store = Gtk.ListStore(str, GObject.TYPE_PYOBJECT)
self.help_store = Gtk.ListStore(str, GObject.TYPE_PYOBJECT)
# NOTE: long timestamp numbers
self.modlist_store = Gtk.ListStore(str, GObject.TYPE_INT64, str)
self.log_store = Gtk.ListStore(str, str, str, str)
self.notes_cache: dict[str, str] = {}
self.mediator: AppNavigation
self.prefs: UserPrefs
self.cooldown = 0
for row in MAIN_MENU_ROWS:
label = row.dict["label"]
self.row_store.append([label, row])
for row in HELP_MENU_ROWS:
label = row.dict["label"]
self.help_store.append([label, row])
self.model_manager = ModelManager()
def set_crumbs(self, crumbs: str) -> None:
self.mediator.grid.set_breadcrumbs(crumbs)
def get_crumbs(self) -> str:
return self.mediator.grid.get_breadcrumbs()
def get_help_store(self) -> Gtk.ListStore:
return self.help_store
def get_map_store(self) -> Gtk.ListStore:
return self.map_store
def get_row_store(self) -> Gtk.ListStore:
return self.row_store
def get_modlist_store(self) -> Gtk.ListStore:
return self.modlist_store
def get_mod_store(self) -> Gtk.ListStore:
return self.model_manager.get_mod_store()
def get_log_store(self) -> Gtk.ListStore:
return self.log_store
def terminate_process(self) -> None:
# TODO: only used by server table multiprocessing queue
self.mediator.notebook.servers.terminate_process()
def get_prefs(self) -> UserPrefs:
return self.prefs
def set_prefs(self, prefs: UserPrefs) -> None:
self.prefs = prefs
def query_config(self, key: Preferences) -> str | bool | list:
config = self.prefs.paths.config
return lookup(config, key)
def is_auto_install(self) -> bool:
return self.query_config(Preferences.INSTALL)
def reinit_map_store(self) -> None:
self.map_store.clear()
self.map_store.append(["All maps"])
def append_map(self, map_row: list) -> None:
self.map_store.append(map_row)
def set_mediator(self, mediator: "AppNavigation") -> None:
self.mediator = mediator
def get_mediator(self) -> "AppNavigation":
return self.mediator
def unblock_signals(self) -> None:
self.block_signals(False)
def block_signals(self, state: bool = True) -> None:
self.suppress_signal(
self.mediator.grid.right_panel.filters_vbox,
self.mediator.grid.right_panel.filters_vbox.maps_combo,
"_on_map_changed",
state,
)
self.suppress_signal(
self.mediator.treeview,
self.mediator.treeview.selected_row,
"_on_tree_selection_changed",
state,
)
self.suppress_signal(self.mediator.treeview, self.mediator.treeview, "_on_keypress", state)
for check in self.mediator.grid.right_panel.filters_vbox.checks:
self.suppress_signal(
self.mediator.grid.right_panel.filters_vbox,
check,
"_on_check_toggled",
state,
)
def suppress_signal(
self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool
) -> None:
func = getattr(owner, func_name)
if state:
widget.handler_block_by_func(func)
else:
widget.handler_unblock_by_func(func)
self.mediator.treeview.sel_blocked = state
def toggle_debug_mode(self) -> None:
self.toggle_config(Preferences.DEBUG)
def save_res_and_quit(self, *args: Any) -> None:
if self.mediator.window.props.is_maximized:
Gtk.main_quit()
return
w, h = self.mediator.window.get_size()
data = {"res": {"width": w, "height": h}}
res_path = self.prefs.paths.resolution
try:
write_json(data, res_path)
except Exception as e:
logger.critical(e)
Gtk.main_quit()
def set_statusbar(self, text: str) -> None:
self.mediator.grid.statusbar.set_text(text)
def delete_multiple_mods(self) -> None:
sel = self.mediator.modtreeview.get_selection()
model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple
for path in reversed(pathlist):
self.delete_single_mod(path)
total_mods, total_size = self.calc_mod_size()
self.update_mod_statusbar()
def load_mods(self) -> None:
model = self.model_manager.get_mod_store()
model.clear()
path = self.query_config(Preferences.DEFAULT)
mods = get_delimited_mods(Path(path))
for mod in mods:
# NOTE: holds color column
mod.append(None)
model.append(mod)
self.update_mod_statusbar()
def toggle_config(self, context: Preferences) -> None:
config = self.prefs.paths.config
try:
update.toggle_config(config, context)
except Exception as e:
logger.critical(e)
self.spawn_dialog(strings.something_wrong, Popup.NOTIFY)
def update_config(self, key: Preferences, value: str) -> None:
try:
update.write_config(self.prefs.paths.config, key, value)
except Exception as e:
logger.critical(e)
self.spawn_dialog(strings.something_wrong, Popup.NOTIFY)
# TODO: suppress signals
# then reenable (or it spawns twice)
self.mediator.grid.notebook.settings.populate_settings()
return
# NOTE: disabled for now
def present_toast(self, text: str) -> None:
self.mediator.window.toast.set_text_and_fade(text)
def start_cooldown(self) -> None:
self.cooldown = cooldown.get_time()
def manage_cooldown(self) -> bool:
if cooldown.is_elapsed(self.cooldown):
self.cooldown = cooldown.get_time()
return True
else:
return False
def open_keybindings(self) -> None:
notebook = self.mediator.grid.notebook
notebook.toggle_keybindings()
def focus_notebook(self) -> None:
notebook = self.mediator.grid.notebook
notebook.focus_current()
def spawn_dialog(self, msg: str, mode: Popup) -> bool:
"""
Spawns a GenericDialog transient to the OuterWindow
"""
msg = textwrap.dedent(msg)
dialog = GenericDialog(self, msg, mode)
response = dialog.run()
dialog.destroy()
match response:
case Gtk.ResponseType.OK:
return False
case Gtk.ResponseType.CANCEL | Gtk.ResponseType.DELETE_EVENT:
return True
return False
def set_statusbar_by_row(self, row: RowType) -> None:
self.mediator.grid.statusbar.refresh(row)
def toggle_mod_panel(self, state: bool) -> None:
self.mediator.grid.sel_panel.set_visible(state)
def open_page(self, page: NotebookPage) -> None:
self.mediator.grid.notebook.set_page_by_enum(page)
def open_page_by_button(self, button: "ContextualButton") -> None:
match button.context:
case ButtonType.EXIT:
logger.info("Normal user exit")
self.save_res_and_quit()
return
case ButtonType.OPTIONS:
self.mediator.grid.notebook.settings.populate_settings()
case ButtonType.MODS:
self.load_mods()
case ButtonType.HELP:
self.mediator.treeview.set_model(self.help_store)
case ButtonType.MAIN_MENU:
self.mediator.treeview.set_model(self.row_store)
self.mediator.grid.notebook.set_page_by_enum(button.opens)
self.set_crumbs(button.get_label())
# TODO: deprecated?
def open_self_workshop(self, uid: str) -> None:
# NOTE: uid may contain leading zeroes, not a real integer
client = self.query_config(Preferences.CLIENT)
open_workshop_page(uid, client)
def copy_log(self, paths: list[Gtk.TreePath]) -> str:
if len(paths) < 1:
return ""
final = []
for path in paths:
record = self.log_store[path]
r = [el for el in record]
concat = strings.delimiter.join(r)
final.append(concat)
text = "\n".join(final)
return text
def copy_clipboard(self, text: str) -> None:
self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
self.clipboard.set_text(text, -1)
def open_mod_page(self, path: Gtk.TreePath) -> None:
mod, it = self.get_mod_from_tree_path(path)
cmd = self.query_config(Preferences.CLIENT)
open_workshop_page(mod, cmd)
# TODO: put in model manager (dedicated manager for mod store)
def get_mod_from_tree_path(self, tree_path: Gtk.TreePath) -> tuple[str, Gtk.TreeIter]:
model = self.model_manager.get_mod_store()
tree_iter = model.get_iter(tree_path)
mod = model.get(tree_iter, 2)[0]
return mod, tree_iter
def delete_single_mod(self, tree_path: Gtk.TreePath) -> None:
config = self.prefs.paths.config
mod, it = self.get_mod_from_tree_path(tree_path)
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
mods_path = get_local_mod_path(steam_path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
md5 = _hash(mod)
symlink = app_path / md5
symlink.unlink()
shutil.rmtree(mods_path / mod)
# NOTE: second pass to unlink DAYZ_EXP mods
try:
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
symlink = app_path_exp / md5
symlink.unlink()
except PeFile.AppNotInstalledError:
pass
# TODO: belongs in model manager
model = self.model_manager.get_mod_store()
model.remove(it)
def update_mod_statusbar(self) -> None:
total_mods, total_size = self.calc_mod_size()
msg = format_mods(total_size, total_mods)
self.mediator.grid.statusbar.set_text(msg)
def calc_mod_size(self) -> tuple[int, int]:
model = self.model_manager.get_mod_store()
total_mods = len(model)
total_size = 0
for mod in model:
total_size += mod[3]
return total_mods, total_size
def menu_action(self, action: ContextMenu, path: Gtk.TreePath) -> None:
match action:
# NOTE: manipulates server store
# TODO: unimplemented
case ContextMenu.ADD_SERVER:
pass
case ContextMenu.ADD_NOTE:
pass
case ContextMenu.COPY_CLIPBOARD:
pass
case ContextMenu.COPY_NAME:
pass
case ContextMenu.REFRESH_PLAYERS:
pass
case ContextMenu.REMOVE_HISTORY:
pass
case ContextMenu.REMOVE_SERVER:
pass
case ContextMenu.SHOW_DETAILS:
pass
case ContextMenu.SHOW_MODS:
pass
# NOTE: manipulates mod store
case ContextMenu.DELETE_MOD:
self.delete_single_mod(path)
self.update_mod_statusbar()
remove_stale_signatures(
self.prefs.paths.config,
self.prefs.paths.version
)
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page(path)
def toggle_mod_selection(self, state: bool) -> None:
sel = self.mediator.modtreeview.get_selection()
if state:
sel.select_all()
else:
sel.unselect_all()
self.mediator.modtreeview.grab_focus()
def populate_log(self) -> None:
log = self.prefs.paths.debug
store = self.log_store
store.clear()
# TODO: pop dialog if log is missing
with open(log, "r") as f:
lines = [line.split(strings.delimiter) for line in f.read().splitlines()]
for record in lines:
store.append(record)
self.open_page(NotebookPage.LOG)
def select_colorized(self) -> None:
model = self.model_manager.get_mod_store()
sel = self.mediator.modtreeview.get_selection()
for mod in model:
it = mod.iter
path = model.get_path(it)
if mod[4] == HEX_RED:
sel.select_path(path)
def uncolorize_mods(self) -> None:
model = self.model_manager.get_mod_store()
for mod in model:
it = mod.iter
path = model.get_path(it)
model[path][4] = None
def colorize_mods(self) -> None:
model = self.model_manager.get_mod_store()
stale = find_stale_mods(self.prefs.paths.config)
for mod in model:
it = mod.iter
path = model.get_path(it)
# TODO: consider storing in ListStore as int
if int(mod[2]) in stale:
model[path][4] = HEX_RED
self.destroy_on_idle()
def unselect_all_mods(self) -> None:
self.mediator.modtreeview.get_selection().unselect_all()
def highlight_stale(self) -> None:
self.call_on_thread(self.colorize_mods)
def call_on_thread(self, func: Callable, *args) -> None:
self.wait_dialog = GenericDialog(self, strings.dialog.fetching, Popup.WAIT)
self.wait_dialog.show_all()
thread = threading.Thread(target=func, args=args)
thread.start()
def destroy_on_idle(self) -> None:
self.wait_dialog.destroy()
# TODO: improve upon this
func = self.callback["func"]
if func is not None:
args = self.callback["args"]
GLib.idle_add(func, *args)
self.set_callback(None, None)
def set_callback(self, callback: Callable | None, *args) -> None:
"""
Lets calling widgets register a callback function
when spawning a threaded process
"""
self.callback = { "func": callback, "args": args }
def dump_diagnostics(self) -> None:
picker = FilePicker(self.mediator.window)
file = picker.pick_file()
if file is not None:
try:
write_diagnostic(self.prefs.paths.config, file)
except Exception as e:
self.spawn_dialog(str(e), Popup.NOTIFY)
def test_api_response(self, text: str, key: Preferences) -> None:
if key is Preferences.STEAM:
res = test_steam_api(text)
else:
res = test_bm_api(text)
if res is True:
self.update_config(key, text)
self.set_callback(None, None)
self.destroy_on_idle()
else:
self.destroy_on_idle()
self.spawn_dialog(strings.api_error, Popup.NOTIFY)
def update_api_key(self, text: str, key: Preferences) -> None:
self.call_on_thread(self.test_api_response, text, key)
def set_resolution(self, window: "OuterWindow") -> None:
if self.prefs.is_game_mode:
window.fullscreen()
return
elif self.query_config(Preferences.WINDOW) is True:
window.fullscreen()
try:
data = read_json(self.prefs.paths.resolution)
valid_json = True
except Exception as e:
valid_json = False
logger.critical(e)
if valid_json:
res = data["res"]
w, h = res["width"], res["height"]
logger.info(f"Restoring window size to {w},{h}")
window.set_default_size(w, h)
else:
w = WINDOW_DEFAULT_X
h = WINDOW_DEFAULT_Y
logger.info(f"Using default window size {w},{h}")
window.set_default_size(w, h)

256
dzgui/controllers/model.py Normal file
View File

@ -0,0 +1,256 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import ListStore
from dzgui.const.enum import FilterMode
class ModelManager:
"""
Manages access to cached ListStore resources and
performs filtering on behalf of TreeViews.
Not thread-safe.
"""
def __init__(self):
# NOTE: packed ListStores
self.filter_cache = {}
self.ping_cache = {}
self.mod_store = ListStore(str, str, str, float, str)
# NOTE: stringwise (list) representation of the model
self.control_model = None
self.filtered = None
self.success = True
def __new__(cls):
if not hasattr(cls, "instance"):
cls.instance = super(ModelManager, cls).__new__(cls)
return cls.instance
def get_mod_store(self) -> ListStore:
return self.mod_store
def filter(self, mode: FilterMode, *args, **kwargs) -> None:
"""
Native Gtk.TreeView.refilter() method was not performant enough
when running in the main loop with 40k+ records
"""
filters = AppNav.right_panel.filters_vbox.get_filters()
if filters in self.filter_cache:
cache = self.filter_cache[filters]
self.set_store(cache[0])
self.set_filtered(cache[1])
GLib.idle_add(AppNav.treeview._filter_cleanup)
return
match mode:
case FilterMode.INITIAL:
rows = self.filter_initial(filters)
case FilterMode.MAP:
panel = AppNav.right_panel.filters_vbox
prior_map = panel.get_prior_map()
if prior_map == "All maps":
rows = self.filter_map(filters)
else:
AppNav.right_panel.ping.set_sensitive(True)
rows = self.filter_toggle_on(filters, *args)
case FilterMode.KEYWORD:
AppNav.right_panel.ping.set_sensitive(True)
rows = self.filter_toggle_on(filters, *args)
case FilterMode.TOGGLE_OFF:
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
rows = self.filtered
case FilterMode.TOGGLE_ON:
AppNav.right_panel.ping.set_sensitive(True)
rows = self.filter_toggle_on(filters, *args)
if mode is not FilterMode.INITIAL:
for row in rows:
if row[7] in self.ping_cache:
row[9] = self.ping_cache[row[7]]
if len(rows) > 0:
clone = ModelMan.new_model()
rows = self.sort_rows(rows)
for row in rows:
clone.append(row)
else:
clone = None
self.set_cache(filters, clone, rows)
self.set_store(clone)
GLib.idle_add(AppNav.treeview._filter_cleanup)
def sort_rows(self, rows: list) -> list:
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower()))
return rows
def filter_initial(self, filters: tuple) -> list:
"""
Simply culls the control model of any disabled filters
"""
self.set_filtered(self.control_model)
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def filter_map(self, filters: tuple) -> list:
"""
Multi-filtration for any context starts by narrowing by map
"""
rows = self.filtered
panel = AppNav.right_panel.filters_vbox
sel_map = panel.get_selected_map()
if sel_map == "All maps":
return rows
rows = [row for row in rows if row[1] == sel_map]
return rows
def filter_keyword(self, filters: tuple) -> list:
keyword = AppNav.right_panel.filters_vbox.get_keyword_filter()
rows = self.filtered
if keyword == "":
return rows
filtered = [
row
for row in rows
if keyword in row[0].lower()
or keyword in row[1].lower()
or keyword in row[7].lower()
]
return filtered
def filter_toggle_off(self, filters: tuple, filter_type: str) -> list:
"""
Sub-filtration of the current model
"""
pairs = {
strings.filter_3pp: strings.filter_1pp,
strings.filter_day: strings.filter_night,
strings.filter_official: strings.filter_unofficial
}
for k, v in pairs.items():
if k in filters and v in filters:
self.set_filtered(None)
return []
rows = self.filtered
match filter_type:
case strings.filter_3pp:
rows = [row for row in rows if row[2] != strings.filter_3pp]
case strings.filter_1pp:
rows = [row for row in rows if row[2] != strings.filter_1pp]
case strings.filter_official:
rows = [row for row in rows if row[10] != strings.filter_official]
case strings.filter_unofficial:
rows = [row for row in rows if row[10] != strings.filter_unofficial]
case strings.filter_empty:
rows = [row for row in rows if row[4] != 0]
case strings.filter_full:
rows = [row for row in rows if row[4] != row[5]]
case strings.filter_duplicate:
seen = []
final = []
for row in rows:
if row[0] in seen:
continue
seen.append(row[0])
final.append(row)
rows = final
case strings.filter_day:
reg = r"([0][0-9]|[1][0-6])"
rows = [row for row in rows if not re.match(reg, row[3])]
case strings.filter_night:
reg = r"([0][0-4]|[1][8]|[2][0-3])"
rows = [row for row in rows if not re.match(reg, row[3])]
case strings.filter_nonascii:
rows = [row for row in rows if row[0].isascii()]
case strings.filter_lowpop:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded:
rows = [row for row in rows if not row[11]]
return rows
def filter_toggle_on(self, filters: tuple, *args: str) -> list:
"""Effectively applies all filters"""
self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters))
self.set_filtered(self.filter_keyword(filters))
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def set_cache(
self, filters: tuple, model: ListStore | None, rows: list
) -> None:
self.filter_cache[filters] = (model, rows)
def new_model(self) -> ListStore:
return ListStore(
str, str, str, str, int, int, int, str, int, int, str, bool
)
def resync_model(self, addr: str, qport: int) -> None:
"""
Handle in-situ updates to model during
row deletion actions. Skipped for ephemeral
actions like player count/ping updates
"""
for row in self.control_model:
if row[7] == addr and row[8] == qport:
self.control_model.remove(row)
self.wipe_cache()
filters = AppNav.right_panel.filters_vbox.get_filters()
refiltered = self.filter_toggle_on(filters)
self.set_filtered(refiltered)
self.set_success(True)
GLib.idle_add(AppNav.treeview._filter_cleanup)
def convert_model_to_list(self, model: ListStore) -> list:
return [[el for el in row] for row in model]
def set_filtered(self, rows: list | None) -> None:
if rows is None:
rows = []
self.filtered = rows
def get_filtered(self) -> list:
return self.filtered
def set_store(self, model: ListStore | None) -> None:
self.store = model
def get_store(self) -> ListStore | None:
return self.store
def set_control(self, rows: list) -> None:
self.control_model = rows
def set_success(self, result: bool) -> None:
self.success = result
def get_success(self) -> bool:
return self.success
def wipe_cache(self, full=False) -> None:
self.success = True
self.filtered = None
self.filter_cache = {}
self.ping_cache = {}
if full:
self.control_model = None

BIN
dzgui/data/images/dzgui Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
dzgui/data/images/grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
dzgui/data/images/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
dzgui/data/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
dzgui/data/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 B

23
dzgui/init/coords.py Normal file
View File

@ -0,0 +1,23 @@
import logging
from typing import TYPE_CHECKING
from dzgui.util.ip import get_local_ip, get_coords, Coords
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
def get_local_coords(path: "Path") -> Coords | None:
try:
my_ip = get_local_ip()
except Exception as e:
logger.warn(e)
return None
try:
return get_coords(path, my_ip)
except Exception as e:
logger.warn(e)
return None

22
dzgui/init/dayz.py Normal file
View File

@ -0,0 +1,22 @@
import logging
import sys
from pathlib import Path
from dzgui.config.query import lookup
from dzgui.const.constants import LIBRARYFOLDERS_PATH, APPID_DAYZ
from dzgui.const.enum import Preferences
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
import dzgui.api.pefile as PeFile
logger = logging.getLogger(__name__)
def is_dayz_installed(config: Path) -> None:
try:
path = lookup(config, Preferences.DEFAULT)
PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ)
except Exception as e:
logger.critical(e)
EarlyAlertDialog(e)
sys.exit(1)

25
dzgui/init/flock.py Normal file
View File

@ -0,0 +1,25 @@
import fcntl
import os
import sys
from pathlib import Path
from typing import TextIO
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.util.strings import cannot_acquire_lock
def lock_release(lock: TextIO) -> None:
fcntl.flock(lock, fcntl.LOCK_UN)
os.close(lock.fileno())
def lock_acquire() -> TextIO:
lockfile = Path("/tmp/dzgui.flock")
lock = open(lockfile, "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
EarlyAlertDialog(cannot_acquire_lock)
sys.exit(1)
return lock

37
dzgui/init/migrate.py Normal file
View File

@ -0,0 +1,37 @@
import sys
from pathlib import Path
from dzgui.const.constants import LEGACY_CONFIG_PATH
from dzgui.config.convert import rc2json
def test_legacy_conf(config: Path) -> None:
if config.exists() is True:
return
old_conf = Path.home() / LEGACY_CONFIG_PATH
if old_conf.is_file():
j = rc2json(old_conf)
Path(config).parent.mkdir(parents=True, exist_ok=True)
config.write_text(j)
else:
print("Unimplemented. You must have a working dztuirc.")
sys.exit(1)
def move_state_files(state_path: Path) -> None:
home = Path.home()
legacy = home / ".local/state/dzgui"
if state_path == legacy:
# TODO: log this
return
for file in legacy.iterdir():
file.rename(state_path / file.name)
def move_ipdb(ips_path: Path) -> None:
home = Path.home()
name = "ips.csv"
legacy = home / ".local/share/helpers/dzgui" / name
if ips_path == legacy:
return
legacy.rename(ips_path)

23
dzgui/init/prefix.py Normal file
View File

@ -0,0 +1,23 @@
import os
import sys
from importlib import metadata
from pathlib import Path
from dzgui.const.constants import APP_NAME_LOWER
def is_prefix_writeable() -> bool:
prefix = sys.prefix
path = Path(prefix)
file = path / ".is_writeable"
try:
with open(file, "w") as f:
f.write("")
os.remove(file)
except OSError:
return False
return True
def get_version() -> str:
return metadata.version(APP_NAME_LOWER)

28
dzgui/init/prereqs.py Normal file
View File

@ -0,0 +1,28 @@
import logging
import shutil
import subprocess
from dzgui.const.constants import STEAM_CMD, FLATPAK_APPID, FLATPAK_CMD
logger = logging.getLogger(__name__)
def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
try:
res = subprocess.run(
[FLATPAK_CMD, "list"],
capture_output=True,
text=True,
check=True
)
if FLATPAK_APPID in res.stdout:
return True
else:
return False
except subprocess.CalledProcessError as e:
logger.critical(e)
return False

26
dzgui/init/proc.py Normal file
View File

@ -0,0 +1,26 @@
import psutil
import sys
from dzgui.const.constants import DAYZ_BINARY, STEAM_CMD
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.util.strings import init
def is_dayz_running() -> None:
if is_running(DAYZ_BINARY) is True:
EarlyAlertDialog(init.is_dayz_running)
sys.exit(1)
def is_steam_running() -> None:
# TODO: check proc name of flatpak steam
if is_running(STEAM_CMD) is False:
EarlyAlertDialog(init.is_steam_running)
sys.exit(1)
def is_running(proc: str) -> bool:
for process in psutil.process_iter(["pid", "name"]):
if process.info["name"] == proc:
return True
return False

51
dzgui/init/scan.py Normal file
View File

@ -0,0 +1,51 @@
import shutil
import subprocess
from pathlib import Path
"""
Find possible locations of default steam path
"""
def get_steam_dirs() -> list[Path]:
dirs = []
# pass 1
if shutil.which("locate") is not None:
# TODO: config or steamapps path? cf. LIBRARYFOLDERS_PATH
proc = subprocess.run(
["/usr/bin/locate", "Steam/config/libraryfolders.vdf"],
capture_output=True,
text=True
)
if proc.returncode == 0:
for d in proc.stdout.splitlines():
dirs.append(Path(d))
return dirs
# pass 2
for d in Path.home().rglob("Steam/config/libraryfolders.vdf"):
dirs.append(d)
# pass 3
# if still nothing, let them select it
# prompt what the dir should look like
if len(dirs) == 0:
print("none found")
return dirs
dirs = get_steam_dirs()
print(dirs)
# TODO: present these in a group of radio buttons
"""
when user toggles radio button, updates text saying
"this is the standard steam location on debian" etc.
if None or if not satisfied, pop a filepicker and choose
if selection was custom, it has to have a libraryfolders.vdf
keeps next button grayed out until requirements are satisfied
Cancel Next
"""

50
dzgui/init/update.py Normal file
View File

@ -0,0 +1,50 @@
import requests
import os
import subprocess
import sys
from importlib import resources
from packaging.version import Version, InvalidVersion
from pathlib import Path
from dzgui.const.constants import APP_NAME_LOWER, REQUEST_TIMEOUT
from dzgui.const.endpoints import GITHUB_RELEASES, CODEBERG_RELEASES
from dzgui.init.prefix import is_prefix_writeable
def get_latest_release() -> str | None:
tag = None
for url in [GITHUB_RELEASES, CODEBERG_RELEASES]:
res = requests.get(url, timeout=REQUEST_TIMEOUT)
if res.status_code == 200:
tag = res.json()["tag_name"]
break
return tag
def allow_updates(allow: bool) -> bool:
if allow is False:
return False
if allow is True:
return is_prefix_writeable()
def check_updates(version: str) -> None:
latest = get_latest_release()
prefix = sys.prefix
if latest is None:
return
try:
if Version(version) >= Version(latest):
return
# TODO: test update logic
print("UNIMPLEMENTED: fetches in-app updates")
return
with resources.path(APP_NAME_LOWER, "scripts/update.sh") as path:
proc = subprocess.Popen(["/usr/bin/env", "bash", path, latest, prefix])
if proc != 0:
# TODO: pop a dialog
pass
sys.exit(proc)
except InvalidVersion:
return

0
dzgui/lib/__init__.py Normal file
View File

115
dzgui/main.py Normal file
View File

@ -0,0 +1,115 @@
import argparse
import logging
import os
import sys
from packaging.version import Version
from dzgui.api.mods import remove_stale_signatures
from dzgui.config.ipdb import get_ipdb
from dzgui.config.userprefs import UserPrefs
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.const.constants import APP_NAME_LOWER
from dzgui.const.update import ALLOW_UPDATES
from dzgui.init.coords import get_local_coords
from dzgui.init.dayz import is_dayz_installed
from dzgui.init.flock import lock_acquire, lock_release
from dzgui.init.migrate import test_legacy_conf, move_state_files, move_ipdb
from dzgui.init.prefix import get_version
from dzgui.init.prereqs import has_steam_client
from dzgui.init.proc import is_dayz_running, is_steam_running
from dzgui.init.update import allow_updates, check_updates
from dzgui.util.map_count import get_map_count
from dzgui.util.deck import is_steam_deck, is_game_mode
from dzgui.util.localize import set_locale
from dzgui.util.symlink import rebuild_symlinks
from dzgui.util.strings import init, flags
from dzgui.views.base import App
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
logger = logging.getLogger(__name__)
parser = argparse.ArgumentParser(description=flags.description)
parser.add_argument("-v", "--version", action="store_true", help=flags.version)
parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
args = parser.parse_args()
# TODO: profile load time
def main() -> None:
lock = lock_acquire() # noqa
if args.version is True:
print(get_version())
sys.exit(0)
# TODO: uninstall data files (-u)
# -u removes state, log, freedesktop
# XDG_SHARE_HOME/dzgui
# XDG_STATE_HOME/dzgui
# XDG_DATA_HOME/dzgui
version = get_version()
set_locale()
# NOTE: consider aborting this check if steam deck
xdg_paths = get_xdg_paths()
XDG = parse_filepaths(xdg_paths)
# NOTE: one-time operation when upgrading
if Version(version) < Version("7.0.0"):
test_legacy_conf(XDG.config)
move_state_files(xdg_paths["XDG_STATE_HOME"])
move_ipdb(XDG.ips)
_format = "%(asctime)s%(levelname)s%(filename)s::%(funcName)s::%(lineno)s%(message)s"
logging.basicConfig(filename=XDG.debug, format=_format, level=logging.DEBUG)
with open(XDG.debug, "w") as f:
f.truncate(0)
_is_steam_deck = is_steam_deck()
_is_game_mode = False
if _is_steam_deck:
_is_game_mode = is_game_mode()
if _is_game_mode:
# NOTE: this may no longer be necessary on newer versions of SteamOS
del os.environ["GTK_IM_MODULE"]
# TODO: test spamming timeout
allow = False
allow = allow_updates(ALLOW_UPDATES)
if allow is True:
check_updates(version)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
count = get_map_count()
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
sys.exit(1)
is_dayz_installed(XDG.config)
is_dayz_running()
is_steam_running()
rebuild_symlinks(XDG.config)
remove_stale_signatures(XDG.config, XDG.version)
get_ipdb(XDG.ips)
local_coords = get_local_coords(XDG.ips)
prefs = UserPrefs(
_is_steam_deck,
_is_game_mode,
local_coords,
version,
allow,
XDG
)
print("All OK. Loading UI...")
App(prefs)

28
dzgui/scripts/update.sh Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# TODO: move this into package data dir
prefix="dzgui"
tag="$1"
prefix="$2"
dir="/tmp/dzgui-${tag}"
mkdir -p "$dir"
git clone -C "$dir" git@github.com:aclist/dzgui.git --branch "${tag}"
git -C "$dir" submodule update --recursive --init
if [[ ! $(which python3.13) ]]; then
echo "Missing python3.13"
exit 1
fi
python3.13 -m venv "${prefix}"
source "$prefix/bin/activate"
rm -rf "$dir"
if [[ $(which uv) ]]; then
uv --directory "$dir" pip install .
else
cd "$dir"
pip install -r requirements.txt
echo "Setup complete. Re-launch DZGUI via the command 'dzgui'"
fi

0
dzgui/util/__init__.py Normal file
View File

27
dzgui/util/_json.py Normal file
View File

@ -0,0 +1,27 @@
import json
from pathlib import Path
def read_json(path: Path) -> dict:
try:
with open(path, "r") as infile:
try:
data = json.load(infile)
except json.decoder.JSONDecodeError as e:
raise e
except OSError as e:
raise e
return data
def write_json(data: dict, path: Path) -> None:
try:
j = json.dumps(data, indent=2)
except Exception as e:
raise e
# TODO: Path.write_text()
try:
with open(path, "w") as outfile:
outfile.write(j)
except OSError as e:
raise e

4
dzgui/util/bash.py Normal file
View File

@ -0,0 +1,4 @@
import shlex
def concat_bash_args(command: str) -> list[str]:
return shlex.split(command)

17
dzgui/util/cooldown.py Normal file
View File

@ -0,0 +1,17 @@
from datetime import datetime
from pathlib import Path
def get_time() -> int:
return int(datetime.now().timestamp())
# TODO: deprecated
#def write_time(path: Path) -> None:
# time = get_time()
# Path.write_text(str(time))
def is_elapsed(time_before: int) -> bool:
time_now = get_time()
if time_now - time_before <= 30:
return False
else:
return True

50
dzgui/util/css.py Normal file
View File

@ -0,0 +1,50 @@
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk
def add_class(widget: Gtk.Widget, label: str) -> None:
"""
Sets the classname of a widget, used
to apply CSS styling later
"""
context = widget.get_style_context()
context.add_class(label)
def load_css() -> None:
# TODO: consider storing this in a data file
css = """
.frame {
border: 0px;
}
.toast-label {
background-color: black;
border: 1px solid white;
}
.frame > border {
border-radius: 5px;
padding: 5px;
}
.page-heading {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.settings-subheading {
font-size: 1.2rem;
font-weight: 700;
}
.left-label {
font-size: 1.3rem;
}
.details-heading {
font-size: 1.2rem
}
"""
prov = Gtk.CssProvider()
prov.load_from_data(css.encode("ascii"))
screen = Gdk.Screen.get_default()
if screen:
Gtk.StyleContext.add_provider_for_screen(
screen, prov, Gtk.STYLE_PROVIDER_PRIORITY_USER
)

30
dzgui/util/deck.py Normal file
View File

@ -0,0 +1,30 @@
import re
import subprocess
from pathlib import Path
CPU_FILE = "/proc/cpuinfo"
def is_steam_deck() -> bool:
# TODO: tests
r = r"AMD Custom APU [0-9]{4}"
with open(CPU_FILE, "r") as f:
s = f.read()
match = re.search(r, s)
if match:
if "0932" in match.group() or "0405" in match.group():
return True
return False
def is_game_mode() -> bool:
proc = subprocess.run(
["/usr/bin/pgrep", "-a", "gamescope"],
capture_output=True,
text=True
)
if proc.returncode != 0:
return False
if "generate-drm-mode" in proc.stdout:
return True
else:
return False

88
dzgui/util/diag.py Normal file
View File

@ -0,0 +1,88 @@
import os
import platform
import re
import textwrap
from datetime import datetime
from pathlib import Path
from dzgui.api.mods import get_local_mod_ids, get_local_mod_path
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.init.prefix import get_version
from dzgui.util.redact import redact
def get_cpu_model() -> str:
cpuinfo = Path("/proc/cpuinfo")
text = cpuinfo.read_text()
lines = text.split("\n")
for i, line in enumerate(lines):
if line.startswith("model name"):
model = line.split(": ")[1]
return model
return ""
def print_servers(servers: list[str]) -> str:
s = ""
for server in servers:
s+= server + "\n"
return s
def print_mods(mods: list[int]) -> str:
s = ""
for mod in mods:
s+= str(mod) + "\n"
return s
def write_diagnostic(config: Path, outfile: Path) -> None:
# TODO: test availability on other distros
date = datetime.now().isoformat()
distro = platform.freedesktop_os_release()["ID_LIKE"]
kernel = os.uname().release
cpu = get_cpu_model()
version = get_version()
branch = lookup(config, Preferences.BRANCH)
debug = lookup(config, Preferences.DEBUG)
install = lookup(config, Preferences.INSTALL)
default = lookup(config, Preferences.DEFAULT)
steam_path = Path(default)
workshop_path = get_local_mod_path(steam_path)
steam_redacted = redact(default)
workshop_redacted = redact(str(workshop_path))
mods = get_local_mod_ids(steam_path)
mods_pretty = print_mods(mods)
servers = lookup(config, Preferences.IP_LIST)
servers_pretty = print_servers(servers)
# FIXME: extraneous newlines in lists
template = f"""\
{APP_NAME} version {version} ({branch})
Date: {date}
===============================
Distribution: {distro}
Kernel: {kernel}
CPU: {cpu}
Debug: {debug}
Auto-install: {install}
Steam path: {steam_redacted}
Workshop path: {workshop_redacted}
Mods:
{mods_pretty}
Servers:
{servers_pretty}
"""
output = "\n".join([line.lstrip() for line in template.split('\n')])
outfile.write_text(output)

86
dzgui/util/dist.py Normal file
View File

@ -0,0 +1,86 @@
import multiprocessing
from math import radians, cos, sin, asin, sqrt
from typing import Self, TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
import dzgui.util.ip as ip
from dzgui.util.ip import GeolocationError
from dzgui.util.localize import number
if TYPE_CHECKING:
from dzgui.util.ip import Coords
class Haversine():
def __init__(self, lat1: float, lon1: float, lat2: float, lon2: float) -> None:
R = 6371 * 1000
dLat = radians(lat2 - lat1)
dLon = radians(lon2 - lon1)
lat1 = radians(lat1)
lat2 = radians(lat2)
a = sin(dLat/2) ** 2 + cos(lat1) * cos(lat2) * sin(dLon/2) ** 2
c = 2 * asin(sqrt(a))
self.dist = R * c
def as_kilometers(self) -> float:
return self.dist / 1000
def as_miles(self) -> float:
return self.dist / 1609.344
def compare(local: "Coords", remote_c: str, fmt: str) -> int | None:
if local is None:
return None
try:
# FIXME: expects XDG.ips
remote = ip.get_coords(remote_c)
except GeolocationError:
return None
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
if fmt == "km":
dist = haversine.as_kilometers()
else:
dist = haversine.as_miles()
return round(dist)
class CalcDist(multiprocessing.Process):
def __init__(
self,
widget: Gtk.Widget,
addr: str,
result_queue: multiprocessing.Queue,
cache: dict,
):
super().__init__()
self.result_queue = result_queue
self.addr = addr
self.ip = addr.split(":")[0]
# TODO: pass controller correctly
def run(self) -> None:
use_miles = MainController.query_config("use_miles")
fmt = "mi" if use_miles else "km"
# TODO: get cache accordingly
if self.addr in cache:
if fmt in cache[self.addr]:
logger.info(f"Address '{self.addr}' already in cache")
self.result_queue.put([self.addr, cache[self.addr]])
return
prefs = MainController.get_prefs()
dist = compare(prefs.coords, prefs.paths.ips, self.ip, fmt)
if dist is None:
dist_pretty = "Unknown"
else:
d = number(dist)
dist_pretty = f"{d} {fmt}"
self.result_queue.put([self.addr, dist_pretty])

44
dzgui/util/format.py Normal file
View File

@ -0,0 +1,44 @@
import re
from dzgui.util.localize import number
from dzgui.util.strings import no_mods
def format_pango(text: str) -> str:
medium = '<span size="medium"><b>'
large = '<span size="large"><b>'
xlarge = '<span size="x-large"><b>'
text = re.sub("^# ", xlarge, text, flags=re.M)
text = re.sub("^## ", large, text, flags=re.M)
text = re.sub("^### ", medium, text, flags=re.M)
text = re.sub(r"(<span size=.*)", r"\1</b></span>", text)
return text
def pluralize(plural: str, count: int) -> str:
suffix = plural[-2:]
if suffix == "es":
base = plural[:-2]
return f"{base}{'es'[:2*count ^ 2]}"
else:
base = plural[:-1]
return f"{base}{'s'[:count ^ 1]}"
def format_hyperlinks(text: str) -> str:
def href(string: str) -> str:
return f'<a href="{string}">{string}</a>'
reg = r"\s(www\.*?)"
text = re.sub(reg, " http://" + r"\1", text)
reg2 = r"(http.*?)([ ,\r\n]|$)"
text = re.sub(reg2, href(r"\1") + r"\2", text)
return text
def format_mods(size: int, mods: int) -> str:
if mods == 0:
return no_mods
l_size = number(size)
plural = pluralize("mods", mods)
return f"Found {mods:n} {plural} taking up {l_size} MiB"

137
dzgui/util/ip.py Normal file
View File

@ -0,0 +1,137 @@
import a2s
import gzip
import requests
import shutil
import subprocess
from dataclasses import dataclass
from typing import TYPE_CHECKING
from dzgui.api.servers import Record
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import COORDS_API, IP_ECHO
if TYPE_CHECKING:
from pathlib import Path
@dataclass
class Coords:
lat: float
lon: float
class GeolocationError(Exception):
pass
class ReservedIPError(Exception):
pass
def ip2list(ip: str) -> list:
return [int(digit) for digit in ip.split(".")]
# TODO: tests
def is_reserved(digits: list) -> bool:
reserved = [0, 10, 127]
if digits[0] in reserved:
return True
if digits[0] == 192 and digits[1] == 168:
return True
return False
def get_coords(ips: "Path", ip: str) -> Coords:
split = ip2list(ip)
if is_reserved(split):
raise ReservedIPError(f"{ip} is a reserved IP address.")
prefix = f"^{split[0]}.{split[1]}."
proc = subprocess.run(
["/usr/bin/grep", "-E", prefix, ips],
capture_output=True,
text=True
)
if proc.returncode != 0:
raise GeolocationError("Failed to split records")
ips = proc.stdout.splitlines()
for address in ips:
fields = address.split(",")
upper = fields[1]
upper_digits = ip2list(upper)
if split[2] > upper_digits[2]:
continue
if split[2] == upper_digits[2]:
if split[3] > upper_digits[3]:
continue
if split[2] <= upper_digits[2]:
return Coords(float(fields[-2]), float(fields[-1]))
raise GeolocationError("No matching records found")
def get_local_ip() -> str:
ip = ""
# TODO: use shlex
if shutil.which("dig") is not None:
proc = subprocess.run(
[
"/usr/bin/dig",
"-4",
"+short",
"myip.opendns.com",
"@resolver1.opendns.com"
],
capture_output=True,
text=True
)
if proc.returncode == 0:
ip = proc.stdout.rstrip("\n")
else:
res = requests.get(IP_ECHO, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
ip = res.text
return ip
def resolve_ip(address: Record) -> Record:
# TODO: unit test for resolved ips
"""
Multiple game modes may be hosted on the same IP and query port,
but resolve to different game ports. The canonical record must
contain the real game port. This is merely used when saving a
record as a UUID.
"""
res = a2s.info((address.ip, address.qport))
real_gport = res.port
return Record(address.ip, real_gport, address.qport)
def is_valid_port(port: str) -> bool:
if len(port) < 1:
return True
if (
not port.isdigit()
or int(port) == 0
or int(port[0]) == 0
or int(port) > 65535
):
return True
return False
# TODO: deprecated
#def get_local_coords(ip: str):
# url = COORDS_API + "/" + ip
# #local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
# return url

26
dzgui/util/keys.py Normal file
View File

@ -0,0 +1,26 @@
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk
def is_navkey(key: int) -> bool:
nav_keys = [
Gdk.KEY_Down,
Gdk.KEY_Up,
Gdk.KEY_Right,
Gdk.KEY_Page_Down,
Gdk.KEY_Page_Up,
Gdk.KEY_j,
Gdk.KEY_k,
Gdk.KEY_g,
Gdk.KEY_G,
]
if key in nav_keys:
return True
return False
def is_ctrl_mask(event: Gdk.EventKey) -> bool:
if event.keyval is Gdk.KEY_l \
and event.state is Gdk.ModifierType.CONTROL_MASK:
return True
else:
return False

16
dzgui/util/localize.py Normal file
View File

@ -0,0 +1,16 @@
import os
import locale
def number(num: int | float) -> str:
spec = ""
if type(num) is int:
spec = "%d"
if type(num) is float:
spec = "%.2f"
return locale.format_string(spec, num, grouping=True)
def set_locale():
locale.setlocale(locale.LC_ALL, "")
user_loc = loc if (loc := os.getenv("LC_CTYPE")) else "en_US.UTF-8"
locale.setlocale(locale.LC_NUMERIC, loc)

28
dzgui/util/map_count.py Normal file
View File

@ -0,0 +1,28 @@
import subprocess
from pathlib import Path
from dzgui.const.constants import VM_FILE, MIN_COUNT
def get_map_count() -> int | None:
path = Path(VM_FILE)
if path.is_file() is False:
return None
count = int(path.read_text())
return count
# TODO: unfinished
#if count < MIN_COUNT:
# print("needs sudo escalation")
# # pop prompt
# # get response
# set_map_count()
#return count
# TODO: unfinished
def set_map_count() -> None:
conf = "/etc/sysctl.d/dayz.conf"
count = f"vm.max_map_count={MIN_COUNT}"
with open(conf, "w") as f:
f.write(count)
# TODO: use concat_bash_args()
subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf])

12
dzgui/util/margins.py Normal file
View File

@ -0,0 +1,12 @@
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None:
"""
Utility function that sets all margins
on a widget to a uniform integer value
"""
widget.set_margin_top(margin)
widget.set_margin_start(margin)
widget.set_margin_end(margin)

61
dzgui/util/open_links.py Normal file
View File

@ -0,0 +1,61 @@
import os
import shlex
import subprocess
from dzgui.const.constants import APPID_DAYZ
from dzgui.const.enum import RowType
from dzgui.const import endpoints
from dzgui.util.bash import concat_bash_args
links = {
RowType.DOCS: endpoints.GITHUB_PAGES,
RowType.DOCS_FALLBACK: endpoints.CODEBERG_PAGES,
RowType.BUGS: endpoints.GITHUB_ISSUES,
RowType.FORUM: endpoints.FORUM,
RowType.SPONSOR: endpoints.SPONSORS,
RowType.CHNG_STEAM_API: endpoints.STEAM_API_SETUP,
RowType.CHNG_BM_API: endpoints.BM_API_SETUP,
RowType.CONN_BY_ID: endpoints.BM_BROWSE,
RowType.ADD_BY_ID: endpoints.BM_BROWSE,
}
def get_browser() -> str:
if (browser := os.getenv("BROWSER")) is not None:
command = browser
else:
command = "xdg-open"
return command
def open_user_workshop(uid: str, client: str) -> None:
base = f"https://steamcommunity.com/profiles/{uid}/myworkshopfiles/?appid={APPID_DAYZ}&browsefilter=mysubscriptions"
uri = endpoints.OPEN_URL + base
args = concat_bash_args(client)
args.append(uri)
subprocess.Popen([*args])
def open_link_by_url(url: str) -> None:
command = get_browser()
args = concat_bash_args(command)
args.append(url)
subprocess.Popen([*args])
def open_link_by_rowtype(enum: RowType) -> None:
command = get_browser()
args = concat_bash_args(command)
try:
url = links[enum]
except KeyError:
return
args.append(url)
subprocess.Popen([*args])
def open_workshop_page(uid: str, client: str) -> None:
uri = endpoints.WORKSHOP + uid
args = concat_bash_args(client)
args.append(uri)
subprocess.Popen([*args])

6
dzgui/util/redact.py Normal file
View File

@ -0,0 +1,6 @@
import re
def redact(text: str) -> str:
r = r"(/home/)([^/])*"
cleaned = re.sub(r, r"/home/REDACTED", text)
return cleaned

479
dzgui/util/strings.py Normal file
View File

@ -0,0 +1,479 @@
from dataclasses import dataclass
from typing import List, Dict
from dzgui.const.constants import SYSTEM_LOG
def build_missing(build: str) -> str:
msg = (
f"This server is running {build}. You can install "
f"{build} by searching for it in your Steam library. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
return msg
def build_path_invalid(build: str) -> str:
msg = (
f"Steam is reporting that {build} is installed at a non-existent location. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
return msg
# General
dialog_header = "DZGUI - Dialog"
dz = "DayZ"
dz_exp = "DayZ Experimental"
delimiter = ""
# GenericDialog
main_menu = "Return to main menu"
exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist"
input_required = "User input required"
confirm = "Confirmation"
notice = "Notice"
wait = "Please wait"
# Wait dialogs
ping = "Ping"
mod = "Mod"
_id = "ID"
# ContextMenu
add_note = "Add note"
edit_note = "Edit note"
show_mods = "Show server-side mods"
show_details = "Server details"
refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop"
delete_mod = "Delete mod"
copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard"
add = "Add to my servers"
remove = "Remove from my servers"
remove_history = "Remove from history"
# Columns
server_mod_cols = ["Mod", "ID", "Installed"]
mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"]
log_cols = ["Timestamp", "Flag", "Traceback", "Message"]
browser_cols = [
"Name",
"Map",
"Perspective",
"Gametime",
"Players",
"Maximum",
"Queue",
"IP",
"Qport",
"Ping",
]
# DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog
default_port = "Use default query port (27016)"
custom_port = "Enter custom query port"
scan_servers = "Scan LAN servers"
select_port = "Select the query port"
# Errors
config_not_found = "DZGUI configuration file not found. Please exit and restart."
cannot_acquire_lock = "DZGUI is already open."
something_wrong = "Something went wrong."
steam_key_missing = "No Steam API key is set."
malformed_mods = "Found mods on system, but was unable to parse results."
steam_missing = "Local Steam installation is not set, possibly malformed config file."
build_corrupted = "Steam settings or DayZ installation may be corrupted. Try restarting Steam."
api_warn_msg = """No servers returned. Possible causes:
no servers in favorites/history, local network issue, or API key on cooldown.
Return to the main menu, wait 30s, and try again.
If this issue persists, your API key may be defunct.
"""
server_timeout = "Timed out when querying server, check IP or try again later"
server_error = "Error while contacting server, possibly timed out. Please wait and try again."
server_protected = (
"This server is password-protected and you will be "
"prompted when connecting. Do you want to proceed?"
)
# KeybindingsDialog
navigation = {
"Enter/space/double click": "select row item",
"Down arrow": "move down a row/scroll down",
"Up arrow": "move up a row/scroll up",
"Right arrow": "jump to sidebar from main area",
"Left arrow": "jump to main area from sidebar",
"Tab": "cycle forward through elements",
"Shift-tab": "cycle backward through elements",
"ESC/Enter": "close dialogs",
"?": "show/hide this dialog",
"Ctrl-q": "Quit",
}
servers = {
"Enter/space/double-click": "connect to server",
"Right-click/Ctrl-l": "additional context menus",
"Ctrl-r": "refresh players",
"Ctrl-p": "refresh ping",
"Ctrl-f": "jump to keyword search field",
"Ctrl-m": "jump to maps field",
"Ctrl-d": "toggle dry run (debug) mode",
"ESC": "return to table",
"1-9": "toggle filter 1-9 on/off",
"0": "toggle filter 10",
"Minus": "toggle filter 11",
"Backslash": "toggle filter 12",
}
vim = {
"j": "Move down a row/scroll up",
"k": "Move up a row/scroll down",
"l": "Jump to main area from sidebar",
"h": "Jump to sidebar from main area",
"gg": "Jump to first row/top of page",
"G": "Jump to last row/bottom of page",
}
key_contexts = ["Servers", "Navigation", "Vim-style keys"]
key_header = "Keybindings"
# ModSelectionPanel
# labels
label_main_menu = "Main menu"
# buttons
ping_servers = "Ping servers"
debug_mode = "Debug mode"
keys_button = "Keys"
keys_tooltip = "Opens the keybindings dialog"
debug_tooltip = (
"Used to perform a dry run without\n"
"actually connecting to a server"
)
ping_tooltip = (
"Refresh the ping for visible servers.\n"
"Available once per unique filter context"
)
statusbar_helptext = "Select a row to see its detailed description"
# use e.g. filters.1pp
filter_1pp = "1PP"
filter_3pp = "3PP"
filter_day = "Day"
filter_night = "Night"
filter_empty = "Empty"
filter_full = "Full"
filter_lowpop = "Low pop"
filter_nonascii = "Non-ASCII"
filter_duplicate = "Duplicate"
filter_official = "Official"
filter_unofficial = "Unoffic."
filter_modded = "Modded"
# maps
all_maps = "All maps"
# state
unknown = "Unknown"
none_provided = "None provided"
disabled = "Disabled"
enabled = "Enabled"
null = "-"
none = "None"
unspecified = "not specified"
# DLC
dlc_frostline = "Frostline"
# platform
windows = "Windows"
linux = "Linux"
# misc
self_workshop = "Open Steam workshop"
settings_updated = "Settings updated!"
@dataclass(slots=True, frozen=True)
class ModPanelStrings:
header: str
unhighlight_stale: str
unhighlight_stale_tooltip: str
highlight_stale: str
highlight_stale_tooltip: str
delete_selected: str
delete_selected_tooltip: str
unselect_all: str
unselect_all_tooltip: str
select_all: str
select_all_tooltip: str
bulk_select: str
clear_highlights: str
select_stale: str
select_stale_tooltip: str
@dataclass(slots=True, frozen=True)
class Dialog:
querying: str
fetching: str
filtering: str
refreshing: str
details: str
modlist: str
working: str
updating_mods: str
@dataclass(slots=True, frozen=True)
class Init:
is_steam_running: str
is_dayz_running: str
requires_steam: str
@dataclass(slots=True, frozen=True)
class Button:
main_label: str
main_tooltip: str
mods_label: str
mods_tooltip: str
options_label: str
options_tooltip: str
help_label: str
help_tooltip: str
exit_label: str
exit_tooltip: str
@dataclass(slots=True, frozen=True)
class Crumbs:
changelog: str
history: str
keys: str
lan: str
log: str
main: str
mods: str
my_servers: str
options: str
servers: str
thanks: str
@dataclass(slots=True, frozen=True)
class Thanks:
header: str
description: str
users: list[str]
@dataclass(slots=True, frozen=True)
class Options:
header: str
steam_web: str
bm_web: str
enter_steam: str
enter_bm: str
steam_placeholder: str
bm_placeholder: str
name_placeholder: str
last_used: str
always_fs: str
steam: str
flatpak: str
km: str
mi: str
client: str
window_size: str
distance: str
name: str
manual_dl: str
auto_dl: str
update: str
install_mode: str
force_update: str
dl_eventbox: str
force_eventbox: str
self_update: str
no_self_update: str
api_keys: str
prefs: str
mods: str
version: str
branch: str
stable: str
testing: str
manual_sub_msg: str
init = Init(
is_steam_running = "Is Steam running? DZGUI must be run on top of Steam.",
is_dayz_running = "Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
requires_steam = "DZGUI requires that Steam or Flatpak Steam be installed on the system.",
)
mod_panel = ModPanelStrings(
header = "Mod actions",
unhighlight_stale = "Unhighlight stale",
unhighlight_stale_tooltip = "Clears highlight from stale mods",
highlight_stale = "Highlight stale",
highlight_stale_tooltip = (
"Shows locally-installed mods which are not used by any server "
"in your Saved Servers"
),
delete_selected = "Delete selected",
delete_selected_tooltip = "Deletes selected mods from the system",
unselect_all = "Unselect all",
unselect_all_tooltip = "Bulk unselects all mods",
select_all = "Select all",
select_all_tooltip = "Bulk selects all mods",
bulk_select = "Bulk selects all currently highlighted mods",
clear_highlights = "Clears highlights and reverts the table to a default state",
select_stale = "Select stale",
select_stale_tooltip = "Only selects highlighted stale mods",
)
thanks = Thanks(
header = "# Special thanks",
description = (
"This page recognizes beta testers, collaborators, code "
"contributors, and sponsors of the project in alphabetical order."
),
users = [
"bongjutsu",
"Deku",
"dj3hac",
"GaryBlackbourne",
"jiriks74",
"Johnofwrong",
"MatheusLasserr",
"nolan-perez",
"scandalouss",
"StevelDusa",
"Thoughtduck216",
],
)
options = Options(
header="Options",
steam_web="Steam API page",
bm_web="Battlemetrics API page",
enter_steam="Enter your Steam API key",
enter_bm="Enter your Battlemetrics API key",
steam_placeholder="Steam API key",
bm_placeholder="Battlemetrics API key",
name_placeholder="Identifies you to other players in-game",
last_used="Last used dimensions",
always_fs="Always fullscreen",
steam="Steam",
flatpak="Flatpak (experimental)",
km="km (kilometers)",
mi="mi (miles)",
client="Steam client",
window_size="Window size at boot",
distance="Distance display",
name="Player name",
manual_dl="Manual",
auto_dl="Auto",
update="Update",
install_mode = "Mod install mode",
force_update = "Force update local mods",
dl_eventbox = (
"Manual: prompt to subscribe to mods in Steam. "
"Auto: unmanned downloads."
),
force_eventbox = "Synchronize all local mods. Automatic mode must be enabled.",
self_update = (
"Stable: only contains stable features. "
"Testing: pre-release beta, contains new features."
),
no_self_update = (
"In-app updates are disabled when DZGUI is "
"installed globally (e.g., via package manager)."
),
api_keys = "API Keys",
prefs = "Preferences",
mods = "Mods",
version = "Version",
branch = "DZGUI branch",
stable = "Stable",
testing = "Testing",
manual_sub_msg = """When switching from MANUAL to AUTO mod install mode,
DZGUI will manage mod installation and deletion for you.
To prevent conflicts with Steam Workshop subscriptions and old mods from being downloaded
when Steam updates, you should unsubscribe from any existing Workshop mods you manually subscribed to.
Open your Profile > Workshop Items and select 'Unsubscribe from all'
on the right-hand side.
""",
)
dialog = Dialog(
querying = "Querying server",
fetching = "Fetching server metadata",
filtering = "Filtering servers",
refreshing = "Refreshing player count",
details = "Fetching details",
modlist = "Fetching modlist",
working = "Working",
updating_mods = "Updating mods",
)
buttons = Button(
main_label="Main menu",
main_tooltip="Search for and connect to servers",
mods_label="Mods",
mods_tooltip="Manage local mods",
options_label="Options",
options_tooltip="Advanced options",
help_label="Help",
help_tooltip="Troubleshooting and documentation",
exit_label="Exit",
exit_tooltip="Quits the application",
)
crumbs = Crumbs(
changelog="Help > Changelog",
history="Main menu > Recent servers",
keys="Keybindings",
lan="Main menu > Local servers",
log="Help > Debug log",
main="Main menu",
mods="Mods",
my_servers="Main menu > Saved servers",
options="Options",
servers="Main menu > Server browser",
thanks="Help > Special thanks"
)
checkmark = ""
no_mods = "No local mods found."
@dataclass(slots=True, frozen=True)
class Flags:
description: str
version: str
uninstall: str
flags = Flags(
description="DayZ server browser and mod manager",
version="Print version information",
uninstall="Uninstall data files (use prior to 'pip uninstall')"
)
@dataclass(slots=True, frozen=True)
class FilePicker:
title: str
placeholder: str
picker = FilePicker(
title = "Save diagnostic log to file",
placeholder = SYSTEM_LOG,
)
api_error = "API key validation error or timeout. See 'Help > Show debug log' for details."

50
dzgui/util/symlink.py Normal file
View File

@ -0,0 +1,50 @@
import hashlib
import logging
from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
import dzgui.api.pefile as PeFile
logger = logging.getLogger(__name__)
def rebuild_symlinks(config: Path) -> None:
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
for file in dayz_path.iterdir():
if file.is_symlink():
file.unlink()
workshop = get_local_mod_path(steam_path)
for uid in get_local_mod_ids(steam_path):
uid = str(uid)
md5sum = _hash(str(uid))
Path(dayz_path / md5sum).symlink_to(workshop / uid)
def clone_symlinks(config: Path) -> None:
"""
Used after any symlink operation
"""
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
try:
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
exp_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
except Exception as e:
logger.critical(e)
return
# TODO: test these two operations
for file in exp_path.iterdir():
if file.is_symlink():
file.unlink()
for file in dayz_path.iterdir():
if file.is_symlink():
uid = file.name
Path(exp_path / uid).symlink_to(dayz_path / uid)

0
dzgui/views/__init__.py Normal file
View File

668
dzgui/views/base.py Normal file
View File

@ -0,0 +1,668 @@
import json
import locale
import logging
import multiprocessing
import os
import re
import signal
import subprocess
import textwrap
import threading
import typing # noqa
import warnings
from collections.abc import Callable
from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal, Any
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
# TODO: most likely very little of this will be retained
from dzgui.const.enum import (
Preferences,
Popup,
RowType,
NotebookPage,
VAdjustment,
MAIN_MENU_ROWS,
)
from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, WINDOW_DEFAULT_X, WINDOW_DEFAULT_Y
from dzgui.controllers.mc import Controller
from dzgui.util import css, dist, localize, strings, ip, deck, open_links
from dzgui.util.format import pluralize
# GRID ITEMS
# TODO: import notebook only and add components there?
from dzgui.views.pages.changelog import Changelog
from dzgui.views.pages.keys import Keybindings
from dzgui.views.pages.thanks import Thanks
from dzgui.views.pages.options import Options
from dzgui.views.components.mod_panel import ModSelectionPanel
from dzgui.views.components.right_panel import RightPanel
from dzgui.views.components.toast import Toast
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
# TREES
from dzgui.views.trees.tree_log import LogTreeView
from dzgui.views.trees.tree_menu import MenuTreeView
from dzgui.views.trees.tree_mods import ModTreeView
from dzgui.views.trees.tree_servers import ServerTreeView
from dzgui.views.dialogs.generic import GenericDialog
# TODO: not going to be in base anymore
import dzgui.util._json as JSON # noqa
logger = logging.getLogger(__name__)
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
# TODO: move to controller
cache: dict[str, int] = {}
# TODO: move to strings.py
def format_metadata(row: RowType) -> str:
prefix = row.dict["tooltip"]
if row == RowType.QUICK_CONNECT or row == RowType.CHNG_FAV:
label = MainController.query_config(Preferences.FAV_LBL)
if len(label) < 1:
label = "unset"
return f"{prefix} ({label})"
else:
return prefix
## TODO: move to configs/servers
#def query_history() -> list | None:
# history_file = MainController.get_prefs().paths.history
# try:
# with open(history_file, "r") as f:
# rows = [row.rstrip("\n") for row in f]
# except OSError:
# rows = None
# return rows
#
#def process_tree_option(choice: RowType) -> None:
# context = AppNav.treeview.view
# command = choice
# cmd_string = command.dict["label"]
# logger.info(f"Parsing tree option '{command}' for the context '{context}'")
#
# # server tables
# if command == RowType.RESOLVE_IP:
# record = AppNav.treeview.get_record()
# wait_msg = command.dict["wait_msg"]
# show_wait_dialog = True
#
# # TODO: needs threading, this is a slow process
# # TODO: needs saving into config
# # TODO: used by add/remove servers
# real_ip = ip.resolve_ip(record)
# #call_on_thread(
# # show_wait_dialog, cmd_string, wait_msg, record, choice=choice
# #)
# return
#
# if command == RowType.QUICK_CONNECT:
# record = MainController.query_config(Preferences.FAV_SRV)
# if record == "":
# AppNav.window.spawn_dialog("No favorite server currently set", Popup.NOTIFY)
# return
#
# record = str_to_record(record)
# thread_new_with_dialog(
# AppNav.treeview.prepare_connection,
# parse_shell_output,
# "Querying server",
# command,
# [record],
# )
# return
#
## TODO: belongs in model
#def str_to_record(record: str) -> Record | None:
# r = record.split(":")
# if len(r) != 3:
# return None
# return Record(r[0], int(r[1]), int(r[2]))
#
## TODO: ibid
#def record_to_str(record: Record) -> str:
# return f"{record.ip}:{record.gameport}:{record.qport}"
#
#
#def connect_by_ip(enum: RowType, response: str) -> None:
# def _prep(response: str) -> None:
# record = Servers.validate_ip(response)
# proc = AppNav.treeview.prepare_connection(record)
# return proc
#
# thread_new_with_dialog(
# _prep, parse_shell_output, "Querying IP", enum, [response]
# )
# return
#
#
#def connect_by_id(enum: RowType, uid: str, key: str) -> None:
# def _prep(key: str, response: str) -> None:
# # TODO: if response is non numeric, raise dialog
# if response.isnumeric() is False:
# pass
# # raise error
# # raise BmIdError("ID must be numeric only")
# # return
# from dzgui.api.bm import map_id_to_record
# try:
# record = map_id_to_record(config, uid)
# except Exception as e:
# logger.critical(e)
# # raise dialog
# return
# # proc = AppNav.treeview.prepare_connection(record)
# # return proc
#
# #thread_new_with_dialog(
# # _prep, parse_shell_output, "Querying API", enum, [key, response]
# #)
# #return
#
#
#def process_user_input(enum: RowType) -> None:
# prompt = enum.dict["prompt"]
# link_label = enum.dict["link_label"]
# cmd_string = enum.dict["label"]
#
# if enum == RowType.CONN_BY_ID:
# key = MainController.query_config(Preferences.BM)
# if len(key) == 0:
# AppNav.window.spawn_dialog(
# "No Battlemetrics API key is set; see Options", Popup.NOTIFY
# )
# return
#
# user_entry = EntryDialog(prompt, Popup.ENTRY, link_label, button_type=enum)
# response = user_entry.get_input()
#
# if response is None:
# logger.info("User aborted entry dialog")
# return
# logger.info(f"User entered: '{response}'")
#
# if enum == RowType.CONN_BY_IP:
# connect_by_ip(enum, response)
# return
#
# if enum == RowType.CONN_BY_ID:
# connect_by_id(enum, response, key)
# return
#
# show_wait_dialog = True
# wait_msg = "Working"
# call_on_thread(
# show_wait_dialog, cmd_string, wait_msg, response, choice=enum
# )
# return
class OuterWindow(Gtk.Window):
def __init__(self) -> None:
super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER)
self.hb = AppHeaderBar()
MainController.set_mediator(AppNav)
AppNav.window = self
# steam deck taskbar may occlude elements
if MainController.get_prefs().is_steam_deck is False:
self.set_titlebar(self.hb)
self.connect("delete-event", self._on_delete_event)
self.connect("key-press-event", self._on_keypress)
MainController.set_resolution(self)
self.grid = Grid()
self.toast = Toast()
self.overlay = Gtk.Overlay()
self.overlay.add_overlay(self.grid)
self.overlay.add_overlay(self.toast)
self.add(self.overlay)
self.show_all()
self.toast.set_visible(False)
self.grid.right_panel.filters_vbox.set_visible(False)
self.grid.right_panel.enable_ping_button(False)
self.grid.sel_panel.set_visible(False)
# TODO: register from treeview or notebook
AppNav.treeview = self.grid.notebook.scrollable_treelist.treeview
css.load_css()
AppNav.grid.notebook.set_page_by_enum(NotebookPage.MAIN)
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
if event.state is Gdk.ModifierType.CONTROL_MASK \
and event.keyval is Gdk.KEY_d:
if AppNav.right_panel.filters_vbox.keyword_entry.is_focus():
return
if AppNav.right_panel.filters_vbox.maps_entry.is_focus():
return
AppNav.right_panel.toggle_debug()
def _on_delete_event(
self, window: "OuterWindow", event: Gdk.EventKey
) -> None:
self.halt_proc_and_quit()
def halt_proc_and_quit(self) -> None:
MainController.terminate_process()
MainController.save_res_and_quit()
# TODO: consolidate
# wrap base tree class in this
class ScrollableTree(Gtk.ScrolledWindow):
def __init__(self):
super().__init__()
self.treeview = MenuTreeView(MainController)
self.add(self.treeview)
def _set_adjustment(self, adjustment: VAdjustment) -> None:
pass
class AppHeaderBar(Gtk.HeaderBar):
def __init__(self):
super().__init__()
self.props.title = APP_NAME
self.set_decoration_layout(":minimize,maximize,close")
self.set_show_close_button(True)
class ScrollableNote(ScrollableMixin, Gtk.Box):
def __init__(self, content_box: Gtk.Box, back_button=False):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.scrollable = Gtk.ScrolledWindow()
self.scrollable.set_vexpand(True)
self.back_button = Gtk.Button(
label="Back", hexpand=True, halign=Gtk.Align.CENTER
)
self.gutter = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.END
)
if back_button:
self.gutter.add(self.back_button)
self.back_button.connect("clicked", self._on_back_clicked)
self.scrollable.add(content_box)
self.add(self.scrollable)
self.add(self.gutter)
def _on_back_clicked(self, button: Gtk.Button) -> None:
AppNav.notebook.return_prior()
class Notebook(ScrollableMixin, Gtk.Notebook):
def __init__(self) -> None:
super().__init__(show_tabs=False, show_border=False)
AppNav.notebook = self
self.prior_page: int
# TODO: set internally
self.scrollable_treelist = ScrollableTree()
self.scrollable_treelist.set_hexpand(False)
self.scrollable_treelist.set_vexpand(True)
self.scrollable_treelist.type = None
self.change = Changelog(MainController)
self.clog = ScrollableNote(self.change, back_button=False)
self.clog.scrollable.set_propagate_natural_width(False)
self.clog.type = RowType.CHANGELOG
# TODO: scrollable internally
self.keys = ScrollableNote(Keybindings())
self.keys.type = RowType.KEYBINDINGS
self.settings = Options(MainController)
# TODO: make all treeviews internally scrollable in base class
# server and quad tables should have hexpand property set to True
# add all treeviews as page and register them to AppNav and self.indexes
# when switching to a treeview, update relevant view and just pop that page
# instead of loading/unloading the model each time
self.ssu = Gtk.ScrolledWindow()
self.servers = ServerTreeView(MainController)
self.ssu.add(self.servers)
self.quad = Gtk.ScrolledWindow()
self.scroll_mod = Gtk.ScrolledWindow()
# TODO: register this table
self.quad_table = ModTreeView(MainController)
AppNav.modtreeview = self.quad_table
self.scroll_mod.add(self.quad_table)
self.scroll_log = Gtk.ScrolledWindow()
self.scroll_log.set_hexpand(True)
self.log_table = LogTreeView(MainController)
AppNav.logtreeview = self.log_table
self.scroll_log.add(self.log_table)
self.thanks = ScrollableNote(Thanks(), back_button=False)
self.pages = {
self.scrollable_treelist: NotebookPage.MAIN,
self.clog: NotebookPage.CHANGELOG,
self.keys: NotebookPage.KEYS,
self.settings: NotebookPage.OPTIONS,
self.ssu: NotebookPage.SERVERS,
self.scroll_mod: NotebookPage.MODS,
self.scroll_log: NotebookPage.LOG,
self.thanks: NotebookPage.THANKS
}
self.indexes = {}
"""
Note that due to historical reasons, GtkNotebook refuses to switch to a page
unless the child widget is visible. Therefore, it is recommended to show child
widgets before adding them to a notebook.
"""
for page in self.pages:
page.show_all()
index = self.append_page(page)
enum = self.pages[page]
crumbs = enum.dict["crumbs"]
self.indexes[enum] = index
self.connect_after("switch-page", self._on_page_changed)
self.connect("key-press-event", self._on_keypress)
def _on_mods_focus_change(self, widget: ModTreeView, event: Gdk.EventFocus) -> None:
MainController.toggle_mod_panel(event.in_)
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
page = self.get_page()
match event.keyval:
case Gdk.KEY_Return:
page = self.get_page()
if page:
page.back_button.clicked()
case Gdk.KEY_Right | Gdk.KEY_l:
if event.state is Gdk.ModifierType.CONTROL_MASK:
return
AppNav.right_panel.focus_button_box()
case Gdk.KEY_question:
self.toggle_keybindings()
# NOTE: abort on non scrollable pages
# TODO: this should be an internal property of those pages
allowed = (NotebookPage.KEYS, NotebookPage.CHANGELOG, NotebookPage.THANKS)
if self.pages[page] not in allowed:
return
match event.keyval:
case Gdk.KEY_k | Gdk.KEY_Up:
page._set_adjustment(VAdjustment.UP)
case Gdk.KEY_Down | Gdk.KEY_j:
page._set_adjustment(VAdjustment.DOWN)
case Gdk.KEY_g:
page._set_adjustment(VAdjustment.TOP)
case Gdk.KEY_G:
page._set_adjustment(VAdjustment.BOTTOM)
def return_prior(self) -> None:
page = self.get_nth_page(self.prior_page)
# FIXME: check enum for NotebookPage.OPTIONS
if hasattr(page, "steam_entry"):
"""
Gtk.Notebook focuses the first input field when changing pages;
this workaround unhighlights the selected region and makes entry
fields unfocusable prior to the page 'switch-page' signal,
then makes them focusable again
Used when switching to keybindings dialog
"""
entries = page.steam_entry, page.bm_entry
for entry in entries:
entry.set_position(-1)
entry.set_can_focus(False)
self.set_current_page(self.prior_page)
for entry in entries:
entry.set_can_focus(True)
self.set_current_page(self.prior_page)
def get_page_by_enum(self) -> NotebookPage | None:
for k, v in self.indexes.items():
if v == self.get_current_page():
return k
return None
def toggle_keybindings(self) -> None:
cur_page = self.get_page_by_enum()
if cur_page == NotebookPage.KEYS:
self.return_prior()
else:
self.prior_page = self.get_current_page()
self.set_page_by_enum(NotebookPage.KEYS)
def focus_current(self) -> None:
widget = self.get_page()
if widget:
w = widget.get_children()[0]
# NOTE: if item contains treeview, focus first cell
try:
w.focus_first_row()
w.grab_focus()
except Exception as e:
w.grab_focus()
def get_page(self) -> Gtk.Widget | None:
ind = self.get_current_page()
widget = self.get_nth_page(ind)
if not widget:
return None
return widget
def set_page_by_enum(self, enum: NotebookPage) -> None:
self.prior_page = self.get_current_page()
self.set_current_page(self.indexes[enum])
self.focus_current()
# TODO: should be an internal property of those pages
blank = [
NotebookPage.OPTIONS,
NotebookPage.THANKS,
NotebookPage.CHANGELOG,
NotebookPage.LOG
]
if enum in blank:
# TODO: delegate to controller
AppNav.grid.statusbar.refresh(None)
def _on_page_changed(
self, notebook: "Notebook", page: Gtk.Widget, page_num: int
) -> None:
enum = self.get_page_by_enum()
is_mods = True if enum is NotebookPage.MODS else False
MainController.toggle_mod_panel(is_mods)
crumbs = enum.dict["crumbs"]
MainController.set_crumbs(crumbs)
if enum is NotebookPage.MODS:
return
# NOTE: Main Menu and Help page are of the same type
if enum is not NotebookPage.MAIN:
return
# FIXME: brittle
if crumbs == "Main menu":
if AppNav.treeview.get_model()[0][0] == MainController.help_store[0][0]:
MainController.set_crumbs("Help")
row = MAIN_MENU_ROWS[0]
AppNav.grid.statusbar.refresh(row)
class Statusbar(Gtk.Statusbar):
def __init__(self) -> None:
super().__init__()
help_text = strings.statusbar_helptext
self.set_text(help_text)
version = MainController.get_prefs().version
self.status_right_label = Gtk.Label(label=version)
self.add(self.status_right_label)
self.players = ""
def get_text(self) -> str:
area = self.get_message_area()
label = area.get_children()[0]
return label.get_text()
def set_text(self, string: str) -> None:
if string is None:
return
meta = self.get_context_id("Statusbar")
self.push(meta, string)
def refresh(self, row: RowType) -> None:
if row is None:
formatted = ""
else:
formatted = format_metadata(row)
self.set_text(formatted)
def append_distance(self, dist: str) -> None:
# TODO: process strings in controller
if dist == strings.unknown:
dist = f"| Distance: {dist}"
else:
dist = f"| Distance: {dist}"
self.set_text(self.players + dist)
def update_server_meta(self) -> None:
model = AppNav.treeview.get_model()
if model is None:
players = 0
hits = 0
else:
hits = len(model)
players = 0
for row in model:
players += row[4]
# TODO: move to util.format
players_pretty = pluralize("players", players)
hits_pretty = pluralize("matches", hits)
formatted = (
f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}"
)
suffix = "| Distance: calculating..."
if players == 0:
suffix = ""
self.set_text(formatted + suffix)
self.players = formatted
class Grid(Gtk.Grid):
def __init__(self) -> None:
super().__init__()
self.set_column_homogeneous(True)
AppNav.grid = self
# FIXME: do not pass AppNav to right panel
self.right_panel = RightPanel(AppNav, MainController)
self.sel_panel = ModSelectionPanel(MainController)
self.right_panel.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, 0)
self.notebook = Notebook()
self.statusbar = Statusbar()
self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START)
self.set_breadcrumbs(strings.label_main_menu)
self.attach(self.notebook, 0, 0, 3, 1)
self.attach_next_to(
self.breadcrumbs, self.notebook, Gtk.PositionType.TOP, 3, 1
)
self.attach_next_to(
self.statusbar, self.notebook, Gtk.PositionType.BOTTOM, 3, 1
)
self.attach_next_to(
self.right_panel, self.notebook, Gtk.PositionType.RIGHT, 1, 1
)
self.show_all()
def get_breadcrumbs(self) -> str:
return self.breadcrumbs.get_text()
def set_breadcrumbs(self, text: str) -> None:
self.breadcrumbs.set_markup(f"<b>{text}</b>")
class App(Gtk.Application):
def __init__(self, prefs) -> None:
GLib.set_prgname(APP_NAME)
MainController.set_prefs(prefs)
self.win = OuterWindow()
accel = Gtk.AccelGroup()
accel.connect(
Gdk.KEY_q,
Gdk.ModifierType.CONTROL_MASK,
Gtk.AccelFlags.VISIBLE,
self._halt_window_subprocess,
)
self.win.add_accel_group(accel)
GLib.unix_signal_add(
GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint
)
Gtk.main()
def _catch_sigint(self) -> None:
self.win.halt_proc_and_quit()
def _halt_window_subprocess(
self,
accel_group: Gtk.AccelGroup,
window: "OuterWindow",
code: Gdk.EventKey,
flag: Gdk.ModifierType,
) -> None:
self.win.halt_proc_and_quit()
class AppNavigation:
grid: Grid
window: OuterWindow
notebook: Notebook
right_panel: RightPanel
treeview: MenuTreeView
modtreeview: ModTreeView
logtreeview: LogTreeView
# TODO: add tree_log and tree_server views here
AppNav = AppNavigation()
MainController = Controller()

View File

@ -0,0 +1,86 @@
import logging
import functools
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk
from dzgui.const.enum import ButtonType
from dzgui.const.enum import NotebookPage
from dzgui.const.enum import RowType
from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL
from typing import Callable
logger = logging.getLogger(__name__)
class ContextualButton(Gtk.Button):
def __init__(self, label, opens, tooltip, context):
super().__init__(
label=label,
tooltip_text=tooltip
)
self.context = context
self.opens = opens
class ButtonBox(Gtk.Box):
def __init__(self, controller):
super().__init__(
spacing=6,
margin_top=0,
margin_start=10,
margin_end=10,
orientation=Gtk.Orientation.VERTICAL,
)
self.prior_button = ButtonType.MAIN_MENU
self.controller = controller
self.buttons = list()
self.connect("key-press-event", self._on_keypress)
prefs = controller.get_prefs()
for side_button in ButtonType:
button = ContextualButton(
label=side_button.dict["label"],
opens=side_button.dict["opens"],
tooltip=side_button.dict["tooltip"],
context=side_button,
)
# FIXME: if debug log fails to load, still opens table
size = (10, 10) if prefs.is_steam_deck else (50, 50)
x, y = size
button.set_size_request(x, y)
self.buttons.append(button)
button.connect("clicked", self._on_selection_button_clicked)
self.pack_start(button, NO_EXPAND, NO_FILL, 0)
def _on_selection_button_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page_by_button(button)
def _walk_buttons(self, increment: int) -> None:
for i, button in enumerate(self.buttons):
if button.is_focus():
n = i + increment
if n == len(self.buttons):
return
if n == -1:
return
n = self.buttons[n]
n.grab_focus()
return
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
match event.keyval:
case Gdk.KEY_h:
self.controller.focus_notebook()
case Gdk.KEY_j:
self._walk_buttons(1)
case Gdk.KEY_k:
self._walk_buttons(-1)
case Gdk.KEY_question:
self.controller.open_keybindings()

View File

@ -0,0 +1,17 @@
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk
class ConnectPanel(Gtk.Grid):
def __init__(self):
super().__init__()
self.label = Gtk.Label(label="Connect by IP/ID")
self.label2 = Gtk.Label(label="Save server by IP/ID")
self.entry1 = Gtk.Entry()
self.entry2 = Gtk.Entry()
self.attach(self.label, 0, 0, 3, 1)
self.attach_next_to(self.entry1, self.label, Gtk.PositionType.BOTTOM, 3, 1)
self.attach_next_to(self.label2, self.entry1, Gtk.PositionType.BOTTOM, 3, 1)
self.attach_next_to(self.entry2, self.label2, Gtk.PositionType.BOTTOM, 3, 1)

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