Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ The rule: **a GUI is shown only when the questions were asked through a GUI.** I

Done: `kdialog --progressbar` returns a DBus reference, and `qdbus6` moves the bar through it. Python counts the unpacking percentage by bytes rather than by files: otherwise a 4 GB file would hold the bar still for a minute. The percentage then goes to the Python side through `--progress-ref`, and it labels its own stages: closing Steam, saving the backups, adding to the library, the artwork, starting it again, verifying. Without a GUI all of this is silently skipped: the install does not depend on the progress bar.

**The wait for the rewrite counts on the bar.** At 96% Steam can take minutes over the rewrite (5.1.0), and a bar that stands still that long reads as a hang. The label says how long it has been and how long it can be: "0:42 so far, it can take up to 3:30".

**The Cancel button answers.** `kdialog --progressbar` shows one by default, from `QProgressDialog`, and pressing it only hides the window and sets `wasCancelled`: the install went on unseen, restarts of Steam included. Now `install.py` asks `wasCancelled`, and a reference that stops answering counts as closed too, since a window closed any other way may take `kdialog` down with it. While Steam is untouched, a closed window stops the install where it is: the unpacking stops, nothing is added, and exit code 6 makes the last window say so and name the folder with what was unpacked. Once Steam is down, the attempt in progress runs to its end, because Steam has to come back up anyway, but no other one starts. If that attempt failed, the list of shortcuts goes back as it was, and a notification says why Steam goes down once more.

**What asks the questions: `kdialog`.** Desktop Mode on SteamOS is KDE Plasma, and kdialog comes from KDE and draws native Qt windows. `--getexistingdirectory` settles the SD card question neatly: there is no need to detect `/run/media/deck/<label>` and know the partition label, the person just picks it in the file dialog. The same tool gives `--progressbar` through DBus: unpacking 8 GB takes minutes, and a silent script looks hung.

The fallback chain: `kdialog` → `zenity` (unlikely to be there, the system is KDE) → an interactive `read` in a terminal → defaults with no questions, everything into the log.
Expand Down Expand Up @@ -345,7 +349,7 @@ The order:
8. Write the `CompatToolMapping` for this appid into `config.vdf`, surgically, see 5.2
9. Lay out the artwork in `userdata/<id>/config/grid/` under names derived from the appid
10. Start Steam again
11. **Wait until Steam rewrites `shortcuts.vdf`, and check that the entry is in place** (5.1.0). Until that moment the contents of the file mean nothing
11. **Wait until Steam rewrites `shortcuts.vdf`, and check that the entry is in place** (5.1.0). Until that moment the contents of the file prove little. The wait lasts up to 210 s, and the bar counts it: Steam can take minutes. If the rewrite does not come, the check runs on the file as it stands; another attempt is only for an edit the check finds undone
12. `install.log` is not a step at the end: `setup.sh` writes it as the install goes (see "An install that fails halfway" below)

Step 5 comes before any write, no exceptions. The old reasoning "step 1 before step 4, because it needs a running client" went away together with `ActiveUser` (5.1.1), so the order of those two steps is now free.
Expand Down Expand Up @@ -392,6 +396,8 @@ About 90 seconds after the client started, the file held a state from two years
4. **The backup is mandatory before the first write**, and it is also what everything rolls back to. A backup taken after a failed attempt is useless
5. **A retry has to be planned for**: if the edit did not survive the restart, it makes sense to try again, but with a limited number of attempts and a guarantee that the list of shortcuts stays whole in the end

**A retry is for an edit Steam undid, not for a rewrite that never came.** From a player's feedback: Steam restarted five or six times in a row, the bar stood at 96% for minutes until they pressed Cancel, and the game was in the library anyway. A rewrite that does not come explains all of it: every attempt waited the full 210 s, had nothing proven, and restarted Steam for the next one, three attempts and a fourth restart to put the list back, while Cancel only hid the window. `[?]` Their `install.log` would confirm it. Now a rewrite that does not come leads to the check on the file as it stands, and only a failed check leads to another attempt.

