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/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())
|