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/launcher.py ADDED
@@ -0,0 +1,1089 @@
1
+ #!/usr/bin/env python3
2
+ """Run Questlog Desktop for AION 2 under Proton, fed with live game traffic.
3
+
4
+ Questlog captures game traffic by calling libpcap functions in
5
+ C:\\Windows\\System32\\Npcap\\wpcap.dll. Npcap is a Windows driver that cannot
6
+ work under Wine, so this launcher puts our own wpcap.dll there (built from
7
+ bridge/wpcap_bridge.c) and feeds it from dumpcap on the Linux side:
8
+
9
+ dumpcap (TCP 13328) -> BridgeServer on 127.0.0.1 -> wpcap.dll -> Questlog
10
+
11
+ Questlog then runs its normal live capture loop, as on Windows.
12
+
13
+ Run it as your normal user (dumpcap through the `wireshark` group). Questlog
14
+ opens at once, game or not.
15
+
16
+ While Questlog runs, its tray icon is ours (a2qlinux/tray.py, drawn by KDE):
17
+ Questlog's own icon is hidden because under Wine its menu opens only once.
18
+ "Quit Questlog" there ends Questlog once it has saved: at once if no game
19
+ data reached it in the last SAVE_WAIT seconds, else hidden at once and ended
20
+ SAVE_WAIT seconds after the last data; asking for it again before then keeps it running.
21
+ The desktop entry asks this launcher through signals (the PID is in the lock
22
+ file): SIGUSR1 = open Questlog's window, SIGUSR2 = keep Questlog running.
23
+ """
24
+
25
+ import argparse
26
+ import collections
27
+ import os
28
+ from pathlib import Path
29
+ import re
30
+ import shutil
31
+ import signal
32
+ import socket
33
+ import stat
34
+ import subprocess
35
+ import sys
36
+ import threading
37
+ import time
38
+
39
+ from . import config
40
+ from . import update as questlog_update
41
+ from .pcap_bridge import BridgeServer, read_records
42
+
43
+ APPID = 3393110
44
+ GAME_PORT = 13328
45
+ # The Windows parts, built by bridge/build.sh and shipped in the package.
46
+ BIN = Path(__file__).resolve().parent / "bin"
47
+ BRIDGE_DLL = BIN / "wpcap.dll"
48
+ # Marks our DLL in the prefix (kept from the project's first name: changing it
49
+ # would make install_bridge_dll take an installed build for a foreign file).
50
+ BRIDGE_SIGNATURE = b"questlog-proton bridge"
51
+
52
+
53
+ def log(message):
54
+ print(f"[a2qlinux] {message}", flush=True)
55
+
56
+
57
+ # ---------------------------------------------------------------- processes
58
+
59
+ def _read(path, limit=262144):
60
+ try:
61
+ with open(path, "rb") as stream:
62
+ return stream.read(limit).decode("utf-8", errors="replace")
63
+ except OSError:
64
+ return ""
65
+
66
+
67
+ def program_name(pid):
68
+ """Lower-case file name of the program a process runs, from its command line.
69
+
70
+ Wine puts the Windows path of the .exe in argv[0]. The kernel's `comm`
71
+ is no use here: Chromium renames its processes (CrBrowserMain,
72
+ CrGpuMain, ...), so Questlog never shows up under its own name there.
73
+ """
74
+ return exe_name(_read(f"/proc/{pid}/cmdline", 4096).split("\0", 1)[0])
75
+
76
+
77
+ def exe_name(argv0):
78
+ """Lower-case file name in a Windows or Linux path."""
79
+ return argv0.replace("\\", "/").rsplit("/", 1)[-1].lower()
80
+
81
+
82
+ def game_processes(appid, names):
83
+ """PIDs of this user's processes in the game's Proton prefix, by program name.
84
+
85
+ The prefix is recognised from the process environment (SteamAppId or the
86
+ compatdata path), so a program of the same name elsewhere is not counted.
87
+ """
88
+ found = {name: set() for name in names}
89
+ uid = str(os.getuid())
90
+ for entry in os.scandir("/proc"):
91
+ if not entry.name.isdigit():
92
+ continue
93
+ name = program_name(entry.name)
94
+ if name not in found:
95
+ continue
96
+ status = dict(line.split(":", 1) for line in _read(f"/proc/{entry.name}/status").splitlines() if ":" in line)
97
+ if status.get("Uid", "").split()[:1] != [uid] or status.get("State", "").strip()[:1] in {"Z", "X"}:
98
+ continue
99
+ env = dict(item.split("=", 1) for item in _read(f"/proc/{entry.name}/environ").split("\0") if "=" in item)
100
+ in_game = any(env.get(key) == str(appid) for key in ("SteamAppId", "STEAM_APPID", "SteamGameId"))
101
+ compat = env.get("STEAM_COMPAT_DATA_PATH", "")
102
+ in_game |= bool(compat) and Path(compat).name == str(appid)
103
+ if in_game:
104
+ found[name].add(int(entry.name))
105
+ return found
106
+
107
+
108
+ FLATPAK_STEAM = Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam"
109
+
110
+
111
+ def steam_roots(home=None):
112
+ """Native Steam installations, each once.
113
+
114
+ Valve's launcher keeps ~/.steam/steam (and ~/.steam/root) pointing at the
115
+ installation, wherever the user or the distribution put it; the usual
116
+ folders are listed after them for an installation without those links.
117
+ """
118
+ home = Path(home) if home else Path.home()
119
+ data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local" / "share")
120
+ roots = []
121
+ for candidate in (home / ".steam" / "steam", home / ".steam" / "root", data / "Steam", home / "Steam"):
122
+ root = candidate.resolve()
123
+ if root.is_dir() and root not in roots:
124
+ roots.append(root)
125
+ return roots
126
+
127
+
128
+ def game_prefix(appid, roots=None):
129
+ """The game's Wine prefix.
130
+
131
+ Read from a running AION2.exe's environment when there is one; otherwise
132
+ looked up in Steam's library folders, the way Steam itself lays it out.
133
+ """
134
+ for pid in game_processes(appid, ["aion2.exe"])["aion2.exe"]:
135
+ env = dict(item.split("=", 1) for item in _read(f"/proc/{pid}/environ").split("\0") if "=" in item)
136
+ if env.get("WINEPREFIX"):
137
+ return Path(env["WINEPREFIX"])
138
+ if env.get("STEAM_COMPAT_DATA_PATH"):
139
+ return Path(env["STEAM_COMPAT_DATA_PATH"]) / "pfx"
140
+ roots = steam_roots() if roots is None else roots
141
+ lists = [root / "steamapps" / "libraryfolders.vdf" for root in roots]
142
+ for libraries in lists:
143
+ for library in [Path(m) for m in re.findall(r'"path"\s+"([^"]+)"', _read(libraries))]:
144
+ prefix = library / "steamapps" / "compatdata" / str(appid) / "pfx"
145
+ if (prefix / "drive_c").is_dir():
146
+ return prefix
147
+ if not roots and FLATPAK_STEAM.is_dir():
148
+ raise RuntimeError(f"Only a Flatpak Steam was found ({FLATPAK_STEAM}). A2Qlinux needs Steam from "
149
+ "the distribution's packages or from Valve; see docs/INSTALL.md.")
150
+ looked = ", ".join(map(str, lists)) or "no Steam installation found"
151
+ raise RuntimeError(f"AION 2's Wine prefix was not found in Steam's libraries ({looked}). "
152
+ "Start the game once from Steam so the prefix exists.")
153
+
154
+
155
+ def install_bridge_dll(prefix):
156
+ """Put our wpcap.dll at C:\\Windows\\System32\\Npcap\\wpcap.dll in the game prefix.
157
+
158
+ Replaces only what this project put there: the empty marker earlier
159
+ versions created, or an older build of our DLL (recognised by its
160
+ signature string). Any other file - a real Npcap, say - is left alone and
161
+ reported, since overwriting it is not ours to decide.
162
+ """
163
+ if not BRIDGE_DLL.is_file():
164
+ raise RuntimeError(f"{BRIDGE_DLL} is missing; in a source checkout build it with: bash bridge/build.sh")
165
+ ours = BRIDGE_DLL.read_bytes()
166
+ target = prefix / "drive_c" / "windows" / "system32" / "Npcap" / "wpcap.dll"
167
+ if target.exists():
168
+ current = target.read_bytes()
169
+ if current == ours:
170
+ return
171
+ if current and BRIDGE_SIGNATURE not in current:
172
+ raise RuntimeError(f"{target} is not ours (a real Npcap?); not replacing it. "
173
+ "Move it away if Questlog should use the Linux capture bridge.")
174
+ target.parent.mkdir(exist_ok=True)
175
+ temporary = target.with_suffix(".tmp")
176
+ temporary.write_bytes(ours)
177
+ temporary.replace(target)
178
+ log(f"Installed the capture bridge DLL at {target}.")
179
+
180
+
181
+ def steam_launch_pending(appid):
182
+ """True while Steam is starting the game and Proton waits for a free prefix.
183
+
184
+ Steam starts the game with Proton's `waitforexitandrun` verb, which first
185
+ waits (`wineserver -w`) until nothing runs in the game's Wine prefix any
186
+ more. A Questlog started before the game keeps that prefix busy, so the
187
+ game would wait for as long as Questlog runs. That Proton process stays
188
+ on as the game's parent once the game runs, so a running AION2.exe means
189
+ the launch is no longer pending.
190
+ """
191
+ if game_processes(appid, ["aion2.exe"])["aion2.exe"]:
192
+ return False
193
+ uid = str(os.getuid())
194
+ for entry in os.scandir("/proc"):
195
+ if not entry.name.isdigit():
196
+ continue
197
+ args = _read(f"/proc/{entry.name}/cmdline", 8192).split("\0")
198
+ # Only Proton's own process counts: python3 <...>/proton waitforexitandrun <...>AION2.exe.
199
+ # Steam's reaper and the runtime container carry the original command
200
+ # line too, even after the a2qlinux-steam wrapper has switched Proton to "run".
201
+ if len(args) < 4 or not Path(args[0]).name.startswith("python") or not args[1].endswith("/proton") \
202
+ or args[2] != "waitforexitandrun" or not args[3].lower().endswith("aion2.exe"):
203
+ continue
204
+ status = dict(line.split(":", 1) for line in _read(f"/proc/{entry.name}/status").splitlines() if ":" in line)
205
+ if status.get("Uid", "").split()[:1] != [uid]:
206
+ continue
207
+ env = dict(item.split("=", 1) for item in _read(f"/proc/{entry.name}/environ").split("\0") if "=" in item)
208
+ if any(env.get(key) == str(appid) for key in ("SteamAppId", "STEAM_APPID", "SteamGameId")) \
209
+ or Path(env.get("STEAM_COMPAT_DATA_PATH", "/")).name == str(appid):
210
+ return True
211
+ return False
212
+
213
+
214
+ def wait_for_game_or_cancel(appid):
215
+ """After yielding to Steam: wait for the game, or for Steam to give up."""
216
+ quiet_since = None
217
+ while not game_processes(appid, ["aion2.exe"])["aion2.exe"]:
218
+ if steam_launch_pending(appid):
219
+ quiet_since = None
220
+ elif quiet_since is None:
221
+ quiet_since = time.monotonic()
222
+ elif time.monotonic() - quiet_since >= 10:
223
+ log("Steam is no longer starting AION 2; reopening Questlog without the game.")
224
+ return
225
+ time.sleep(0.5)
226
+ time.sleep(2)
227
+ log("AION 2 is running; reopening Questlog.")
228
+
229
+
230
+ # ------------------------------------------------------------------ capture
231
+
232
+ def system_dumpcap():
233
+ found = shutil.which("dumpcap")
234
+ if not found:
235
+ raise RuntimeError("dumpcap is missing. Run a2qlinux-setup, which names the package to install.")
236
+ path = Path(found).resolve(strict=True)
237
+ info = path.stat()
238
+ if info.st_uid != 0 or info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
239
+ raise RuntimeError(f"{path} is not the root-owned dumpcap from the distribution.")
240
+ return path
241
+
242
+
243
+ def interface_address(name):
244
+ import fcntl
245
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
246
+ result = fcntl.ioctl(sock.fileno(), 0x8915, name.encode()[:15].ljust(256, b"\0"))
247
+ return socket.inet_ntoa(result[20:24])
248
+
249
+
250
+ def choose_interface(requested):
251
+ usable = {}
252
+ for _, name in socket.if_nameindex():
253
+ if name == "lo":
254
+ continue
255
+ try:
256
+ usable[name] = interface_address(name)
257
+ except OSError:
258
+ continue
259
+ if requested:
260
+ if requested not in usable:
261
+ raise RuntimeError(f"Interface {requested} has no IPv4 address. Usable: {', '.join(usable) or 'none'}.")
262
+ return requested, usable[requested]
263
+ if len(usable) == 1:
264
+ return next(iter(usable.items()))
265
+ if not usable:
266
+ raise RuntimeError("No network interface with an IPv4 address was found.")
267
+ raise RuntimeError(f"Several interfaces are up ({', '.join(usable)}); choose one with --interface.")
268
+
269
+
270
+ def start_dumpcap(dumpcap, interface, address):
271
+ command = [str(dumpcap), "-i", interface, "-p", "-F", "pcap", "-q", "-s", "262144", "-B", "8",
272
+ "--update-interval", "20", "-f", f"tcp and host {address} and port {GAME_PORT}", "-w", "-"]
273
+ return subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, start_new_session=True)
274
+
275
+
276
+ def stop_process(process):
277
+ if process.poll() is None:
278
+ process.terminate()
279
+ try:
280
+ process.wait(timeout=3)
281
+ except subprocess.TimeoutExpired:
282
+ process.kill()
283
+ process.wait(timeout=3)
284
+
285
+
286
+ def pump_records(stream, server, status):
287
+ """Thread body: hand dumpcap's packets to the bridge; record how it ended."""
288
+ try:
289
+ for header, record in read_records(stream):
290
+ server.publish(header, record)
291
+ if record is not None:
292
+ status["packets"] += 1
293
+ status["last_packet"] = time.monotonic()
294
+ status["error"] = "dumpcap stopped. Check capture permission: run a2qlinux-setup, which tests it and says what to do."
295
+ except (ValueError, OSError) as error:
296
+ status["error"] = f"Capture stream unreadable: {error}"
297
+
298
+
299
+ # ------------------------------------------------------------------- session
300
+
301
+ def session_lock(directory):
302
+ """Hold a lock for as long as this launcher runs; refuse a second launcher.
303
+
304
+ A second launcher would start a second bridge and a second Questlog launch
305
+ that hands over to the running one, which stays on the first bridge. The lock is released by
306
+ the kernel when this process ends, however it ends.
307
+ """
308
+ import fcntl
309
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
310
+ fd = os.open(directory / ".lock", os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
311
+ try:
312
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
313
+ except BlockingIOError:
314
+ os.close(fd)
315
+ raise RuntimeError("A2Qlinux is already running in another window; use that one.") from None
316
+ # The desktop entry signals this PID (SIGUSR1/SIGUSR2) instead of starting a second launcher.
317
+ os.ftruncate(fd, 0)
318
+ os.pwrite(fd, f"{os.getpid()}\n".encode(), 0)
319
+ return fd
320
+
321
+
322
+ # ----------------------------------------------------------- tray and quit
323
+
324
+ CTL = BIN / "questlog_ctl.exe"
325
+ SAVE_WAIT = 10.5 # s after the last game data before Questlog may be ended: it saves pending data within 10 s
326
+
327
+
328
+ class Requests:
329
+ """What the user asked for: from our tray icon (a reader thread) and the desktop entry (signals).
330
+
331
+ A deque, because its append and popleft are atomic and take no lock that
332
+ a signal handler could interrupt.
333
+ """
334
+
335
+ def __init__(self):
336
+ self._items = collections.deque()
337
+
338
+ def put(self, item):
339
+ self._items.append(item)
340
+
341
+ def take(self):
342
+ items = []
343
+ while self._items:
344
+ items.append(self._items.popleft())
345
+ return items
346
+
347
+
348
+ class QuitControl:
349
+ """Quitting from our tray icon, and the requests that call it off.
350
+
351
+ Electron's session end - the only way to end Questlog from outside - skips
352
+ Questlog's own saving, and Questlog writes pending data within 10 s of
353
+ receiving it. So Questlog is ended SAVE_WAIT seconds after the last game
354
+ data reached it: at once if that was long enough ago (no game, or the game
355
+ closed), otherwise hidden at once - our icon goes, its window closes to the
356
+ background - and ended when the time is up. Opening it again before then
357
+ (SIGUSR1, our icon) or the game being started from Steam (SIGUSR2) calls
358
+ the quit off.
359
+
360
+ handle() returns the actions to carry out, in order:
361
+ tray-off / tray-on stop / start our tray icon
362
+ close-window hide Questlog's main window (only when ending is not immediate)
363
+ show-window show Questlog's main window
364
+ """
365
+
366
+ def __init__(self, save_wait=SAVE_WAIT):
367
+ self.save_wait = save_wait
368
+ self.due_at = None
369
+
370
+ @property
371
+ def quitting(self):
372
+ return self.due_at is not None
373
+
374
+ def handle(self, request, now, last_data=None):
375
+ """last_data: monotonic time of the last game data Questlog received, or None."""
376
+ if request == "quit":
377
+ if self.quitting:
378
+ return []
379
+ self.due_at = max(now, last_data + self.save_wait) if last_data is not None else now
380
+ return ["tray-off"] if self.due_at <= now else ["tray-off", "close-window"]
381
+ if request == "open":
382
+ if self.quitting:
383
+ self.due_at = None
384
+ return ["tray-on", "show-window"]
385
+ return ["show-window"]
386
+ if request == "keep":
387
+ if self.quitting:
388
+ self.due_at = None
389
+ return ["tray-on"]
390
+ return []
391
+ raise ValueError(f"unknown request {request!r}")
392
+
393
+ def due(self, now):
394
+ return self.quitting and now >= self.due_at
395
+
396
+ def reset(self):
397
+ self.due_at = None
398
+
399
+
400
+ def run_ctl(protontricks, command, log_path):
401
+ """Run questlog_ctl.exe <command> in the game prefix; log what it reports. Returns its exit code."""
402
+ with log_path.open("ab") as wine_log:
403
+ try:
404
+ done = subprocess.run([protontricks, "--appid", str(APPID), str(CTL), command],
405
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=wine_log,
406
+ timeout=120, start_new_session=True)
407
+ except subprocess.TimeoutExpired:
408
+ log(f"questlog_ctl.exe {command} did not finish within 120 s.")
409
+ return -1
410
+ lines = [line for line in done.stdout.decode("utf-8", "replace").splitlines() if line.strip()]
411
+ for line in lines:
412
+ log(f"Questlog control: {line}")
413
+ if not lines:
414
+ log(f"questlog_ctl.exe {command} printed nothing (exit code {done.returncode}); "
415
+ f"Wine's output is in {log_path}.")
416
+ return done.returncode
417
+
418
+
419
+ def run_ctl_async(protontricks, command, log_path):
420
+ threading.Thread(target=run_ctl, args=(protontricks, command, log_path), daemon=True).start()
421
+
422
+
423
+ def hide_questlog_tray_icon(protontricks, log_path, timeout=60):
424
+ """Hide Questlog's own tray icon once Questlog runs (in a thread).
425
+
426
+ Not at the same moment as Questlog's launch: with no Wine session running
427
+ yet, two protontricks-launch calls starting together can bring up two
428
+ separate sessions in the prefix, and questlog_ctl.exe would then look for
429
+ the icon in one where Questlog does not run.
430
+ """
431
+ def hide():
432
+ deadline = time.monotonic() + timeout
433
+ while not game_processes(APPID, ["questlog.exe"])["questlog.exe"]:
434
+ if time.monotonic() > deadline:
435
+ log(f"Questlog did not start within {timeout} s; its own tray icon was not hidden.")
436
+ return
437
+ time.sleep(0.5)
438
+ time.sleep(2)
439
+ run_ctl(protontricks, "hide-tray", log_path)
440
+ threading.Thread(target=hide, daemon=True).start()
441
+
442
+
443
+ class Tray:
444
+ """Our tray icon (a2qlinux/tray.py); what is chosen there goes into requests.
445
+
446
+ It runs in its own session, so a Ctrl+C meant for this launcher does not
447
+ reach it; it ends when its stdin closes. If it ends by itself, "tray-died"
448
+ is put into requests so that Questlog's own icon can be shown again.
449
+ """
450
+
451
+ def __init__(self, requests):
452
+ self.requests = requests
453
+ self.process = None
454
+ self.stopping = False
455
+
456
+ def start(self):
457
+ if self.process and self.process.poll() is None:
458
+ return
459
+ self.stopping = False
460
+ self.process = subprocess.Popen([sys.executable, "-m", "a2qlinux.tray"], stdin=subprocess.PIPE,
461
+ stdout=subprocess.PIPE, start_new_session=True)
462
+ threading.Thread(target=self._read, args=(self.process,), daemon=True).start()
463
+
464
+ def _read(self, process):
465
+ for line in process.stdout:
466
+ word = line.decode("utf-8", "replace").strip()
467
+ if word in ("open", "quit"):
468
+ self.requests.put(word)
469
+ code = process.wait()
470
+ if not self.stopping:
471
+ log(f"Our tray icon stopped by itself (exit code {code}).")
472
+ self.requests.put("tray-died")
473
+
474
+ def stop(self):
475
+ process, self.process = self.process, None
476
+ if not process:
477
+ return
478
+ self.stopping = True
479
+ try:
480
+ process.stdin.close()
481
+ process.wait(timeout=3)
482
+ except (OSError, subprocess.TimeoutExpired):
483
+ process.kill()
484
+ process.wait(timeout=3)
485
+
486
+
487
+ def find_questlog_window():
488
+ """X window id of Questlog's main window while it is shown, else None.
489
+
490
+ Shown windows are in the window manager's _NET_CLIENT_LIST; a hidden one
491
+ is not. Recognised by title "Questlog" and a _NET_WM_PID that is one of
492
+ Questlog's processes (overlay windows are not managed, so not listed).
493
+ """
494
+ from Xlib import X, display
495
+ screen = display.Display()
496
+ try:
497
+ pids = game_processes(APPID, ["questlog.exe"])["questlog.exe"]
498
+ listed = screen.screen().root.get_full_property(screen.intern_atom("_NET_CLIENT_LIST"), X.AnyPropertyType)
499
+ pid_atom = screen.intern_atom("_NET_WM_PID")
500
+ for wid in (listed.value if listed else []):
501
+ window = screen.create_resource_object("window", wid)
502
+ try:
503
+ name = window.get_wm_name()
504
+ pid = window.get_full_property(pid_atom, X.AnyPropertyType)
505
+ except Exception: # the window went away meanwhile
506
+ continue
507
+ if isinstance(name, bytes):
508
+ name = name.decode("utf-8", "replace")
509
+ if name == "Questlog" and pid and pid.value[0] in pids:
510
+ return wid
511
+ return None
512
+ finally:
513
+ screen.close()
514
+
515
+
516
+ def activate_questlog_window(timeout=15):
517
+ """Bring Questlog's main window to the front once it is shown.
518
+
519
+ Questlog shows it itself when it is started a second time, but Wine does
520
+ not let it take the focus (no recent user input in its process;
521
+ docs/ARCHITECTURE.md §6.3), so KWin is asked instead, through
522
+ _NET_ACTIVE_WINDOW (xdotool).
523
+ """
524
+ deadline = time.monotonic() + timeout
525
+ while time.monotonic() < deadline:
526
+ wid = find_questlog_window()
527
+ if wid:
528
+ subprocess.run(["xdotool", "windowactivate", str(wid)], check=False)
529
+ return True
530
+ time.sleep(0.3)
531
+ log(f"Questlog's window did not appear within {timeout} s of asking for it.")
532
+ return False
533
+
534
+
535
+ def show_questlog_window(start_again):
536
+ """Show Questlog's main window: start Questlog again - it hands over to the
537
+ running copy, which shows its window - then bring that window to the front."""
538
+ def show():
539
+ start_again().wait()
540
+ activate_questlog_window()
541
+ threading.Thread(target=show, daemon=True).start()
542
+
543
+
544
+ # ------------------------------------------------------------------- launch
545
+
546
+ UPDATE_CHECK_EVERY = 6 * 3600 # s between checks for a new Questlog; set from config (update_check_hours)
547
+ UPDATE_CHECK_DEFER = 600 # s to wait while the game runs: no 115 MB download during play
548
+ UPDATE_TRIAL = 60 # s: a just-updated Questlog that exits sooner is rolled back
549
+
550
+
551
+ def notify(text):
552
+ """Desktop notification, for what the user should know even with the terminal hidden."""
553
+ subprocess.run(["notify-send", "--app-name=Questlog (AION 2)",
554
+ "--icon=" + str(Path.home() / ".local/share/icons/questlog-aion2.png"),
555
+ "Questlog (AION 2)", text], check=False)
556
+
557
+
558
+ def check_for_updates(questlog_dir, state_dir):
559
+ """Thread body: stage new Questlog versions, never while the game runs."""
560
+ time.sleep(60)
561
+ while True:
562
+ if game_processes(APPID, ["aion2.exe"])["aion2.exe"]:
563
+ time.sleep(UPDATE_CHECK_DEFER)
564
+ continue
565
+ questlog_update.stage(questlog_dir, state_dir, log, notify)
566
+ time.sleep(UPDATE_CHECK_EVERY)
567
+
568
+
569
+ GAME_EXIT_GRACE = 15 # s without AION2.exe before Questlog closes with the game; set from config (after_game_delay)
570
+ QUESTLOG_QUIT_WAIT = 20 # s for Questlog to quit after the session end
571
+
572
+
573
+ def wait_for_questlog_exit(saved_note):
574
+ """After a session end: wait for all of Questlog's processes to go; stop any left over."""
575
+ deadline = time.monotonic() + QUESTLOG_QUIT_WAIT
576
+ while time.monotonic() < deadline:
577
+ if not game_processes(APPID, ["questlog.exe"])["questlog.exe"]:
578
+ log("Questlog closed.")
579
+ return
580
+ time.sleep(0.5)
581
+ left = game_processes(APPID, ["questlog.exe"])["questlog.exe"]
582
+ for pid in left:
583
+ try:
584
+ os.kill(pid, signal.SIGTERM)
585
+ except ProcessLookupError:
586
+ pass
587
+ log(f"Questlog did not quit within {QUESTLOG_QUIT_WAIT} s of the session end; "
588
+ f"stopped its {len(left)} processes ({saved_note}).")
589
+
590
+
591
+ def wine_session_running(prefix):
592
+ """True while a wineserver serves the game's prefix (WINEPREFIX, else STEAM_COMPAT_DATA_PATH/pfx)."""
593
+ prefix = Path(prefix).resolve()
594
+ uid = str(os.getuid())
595
+ with os.scandir("/proc") as entries:
596
+ for entry in entries:
597
+ if not entry.name.isdigit() or _read(f"/proc/{entry.name}/comm").strip() != "wineserver":
598
+ continue
599
+ status = dict(line.split(":", 1) for line in _read(f"/proc/{entry.name}/status").splitlines() if ":" in line)
600
+ if status.get("Uid", "").split()[:1] != [uid]:
601
+ continue
602
+ env = dict(item.split("=", 1) for item in _read(f"/proc/{entry.name}/environ").split("\0") if "=" in item)
603
+ served = env.get("WINEPREFIX") or (env.get("STEAM_COMPAT_DATA_PATH", "") + "/pfx")
604
+ if served and Path(served).resolve() == prefix:
605
+ return True
606
+ return False
607
+
608
+
609
+ SESSION_END_WAIT = 30 # s for the game's Wine session to close once Questlog has gone
610
+ STEAM_HELPER_WAIT = 5 # s for the game's steam.exe to end after SIGTERM
611
+ RELEASE_SETTLE = 2 # s the game's steam.exe must be alone in its launch before it is ended
612
+
613
+
614
+ def process_table():
615
+ """{pid: (parent pid, kernel name, argv[0])} of this user's processes."""
616
+ table = {}
617
+ uid = str(os.getuid())
618
+ with os.scandir("/proc") as entries:
619
+ for entry in entries:
620
+ if not entry.name.isdigit():
621
+ continue
622
+ status = dict(line.split(":", 1) for line in _read(f"/proc/{entry.name}/status").splitlines() if ":" in line)
623
+ if status.get("Uid", "").split()[:1] != [uid] or status.get("State", "").strip()[:1] in {"Z", "X"}:
624
+ continue
625
+ table[int(entry.name)] = (int(status.get("PPid", "0").strip() or 0), status.get("Name", "").strip(),
626
+ _read(f"/proc/{entry.name}/cmdline", 4096).split("\0", 1)[0])
627
+ return table
628
+
629
+
630
+ def is_windows_program(argv0):
631
+ """Wine puts the program's Windows path (C:\\…, Z:/…) in argv[0]."""
632
+ return bool(re.match(r"[A-Za-z]:[\\/]", argv0))
633
+
634
+
635
+ def game_launch_state(table, steam_pids):
636
+ """What is left of Steam's launch of the game once AION2.exe has gone: (state, detail).
637
+
638
+ Steam counts the game as running until its `reaper` for the game ends,
639
+ and that waits for every process below it (the runtime container's
640
+ pv-adverb is a subreaper and lets nothing go). Below it are Proton's
641
+ script and steam.exe, which waits until no ordinary program is left in
642
+ the Wine session (docs/ARCHITECTURE.md §6.7). Whose launch started the
643
+ session's wineserver decides what can be done:
644
+
645
+ "free" no steam.exe of the game is left: Steam already sees the game as closed
646
+ "release" only steam.exe is left in the game's launch: ending it releases Steam;
647
+ Questlog's session is elsewhere and stays (detail: steam.exe PIDs)
648
+ "wait" other Windows programs of the game's launch still run (detail: their names)
649
+ "restart" the Wine session itself was started by the game's launch, so it lives in
650
+ the launch until Questlog leaves it; or the launch cannot be told
651
+ (detail: why)
652
+ """
653
+ if not steam_pids:
654
+ return "free", None
655
+ reapers = set()
656
+ for pid in steam_pids:
657
+ ancestor = table.get(pid, (0,))[0]
658
+ while ancestor in table and table[ancestor][1] != "reaper":
659
+ ancestor = table[ancestor][0]
660
+ if ancestor not in table:
661
+ return "restart", f"steam.exe {pid} has no Steam reaper above it"
662
+ reapers.add(ancestor)
663
+ children = collections.defaultdict(list)
664
+ for pid, (parent, _, _) in table.items():
665
+ children[parent].append(pid)
666
+ below, todo = [], list(reapers)
667
+ while todo:
668
+ pid = todo.pop()
669
+ for child in children[pid]:
670
+ below.append(child)
671
+ todo.append(child)
672
+ if any(table[pid][1] == "wineserver" for pid in below):
673
+ return "restart", "the game's launch started the Wine session Questlog runs in"
674
+ others = sorted({exe_name(table[pid][2]) for pid in below
675
+ if is_windows_program(table[pid][2]) and pid not in steam_pids})
676
+ if others:
677
+ return "wait", others
678
+ return "release", set(steam_pids)
679
+
680
+
681
+ class AfterGame:
682
+ """What to do after the game has exited, from game_launch_state, polled.
683
+
684
+ next() returns one action, or None while there is nothing to do yet:
685
+ "free" Steam sees the game as closed already; Questlog stays as it is
686
+ "release" end the game's steam.exe; Questlog stays as it is
687
+ "restart" move Questlog out of the game's session and start it again
688
+
689
+ steam.exe must have been alone for `settle` seconds before it is ended:
690
+ a game that restarts itself goes through it. A restart ends Questlog
691
+ without letting it save, so it waits until `save_wait` after the last
692
+ game data reached Questlog (with the game closed, that is about when it
693
+ closed).
694
+ """
695
+
696
+ def __init__(self, exited_at, save_wait=SAVE_WAIT, settle=RELEASE_SETTLE):
697
+ self.exited_at = exited_at
698
+ self.save_wait = save_wait
699
+ self.settle = settle
700
+ self.alone_since = None
701
+
702
+ def restart_at(self, last_data=None):
703
+ return max(self.exited_at, last_data + self.save_wait) if last_data is not None else self.exited_at
704
+
705
+ def next(self, state, now, last_data=None):
706
+ if state == "free":
707
+ return "free"
708
+ if state != "release":
709
+ self.alone_since = None
710
+ return "restart" if state == "restart" and now >= self.restart_at(last_data) else None
711
+ if self.alone_since is None:
712
+ self.alone_since = now
713
+ return "release" if now - self.alone_since >= self.settle else None
714
+
715
+
716
+ def end_game_steam_helper(why, pids=None):
717
+ """End the game's steam.exe (those in `pids`, else every one of the game's). Returns how many were ended.
718
+
719
+ SIGTERM ends it and, with it, Proton's script, the runtime container and
720
+ Steam's reaper, exactly as Steam's own Stop does. Tested both after the
721
+ game's Wine session had closed (where steam.exe was seen sleeping on in
722
+ ntsync waits the server's exit does not wake) and with Questlog's session
723
+ still running, which it leaves alone (§6.7).
724
+ """
725
+ orphans = set(pids) if pids is not None else game_processes(APPID, ["steam.exe"])["steam.exe"]
726
+ for pid in orphans:
727
+ try:
728
+ os.kill(pid, signal.SIGTERM)
729
+ except ProcessLookupError:
730
+ pass
731
+ deadline = time.monotonic() + STEAM_HELPER_WAIT
732
+ while orphans and time.monotonic() < deadline and game_processes(APPID, ["steam.exe"])["steam.exe"] & orphans:
733
+ time.sleep(0.2)
734
+ left = game_processes(APPID, ["steam.exe"])["steam.exe"] & orphans
735
+ if orphans:
736
+ log(f"Ended {len(orphans) - len(left)} of {len(orphans)} steam.exe left over from the game's launch "
737
+ f"{why}; Steam sees AION 2 as closed now."
738
+ + (f" Still running: {sorted(left)} - use Stop in Steam." if left else ""))
739
+ return len(orphans) - len(left)
740
+
741
+
742
+ def move_to_fresh_session(prefix, protontricks, log_path):
743
+ """End Questlog and wait until the game's Wine session is gone. Returns True if it went.
744
+
745
+ Proton's steam.exe, which started the game, waits until no ordinary
746
+ program is left in the Wine session - Questlog included - and Steam shows
747
+ the game as running until then (docs/ARCHITECTURE.md §6.7). Ending
748
+ Questlog lets the session close and Steam see the game end; the caller
749
+ then starts Questlog again in a session of its own.
750
+ """
751
+ run_ctl(protontricks, "end-session", log_path)
752
+ wait_for_questlog_exit(f"it had {SAVE_WAIT:.0f} s since the last game data to save")
753
+ return release_steam_after_questlog(prefix, "Questlog starts again anyway.")
754
+
755
+
756
+ def release_steam_after_questlog(prefix, then=""):
757
+ """Once Questlog has been ended with the game gone: let Steam see the game end. True if it does.
758
+
759
+ Ending Questlog lets the game's Wine session close, but the game's
760
+ steam.exe was seen sleeping on after that, never woken (§6.7, step 3b),
761
+ and Steam then shows AION 2 as running for hours. So wait for the session
762
+ to close and end any steam.exe of the game left over. Nothing to do, and
763
+ no wait, while the game runs or when no steam.exe of it is left.
764
+ """
765
+ if game_processes(APPID, ["aion2.exe"])["aion2.exe"] or not game_processes(APPID, ["steam.exe"])["steam.exe"]:
766
+ return True
767
+ deadline = time.monotonic() + SESSION_END_WAIT
768
+ while time.monotonic() < deadline:
769
+ if not wine_session_running(prefix):
770
+ end_game_steam_helper("after its Wine session had closed")
771
+ return True
772
+ time.sleep(0.5)
773
+ log(f"The game's Wine session is still open {SESSION_END_WAIT} s after Questlog left it: another "
774
+ "program in the prefix keeps it (e.g. a second meter), and Steam shows AION 2 as running "
775
+ f"until that program ends. {then}".rstrip())
776
+ return False
777
+
778
+
779
+ def close_questlog(prefix, protontricks, log_path):
780
+ """Close Questlog after the game has gone, the way Windows does at log-off.
781
+
782
+ Closing its window only hides Questlog to the tray. A session end
783
+ (`wineboot -e`) makes it quit; it went in 2-3 s when measured. Questlog
784
+ keeps unsaved changes for at most 10 s (combat data) and 0.5 s (settings),
785
+ so after GAME_EXIT_GRACE nothing is lost even if it has to be stopped hard.
786
+ The wineboot launcher itself does not return once the session has ended,
787
+ so it is stopped here as soon as Questlog is gone.
788
+ """
789
+ wineboot = prefix / "drive_c" / "windows" / "system32" / "wineboot.exe"
790
+ with log_path.open("ab") as out:
791
+ ender = subprocess.Popen([protontricks, "--appid", str(APPID), str(wineboot), "-e"],
792
+ stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT,
793
+ start_new_session=True)
794
+ try:
795
+ wait_for_questlog_exit(f"its data had been saved for {GAME_EXIT_GRACE} s")
796
+ finally:
797
+ try:
798
+ os.killpg(ender.pid, signal.SIGTERM)
799
+ except ProcessLookupError:
800
+ pass
801
+ try:
802
+ ender.wait(timeout=5)
803
+ except subprocess.TimeoutExpired:
804
+ os.killpg(ender.pid, signal.SIGKILL)
805
+ ender.wait(timeout=5)
806
+
807
+
808
+ def launch_questlog(executable, server, address, protontricks, log_path, hidden=False):
809
+ env = os.environ.copy()
810
+ env["QUESTLOG_PROTON_BRIDGE"] = server.environment_value(address)
811
+ # Wine ships a builtin wpcap.dll and loads it instead of ours even when
812
+ # Questlog names our file by full path; "n" makes it take the native file.
813
+ overrides = [o for o in env.get("WINEDLLOVERRIDES", "").split(";") if o and not o.startswith("wpcap=")]
814
+ env["WINEDLLOVERRIDES"] = ";".join(overrides + ["wpcap=n"])
815
+ env["PWD"] = str(executable.parent)
816
+ # Inside Steam's container (pressure-vessel) Chromium cannot set up its
817
+ # Windows sandbox: the GPU process fails with error 39 and the renderers
818
+ # never start, so Questlog quits or shows no pages. Wine does not enforce
819
+ # the Windows security boundaries that sandbox relies on in any case.
820
+ # --hidden is Questlog's own switch: start with its window hidden, tray only.
821
+ extra = ["--hidden"] if hidden else []
822
+ return subprocess.Popen([protontricks, "--appid", str(APPID), str(executable), "--no-sandbox", *extra],
823
+ cwd=str(executable.parent), env=env, start_new_session=True,
824
+ stdin=subprocess.DEVNULL, stdout=log_path.open("ab"), stderr=subprocess.STDOUT)
825
+
826
+
827
+ def main():
828
+ global GAME_EXIT_GRACE, UPDATE_CHECK_EVERY
829
+ settings, config_problems = config.load()
830
+ GAME_EXIT_GRACE = settings.after_game_delay
831
+ UPDATE_CHECK_EVERY = settings.update_check_hours * 3600
832
+ default_exe = settings.questlog_dir / "Questlog.exe"
833
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter,
834
+ epilog=f"Settings file: {config.CONFIG_PATH} (options here override it).")
835
+ parser.add_argument("--exe", type=Path, default=default_exe, help=f"Questlog.exe (default {default_exe})")
836
+ parser.add_argument("--interface", default=settings.interface or None,
837
+ help="network interface carrying the game connection")
838
+ parser.add_argument("--no-auto-update", action="store_true",
839
+ help="do not download or install new Questlog versions (default: download them "
840
+ "in the background and install them at the next start)")
841
+ parser.add_argument("--close-with-game", action="store_true",
842
+ help=f"close Questlog {GAME_EXIT_GRACE} s after AION 2 exits "
843
+ "(default: Questlog stays open, e.g. to look through fights afterwards)")
844
+ args = parser.parse_args()
845
+ close_with_game = args.close_with_game or settings.after_game == "close"
846
+ auto_update = settings.auto_update and not args.no_auto_update
847
+ for problem in config_problems:
848
+ log(f"Settings: {problem}")
849
+ if config_problems:
850
+ notify(f"{len(config_problems)} problem(s) in {config.CONFIG_PATH}; defaults used for those settings. "
851
+ "Details in the A2Qlinux window.")
852
+ if os.geteuid() == 0:
853
+ parser.error("Run as your normal user, without sudo.")
854
+ executable = args.exe.expanduser().resolve()
855
+ if not executable.is_file():
856
+ parser.error(f"{executable} not found; install Questlog there or pass --exe.")
857
+ protontricks = shutil.which("protontricks-launch")
858
+ if not protontricks:
859
+ parser.error("protontricks-launch not found; install the native protontricks package.")
860
+ requests = Requests()
861
+ # Before the PID is published in the lock file: the default action of both would end this launcher.
862
+ signal.signal(signal.SIGUSR1, lambda *_: requests.put("open"))
863
+ signal.signal(signal.SIGUSR2, lambda *_: requests.put("keep"))
864
+ try:
865
+ state_dir = executable.parent / "proton-stream"
866
+ session_lock(state_dir)
867
+ dumpcap = system_dumpcap()
868
+ interface, address = choose_interface(args.interface)
869
+ running = game_processes(APPID, ["questlog.exe"])["questlog.exe"]
870
+ if running:
871
+ fed = any("QUESTLOG_PROTON_BRIDGE=" in _read(f"/proc/{pid}/environ") for pid in running)
872
+ if fed:
873
+ raise RuntimeError("Questlog is already running and fed by another A2Qlinux "
874
+ "(another terminal window). Nothing to do here.")
875
+ raise RuntimeError("Questlog is already running in the game prefix without the capture bridge. "
876
+ "Quit it (tray icon > Quit) and run this again.")
877
+ server = BridgeServer(log)
878
+ capture = start_dumpcap(dumpcap, interface, address)
879
+ status = {"packets": 0, "error": None}
880
+ threading.Thread(target=pump_records, args=(capture.stdout, server, status), daemon=True).start()
881
+ log(f"Capturing on {interface} ({address}), TCP port {GAME_PORT}.")
882
+ tray = None
883
+ try:
884
+ game_seen = bool(game_processes(APPID, ["aion2.exe"])["aion2.exe"])
885
+ prefix = game_prefix(APPID)
886
+ install_bridge_dll(prefix)
887
+ if game_seen:
888
+ log("AION 2 is running; starting Questlog.")
889
+ else:
890
+ log("AION 2 is not running; starting Questlog now (saved fights, database, map). "
891
+ "Start the game any time - its traffic then flows to Questlog.")
892
+ log_path = state_dir / "questlog.log"
893
+ log_path.write_bytes(b"") # one log per launcher run; relaunches append
894
+ tray = Tray(requests)
895
+ quit_control = QuitControl()
896
+
897
+ trial = {"until": 0.0} # end of a just-installed update's trial run
898
+
899
+ def start_questlog(hidden=False):
900
+ # A fresh Questlog shows its window (unless hidden); earlier "open" requests are answered by that.
901
+ requests.take()
902
+ questlog_update.disable_builtin_updater(executable.parent, log)
903
+ if auto_update and questlog_update.install_staged(executable.parent, state_dir, log):
904
+ trial["until"] = time.monotonic() + UPDATE_TRIAL
905
+ log(f"Starting Questlog {questlog_update.installed_version(executable.parent) or '(version unknown)'}.")
906
+ started = launch_questlog(executable, server, address, protontricks, log_path, hidden=hidden)
907
+ tray.start()
908
+ hide_questlog_tray_icon(protontricks, log_path)
909
+ return started
910
+
911
+ def start_again():
912
+ return launch_questlog(executable, server, address, protontricks, log_path)
913
+
914
+ questlog = start_questlog()
915
+ if auto_update:
916
+ threading.Thread(target=check_for_updates, args=(executable.parent, state_dir), daemon=True).start()
917
+ told = False
918
+ game_gone_since = None
919
+ after_exit = None
920
+ told_waiting = False
921
+ closing = False
922
+ while True:
923
+ if questlog.poll() is not None:
924
+ if time.monotonic() < trial["until"]:
925
+ trial["until"] = 0.0
926
+ version = questlog_update.installed_version(executable.parent)
927
+ log(f"Questlog {version} ended within {UPDATE_TRIAL} s of its first start after the update "
928
+ f"(code {questlog.returncode}); going back to the previous version.")
929
+ tray.stop()
930
+ if questlog_update.rollback(executable.parent, state_dir, log):
931
+ notify(f"Questlog {version} did not start under Proton; went back to the previous "
932
+ "version. Details in the terminal window.")
933
+ questlog = start_questlog()
934
+ continue
935
+ break
936
+ for request in requests.take():
937
+ if request == "tray-died":
938
+ log("Showing Questlog's own tray icon again, so it can still be quit.")
939
+ run_ctl_async(protontricks, "show-tray", log_path)
940
+ continue
941
+ was_quitting = quit_control.quitting
942
+ for action in quit_control.handle(request, time.monotonic(), status.get("last_packet")):
943
+ if action == "tray-off":
944
+ tray.stop()
945
+ elif action == "tray-on":
946
+ tray.start()
947
+ elif action == "close-window":
948
+ run_ctl_async(protontricks, "close-window", log_path)
949
+ elif action == "show-window":
950
+ show_questlog_window(start_again)
951
+ if request == "quit" and not was_quitting:
952
+ left = max(0.0, quit_control.due_at - time.monotonic())
953
+ if left > 0.5:
954
+ log(f"Quitting Questlog: hidden now, ended in {left:.0f} s, once it has saved the "
955
+ "last game data. Open it again before then and it keeps running.")
956
+ else:
957
+ log("Quitting Questlog now (no game data in the last "
958
+ f"{SAVE_WAIT:.0f} s, so nothing is waiting to be saved).")
959
+ elif request in ("open", "keep") and was_quitting:
960
+ log("Quit called off: Questlog was asked for again; it keeps running.")
961
+ elif request == "open":
962
+ log("Opening Questlog's window (asked from our tray icon or its menu entry).")
963
+ elif request == "keep":
964
+ log("The game is being started from Steam; Questlog keeps running.")
965
+ if quit_control.due(time.monotonic()):
966
+ log("Ending Questlog's session now.")
967
+ run_ctl(protontricks, "end-session", log_path)
968
+ wait_for_questlog_exit(f"it had {SAVE_WAIT:.0f} s since the last game data to save")
969
+ release_steam_after_questlog(prefix)
970
+ try:
971
+ questlog.wait(timeout=30)
972
+ except subprocess.TimeoutExpired:
973
+ log("The Questlog launcher did not end after Questlog quit; continuing anyway.")
974
+ quit_control.reset()
975
+ if any(r in ("open", "keep") for r in requests.take()):
976
+ log("Questlog was asked for again while it was ending; starting it again.")
977
+ questlog = start_questlog()
978
+ told = False
979
+ continue
980
+ log("Questlog has quit.")
981
+ return 0
982
+ if not game_seen and steam_launch_pending(APPID):
983
+ log("Steam is starting AION 2 and waits until nothing else runs in the game's "
984
+ "Wine prefix; closing Questlog now - it reopens once the game runs.")
985
+ tray.stop()
986
+ quit_control.reset()
987
+ close_questlog(prefix, protontricks, log_path)
988
+ try:
989
+ questlog.wait(timeout=30)
990
+ except subprocess.TimeoutExpired:
991
+ log("The previous Questlog launcher did not end; continuing anyway.")
992
+ wait_for_game_or_cancel(APPID)
993
+ game_seen = bool(game_processes(APPID, ["aion2.exe"])["aion2.exe"])
994
+ questlog = start_questlog()
995
+ told = False
996
+ continue
997
+ if status["error"]:
998
+ raise RuntimeError(status["error"])
999
+ if not told and status["packets"] and server.client_count():
1000
+ log("Game traffic is flowing to Questlog.")
1001
+ told = True
1002
+ if game_processes(APPID, ["aion2.exe"])["aion2.exe"]:
1003
+ if not game_seen:
1004
+ log("AION 2 has started.")
1005
+ elif game_gone_since is not None:
1006
+ log("AION 2 is running again.")
1007
+ game_seen = True
1008
+ game_gone_since = None
1009
+ after_exit = None
1010
+ elif not game_seen:
1011
+ pass
1012
+ elif game_gone_since is None:
1013
+ game_gone_since = time.monotonic()
1014
+ after_exit = AfterGame(game_gone_since)
1015
+ told_waiting = False
1016
+ if close_with_game:
1017
+ log(f"AION 2 has exited; closing Questlog in {GAME_EXIT_GRACE} s unless the game comes back.")
1018
+ else:
1019
+ log("AION 2 has exited; Questlog stays open and waits for the next start of the game.")
1020
+ elif close_with_game:
1021
+ if not closing and time.monotonic() - game_gone_since >= GAME_EXIT_GRACE:
1022
+ closing = True
1023
+ tray.stop()
1024
+ close_questlog(prefix, protontricks, log_path)
1025
+ release_steam_after_questlog(prefix)
1026
+ elif not quit_control.quitting:
1027
+ steam_pids = game_processes(APPID, ["steam.exe"])["steam.exe"]
1028
+ state, detail = game_launch_state(process_table(), steam_pids)
1029
+ action = after_exit.next(state, time.monotonic(), status.get("last_packet"))
1030
+ if state == "wait" and not told_waiting \
1031
+ and time.monotonic() - game_gone_since >= RELEASE_SETTLE + 3:
1032
+ log(f"Steam shows AION 2 as running while {', '.join(detail)} from the game's launch "
1033
+ "still runs; it is ended as soon as that program ends.")
1034
+ told_waiting = True
1035
+ elif state == "restart" and not told_waiting:
1036
+ left = max(0.0, after_exit.restart_at(status.get("last_packet")) - time.monotonic())
1037
+ log(f"Questlog restarts in {left:.0f} s, once it has saved the last game data: {detail}, "
1038
+ "so Steam shows AION 2 as running for as long as Questlog stays in that session.")
1039
+ told_waiting = True
1040
+ if action == "free":
1041
+ log("Steam sees AION 2 as closed; Questlog keeps running.")
1042
+ elif action == "release":
1043
+ end_game_steam_helper("- the only thing left of it, as Questlog's Wine session is "
1044
+ "its own; Questlog keeps running", detail)
1045
+ elif action == "restart":
1046
+ shown = find_questlog_window() is not None
1047
+ log("Moving Questlog out of the game's Wine session; back in a few seconds"
1048
+ + (" with its window open." if shown else ", in the tray."))
1049
+ move_to_fresh_session(prefix, protontricks, log_path)
1050
+ try:
1051
+ questlog.wait(timeout=30)
1052
+ except subprocess.TimeoutExpired:
1053
+ log("The previous Questlog launcher did not end; continuing anyway.")
1054
+ questlog = start_questlog(hidden=not shown)
1055
+ told = False
1056
+ if action:
1057
+ game_seen = False
1058
+ game_gone_since = None
1059
+ after_exit = None
1060
+ if action == "restart":
1061
+ continue
1062
+ time.sleep(0.5)
1063
+ log(f"Questlog has exited (code {questlog.returncode}); its output is in {log_path}.")
1064
+ return 0
1065
+ finally:
1066
+ if tray:
1067
+ tray.stop()
1068
+ # Ending while Questlog runs on (Ctrl+C, an error): give it its own tray icon back.
1069
+ if game_processes(APPID, ["questlog.exe"])["questlog.exe"]:
1070
+ run_ctl(protontricks, "show-tray", log_path)
1071
+ server.stop()
1072
+ stop_process(capture)
1073
+ except (OSError, RuntimeError) as error:
1074
+ print(f"a2qlinux: {error}", file=sys.stderr)
1075
+ return 1
1076
+
1077
+
1078
+ def run():
1079
+ """Entry point (python -m a2qlinux.launcher, a2qlinux-run)."""
1080
+ try:
1081
+ return main()
1082
+ except KeyboardInterrupt:
1083
+ print("\nStopped. Questlog keeps running without live traffic; quit it from its own tray icon, "
1084
+ "which is shown again.", file=sys.stderr)
1085
+ return 130
1086
+
1087
+
1088
+ if __name__ == "__main__":
1089
+ sys.exit(run())