`[?]` **The cause of the rollback is not established.** The best candidate is Steam Cloud: the client syncs shortcuts between devices and may have decided the cloud copy wins. But that does not explain why the rollback went two years back, or why the same procedure a minute later went through without trouble. For now we treat it as nondeterministic behavior that verification and retries protect against, not as a condition that can be predicted.

#### 5.1.1 Finding the current Steam user
Expand Down
2 changes: 1 addition & 1 deletion packages/installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ DEST=~/Games PROTON=proton_10 ./nfsmw.run # without dialogs
./nfsmw.run --dry-run # show what would happen, change nothing
```

The log is `<game-dir>/install.log`, or `/tmp/<slug>-install.log` when the game folder cannot be written to. `setup.sh` writes it as the install goes, with the tracebacks, so it is there after a crash as well. The exit codes are described at the top of `install.py`; 4 means "the game is in the library, but not all of its setup went through".
The log is `<game-dir>/install.log`, or `/tmp/<slug>-install.log` when the game folder cannot be written to. `setup.sh` writes it as the install goes, with the tracebacks, so it is there after a crash as well. The exit codes are described at the top of `install.py`; 4 means "the game is in the library, but not all of its setup went through", and 6 that the progress window was closed before Steam was touched.

Uninstall with what the installer put next to the game:

Expand Down
111 changes: 96 additions & 15 deletions packages/installer/src/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
install that went wrong, so it cannot wait for the install to get far enough
to write it.

Exit codes: 0 installed; 1 failed; 2 Steam kept undoing the edit; 3 the Steam
account has to be chosen; 4 (PARTIAL) the game is in the library, but a step
after the shortcut did not go through; 5 (NOT_ITS_FOLDER) nothing removed: the
folder to delete whole does not look like the game's own.
Exit codes: 0 installed; 1 failed; 2 Steam did not keep the edit, and the
shortcut list is back as it was; 3 the Steam account has to be chosen; 4
(PARTIAL) the game is in the library, but a step after the shortcut did not go
through; 5 (NOT_ITS_FOLDER) nothing removed: the folder to delete whole does
not look like the game's own; 6 (CANCELLED) the progress window was closed
before Steam was touched.
"""

import argparse
Expand All @@ -36,6 +38,7 @@

PARTIAL = 4
NOT_ITS_FOLDER = 5
CANCELLED = 6
PROGRESS_REF = None
QDBUS = shutil.which('qdbus6') or shutil.which('qdbus')
# True while the client is down because of us. Every way out of main(), an
Expand Down Expand Up @@ -66,6 +69,53 @@ def progress(pct=None, text=None):
pass


class Cancelled(Exception):
"""The progress window was closed while Steam was still untouched."""


def cancelled():
"""Whether the person closed the progress window. Its Cancel button hides
it and sets wasCancelled, and the install used to go on unseen, Steam
restarts and all. A window closed any other way may take kdialog down with
it, so a reference that stops answering counts as closed too. Without a
window there is nothing to close."""
if not PROGRESS_REF or not QDBUS:
return False
try:
result = subprocess.run(
[QDBUS] + PROGRESS_REF.split() + ['wasCancelled'],
capture_output=True, text=True, timeout=5)
except Exception:
# A qdbus that hangs says nothing either way
return False
return result.returncode != 0 or result.stdout.strip() == 'true'


def notify(title, text):
"""Speaks up once the progress window is gone: a notification that goes
away by itself, so nothing waits on it."""
if not PROGRESS_REF:
return
try:
subprocess.Popen(
['kdialog', '--title', title, '--passivepopup', text, '15'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True)
except OSError:
pass


def clock(seconds):
return '%d:%02d' % divmod(int(seconds), 60)


def waiting(elapsed):
"""The label while Steam takes its time over the rewrite: minutes at 96%
with nothing moving read as a hang, and a player closed the window over it."""
return 'Waiting for Steam to save the library...\n%s so far, it can take up to %s' % (
clock(elapsed), clock(steamctl.REWRITE_TIMEOUT))


def stop_steam():
global STEAM_STOPPED
if not steamctl.shutdown(log=log):
Expand Down Expand Up @@ -160,6 +210,10 @@ def main(argv=None):
log('%s, %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), system_line()))
try:
return run(a)
except Cancelled:
# The person stopped it, and nothing of theirs has changed yet
log('\nCANCELLED: the progress window was closed before Steam was touched')
return CANCELLED
except Exception:
# Whatever it is, the log has to name it: the log is all the player
# has to send back
Expand Down Expand Up @@ -280,6 +334,10 @@ def run(a):
log('proton: none chosen, it has to be picked in the game\'s Properties')
problems.append('Proton')

# The last point where a closed window can stop the install with nothing
# of the person's changed: past it Steam is down and comes back with the game
if cancelled():
raise Cancelled()
progress(82, 'Closing Steam...')
if not stop_steam():
log('Steam did not shut down - aborting without touching anything')
Expand All @@ -294,11 +352,17 @@ def run(a):
si.backup(cfg_path, log=log)

for attempt in (1, 2, 3):
# Another attempt restarts Steam once more, and with the window closed
# nobody would see why: the list goes back as it was instead
if attempt > 1 and cancelled():
log('\nFAILED: the progress window was closed, so no other attempt; '
'restoring the shortcut list')
break
log('\n--- attempt %d ---' % attempt)
failed = []

if attempt > 1:
progress(82, 'Closing Steam...')
progress(82, 'Steam did not keep the game, trying again: attempt %d of 3...' % attempt)
if not stop_steam():
log('Steam did not shut down - stopping, the backup is intact')
return 1
Expand Down Expand Up @@ -328,16 +392,21 @@ def run(a):
si.restore(sc_backup, sc_path, log=log)
return 1

progress(96, 'Waiting for Steam to save the library...')
rewritten = steamctl.wait_rewrite(sc_path, mtime, log=log)
progress(96, waiting(0))
rewritten = steamctl.wait_rewrite(
sc_path, mtime, log=log,
tick=lambda elapsed: progress(text=waiting(elapsed)))
time.sleep(5)

progress(99, 'Verifying...')
log('verification after restart:')
if not rewritten:
log(' Steam never rewrote the file, so this proves nothing yet')
# Another attempt would only wait the same way, and restart Steam
# once more for it: the file as it stands, with the client up, is
# all there is to check
log(' Steam never rewrote the file: checking it as it stands')

if si.verify(uid, appid, proton, expect_count=expect, log=log) and rewritten:
if si.verify(uid, appid, proton, expect_count=expect, log=log):
if not step('uninstaller', install_uninstaller,
os.path.abspath(a.game_dir), a.recipe, log=log):
failed.append('uninstaller')
Expand All @@ -348,11 +417,19 @@ def run(a):
return PARTIAL
log('\nINSTALLED on attempt %d' % attempt)
return 0
log('changes did not survive, retrying')

# Out of attempts. Leave the shortcut list as we found it rather than as
# whatever the last round happened to produce.
log('\nFAILED after three attempts, restoring the shortcut list')
log('changes did not survive')
else:
log('\nFAILED after three attempts, restoring the shortcut list')

# Leave the shortcut list as we found it rather than as whatever the last
# round happened to produce. That takes one more restart of Steam, and a
# closed window leaves a notification to say why it goes down.
progress(text='Steam did not keep the game: putting your library back as it was...')
if cancelled():
notify(
name,
'Steam did not keep %s in the library. It restarts once more '
'to put your library back as it was.' % name)
if stop_steam():
si.restore(sc_backup, sc_path, log=log)
start_steam()
Expand Down Expand Up @@ -398,14 +475,18 @@ def unpack_game(a, recipe):

def unpack_progress():
"""Moves the bar from 3 to 80 percent as the bytes come out, a couple of
times a second at most: every move is two qdbus processes."""
times a second at most: every move is three qdbus processes, the look at
whether the window is still there included. A closed one stops the
unpacking where it is."""
last = [0.0]

def report(done, total):
now = time.time()
if now - last[0] < 0.5 and done < total:
return
last[0] = now
if cancelled():
raise Cancelled()
progress(
3 + 77 * done // max(total, 1),
'Extracting: %s of %s' % (unpack.human(done), unpack.human(total)))
Expand Down
Loading
Loading