a2qlinux 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2qlinux/__init__.py +6 -0
- a2qlinux/_version.py +24 -0
- a2qlinux/bin/questlog_ctl.exe +0 -0
- a2qlinux/bin/wpcap.dll +0 -0
- a2qlinux/config.py +148 -0
- a2qlinux/desktop.py +138 -0
- a2qlinux/launcher.py +1089 -0
- a2qlinux/pcap_bridge.py +205 -0
- a2qlinux/setup_desktop.py +403 -0
- a2qlinux/steam.py +101 -0
- a2qlinux/tray.py +66 -0
- a2qlinux/update.py +411 -0
- a2qlinux-0.2.0.dist-info/METADATA +142 -0
- a2qlinux-0.2.0.dist-info/RECORD +17 -0
- a2qlinux-0.2.0.dist-info/WHEEL +4 -0
- a2qlinux-0.2.0.dist-info/entry_points.txt +5 -0
- a2qlinux-0.2.0.dist-info/licenses/LICENSE +21 -0
a2qlinux/__init__.py
ADDED
a2qlinux/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
Binary file
|
a2qlinux/bin/wpcap.dll
ADDED
|
Binary file
|
a2qlinux/config.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""User settings: ~/.config/a2qlinux/config.toml.
|
|
2
|
+
|
|
3
|
+
Which value wins, highest first:
|
|
4
|
+
1. a command-line option (--interface, --close-with-game, --no-auto-update, --exe)
|
|
5
|
+
2. an environment variable (QUESTLOG_DIR)
|
|
6
|
+
3. this file
|
|
7
|
+
4. the default in SETTINGS
|
|
8
|
+
|
|
9
|
+
Only behaviour a user may want to change is here. Values that follow from
|
|
10
|
+
how Questlog or Wine work (how long Questlog needs to save, wait limits) are
|
|
11
|
+
named constants in the code, documented in docs/ARCHITECTURE.md.
|
|
12
|
+
|
|
13
|
+
Nothing wrong in the file is ignored silently: load() returns each problem
|
|
14
|
+
(file, line, what is wrong) next to the settings, using the default for that
|
|
15
|
+
setting; the launcher shows them in its window and as a notification.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import re
|
|
22
|
+
import tomllib
|
|
23
|
+
|
|
24
|
+
CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "a2qlinux" / "config.toml"
|
|
25
|
+
# Closing Questlog after the game does not wait for it to save, so the delay
|
|
26
|
+
# must cover the time Questlog takes to save game data (see launcher.SAVE_WAIT).
|
|
27
|
+
MIN_AFTER_GAME_DELAY = 10.5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Setting:
|
|
32
|
+
default: object
|
|
33
|
+
kind: type
|
|
34
|
+
help: str
|
|
35
|
+
choices: tuple = ()
|
|
36
|
+
minimum: float = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
SETTINGS = {
|
|
40
|
+
"questlog_dir": Setting("~/Games/Questlog", str,
|
|
41
|
+
"Folder with Questlog.exe (the QUESTLOG_DIR environment variable overrides it)."),
|
|
42
|
+
"interface": Setting("", str,
|
|
43
|
+
"Network interface carrying the game's traffic; empty: the only one besides lo."),
|
|
44
|
+
"after_game": Setting("keep", str,
|
|
45
|
+
'"keep": Questlog stays open after the game, and Steam sees the game as closed; '
|
|
46
|
+
'"close": Questlog closes with the game.',
|
|
47
|
+
choices=("keep", "close")),
|
|
48
|
+
"after_game_delay": Setting(15, (int, float),
|
|
49
|
+
'With after_game = "close": seconds after the game exits before Questlog closes '
|
|
50
|
+
f"(a game restarted sooner keeps it open). At least {MIN_AFTER_GAME_DELAY:g}.",
|
|
51
|
+
minimum=MIN_AFTER_GAME_DELAY),
|
|
52
|
+
"auto_update": Setting(True, bool, "Download new Questlog versions in the background and install them "
|
|
53
|
+
"at the next start."),
|
|
54
|
+
"update_check_hours": Setting(6, (int, float), "Hours between checks for a new Questlog.", minimum=0.25),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Settings:
|
|
60
|
+
questlog_dir: Path
|
|
61
|
+
interface: str
|
|
62
|
+
after_game: str
|
|
63
|
+
after_game_delay: float
|
|
64
|
+
auto_update: bool
|
|
65
|
+
update_check_hours: float
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _line_of(text, key):
|
|
69
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
70
|
+
if re.match(rf"\s*{re.escape(key)}\s*=", line):
|
|
71
|
+
return number
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load(path=CONFIG_PATH):
|
|
76
|
+
"""(Settings, problems). A missing file is no problem: all defaults."""
|
|
77
|
+
values = {name: spec.default for name, spec in SETTINGS.items()}
|
|
78
|
+
problems = []
|
|
79
|
+
path = Path(path)
|
|
80
|
+
try:
|
|
81
|
+
text = path.read_text()
|
|
82
|
+
data = tomllib.loads(text)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
text, data = "", {}
|
|
85
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
86
|
+
text, data = "", {}
|
|
87
|
+
problems.append(f"{path}: cannot be read ({error}); using the defaults.")
|
|
88
|
+
except tomllib.TOMLDecodeError as error:
|
|
89
|
+
text, data = "", {}
|
|
90
|
+
problems.append(f"{path}: not valid TOML ({error}); using the defaults.")
|
|
91
|
+
for name, value in data.items():
|
|
92
|
+
where = f"{path}, line {_line_of(text, name) or '?'}"
|
|
93
|
+
spec = SETTINGS.get(name)
|
|
94
|
+
if spec is None:
|
|
95
|
+
problems.append(f"{where}: unknown setting '{name}' (known: {', '.join(SETTINGS)}); ignored.")
|
|
96
|
+
continue
|
|
97
|
+
wrong_type = not isinstance(value, spec.kind) or (spec.kind is not bool and isinstance(value, bool))
|
|
98
|
+
if wrong_type:
|
|
99
|
+
problems.append(f"{where}: '{name}' must be {_kind_name(spec.kind)}, not {value!r}; "
|
|
100
|
+
f"using {spec.default!r}.")
|
|
101
|
+
continue
|
|
102
|
+
if spec.choices and value not in spec.choices:
|
|
103
|
+
problems.append(f"{where}: '{name}' must be one of {', '.join(map(repr, spec.choices))}, "
|
|
104
|
+
f"not {value!r}; using {spec.default!r}.")
|
|
105
|
+
continue
|
|
106
|
+
if spec.minimum is not None and value < spec.minimum:
|
|
107
|
+
problems.append(f"{where}: '{name}' must be at least {spec.minimum:g}, not {value!r}; "
|
|
108
|
+
f"using {spec.default!r}.")
|
|
109
|
+
continue
|
|
110
|
+
values[name] = value
|
|
111
|
+
env_dir = os.environ.get("QUESTLOG_DIR")
|
|
112
|
+
values["questlog_dir"] = Path(env_dir or values["questlog_dir"]).expanduser()
|
|
113
|
+
values["after_game_delay"] = float(values["after_game_delay"])
|
|
114
|
+
values["update_check_hours"] = float(values["update_check_hours"])
|
|
115
|
+
return Settings(**values), problems
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _kind_name(kind):
|
|
119
|
+
if kind is bool:
|
|
120
|
+
return "true or false"
|
|
121
|
+
if kind is str:
|
|
122
|
+
return "a quoted text"
|
|
123
|
+
return "a number"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def questlog_dir():
|
|
127
|
+
"""Where Questlog is, for the parts that need only that (the menu entry, the Steam wrapper)."""
|
|
128
|
+
return load()[0].questlog_dir
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def template(questlog_dir=None):
|
|
132
|
+
"""The file a2qlinux-setup writes when there is none: every setting, at its default, explained."""
|
|
133
|
+
lines = ["# A2Qlinux settings. Delete a line to get its default back.",
|
|
134
|
+
"# A command-line option or the QUESTLOG_DIR variable overrides the value here.", ""]
|
|
135
|
+
for name, spec in SETTINGS.items():
|
|
136
|
+
value = str(questlog_dir) if name == "questlog_dir" and questlog_dir else spec.default
|
|
137
|
+
lines.append(f"# {spec.help}")
|
|
138
|
+
lines.append(f"{name} = {_toml(value)}")
|
|
139
|
+
lines.append("")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _toml(value):
|
|
144
|
+
if isinstance(value, bool):
|
|
145
|
+
return "true" if value else "false"
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
148
|
+
return repr(value)
|
a2qlinux/desktop.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Desktop and menu entry point: open the launcher (a2qlinux.launcher) in a terminal, once.
|
|
3
|
+
|
|
4
|
+
Arguments are passed on to the launcher (a2qlinux.launcher), e.g. --close-with-game.
|
|
5
|
+
|
|
6
|
+
If the launcher (a2qlinux.launcher) is already running (its lock is held), no second one is
|
|
7
|
+
started; the running one is asked instead, by a signal to the PID in its lock
|
|
8
|
+
file:
|
|
9
|
+
- from the icon or menu: SIGUSR1, "open Questlog's window" - which also calls
|
|
10
|
+
off a quit chosen in our tray icon a moment before;
|
|
11
|
+
- from the Steam launch option (--for-game; the game is starting):
|
|
12
|
+
SIGUSR2, "keep Questlog running" - calls off such a quit, opens nothing.
|
|
13
|
+
If the launcher was just ending, it lets go of the lock instead of answering;
|
|
14
|
+
a new one is then started here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import fcntl
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from .config import questlog_dir
|
|
27
|
+
|
|
28
|
+
LOCK = questlog_dir() / "proton-stream" / ".lock"
|
|
29
|
+
|
|
30
|
+
# Terminals the status window can open in, first found wins, each with the
|
|
31
|
+
# option after which it takes a program and its arguments as separate words
|
|
32
|
+
# (from each one's manual). x-terminal-emulator is Debian's alternative for
|
|
33
|
+
# whichever terminal is installed; Debian policy requires it to take -e so.
|
|
34
|
+
TERMINALS = (
|
|
35
|
+
("konsole", ["-e"]),
|
|
36
|
+
("gnome-terminal", ["--"]),
|
|
37
|
+
("ptyxis", ["--"]),
|
|
38
|
+
("xfce4-terminal", ["-x"]),
|
|
39
|
+
("mate-terminal", ["-x"]),
|
|
40
|
+
("x-terminal-emulator", ["-e"]),
|
|
41
|
+
("xterm", ["-e"]),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def terminal_command(program, which=shutil.which):
|
|
46
|
+
"""The command that runs `program` (a list of words) in a new terminal window; None without a terminal."""
|
|
47
|
+
for name, option in TERMINALS:
|
|
48
|
+
found = which(name)
|
|
49
|
+
if found:
|
|
50
|
+
return [found, *option, *program]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def launcher_running():
|
|
55
|
+
"""True if the launcher (a2qlinux.launcher) holds its lock right now."""
|
|
56
|
+
try:
|
|
57
|
+
fd = os.open(LOCK, os.O_RDWR | os.O_CLOEXEC)
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
62
|
+
except BlockingIOError:
|
|
63
|
+
return True
|
|
64
|
+
finally:
|
|
65
|
+
os.close(fd)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
HANDOVER_WAIT = 10 # s for a launcher that was ending to let go of its lock
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def launcher_pid():
|
|
73
|
+
"""PID of the running the launcher (a2qlinux.launcher), from its lock file; None if it cannot be told.
|
|
74
|
+
|
|
75
|
+
Checked against the process's command line, so a stale or reused PID is
|
|
76
|
+
never signalled.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
pid = int(LOCK.read_text().strip())
|
|
80
|
+
command = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
return pid if b"a2qlinux.launcher" in command else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ask_running_launcher(game_starting):
|
|
87
|
+
"""Signal the running launcher. True if it answers, False if it has just ended."""
|
|
88
|
+
pid = launcher_pid()
|
|
89
|
+
if pid is None:
|
|
90
|
+
notify("Already running - use its open terminal window.")
|
|
91
|
+
return True
|
|
92
|
+
try:
|
|
93
|
+
os.kill(pid, signal.SIGUSR2 if game_starting else signal.SIGUSR1)
|
|
94
|
+
except ProcessLookupError:
|
|
95
|
+
pass
|
|
96
|
+
deadline = time.monotonic() + HANDOVER_WAIT
|
|
97
|
+
while time.monotonic() < deadline:
|
|
98
|
+
if not launcher_running():
|
|
99
|
+
return False
|
|
100
|
+
time.sleep(0.2)
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def notify(text):
|
|
105
|
+
subprocess.run(["notify-send", "--app-name=Questlog (AION 2)",
|
|
106
|
+
"--icon=" + str(Path.home() / ".local/share/icons/questlog-aion2.png"),
|
|
107
|
+
"Questlog (AION 2)", text], check=False)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
try:
|
|
112
|
+
from .setup_desktop import refresh_entries
|
|
113
|
+
refresh_entries(lambda text: print(f"a2qlinux: {text}", file=sys.stderr))
|
|
114
|
+
except (OSError, RuntimeError) as error: # never in the way of starting Questlog
|
|
115
|
+
print(f"a2qlinux: could not check the menu entry: {error}", file=sys.stderr)
|
|
116
|
+
# --for-game only tells who is asking; the launcher starts Questlog at once either way.
|
|
117
|
+
for_game = "--for-game" in sys.argv[1:]
|
|
118
|
+
options = [arg for arg in sys.argv[1:] if arg != "--for-game"]
|
|
119
|
+
if launcher_running() and ask_running_launcher(for_game):
|
|
120
|
+
return 0
|
|
121
|
+
# The window closes by itself when the launcher ends normally (Questlog
|
|
122
|
+
# closed after the game); on an error it stays open until Enter, so the
|
|
123
|
+
# message can be read.
|
|
124
|
+
# Any arguments (e.g. --close-with-game) are passed on to the launcher (a2qlinux.launcher).
|
|
125
|
+
script = ('"$0" "$@"; code=$?; if [ $code -ne 0 ]; then echo; '
|
|
126
|
+
'read -rp "A2Qlinux ended with code $code - press Enter to close. "; fi')
|
|
127
|
+
command = terminal_command(["bash", "-c", script, sys.executable, "-m", "a2qlinux.launcher", *options])
|
|
128
|
+
if command is None:
|
|
129
|
+
message = ("No terminal for the status window was found. Install one of: "
|
|
130
|
+
+ ", ".join(name for name, _ in TERMINALS) + ".")
|
|
131
|
+
print(f"a2qlinux: {message}", file=sys.stderr)
|
|
132
|
+
notify(message)
|
|
133
|
+
return 1
|
|
134
|
+
os.execv(command[0], command)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|