feat: dzgui 7 alpha
24
CHANGELOG.md
Normal 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
@ -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.
|
||||
3
LICENSE
@ -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
@ -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)
|
||||
|
||||
|
||||

|
||||
|
||||
## 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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,2 @@
|
||||
sphinx-rtd-theme
|
||||
sphinx-copybutton
|
||||
60
docs/source/bugs.rst
Normal 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
@ -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"
|
||||
12
docs/source/development.rst
Normal 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
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/source/images/02.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/source/images/03.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/source/images/04.png
Normal file
|
After Width: | Height: | Size: 608 KiB |
BIN
docs/source/images/05.png
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
docs/source/images/06.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
docs/source/images/07.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/source/images/08.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/source/images/09.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
69
docs/source/index.rst
Normal 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
|
||||
64
docs/source/installation.rst
Normal file
@ -0,0 +1,64 @@
|
||||
Installation
|
||||
==================
|
||||
|
||||
Dependencies
|
||||
----------------------
|
||||
|
||||
If not already installed, the below can be found in your system’s 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
@ -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
@ -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
@ -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
@ -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 app’s 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 game’s
|
||||
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``.
|
||||
12
docs/source/uninstallation.rst
Normal 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
@ -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 system’s 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
0
dzgui/api/__init__.py
Normal file
40
dzgui/api/bm.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
72
dzgui/config/convert.py
Normal 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)
|
||||
42
dzgui/config/freedesktop.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
After Width: | Height: | Size: 497 KiB |
BIN
dzgui/data/images/example.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
dzgui/data/images/grid.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
dzgui/data/images/hero.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
dzgui/data/images/icon.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
dzgui/data/images/icons/128.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
dzgui/data/images/icons/16.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dzgui/data/images/icons/24.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
dzgui/data/images/icons/256.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
dzgui/data/images/icons/32.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
dzgui/data/images/icons/48.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
dzgui/data/images/icons/64.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
dzgui/data/images/icons/96.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
dzgui/data/images/logo.png
Normal file
|
After Width: | Height: | Size: 81 B |
23
dzgui/init/coords.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
115
dzgui/main.py
Normal 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
@ -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
27
dzgui/util/_json.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
668
dzgui/views/base.py
Normal 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()
|
||||
86
dzgui/views/components/buttonbox.py
Normal 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()
|
||||
17
dzgui/views/components/connect_panel.py
Normal 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)
|
||||