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.
@@ -0,0 +1,205 @@
1
+ """Serve captured packets to the wpcap.dll stand-in inside Proton.
2
+
3
+ The DLL (bridge/wpcap_bridge.c) connects to this server on 127.0.0.1, sends
4
+ "QLPB1 <token>\\n" and then receives a classic pcap stream: the 24-byte global
5
+ header, followed by packet records (16-byte record header + frame). Each
6
+ pcap_open_live in Questlog is one such connection; Questlog opens a new one
7
+ when the game's connections change and only then closes the old, so several
8
+ clients can be connected at once and each gets every packet.
9
+
10
+ The token keeps other local programs from reading the game traffic (it
11
+ carries chat). It reaches the DLL through the QUESTLOG_PROTON_BRIDGE variable
12
+ of the Questlog process only.
13
+ """
14
+
15
+ import hmac
16
+ import queue
17
+ import secrets
18
+ import socket
19
+ import struct
20
+ import threading
21
+
22
+ HELLO_PREFIX = b"QLPB1 "
23
+ MICROSECOND_MAGIC = b"\xd4\xc3\xb2\xa1"
24
+ CLIENT_QUEUE_RECORDS = 5000 # ~2 min of ordinary game traffic, ~10 s of the heaviest; beyond it the client is stuck
25
+
26
+
27
+ def read_records(stream):
28
+ """Yield (global_header, None) once, then (None, record_bytes) per packet.
29
+
30
+ Output is always little-endian microsecond pcap. Raises ValueError on
31
+ anything else coming in, so a wrong dumpcap setting is never read as
32
+ "no packets".
33
+ """
34
+ header = _read_exact(stream, 24)
35
+ if header is None or len(header) < 24:
36
+ raise ValueError("The capture stream ended before its pcap header.")
37
+ if header[:4] == b"\xd4\xc3\xb2\xa1":
38
+ nanos = False
39
+ elif header[:4] == b"\x4d\x3c\xb2\xa1":
40
+ nanos = True # what dumpcap -F pcap writes
41
+ else:
42
+ raise ValueError("The capture stream is not little-endian classic pcap (dumpcap needs -F pcap).")
43
+ # The DLL hands record headers to Questlog as they are, and Questlog reads
44
+ # the second field as microseconds; nanosecond stamps are converted here.
45
+ yield MICROSECOND_MAGIC + header[4:], None
46
+ while True:
47
+ head = _read_exact(stream, 16)
48
+ if head is None:
49
+ return
50
+ if len(head) < 16:
51
+ raise ValueError("The capture stream ended inside a packet header.")
52
+ seconds, fraction, caplen, length = struct.unpack("<IIII", head)
53
+ frame = _read_exact(stream, caplen)
54
+ if frame is None or len(frame) < caplen:
55
+ raise ValueError("The capture stream ended inside a packet.")
56
+ if nanos:
57
+ head = struct.pack("<IIII", seconds, fraction // 1000, caplen, length)
58
+ yield None, head + frame
59
+
60
+
61
+ def _read_exact(stream, n):
62
+ data = b""
63
+ while len(data) < n:
64
+ chunk = stream.read(n - len(data))
65
+ if not chunk:
66
+ return None if not data else data
67
+ data += chunk
68
+ return data
69
+
70
+
71
+ class _Client:
72
+ def __init__(self, sock, peer, global_header, log):
73
+ self.sock = sock
74
+ self.peer = peer
75
+ self.log = log
76
+ self.queue = queue.Queue(maxsize=CLIENT_QUEUE_RECORDS)
77
+ self.closed = threading.Event()
78
+ self.sent = 0
79
+ sock.sendall(global_header)
80
+ threading.Thread(target=self._send_loop, daemon=True).start()
81
+
82
+ def offer(self, record):
83
+ """Queue a record; a client that has stopped reading is disconnected."""
84
+ if self.closed.is_set():
85
+ return False
86
+ try:
87
+ self.queue.put_nowait(record)
88
+ return True
89
+ except queue.Full:
90
+ self.log(f"Questlog stopped reading packets ({CLIENT_QUEUE_RECORDS} waiting); "
91
+ "dropping its connection - it reconnects by itself within a second.")
92
+ self.close()
93
+ return False
94
+
95
+ def _send_loop(self):
96
+ try:
97
+ while not self.closed.is_set():
98
+ record = self.queue.get()
99
+ if record is None:
100
+ break
101
+ self.sock.sendall(record)
102
+ self.sent += 1
103
+ except OSError:
104
+ pass
105
+ finally:
106
+ self.close()
107
+
108
+ def close(self):
109
+ if not self.closed.is_set():
110
+ self.closed.set()
111
+ try:
112
+ self.queue.put_nowait(None)
113
+ except queue.Full:
114
+ pass
115
+ try:
116
+ self.sock.close()
117
+ except OSError:
118
+ pass
119
+
120
+
121
+ class BridgeServer:
122
+ """Accept authenticated DLL connections and fan packet records out to them."""
123
+
124
+ def __init__(self, log, host="127.0.0.1"):
125
+ self.log = log
126
+ self.token = secrets.token_hex(32)
127
+ self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
128
+ self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
129
+ self.listener.bind((host, 0))
130
+ self.listener.listen(8)
131
+ self.host, self.port = self.listener.getsockname()
132
+ self.global_header = None
133
+ self.header_ready = threading.Event()
134
+ self.clients = []
135
+ self.lock = threading.Lock()
136
+ self.connections = 0
137
+ self.stopped = threading.Event()
138
+ threading.Thread(target=self._accept_loop, daemon=True).start()
139
+
140
+ def environment_value(self, local_ip):
141
+ """The QUESTLOG_PROTON_BRIDGE value the DLL reads."""
142
+ return f"{self.host}:{self.port}:{self.token}:{local_ip}"
143
+
144
+ def _accept_loop(self):
145
+ while not self.stopped.is_set():
146
+ try:
147
+ sock, peer = self.listener.accept()
148
+ except OSError:
149
+ return
150
+ threading.Thread(target=self._admit, args=(sock, peer), daemon=True).start()
151
+
152
+ def _admit(self, sock, peer):
153
+ try:
154
+ sock.settimeout(5)
155
+ line = b""
156
+ while not line.endswith(b"\n") and len(line) < 200:
157
+ chunk = sock.recv(200 - len(line))
158
+ if not chunk:
159
+ break
160
+ line += chunk
161
+ expected = HELLO_PREFIX + self.token.encode() + b"\n"
162
+ if not hmac.compare_digest(line, expected):
163
+ self.log(f"Refused a connection from {peer[0]}:{peer[1]} without the right token.")
164
+ sock.close()
165
+ return
166
+ if not self.header_ready.wait(timeout=5):
167
+ self.log("Questlog connected before dumpcap started; it will retry.")
168
+ sock.close()
169
+ return
170
+ sock.settimeout(None)
171
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
172
+ client = _Client(sock, peer, self.global_header, self.log)
173
+ except OSError:
174
+ sock.close()
175
+ return
176
+ with self.lock:
177
+ self.clients.append(client)
178
+ self.connections += 1
179
+ count = self.connections
180
+ self.log(f"Questlog connected to the capture bridge (connection {count}).")
181
+
182
+ def publish(self, global_header, record):
183
+ """Feed one item from read_records()."""
184
+ if global_header is not None:
185
+ self.global_header = global_header
186
+ self.header_ready.set()
187
+ return
188
+ with self.lock:
189
+ self.clients = [c for c in self.clients if c.offer(record)]
190
+
191
+ def client_count(self):
192
+ with self.lock:
193
+ self.clients = [c for c in self.clients if not c.closed.is_set()]
194
+ return len(self.clients)
195
+
196
+ def stop(self):
197
+ self.stopped.set()
198
+ try:
199
+ self.listener.close()
200
+ except OSError:
201
+ pass
202
+ with self.lock:
203
+ for c in self.clients:
204
+ c.close()
205
+ self.clients = []
@@ -0,0 +1,403 @@
1
+ """a2qlinux-setup: the menu entry for Questlog (AION 2), and a check of what A2Qlinux needs.
2
+
3
+ a2qlinux-setup [--questlog-dir DIR] [--desktop-shortcut]
4
+
5
+ What it does, in order:
6
+ 1. Checks the programs A2Qlinux uses. For the missing ones it prints the
7
+ one command that installs them on this distribution (read from
8
+ /etc/os-release); it never runs sudo itself.
9
+ 2. Checks that capturing works, with a one-second capture on `lo` that
10
+ matches nothing, and prints the commands that allow it if not.
11
+ 3. Starts the tray icon's check (a2qlinux.tray --check) to see that it can
12
+ run on this desktop.
13
+ 4. Checks that the Windows parts are present: shipped in the package, built
14
+ by bridge/build.sh in a source checkout.
15
+ 5. Installs the newest Questlog when its folder has no Questlog.exe
16
+ (update.install_fresh: the updater's download and checks).
17
+ 6. Takes Questlog's icon from the installed Questlog (resources/icon.png in
18
+ its app.asar) - the icon is Questlog's and is not shipped here.
19
+ 7. Writes the menu entry "Questlog (AION 2)" (and, with --desktop-shortcut,
20
+ the same on the desktop), running the `a2qlinux` command.
21
+ 8. Prints the Steam launch option to set by hand.
22
+
23
+ Run it again after fixing what it lists, and after moving Questlog's folder.
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ from pathlib import Path
29
+ import shutil
30
+ import struct
31
+ import subprocess
32
+ import sys
33
+
34
+ from . import config
35
+ from .desktop import TERMINALS, terminal_command
36
+ from .update import install_fresh, read_asar_file
37
+
38
+ BIN = Path(__file__).resolve().parent / "bin"
39
+ ENTRY_ID = "questlog-aion2"
40
+ ICON = Path.home() / ".local" / "share" / "icons" / f"{ENTRY_ID}.png"
41
+ MENU_ENTRY = Path.home() / ".local" / "share" / "applications" / f"{ENTRY_ID}.desktop"
42
+
43
+ # program -> the package that provides it, by its name in PACKAGES
44
+ NEEDED = {
45
+ "dumpcap": "wireshark",
46
+ "7z": "7zip",
47
+ "protontricks-launch": "protontricks",
48
+ "notify-send": "notify",
49
+ "xdotool": "xdotool",
50
+ }
51
+ NEEDED_PYTHON = ("PyQt6", "Xlib") # installed with A2Qlinux by pipx
52
+ TRAY_CHECK_WAIT = 30 # s for the tray check to start Qt and answer
53
+ CAPTURE_CHECK_WAIT = 15 # s for the one-second capture check
54
+
55
+ # Package names per distribution family, as in docs/INSTALL.md ("Installing the packages").
56
+ PACKAGES = {
57
+ "arch": {"wireshark": "wireshark-cli", "7zip": "7zip", "protontricks": "protontricks",
58
+ "notify": "libnotify", "xdotool": "xdotool", "xcb-cursor": "xcb-util-cursor", "terminal": "xterm"},
59
+ "debian": {"wireshark": "wireshark-common", "7zip": "p7zip-full", "protontricks": "protontricks",
60
+ "notify": "libnotify-bin", "xdotool": "xdotool", "xcb-cursor": "libxcb-cursor0", "terminal": "xterm"},
61
+ "fedora": {"wireshark": "wireshark-cli", "7zip": "7zip", "protontricks": "protontricks",
62
+ "notify": "libnotify", "xdotool": "xdotool", "xcb-cursor": "xcb-util-cursor", "terminal": "xterm"},
63
+ "suse": {"wireshark": "wireshark", "7zip": "7zip", "protontricks": "protontricks",
64
+ "notify": "libnotify-tools", "xdotool": "xdotool", "xcb-cursor": "libxcb-cursor0", "terminal": "xterm"},
65
+ }
66
+ PACKAGES["ubuntu"] = PACKAGES["debian"]
67
+ INSTALLER = {"arch": "sudo pacman -S --needed", "debian": "sudo apt install", "ubuntu": "sudo apt install",
68
+ "fedora": "sudo dnf install", "suse": "sudo zypper install"}
69
+
70
+
71
+ def os_release(path="/etc/os-release"):
72
+ """/etc/os-release as a dict; empty if it cannot be read."""
73
+ info = {}
74
+ try:
75
+ text = Path(path).read_text()
76
+ except OSError:
77
+ return info
78
+ for line in text.splitlines():
79
+ key, sep, value = line.partition("=")
80
+ if sep and key.strip() and not key.startswith("#"):
81
+ info[key.strip()] = value.strip().strip("\"'")
82
+ return info
83
+
84
+
85
+ def distro_family(info):
86
+ """'arch', 'ubuntu', 'debian', 'fedora', 'suse' or None, from ID and ID_LIKE.
87
+
88
+ Ubuntu comes before Debian, since Ubuntu and its derivatives (Mint, Pop!_OS)
89
+ name both and differ in where protontricks comes from.
90
+ """
91
+ ids = {info.get("ID", "")} | set(info.get("ID_LIKE", "").split())
92
+ for family, names in (("arch", {"arch"}), ("ubuntu", {"ubuntu"}), ("debian", {"debian"}),
93
+ ("fedora", {"fedora"}), ("suse", {"suse", "opensuse"})):
94
+ if ids & names:
95
+ return family
96
+ return None
97
+
98
+
99
+ def install_commands(wanted, info):
100
+ """The commands that install the packages named in `wanted` (names from PACKAGES) on this system.
101
+
102
+ Unknown distributions get the Arch names and a pointer to the guide,
103
+ which has the table for the others.
104
+ """
105
+ if not wanted:
106
+ return []
107
+ family = distro_family(info)
108
+ if family is None:
109
+ names = " ".join(PACKAGES["arch"][name] for name in wanted)
110
+ return [f"(package names as on Arch; docs/INSTALL.md lists other distributions) {names}"]
111
+ wanted = list(wanted)
112
+ commands = []
113
+ if "protontricks" in wanted and family == "debian" and info.get("VERSION_ID") != "12":
114
+ wanted.remove("protontricks") # Debian 13 and later have no protontricks package
115
+ commands.append("pipx install protontricks")
116
+ packages = " ".join(PACKAGES[family][name] for name in wanted)
117
+ if packages:
118
+ prefix = "sudo add-apt-repository multiverse && " if family == "ubuntu" and "protontricks" in wanted else ""
119
+ commands.insert(0, f"{prefix}{INSTALLER[family]} {packages}")
120
+ return commands
121
+
122
+
123
+ def installed(program):
124
+ """Path of `program` in PATH, executable by this user or not; None if it is not installed.
125
+
126
+ dumpcap is installed root:wireshark 0750 on Arch, Fedora and openSUSE, so
127
+ for a user outside that group shutil.which's executable test misses it;
128
+ that is a missing group membership (capture_problem), not a missing package.
129
+ """
130
+ return shutil.which(program, mode=os.F_OK)
131
+
132
+
133
+ def missing_packages():
134
+ """(missing things to report, package names for install_commands)."""
135
+ missing, wanted = [], []
136
+ for program, package in NEEDED.items():
137
+ if not installed(program):
138
+ missing.append(program)
139
+ wanted.append(package)
140
+ if terminal_command([]) is None:
141
+ missing.append("a terminal for the status window (one of: " + ", ".join(name for name, _ in TERMINALS) + ")")
142
+ wanted.append("terminal")
143
+ if not shutil.which("systemd-run"):
144
+ missing.append("systemd-run (part of systemd; without it the Steam launch option cannot start Questlog)")
145
+ for module in NEEDED_PYTHON:
146
+ if subprocess.run([sys.executable, "-c", f"import {module}"], capture_output=True).returncode:
147
+ missing.append(f"the Python module {module}, which comes with A2Qlinux: run pipx reinstall a2qlinux")
148
+ return missing, wanted
149
+
150
+
151
+ def _has_capture_rights(path):
152
+ """True if dumpcap carries file capabilities or the setuid bit (what lets the group capture)."""
153
+ try:
154
+ os.getxattr(path, "security.capability")
155
+ return True
156
+ except OSError:
157
+ return bool(os.stat(path).st_mode & 0o4000)
158
+
159
+
160
+ def capture_problem(info):
161
+ """Why capturing does not work yet, with the commands that fix it; None if it works.
162
+
163
+ Capturing is tried for real: one second on `lo` with a filter nothing
164
+ matches, written nowhere. Listing devices (dumpcap -D) is no check: it
165
+ works without any capture rights.
166
+ """
167
+ dumpcap = installed("dumpcap")
168
+ if not dumpcap:
169
+ return None # reported with the missing packages
170
+ try:
171
+ if not os.access(dumpcap, os.X_OK):
172
+ raise PermissionError(f"dumpcap: only members of the 'wireshark' group may run {dumpcap}")
173
+ done = subprocess.run([dumpcap, "-i", "lo", "-f", "tcp port 1", "-a", "duration:1", "-q", "-w", "-"],
174
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
175
+ text=True, timeout=CAPTURE_CHECK_WAIT)
176
+ except subprocess.TimeoutExpired:
177
+ return f"the capture check did not end within {CAPTURE_CHECK_WAIT} s"
178
+ except PermissionError as error:
179
+ reason = str(error)
180
+ else:
181
+ if done.returncode == 0:
182
+ return None
183
+ reason = next((line.strip() for line in done.stderr.splitlines() if line.startswith("dumpcap:")),
184
+ f"dumpcap ended with code {done.returncode}")
185
+ steps = []
186
+ if os.access(dumpcap, os.X_OK) and not _has_capture_rights(os.path.realpath(dumpcap)):
187
+ if distro_family(info) in ("debian", "ubuntu"):
188
+ steps.append("sudo dpkg-reconfigure wireshark-common (answer Yes)")
189
+ else:
190
+ steps.append(f"{dumpcap} has no capture capabilities; reinstall the Wireshark package")
191
+ user = os.environ.get("USER") or subprocess.run(["id", "-un"], capture_output=True, text=True).stdout.strip()
192
+ session = subprocess.run(["id", "-nG"], capture_output=True, text=True).stdout.split()
193
+ configured = subprocess.run(["id", "-nG", user], capture_output=True, text=True).stdout.split()
194
+ if "wireshark" not in configured:
195
+ steps.append("sudo usermod -aG wireshark $USER")
196
+ if "wireshark" not in session or "wireshark" not in configured:
197
+ steps.append("then log out of the desktop and log in again (the group is read at login)")
198
+ return ("capturing is not allowed yet (" + reason + ")"
199
+ + "".join(f"\n {step}" for step in steps))
200
+
201
+
202
+ def tray_problem():
203
+ """Why A2Qlinux's tray icon could not run on this desktop; None if it can.
204
+
205
+ Runs the tray itself (a2qlinux.tray --check), so what is checked is what
206
+ the launcher will start: Qt with the desktop's display, and a system tray.
207
+ """
208
+ if not (os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")):
209
+ return "the tray icon could not be checked: no desktop session here (run a2qlinux-setup on the desktop)"
210
+ try:
211
+ # Qt logs to the journal, not to a stderr that is not a terminal, unless told to.
212
+ done = subprocess.run([sys.executable, "-m", "a2qlinux.tray", "--check"], stdin=subprocess.DEVNULL,
213
+ capture_output=True, text=True, timeout=TRAY_CHECK_WAIT,
214
+ env={**os.environ, "QT_FORCE_STDERR_LOGGING": "1"})
215
+ except subprocess.TimeoutExpired:
216
+ return f"the tray icon check did not answer within {TRAY_CHECK_WAIT} s"
217
+ if done.returncode == 0:
218
+ return None
219
+ lines = [line.strip() for line in done.stderr.splitlines() if line.strip()]
220
+ # Qt names a missing library in its qt.qpa lines, above its closing summary.
221
+ reason = " / ".join([line for line in lines if line.startswith(("qt.qpa", "a2qlinux tray:"))]
222
+ or lines[-3:]) or f"exit code {done.returncode}"
223
+ return (f"A2Qlinux's tray icon cannot run here ({reason}); "
224
+ "see 'The tray icon' in docs/TROUBLESHOOTING.md")
225
+
226
+
227
+ def windows_parts_problem():
228
+ missing = [name for name in ("wpcap.dll", "questlog_ctl.exe") if not (BIN / name).is_file()]
229
+ if missing:
230
+ return (f"{', '.join(missing)} missing from {BIN}; in a source checkout run: bash bridge/build.sh "
231
+ "(needs clang, lld and wine)")
232
+ return None
233
+
234
+
235
+ def command(name):
236
+ """Absolute path of one of A2Qlinux's commands, as installed with this Python.
237
+
238
+ pipx links the commands into ~/.local/bin and keeps them in the
239
+ environment's bin folder; either works, the first is stable across upgrades.
240
+ """
241
+ found = shutil.which(name)
242
+ if found:
243
+ return found
244
+ beside = Path(sys.executable).parent / name
245
+ if beside.is_file():
246
+ return str(beside)
247
+ raise RuntimeError(f"the command {name} is not installed; install A2Qlinux with pipx first")
248
+
249
+
250
+ def desktop_entry(questlog_dir):
251
+ run = command("a2qlinux") # where Questlog is comes from the settings file
252
+ return ("[Desktop Entry]\n"
253
+ "Type=Application\n"
254
+ "Name=Questlog (AION 2)\n"
255
+ "Comment=Questlog Desktop for AION 2 under Proton, fed with live game traffic\n"
256
+ f"Exec={run}\n"
257
+ f"Icon={ICON}\n"
258
+ "Terminal=false\n"
259
+ "Categories=Game;\n")
260
+
261
+
262
+ def desktop_shortcut_path():
263
+ folder = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True).stdout.strip()
264
+ return Path(folder or Path.home() / "Desktop") / f"{ENTRY_ID}.desktop"
265
+
266
+
267
+ def entry_command(path):
268
+ """The program an existing entry runs (after any `env VAR=…`), or None."""
269
+ try:
270
+ text = Path(path).read_text()
271
+ except OSError:
272
+ return None
273
+ for line in text.splitlines():
274
+ if line.startswith("Exec="):
275
+ words = line[len("Exec="):].split()
276
+ if words and words[0] == "env":
277
+ words = [w for w in words[1:] if "=" not in w]
278
+ return words[0] if words else None
279
+ return None
280
+
281
+
282
+ def refresh_entries(log=lambda text: None):
283
+ """Keep the menu entry (and an existing desktop shortcut) pointing at this installation.
284
+
285
+ Run on every start of `a2qlinux`, like sto-clare does for its own entry.
286
+ An entry whose command is this installation's `a2qlinux` is left alone,
287
+ with any options added to it by hand; any other - missing, from an older
288
+ layout (python3 …/questlog_desktop.py), or from an installation that was
289
+ moved or removed - is written again. The desktop shortcut is only
290
+ rewritten if it exists. Returns the paths written.
291
+ """
292
+ ours = command("a2qlinux")
293
+ questlog_dir = config.questlog_dir().resolve()
294
+ written = []
295
+ for path, create in ((MENU_ENTRY, True), (desktop_shortcut_path(), False)):
296
+ if not path.exists() and not create:
297
+ continue
298
+ if entry_command(path) == ours:
299
+ continue
300
+ path.parent.mkdir(parents=True, exist_ok=True)
301
+ path.write_text(desktop_entry(questlog_dir))
302
+ if path != MENU_ENTRY:
303
+ path.chmod(0o755)
304
+ log(f"Rewrote {path}: it did not run this installation's a2qlinux.")
305
+ written.append(path)
306
+ if not ICON.exists():
307
+ try:
308
+ ICON.parent.mkdir(parents=True, exist_ok=True)
309
+ ICON.write_bytes(read_asar_file(questlog_dir / "resources" / "app.asar", "resources/icon.png"))
310
+ except (OSError, KeyError, ValueError, struct.error):
311
+ pass # the entry works without its icon; a2qlinux-setup reports why
312
+ return written
313
+
314
+
315
+ def main():
316
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
317
+ parser.add_argument("--questlog-dir", type=Path,
318
+ default=None,
319
+ help=f"folder with Questlog.exe (default: from {config.CONFIG_PATH}, else ~/Games/Questlog); "
320
+ "written into a new settings file")
321
+ parser.add_argument("--desktop-shortcut", action="store_true", help="also put the entry on the desktop")
322
+ args = parser.parse_args()
323
+ settings, config_problems = config.load()
324
+ questlog_dir = (args.questlog_dir or settings.questlog_dir).expanduser().resolve()
325
+ problems = list(config_problems)
326
+ if not config.CONFIG_PATH.exists():
327
+ config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
328
+ config.CONFIG_PATH.write_text(config.template(questlog_dir))
329
+ print(f"Settings: {config.CONFIG_PATH} (every setting at its default, with comments)")
330
+ elif questlog_dir != settings.questlog_dir.resolve():
331
+ problems.append(f"--questlog-dir {questlog_dir} differs from {settings.questlog_dir} in "
332
+ f"{config.CONFIG_PATH}; set questlog_dir there, which A2Qlinux reads")
333
+ else:
334
+ print(f"Settings: {config.CONFIG_PATH}")
335
+
336
+ info = os_release()
337
+ missing, wanted = missing_packages()
338
+ tray = tray_problem()
339
+ if tray and "xcb-cursor" in tray:
340
+ wanted.append("xcb-cursor")
341
+ commands = install_commands(wanted, info)
342
+ if missing or commands:
343
+ problems.append("missing: " + "; ".join(missing or ["a library for the tray icon"])
344
+ + "".join(f"\n {line}" for line in commands))
345
+ capture = capture_problem(info)
346
+ if capture:
347
+ problems.append(capture)
348
+ if tray:
349
+ problems.append(tray)
350
+
351
+ parts_problem = windows_parts_problem()
352
+ if parts_problem:
353
+ problems.append(parts_problem)
354
+
355
+ asar = questlog_dir / "resources" / "app.asar"
356
+ if not (questlog_dir / "Questlog.exe").is_file():
357
+ if not shutil.which("7z"):
358
+ problems.append(f"Questlog is not in {questlog_dir} yet; it is installed there once 7z is installed "
359
+ "(see 'missing' above)")
360
+ else:
361
+ print(f"Questlog is not in {questlog_dir}; installing the newest version there. To use a Questlog "
362
+ "you already have instead, stop with Ctrl+C and pass its folder with --questlog-dir.")
363
+ try:
364
+ install_fresh(questlog_dir, print)
365
+ except RuntimeError as error:
366
+ problems.append(str(error))
367
+ if (questlog_dir / "Questlog.exe").is_file():
368
+ try:
369
+ icon = read_asar_file(asar, "resources/icon.png")
370
+ ICON.parent.mkdir(parents=True, exist_ok=True)
371
+ ICON.write_bytes(icon)
372
+ print(f"Icon: {ICON}")
373
+ except (OSError, KeyError, ValueError, struct.error) as error:
374
+ problems.append(f"could not take Questlog's icon from {asar}: {error!r}")
375
+
376
+ try:
377
+ entry = desktop_entry(questlog_dir)
378
+ except RuntimeError as error:
379
+ print(f"a2qlinux-setup: {error}", file=sys.stderr)
380
+ return 1
381
+ MENU_ENTRY.parent.mkdir(parents=True, exist_ok=True)
382
+ MENU_ENTRY.write_text(entry)
383
+ print(f"Menu entry: {MENU_ENTRY}")
384
+ if args.desktop_shortcut:
385
+ shortcut = desktop_shortcut_path()
386
+ shortcut.write_text(entry)
387
+ shortcut.chmod(0o755)
388
+ print(f"Desktop shortcut: {shortcut}")
389
+
390
+ print("\nSteam > AION 2 > Properties > General > Launch Options (optional, starts Questlog with the game):")
391
+ print(f" {command('a2qlinux-steam')} %command%")
392
+
393
+ if problems:
394
+ print("\nNot ready yet. Fix these, then run a2qlinux-setup again:", file=sys.stderr)
395
+ for problem in problems:
396
+ print(f" - {problem}", file=sys.stderr)
397
+ return 1
398
+ print("\nReady. Start 'Questlog (AION 2)' from the menu.")
399
+ return 0
400
+
401
+
402
+ if __name__ == "__main__":
403
+ sys.exit(main())