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