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 ADDED
@@ -0,0 +1,6 @@
1
+ """A2Qlinux: Questlog Desktop for AION 2 on Linux under Proton, with live game traffic."""
2
+
3
+ try:
4
+ from ._version import __version__
5
+ except ImportError: # a source checkout that was never built or installed
6
+ __version__ = "0+unknown"
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())