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/steam.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Steam launch-option wrapper for AION 2: start A2Qlinux, then the game.
|
|
2
|
+
|
|
3
|
+
Steam > AION 2 > Properties > General > Launch Options:
|
|
4
|
+
a2qlinux-steam %command%
|
|
5
|
+
Launcher options go before %command%, e.g.
|
|
6
|
+
a2qlinux-steam --close-with-game %command%
|
|
7
|
+
Where Questlog is, and what happens after the game, come from
|
|
8
|
+
~/.config/a2qlinux/config.toml (a2qlinux.config).
|
|
9
|
+
|
|
10
|
+
The launcher is started through the user's systemd manager, not as a child of
|
|
11
|
+
this process: it then runs with the desktop session's environment instead of
|
|
12
|
+
Steam's (overlay preload, container variables), and Steam does not count it as
|
|
13
|
+
part of the game. Whatever happens to it, the game is started.
|
|
14
|
+
|
|
15
|
+
The game starts once Questlog runs, and joins Questlog's Wine session. The
|
|
16
|
+
session then belongs to Questlog's launch, not to the game's, so when the game
|
|
17
|
+
ends, Steam can be told so without touching Questlog (docs/ARCHITECTURE.md
|
|
18
|
+
§5.1, §6.7).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from .desktop import LOCK
|
|
27
|
+
from .launcher import APPID, game_processes
|
|
28
|
+
|
|
29
|
+
# Launcher options accepted in front of %command%.
|
|
30
|
+
OPTIONS = ("--close-with-game", "--no-auto-update")
|
|
31
|
+
QUESTLOG_UP_WAIT = 60 # s for Questlog to be running before the game starts without it
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def wait_for_questlog(timeout=QUESTLOG_UP_WAIT):
|
|
35
|
+
"""True once Questlog runs in the game's prefix, False if it does not within `timeout` s."""
|
|
36
|
+
deadline = time.monotonic() + timeout
|
|
37
|
+
while not game_processes(APPID, ["questlog.exe"])["questlog.exe"]:
|
|
38
|
+
if time.monotonic() >= deadline:
|
|
39
|
+
return False
|
|
40
|
+
time.sleep(0.25)
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def record(text):
|
|
45
|
+
with open(LOCK.parent / "steam_launch.log", "a") as log:
|
|
46
|
+
log.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {text}\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def switch_proton_verb(command):
|
|
50
|
+
"""Proton's `waitforexitandrun` -> `run`; returns (command, switched).
|
|
51
|
+
|
|
52
|
+
Questlog may already run in the game's prefix (started from its icon, e.g.
|
|
53
|
+
to look through saved fights). Proton's waitforexitandrun would then wait
|
|
54
|
+
for the prefix to go idle - i.e. until Questlog is closed. With `run`, the
|
|
55
|
+
game joins the Wine session Questlog runs in, and Questlog sees it at once.
|
|
56
|
+
Only Proton's own verb is switched: the word right after a `…/proton`
|
|
57
|
+
argument. Steam's runtime keeps --verb=waitforexitandrun, which marks the
|
|
58
|
+
game's main container.
|
|
59
|
+
"""
|
|
60
|
+
command = list(command)
|
|
61
|
+
for i in range(1, len(command)):
|
|
62
|
+
if command[i] == "waitforexitandrun" and command[i - 1].endswith("/proton"):
|
|
63
|
+
command[i] = "run"
|
|
64
|
+
return command, True
|
|
65
|
+
return command, False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
args = sys.argv[1:]
|
|
70
|
+
options = []
|
|
71
|
+
while args and args[0] in OPTIONS:
|
|
72
|
+
options.append(args.pop(0))
|
|
73
|
+
if not args:
|
|
74
|
+
print("usage: a2qlinux-steam [--close-with-game] [--no-auto-update] %command%", file=sys.stderr)
|
|
75
|
+
return 2
|
|
76
|
+
started = subprocess.run(
|
|
77
|
+
["systemd-run", "--user", "--quiet", "--collect", f"--unit=questlog-aion2-{os.getpid()}",
|
|
78
|
+
sys.executable, "-m", "a2qlinux.desktop", "--for-game", *options],
|
|
79
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
80
|
+
if started.returncode:
|
|
81
|
+
subprocess.run(["notify-send", "--app-name=Questlog (AION 2)", "Questlog (AION 2)",
|
|
82
|
+
"Could not start Questlog from the Steam launch option; start it from its icon."],
|
|
83
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
84
|
+
|
|
85
|
+
# The game waits for Questlog, then joins its Wine session (`run`); the two
|
|
86
|
+
# never bring up the prefix's Wine at the same moment. Without Questlog the
|
|
87
|
+
# game starts as Steam asked; a Questlog that comes up later yields to it
|
|
88
|
+
# (the launcher's steam_launch_pending).
|
|
89
|
+
# (A Questlog already running is joined even if starting the launcher failed.)
|
|
90
|
+
if wait_for_questlog(QUESTLOG_UP_WAIT if not started.returncode else 0):
|
|
91
|
+
args, switched = switch_proton_verb(args)
|
|
92
|
+
if switched:
|
|
93
|
+
record("Questlog running: Proton verb waitforexitandrun -> run")
|
|
94
|
+
elif not started.returncode:
|
|
95
|
+
record(f"Questlog not running {QUESTLOG_UP_WAIT} s after the launcher was started; "
|
|
96
|
+
"the game starts on its own")
|
|
97
|
+
os.execvp(args[0], args)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
a2qlinux/tray.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Questlog's tray icon, drawn by the desktop instead of by Wine.
|
|
3
|
+
|
|
4
|
+
Questlog's own tray icon is a Windows icon shown through Wine, and under Wine
|
|
5
|
+
its menu opens only once: Electron gives up on the menu when it cannot bring
|
|
6
|
+
its window to the foreground, and Wine's explorer.exe never grants that
|
|
7
|
+
(docs/ARCHITECTURE.md §6.2). the launcher (a2qlinux/launcher.py) hides that icon and runs this one instead, for
|
|
8
|
+
as long as Questlog runs.
|
|
9
|
+
|
|
10
|
+
This process only reports what was chosen, one word per line on stdout:
|
|
11
|
+
open left click, or "Open Questlog"
|
|
12
|
+
quit "Quit Questlog"
|
|
13
|
+
the launcher (a2qlinux/launcher.py) does the rest. It ends when its stdin closes, i.e. when
|
|
14
|
+
the launcher (a2qlinux/launcher.py) stops it or ends itself.
|
|
15
|
+
|
|
16
|
+
With --check it only tells whether it could run here: exit code 0 if Qt
|
|
17
|
+
starts and the desktop has a system tray, otherwise 1 with the reason on
|
|
18
|
+
stderr (Qt's own message when Qt cannot start). a2qlinux-setup runs it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from PyQt6.QtCore import QSocketNotifier
|
|
25
|
+
from PyQt6.QtGui import QIcon
|
|
26
|
+
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
|
27
|
+
|
|
28
|
+
ICON = Path.home() / ".local" / "share" / "icons" / "questlog-aion2.png" # written by a2qlinux-setup
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def report(action):
|
|
32
|
+
print(action, flush=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
app = QApplication(sys.argv)
|
|
37
|
+
app.setApplicationName("Questlog (AION 2)")
|
|
38
|
+
app.setDesktopFileName("questlog-aion2")
|
|
39
|
+
app.setQuitOnLastWindowClosed(False)
|
|
40
|
+
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
41
|
+
print("a2qlinux tray: the desktop has no system tray", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
if "--check" in sys.argv[1:]:
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
tray = QSystemTrayIcon(QIcon(str(ICON)), app)
|
|
47
|
+
tray.setToolTip("Questlog (AION 2)")
|
|
48
|
+
menu = QMenu()
|
|
49
|
+
menu.addAction("Open Questlog").triggered.connect(lambda: report("open"))
|
|
50
|
+
menu.addSeparator()
|
|
51
|
+
menu.addAction("Quit Questlog").triggered.connect(lambda: report("quit"))
|
|
52
|
+
tray.setContextMenu(menu)
|
|
53
|
+
tray.activated.connect(lambda reason: reason == QSystemTrayIcon.ActivationReason.Trigger and report("open"))
|
|
54
|
+
tray.show()
|
|
55
|
+
|
|
56
|
+
def parent_gone():
|
|
57
|
+
if not sys.stdin.buffer.read1(4096):
|
|
58
|
+
app.quit()
|
|
59
|
+
|
|
60
|
+
watcher = QSocketNotifier(sys.stdin.fileno(), QSocketNotifier.Type.Read)
|
|
61
|
+
watcher.activated.connect(parent_gone)
|
|
62
|
+
return app.exec()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
sys.exit(main())
|
a2qlinux/update.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Keep Questlog up to date under Proton, where its own installer cannot run.
|
|
2
|
+
|
|
3
|
+
Questlog updates itself with electron-updater: it downloads the new installer
|
|
4
|
+
in the background and runs it on quit. Under Wine that installer stops with
|
|
5
|
+
code 2 (docs/ARCHITECTURE.md §7.5), so the launcher does the update instead,
|
|
6
|
+
the way docs/UPDATING.md describes doing it by hand:
|
|
7
|
+
|
|
8
|
+
while Questlog runs stage(): read the feed, download the installer, check
|
|
9
|
+
size and SHA-512, unpack it, check that the new
|
|
10
|
+
version still fits A2Qlinux (compatibility_problems),
|
|
11
|
+
and leave it in <state>/update/<version>/app
|
|
12
|
+
before the next start install_staged(): swap the Questlog folder for the
|
|
13
|
+
staged one, keeping the old one as a backup
|
|
14
|
+
if it then fails rollback(): put the old folder back
|
|
15
|
+
|
|
16
|
+
The first installation goes through the same download and checks:
|
|
17
|
+
install_fresh(), run by a2qlinux-setup when the Questlog folder has no
|
|
18
|
+
Questlog.exe, puts the newest version that fits straight into place.
|
|
19
|
+
|
|
20
|
+
Questlog's own updater is switched off (disable_builtin_updater), so it no
|
|
21
|
+
longer downloads 115 MB that it cannot install, nor offers an install button
|
|
22
|
+
that does nothing. Without resources/app-update.yml it only logs
|
|
23
|
+
"[updater] … ENOENT" every five minutes; nothing else changes (tested).
|
|
24
|
+
|
|
25
|
+
Nothing here is silent: each step reports through the `log` callback, and
|
|
26
|
+
what the user should know (an update ready, an update that does not fit) goes
|
|
27
|
+
to `notify` as well.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
36
|
+
import struct
|
|
37
|
+
import subprocess
|
|
38
|
+
import tempfile
|
|
39
|
+
import threading
|
|
40
|
+
import urllib.request
|
|
41
|
+
|
|
42
|
+
FEED = "https://cdn.questlog.gg/desktop/latest.yml"
|
|
43
|
+
DOWNLOADS = "https://cdn.questlog.gg/desktop/"
|
|
44
|
+
# The libpcap calls bridge/wpcap_bridge.c implements.
|
|
45
|
+
PCAP_CALLS = {"pcap_close", "pcap_compile", "pcap_datalink", "pcap_findalldevs", "pcap_freealldevs",
|
|
46
|
+
"pcap_freecode", "pcap_geterr", "pcap_next_ex", "pcap_open_live", "pcap_setfilter",
|
|
47
|
+
"pcap_setnonblock"}
|
|
48
|
+
# Electron versions whose tray icon id and WM_ENDSESSION handling were read in
|
|
49
|
+
# their source (questlog_ctl.exe depends on both; docs/ARCHITECTURE.md §10).
|
|
50
|
+
KNOWN_ELECTRON = {"44.5.1"}
|
|
51
|
+
STATE_FILE = "staged.json"
|
|
52
|
+
# The CDN refuses Python's default User-Agent (403); it accepts one that names us.
|
|
53
|
+
USER_AGENT = "A2Qlinux (Questlog updater for Linux)"
|
|
54
|
+
# Held while staging or installing, so an install never meets a half-written stage.
|
|
55
|
+
BUSY = threading.Lock()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ archive
|
|
59
|
+
|
|
60
|
+
def read_asar_file(asar, name):
|
|
61
|
+
"""One file from an Electron app.asar archive.
|
|
62
|
+
|
|
63
|
+
Layout: a 16-byte prefix (pickle sizes; the 4th field is the length of
|
|
64
|
+
the JSON header), the JSON header describing every file with an offset
|
|
65
|
+
relative to the end of the header pickle, then the file data. Files kept
|
|
66
|
+
outside the archive are in app.asar.unpacked.
|
|
67
|
+
"""
|
|
68
|
+
with open(asar, "rb") as stream:
|
|
69
|
+
_, header_size, _, json_size = struct.unpack("<4I", stream.read(16))
|
|
70
|
+
node = json.loads(stream.read(json_size))
|
|
71
|
+
for part in name.split("/"):
|
|
72
|
+
node = node["files"][part]
|
|
73
|
+
if node.get("unpacked"):
|
|
74
|
+
return (Path(str(asar) + ".unpacked") / name).read_bytes()
|
|
75
|
+
stream.seek(8 + header_size + int(node["offset"]))
|
|
76
|
+
return stream.read(node["size"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def version_key(version):
|
|
80
|
+
return tuple(int(part) for part in re.findall(r"\d+", version))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def installed_version(questlog_dir):
|
|
84
|
+
"""Questlog's version from its app.asar; None if it cannot be read."""
|
|
85
|
+
try:
|
|
86
|
+
package = read_asar_file(Path(questlog_dir) / "resources" / "app.asar", "package.json")
|
|
87
|
+
return json.loads(package)["version"]
|
|
88
|
+
except (OSError, KeyError, ValueError, struct.error):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_feed(text):
|
|
93
|
+
"""version, installer file name, SHA-512 (base64) and size from latest.yml.
|
|
94
|
+
|
|
95
|
+
The file is electron-builder's fixed format; only its top-level scalars
|
|
96
|
+
and the first entry of `files` are needed, so no YAML library is used.
|
|
97
|
+
"""
|
|
98
|
+
found = {}
|
|
99
|
+
for line in text.splitlines():
|
|
100
|
+
match = re.match(r"^(version|path|sha512):\s*'?([^']+?)'?\s*$", line)
|
|
101
|
+
if match:
|
|
102
|
+
found[match.group(1)] = match.group(2)
|
|
103
|
+
match = re.match(r"^\s+size:\s*(\d+)\s*$", line)
|
|
104
|
+
if match and "size" not in found:
|
|
105
|
+
found["size"] = int(match.group(1))
|
|
106
|
+
missing = {"version", "path", "sha512", "size"} - found.keys()
|
|
107
|
+
if missing:
|
|
108
|
+
raise ValueError(f"latest.yml lacks {', '.join(sorted(missing))}")
|
|
109
|
+
if "/" in found["path"] or not found["path"].endswith(".exe"):
|
|
110
|
+
raise ValueError(f"latest.yml names an unexpected installer: {found['path']!r}")
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------ compatibility
|
|
115
|
+
|
|
116
|
+
def compatibility_problems(app_dir):
|
|
117
|
+
"""What in an unpacked Questlog would break A2Qlinux; an empty list means it fits.
|
|
118
|
+
|
|
119
|
+
The checks listed in docs/ARCHITECTURE.md §10.
|
|
120
|
+
"""
|
|
121
|
+
app_dir = Path(app_dir)
|
|
122
|
+
asar = app_dir / "resources" / "app.asar"
|
|
123
|
+
problems = []
|
|
124
|
+
try:
|
|
125
|
+
main = read_asar_file(asar, ".output/electron/main.cjs").decode("utf-8", "replace")
|
|
126
|
+
worker = read_asar_file(asar, ".output/electron/workers/aion-2/combat.cjs").decode("utf-8", "replace")
|
|
127
|
+
except (OSError, KeyError, ValueError, struct.error) as error:
|
|
128
|
+
return [f"its app.asar could not be read ({error!r})"]
|
|
129
|
+
calls = set(re.findall(r"(pcap_[a-z_]+)\(", worker))
|
|
130
|
+
if calls - PCAP_CALLS:
|
|
131
|
+
problems.append(f"it calls libpcap functions the bridge DLL lacks: {', '.join(sorted(calls - PCAP_CALLS))}")
|
|
132
|
+
if not calls:
|
|
133
|
+
problems.append("its capture worker calls no libpcap functions any more")
|
|
134
|
+
electron = re.search(rb"Electron/(\d+\.\d+\.\d+)", (app_dir / "Questlog.exe").read_bytes()) \
|
|
135
|
+
if (app_dir / "Questlog.exe").is_file() else None
|
|
136
|
+
if not electron:
|
|
137
|
+
problems.append("its Electron version could not be read")
|
|
138
|
+
elif electron.group(1).decode() not in KNOWN_ELECTRON:
|
|
139
|
+
problems.append(f"it uses Electron {electron.group(1).decode()}, not yet checked "
|
|
140
|
+
f"(known: {', '.join(sorted(KNOWN_ELECTRON))}); its tray icon id and session end "
|
|
141
|
+
"need reading in that version's source")
|
|
142
|
+
if not re.search(r'on\("second-instance",[A-Za-z0-9_$]+\.show\)', main):
|
|
143
|
+
problems.append("a second start no longer simply shows its window (Open Questlog relies on it)")
|
|
144
|
+
if "focusable:!1" not in main:
|
|
145
|
+
problems.append("its overlay windows are no longer non-activatable (the window-order guard relies on it)")
|
|
146
|
+
try:
|
|
147
|
+
read_asar_file(asar, "resources/icon.png")
|
|
148
|
+
except (KeyError, OSError):
|
|
149
|
+
problems.append("its icon resources/icon.png is gone (a2qlinux-setup takes it from there)")
|
|
150
|
+
return problems
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------- built-in updater
|
|
154
|
+
|
|
155
|
+
def disable_builtin_updater(questlog_dir, log):
|
|
156
|
+
"""Remove resources/app-update.yml, which is all Questlog's own updater runs on."""
|
|
157
|
+
config = Path(questlog_dir) / "resources" / "app-update.yml"
|
|
158
|
+
if config.exists():
|
|
159
|
+
config.unlink()
|
|
160
|
+
log("Switched off Questlog's own updater (its installer cannot run under Wine); "
|
|
161
|
+
"A2Qlinux updates Questlog instead.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ staging
|
|
165
|
+
|
|
166
|
+
def _open(url, timeout):
|
|
167
|
+
return urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": USER_AGENT}), timeout=timeout)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _download(url, target, size, sha512_b64, timeout=60):
|
|
171
|
+
digest = hashlib.sha512()
|
|
172
|
+
got = 0
|
|
173
|
+
with _open(url, timeout) as response, open(target, "wb") as out:
|
|
174
|
+
while chunk := response.read(1 << 20):
|
|
175
|
+
out.write(chunk)
|
|
176
|
+
digest.update(chunk)
|
|
177
|
+
got += len(chunk)
|
|
178
|
+
if got != size:
|
|
179
|
+
raise ValueError(f"downloaded {got} bytes, latest.yml says {size}")
|
|
180
|
+
if base64.b64encode(digest.digest()).decode() != sha512_b64:
|
|
181
|
+
raise ValueError("the download's SHA-512 does not match latest.yml")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _unpack(installer, work):
|
|
185
|
+
"""installer.exe (NSIS) -> work/app, Questlog's files; needs 7z."""
|
|
186
|
+
seven = shutil.which("7z")
|
|
187
|
+
if not seven:
|
|
188
|
+
raise RuntimeError("7z is missing; run a2qlinux-setup, which names the package to install")
|
|
189
|
+
nsis, app = work / "nsis", work / "app"
|
|
190
|
+
for folder in (nsis, app):
|
|
191
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
192
|
+
folder.mkdir(parents=True)
|
|
193
|
+
for archive, into in ((installer, nsis), (nsis / "$PLUGINSDIR" / "app-64.7z", app)):
|
|
194
|
+
done = subprocess.run([seven, "x", "-y", f"-o{into}", str(archive)], capture_output=True, text=True)
|
|
195
|
+
if done.returncode:
|
|
196
|
+
raise RuntimeError(f"7z could not unpack {archive.name}: {done.stderr.strip() or done.stdout[-300:]}")
|
|
197
|
+
shutil.rmtree(nsis)
|
|
198
|
+
if not (app / "Questlog.exe").is_file():
|
|
199
|
+
raise RuntimeError("the installer did not contain Questlog.exe where expected")
|
|
200
|
+
return app
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def read_state(state_dir):
|
|
204
|
+
try:
|
|
205
|
+
return json.loads((Path(state_dir) / "update" / STATE_FILE).read_text())
|
|
206
|
+
except (OSError, ValueError):
|
|
207
|
+
return {}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_state(state_dir, state):
|
|
211
|
+
path = Path(state_dir) / "update" / STATE_FILE
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
path.write_text(json.dumps(state, indent=1))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def stage(questlog_dir, state_dir, log, notify, feed_url=FEED, downloads=DOWNLOADS):
|
|
217
|
+
"""Check for a newer Questlog and stage it for the next start. Returns the staged state.
|
|
218
|
+
|
|
219
|
+
Network or format errors are logged and end this attempt; the next check
|
|
220
|
+
tries again. A version already staged or already judged is not fetched again.
|
|
221
|
+
"""
|
|
222
|
+
with BUSY:
|
|
223
|
+
return _stage(questlog_dir, state_dir, log, notify, feed_url, downloads)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def read_feed(feed_url=FEED):
|
|
227
|
+
"""The newest version as latest.yml describes it (parse_feed); raises OSError or ValueError."""
|
|
228
|
+
with _open(feed_url, 20) as response:
|
|
229
|
+
return parse_feed(response.read().decode("utf-8", "replace"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _fetch(feed, work, downloads):
|
|
233
|
+
"""Download the feed's installer into `work`, verify and unpack it; (app folder, compatibility problems).
|
|
234
|
+
|
|
235
|
+
The installer is deleted once unpacked, and Questlog's own updater
|
|
236
|
+
configuration is removed from the unpacked files (disable_builtin_updater).
|
|
237
|
+
Raises OSError, ValueError or RuntimeError when a step fails.
|
|
238
|
+
"""
|
|
239
|
+
installer = work / feed["path"]
|
|
240
|
+
_download(downloads + feed["path"], installer, feed["size"], feed["sha512"])
|
|
241
|
+
app = _unpack(installer, work)
|
|
242
|
+
installer.unlink()
|
|
243
|
+
problems = compatibility_problems(app)
|
|
244
|
+
(app / "resources" / "app-update.yml").unlink(missing_ok=True)
|
|
245
|
+
return app, problems
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _stage(questlog_dir, state_dir, log, notify, feed_url, downloads):
|
|
249
|
+
current = installed_version(questlog_dir)
|
|
250
|
+
try:
|
|
251
|
+
feed = read_feed(feed_url)
|
|
252
|
+
except (OSError, ValueError) as error:
|
|
253
|
+
log(f"Questlog update check failed ({error}); trying again later.")
|
|
254
|
+
return None
|
|
255
|
+
if current and version_key(feed["version"]) <= version_key(current):
|
|
256
|
+
return None
|
|
257
|
+
state = read_state(state_dir)
|
|
258
|
+
if state.get("version") == feed["version"]:
|
|
259
|
+
return state # already staged or already refused
|
|
260
|
+
work = Path(state_dir) / "update" / feed["version"]
|
|
261
|
+
log(f"Questlog {feed['version']} is out (installed: {current or 'unknown'}); downloading it.")
|
|
262
|
+
try:
|
|
263
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
app, problems = _fetch(feed, work, downloads)
|
|
265
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
266
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
267
|
+
log(f"Questlog {feed['version']} could not be staged: {error}. Trying again later.")
|
|
268
|
+
return None
|
|
269
|
+
state = {"version": feed["version"], "app": str(app), "fits": not problems, "problems": problems}
|
|
270
|
+
# Older staged versions are superseded by this one.
|
|
271
|
+
for other in (Path(state_dir) / "update").iterdir():
|
|
272
|
+
if other.is_dir() and other != work:
|
|
273
|
+
shutil.rmtree(other, ignore_errors=True)
|
|
274
|
+
write_state(state_dir, state)
|
|
275
|
+
if problems:
|
|
276
|
+
shutil.rmtree(app, ignore_errors=True)
|
|
277
|
+
text = (f"Questlog {feed['version']} does not fit this A2Qlinux yet, so it was not installed; "
|
|
278
|
+
f"Questlog {current} stays. Why: " + "; ".join(problems) + ".")
|
|
279
|
+
log(text)
|
|
280
|
+
notify(text)
|
|
281
|
+
else:
|
|
282
|
+
log(f"Questlog {feed['version']} is downloaded and checked; it is installed the next time "
|
|
283
|
+
"Questlog starts.")
|
|
284
|
+
notify(f"Questlog {feed['version']} is ready. It is installed the next time Questlog starts.")
|
|
285
|
+
return state
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------- installation
|
|
289
|
+
|
|
290
|
+
def install_fresh(questlog_dir, log, feed_url=FEED, downloads=DOWNLOADS):
|
|
291
|
+
"""Install the newest Questlog into a missing or empty folder; returns its version.
|
|
292
|
+
|
|
293
|
+
For a2qlinux-setup on a machine without Questlog: the same download,
|
|
294
|
+
checksum, unpacking and compatibility checks as an update (_fetch), then
|
|
295
|
+
the unpacked program is moved into place. The work folder is made next
|
|
296
|
+
to the target, so the move is a rename on one file system.
|
|
297
|
+
|
|
298
|
+
Raises RuntimeError with what went wrong and what to do; a folder that
|
|
299
|
+
holds anything is never written into, and nothing is left behind.
|
|
300
|
+
"""
|
|
301
|
+
questlog_dir = Path(questlog_dir)
|
|
302
|
+
if questlog_dir.exists() and (not questlog_dir.is_dir() or any(questlog_dir.iterdir())):
|
|
303
|
+
raise RuntimeError(f"{questlog_dir} already exists and is not an empty folder, but holds no "
|
|
304
|
+
"Questlog.exe; move it away, or pass another folder with --questlog-dir")
|
|
305
|
+
try:
|
|
306
|
+
feed = read_feed(feed_url)
|
|
307
|
+
except (OSError, ValueError) as error:
|
|
308
|
+
raise RuntimeError(f"Questlog's download information could not be read ({error}); "
|
|
309
|
+
"check the internet connection and run a2qlinux-setup again") from error
|
|
310
|
+
version = feed["version"]
|
|
311
|
+
log(f"Downloading Questlog {version} from its authors' server ({feed['size'] / 1e6:.0f} MB), "
|
|
312
|
+
"checking and unpacking it …")
|
|
313
|
+
try:
|
|
314
|
+
questlog_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
work = Path(tempfile.mkdtemp(prefix=".a2qlinux-questlog-", dir=questlog_dir.parent))
|
|
316
|
+
except OSError as error:
|
|
317
|
+
raise RuntimeError(f"no folder for Questlog can be made in {questlog_dir.parent}: {error}") from error
|
|
318
|
+
try:
|
|
319
|
+
try:
|
|
320
|
+
app, problems = _fetch(feed, work, downloads)
|
|
321
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
322
|
+
raise RuntimeError(f"Questlog {version} could not be installed: {error}") from error
|
|
323
|
+
if problems:
|
|
324
|
+
raise RuntimeError(f"Questlog {version} does not fit this A2Qlinux yet: " + "; ".join(problems)
|
|
325
|
+
+ ". Update A2Qlinux (pipx upgrade a2qlinux), or install an earlier "
|
|
326
|
+
"Questlog by hand (docs/UPDATING.md, 'Installing Questlog by hand')")
|
|
327
|
+
try:
|
|
328
|
+
if questlog_dir.exists():
|
|
329
|
+
questlog_dir.rmdir()
|
|
330
|
+
app.rename(questlog_dir)
|
|
331
|
+
except OSError as error:
|
|
332
|
+
raise RuntimeError(f"Questlog {version} could not be put in {questlog_dir}: {error}") from error
|
|
333
|
+
finally:
|
|
334
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
335
|
+
log(f"Installed Questlog {version} in {questlog_dir}.")
|
|
336
|
+
return version
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def install_staged(questlog_dir, state_dir, log):
|
|
340
|
+
"""Swap in a staged version that fits. Returns (old version, backup folder) or None.
|
|
341
|
+
|
|
342
|
+
Must run while Questlog does not. The state folder (proton-stream) lives in
|
|
343
|
+
the Questlog folder, so it moves across with the swap; the launcher's lock
|
|
344
|
+
stays held, since it is an open file that moves with its folder. Skipped
|
|
345
|
+
(None) while a download is being staged; the next start installs it.
|
|
346
|
+
"""
|
|
347
|
+
if not BUSY.acquire(blocking=False):
|
|
348
|
+
log("A Questlog update is still being downloaded; it is installed at a later start.")
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
return _install_staged(Path(questlog_dir).resolve(), Path(state_dir).resolve(), log)
|
|
352
|
+
finally:
|
|
353
|
+
BUSY.release()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _install_staged(questlog_dir, state_dir, log):
|
|
357
|
+
state = read_state(state_dir)
|
|
358
|
+
app = Path(state.get("app", "")).resolve()
|
|
359
|
+
if not state.get("fits") or not (app / "Questlog.exe").is_file():
|
|
360
|
+
return None
|
|
361
|
+
old = installed_version(questlog_dir) or "previous"
|
|
362
|
+
backup = questlog_dir.with_name(f"{questlog_dir.name}-{old}")
|
|
363
|
+
if backup.exists():
|
|
364
|
+
backup = questlog_dir.with_name(f"{questlog_dir.name}-{old}-a2q")
|
|
365
|
+
if backup.exists():
|
|
366
|
+
log(f"Questlog {state['version']} is staged, but {backup} is in the way; not installed.")
|
|
367
|
+
return None
|
|
368
|
+
staged_rel = app.relative_to(questlog_dir) # app sits inside the folder being moved
|
|
369
|
+
questlog_dir.rename(backup)
|
|
370
|
+
(backup / staged_rel).rename(questlog_dir)
|
|
371
|
+
(backup / state_dir.name).rename(questlog_dir / state_dir.name)
|
|
372
|
+
shutil.rmtree(state_dir / "update", ignore_errors=True)
|
|
373
|
+
previous = read_backup_record(state_dir)
|
|
374
|
+
write_backup_record(state_dir, {"version": old, "path": str(backup), "installed": state["version"]})
|
|
375
|
+
# Keep one backup: the one this update made. Older backups made by A2Qlinux go.
|
|
376
|
+
if previous and Path(previous.get("path", "")).is_dir() and Path(previous["path"]) != backup:
|
|
377
|
+
shutil.rmtree(previous["path"], ignore_errors=True)
|
|
378
|
+
log(f"Removed the older backup {previous['path']}.")
|
|
379
|
+
log(f"Installed Questlog {state['version']}; Questlog {old} is kept in {backup}.")
|
|
380
|
+
return old, backup
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def read_backup_record(state_dir):
|
|
384
|
+
try:
|
|
385
|
+
return json.loads((Path(state_dir) / "questlog-backup.json").read_text())
|
|
386
|
+
except (OSError, ValueError):
|
|
387
|
+
return {}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def write_backup_record(state_dir, record):
|
|
391
|
+
(Path(state_dir) / "questlog-backup.json").write_text(json.dumps(record, indent=1))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def rollback(questlog_dir, state_dir, log):
|
|
395
|
+
"""Put the backup made by the last install_staged back. Returns True if done."""
|
|
396
|
+
questlog_dir, state_dir = Path(questlog_dir), Path(state_dir)
|
|
397
|
+
record = read_backup_record(state_dir)
|
|
398
|
+
backup = Path(record.get("path", ""))
|
|
399
|
+
if not (backup / "Questlog.exe").is_file():
|
|
400
|
+
log("No Questlog backup to go back to.")
|
|
401
|
+
return False
|
|
402
|
+
failed = questlog_dir.with_name(f"{questlog_dir.name}-{record.get('installed', 'failed')}-failed")
|
|
403
|
+
shutil.rmtree(failed, ignore_errors=True)
|
|
404
|
+
questlog_dir.rename(failed)
|
|
405
|
+
backup.rename(questlog_dir)
|
|
406
|
+
(failed / state_dir.name).rename(questlog_dir / state_dir.name)
|
|
407
|
+
(state_dir / "questlog-backup.json").unlink(missing_ok=True)
|
|
408
|
+
write_state(state_dir, {"version": record.get("installed"), "fits": False,
|
|
409
|
+
"problems": ["it did not start under Proton after installing"]})
|
|
410
|
+
log(f"Went back to Questlog {record.get('version')}; the version that failed is in {failed}.")
|
|
411
|
+
return True
|