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