feat: the Longshot Launcher v1.0.0: installs Longshot's builder, updates itself on its own channel, imports Hookshot saves #1

Merged
HumanBean merged 7 commits from HumanBean/LongshotLauncher:feat/launcher into main 2026-11-13 02:07:51 +00:00
Owner

Why

Players need one program to get Longshot: something that installs the builder, builds the game from their own copy, and keeps the game and itself up to date. This adds the whole Longshot Launcher to its own repository, released apart from Longshot, and lets it install itself on Windows (or run portable) with no installer program.

What changed

The launcher replaces Longshot PR #12, which was closed. Longshot's side of the release system is MagicBeanDev/Longshot#13.

  • The launcher (Core/, no UI and no package; App/, Avalonia on .NET 10). Ported from Hookshot's 3.0 launcher and renamed to Longshot. Keeps several builds side by side, with a three-step build wizard. Clean break: nothing of a Hookshot install is read except its saves. Longshot's numbers start at v3.0.0, the launcher's at v1.0.0.
  • Installing it (Core/LauncherLayout.cs, Core/Installer.cs, App/SetupWindows.cs, App/WindowsInstall.cs; README, Installing). There is no installer tool: the launcher installs itself.
    • Windows, first start of a launcher that is neither installed nor portable: Install or Run portable.
    • Install copies the program to %LOCALAPPDATA%\Programs\LongshotLauncher\LongshotLauncher.exe (per user, no admin rights), adds a Start menu shortcut and an Apps & features entry (HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\LongshotLauncher: DisplayName, DisplayVersion, Publisher "Magic Bean", DisplayIcon, UninstallString "…\LongshotLauncher.exe" --uninstall), then starts the installed copy. Data stays in %LOCALAPPDATA%\LongshotLauncher. --install does it without asking.
    • --uninstall removes the shortcut, the entry and the program (deleted by a hidden PowerShell once the window has closed), asks whether to delete the data, and never deletes a build's game folder or the save backups, even inside the data.
    • Portable: a portable file beside the program; its data, its builds folder and its save backups go in LongshotLauncher-data beside it. It never writes the registry or shortcuts.
    • Linux: the AppImage stays as it is. Settings > Applications menu adds (or removes) ~/.local/share/applications/longshot-launcher.desktop and its icon, pointing at the AppImage where it is. Portable is an <AppImage>.home folder beside it.
    • The registry and the shortcuts are behind IUserRegistry and IShortcutMaker, so the tests use fakes and never touch the real ones.
  • Releases (docs/releases.md and RELEASING.md are the same files as in Longshot): LongshotLauncher-<v>-windows-x64-setup.exe (the program, which offers Install), LongshotLauncher-<v>-windows-x64-portable.zip (the same program as LongshotLauncher.exe, with its portable file) and LongshotLauncher-<v>-x86_64.AppImage, all in the signed SHA256SUMS. release.yml makes nightlies from main and rc/stable releases from tags, with the same tag checks as Longshot, git-cliff notes, a draft published last, and old nightlies pruned. It uses the job's own token (forgejo.token, no RELEASE_TOKEN secret, no bot), with contents: write only on the publish job. Every action is pinned by commit.
  • Its own updates (Core/SelfUpdate.cs): download the release's launcher of this kind (setup program, or the program out of the portable zip, or the AppImage), check it against the signed SHA256SUMS, run it with --self-check, swap it in under the same name it was installed or started as, start it. --rollback restores the previous one. A start puts right an update cut short (the .old back when the program is missing, a leftover .new removed).
  • One launcher per home (Core/InstanceLock.cs): a named mutex on Windows, a flock'd launcher.lock on Linux. A second start brings the open window forward. settings.json and builds.json are written through a temporary file of their own (unique name, flushed, renamed).
  • Saves. The Hookshot import, import/export to emulators, and copies between builds. A copy is now made whole in a folder beside the target and swapped in, so a failure (a full disk) leaves the target as it was, and the window names the backup (Core/Saves.cs Copy).
  • Verification. The P-256 signature over SHA256SUMS against the built-in key (5e18330371d7f5aa), then each file's SHA-256. The launcher never moves a build or itself to a lower version on its own. After the player switches the launcher's channel, an older launcher is offered as "available on , older than yours", never as "is out".

Flags and settings

Flag New or changed What it does Default now Goal: on by default?
--install new Windows: installs this launcher without asking, then starts the installed copy off no (a command)
--uninstall new Windows: what Apps & features runs; asks about the data off no (a command)
portable file beside the program new Windows: the data stays in LongshotLauncher-data beside it absent (the portable zip has it) no
<AppImage>.home folder new Linux: the data stays there absent no
Settings > Applications menu new Linux: the AppImage in the applications menu not added no
LONGSHOT_LAUNCHER_HOME, _OFFLINE, _PARTS, _TRUST, --launch, --update, --play, --self-check, --rollback unchanged README, Build from source

Measurements

main: d6743992 (the README stub)
this PR: feat/launcher @ ddca39cd
Hardware: Linux (CachyOS), .NET 10.0.404, Python 3.14
Scene: none (the launcher; no game)
Method: dotnet build with locked restore, tests/test_launcher_core.py --strict --longshot <Longshot #13>, tests/test_release.py, scripts/package.sh, the AppImage's --self-check, and the AppImage started while its home's lock is held
How to reproduce: the commands above

main @ d6743992 this PR @ ddca39cd
dotnet build LongshotLauncher.slnx -c Release -p:RestoreLockedMode=true no code yet 0 warnings, 0 errors
Core checks no code yet 518 passed (471 before this round)
tests/test_release.py no code yet 15 passed
scripts/package.sh v1.1.0-rc.1 no code yet setup 50 MB, portable zip 45 MB (the same program inside), AppImage 40 MB, --self-check OK
A second start on a held home no code yet exits at once, pokes the first (launcher.show)
launcher core: 518 checks passed
test_release: Ran 15 tests ... OK
-rw-r--r-- 44945449 LongshotLauncher-v1.1.0-rc.1-windows-x64-portable.zip
-rwxr-xr-x 50374938 LongshotLauncher-v1.1.0-rc.1-windows-x64-setup.exe
-rwxr-xr-x 40049144 LongshotLauncher-v1.1.0-rc.1-x86_64.AppImage
$ LongshotLauncher-v1.1.0-rc.1-x86_64.AppImage --self-check
LongshotLauncher v1.1.0-rc.1
$ (home locked) LongshotLauncher-v1.1.0-rc.1-x86_64.AppImage
Longshot: the launcher is already open; its window was brought forward.

Not tested

Not tested:

  • The Windows program on Windows: Install, Run portable, --uninstall, the registry entry, the shortcuts, the named mutex across processes, the swap and its recovery. There is no Windows PC or Wine here. They run in the tests with fakes, and the mutex across threads only on Windows.
  • The Wine step of release.yml (wine64 …-setup.exe --self-check): never run, since there is no runner yet.
  • The release workflow: it has not run yet. CI (ci.yml) runs on the Forgejo runner, and both of its jobs pass at ddca39cd. A real release on git.magicbean.dev: releases were tested against a fake Forgejo only.
  • A real game build, real Hookshot saves (only fake fixtures).

Tested but not measured: the Linux AppImage starting and the second-start poke.

Screenshots

Drawn headless with Avalonia's Skia renderer on Linux (the Windows paths are set by hand).

The first start of the Windows setup:

Install or Run portable

--uninstall:

Uninstall

Settings on Linux, with Add to applications menu:

Settings with the applications menu

Earlier screens (unchanged): the Hookshot offer, the importer.

Risk and rollback

  • Windows install and uninstall are new and never ran on Windows. A failed Install leaves the download as it was and says why. A failed uninstall step shows its error, and Apps & features can run it again.
  • The release workflow now uses the job token. If Forgejo 17 refuses an operation with it, the release fails at that step, before anything is published.
  • The release file names changed (-setup.exe, -portable.zip). No launcher is released yet, so nothing out there reads the old name.
  • Nothing is released yet: reverting the PR is enough.

Issues

See MagicBeanDev/Longshot#13.

Checklist

For everyone

  • One topic: the launcher, with its install, CI and releases.
  • The title is a Conventional Commit.
  • Rebased on current main.
  • Tests run and passing (above).
  • Tested in the game, or what wasn't is named under Not tested (no game involved).
  • Measured: n/a (no performance change).
  • No game files, dumps, build output or generated files: the save tests use fake text fixtures.
  • The license is OK: GPL-3.0-or-later; Inter keeps its OFL.

If applicable

  • Flags table filled in; README updated.
  • Screenshots for what shows on screen.
  • A game-free test for each fix (the program's name, the swap recovery, the portable zip, install and uninstall with fakes, the lock, a stalled download, a save copy with an injected failure).
  • Host code builds on Linux; Windows-only code is guarded and compiled in the same build.
  • Docs: the shared docs/releases.md and RELEASING.md still name the old Windows file and RELEASE_TOKEN. They come over byte-identical from Longshot#13's next commit.

Manual steps (none done)

  1. Org secret. RELEASE_SIGNING_KEY (the key is built in: Core/ReleaseTrust.cs, id 5e18330371d7f5aa), and the org variable FORGEJO_UPLOAD_API, which bypasses Cloudflare's 100 MB upload limit. RELEASE_TOKEN is no longer needed.
  2. Actions. Enable Actions on this repository and register a Linux runner (Longshot's docs/forgejo-runners.md). The build job installs Wine with apt (as root, or with sudo).
  3. First release. v1.0.0-rc.1, then v1.0.0 (RELEASING.md).
  4. Repository settings. Already done: squash only, branch deleted after merge, main protected with 1 approval.
## Why Players need one program to get Longshot: something that installs the builder, builds the game from their own copy, and keeps the game and itself up to date. This adds the whole Longshot Launcher to its own repository, released apart from Longshot, and lets it install itself on Windows (or run portable) with no installer program. ## What changed The launcher replaces Longshot PR #12, which was closed. Longshot's side of the release system is MagicBeanDev/Longshot#13. - **The launcher** (`Core/`, no UI and no package; `App/`, Avalonia on .NET 10). Ported from Hookshot's 3.0 launcher and renamed to Longshot. Keeps several builds side by side, with a three-step build wizard. Clean break: nothing of a Hookshot install is read except its saves. Longshot's numbers start at v3.0.0, the launcher's at **v1.0.0**. - **Installing it** (`Core/LauncherLayout.cs`, `Core/Installer.cs`, `App/SetupWindows.cs`, `App/WindowsInstall.cs`; README, *Installing*). There is no installer tool: the launcher installs itself. - **Windows, first start** of a launcher that is neither installed nor portable: *Install* or *Run portable*. - **Install** copies the program to `%LOCALAPPDATA%\Programs\LongshotLauncher\LongshotLauncher.exe` (per user, no admin rights), adds a Start menu shortcut and an *Apps & features* entry (`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\LongshotLauncher`: DisplayName, DisplayVersion, Publisher "Magic Bean", DisplayIcon, `UninstallString` `"…\LongshotLauncher.exe" --uninstall`), then starts the installed copy. Data stays in `%LOCALAPPDATA%\LongshotLauncher`. `--install` does it without asking. - **`--uninstall`** removes the shortcut, the entry and the program (deleted by a hidden PowerShell once the window has closed), asks whether to delete the data, and never deletes a build's game folder or the save backups, even inside the data. - **Portable**: a `portable` file beside the program; its data, its builds folder and its save backups go in `LongshotLauncher-data` beside it. It never writes the registry or shortcuts. - **Linux**: the AppImage stays as it is. *Settings > Applications menu* adds (or removes) `~/.local/share/applications/longshot-launcher.desktop` and its icon, pointing at the AppImage where it is. Portable is an `<AppImage>.home` folder beside it. - The registry and the shortcuts are behind `IUserRegistry` and `IShortcutMaker`, so the tests use fakes and never touch the real ones. - **Releases** (docs/releases.md and RELEASING.md are the same files as in Longshot): `LongshotLauncher-<v>-windows-x64-setup.exe` (the program, which offers Install), `LongshotLauncher-<v>-windows-x64-portable.zip` (the same program as `LongshotLauncher.exe`, with its `portable` file) and `LongshotLauncher-<v>-x86_64.AppImage`, all in the signed `SHA256SUMS`. `release.yml` makes nightlies from main and rc/stable releases from tags, with the same tag checks as Longshot, git-cliff notes, a draft published last, and old nightlies pruned. It uses the **job's own token** (`forgejo.token`, no `RELEASE_TOKEN` secret, no bot), with `contents: write` only on the publish job. Every action is pinned by commit. - **Its own updates** (`Core/SelfUpdate.cs`): download the release's launcher of this kind (setup program, or the program out of the portable zip, or the AppImage), check it against the signed `SHA256SUMS`, run it with `--self-check`, swap it in under the **same name** it was installed or started as, start it. `--rollback` restores the previous one. A start puts right an update cut short (the `.old` back when the program is missing, a leftover `.new` removed). - **One launcher per home** (`Core/InstanceLock.cs`): a named mutex on Windows, a flock'd `launcher.lock` on Linux. A second start brings the open window forward. `settings.json` and `builds.json` are written through a temporary file of their own (unique name, flushed, renamed). - **Saves.** The Hookshot import, import/export to emulators, and copies between builds. A copy is now made whole in a folder beside the target and swapped in, so a failure (a full disk) leaves the target as it was, and the window names the backup (`Core/Saves.cs` `Copy`). - **Verification.** The P-256 signature over `SHA256SUMS` against the built-in key (`5e18330371d7f5aa`), then each file's SHA-256. The launcher never moves a build or itself to a lower version on its own. After the player switches the launcher's channel, an older launcher is offered as "available on <channel>, older than yours", never as "is out". ## Flags and settings | Flag | New or changed | What it does | Default now | Goal: on by default? | |---|---|---|---|---| | `--install` | new | Windows: installs this launcher without asking, then starts the installed copy | off | no (a command) | | `--uninstall` | new | Windows: what *Apps & features* runs; asks about the data | off | no (a command) | | `portable` file beside the program | new | Windows: the data stays in `LongshotLauncher-data` beside it | absent (the portable zip has it) | no | | `<AppImage>.home` folder | new | Linux: the data stays there | absent | no | | *Settings > Applications menu* | new | Linux: the AppImage in the applications menu | not added | no | | `LONGSHOT_LAUNCHER_HOME`, `_OFFLINE`, `_PARTS`, `_TRUST`, `--launch`, `--update`, `--play`, `--self-check`, `--rollback` | unchanged | README, *Build from source* | | | ## Measurements main: d6743992 (the README stub) this PR: feat/launcher @ ddca39cd Hardware: Linux (CachyOS), .NET 10.0.404, Python 3.14 Scene: none (the launcher; no game) Method: `dotnet build` with locked restore, `tests/test_launcher_core.py --strict --longshot <Longshot #13>`, `tests/test_release.py`, `scripts/package.sh`, the AppImage's `--self-check`, and the AppImage started while its home's lock is held How to reproduce: the commands above | | main @ d6743992 | this PR @ ddca39cd | |---|---|---| | `dotnet build LongshotLauncher.slnx -c Release -p:RestoreLockedMode=true` | no code yet | 0 warnings, 0 errors | | Core checks | no code yet | 518 passed (471 before this round) | | `tests/test_release.py` | no code yet | 15 passed | | `scripts/package.sh v1.1.0-rc.1` | no code yet | setup 50 MB, portable zip 45 MB (the same program inside), AppImage 40 MB, `--self-check` OK | | A second start on a held home | no code yet | exits at once, pokes the first (`launcher.show`) | ``` launcher core: 518 checks passed test_release: Ran 15 tests ... OK -rw-r--r-- 44945449 LongshotLauncher-v1.1.0-rc.1-windows-x64-portable.zip -rwxr-xr-x 50374938 LongshotLauncher-v1.1.0-rc.1-windows-x64-setup.exe -rwxr-xr-x 40049144 LongshotLauncher-v1.1.0-rc.1-x86_64.AppImage $ LongshotLauncher-v1.1.0-rc.1-x86_64.AppImage --self-check LongshotLauncher v1.1.0-rc.1 $ (home locked) LongshotLauncher-v1.1.0-rc.1-x86_64.AppImage Longshot: the launcher is already open; its window was brought forward. ``` ## Not tested Not tested: - **The Windows program on Windows**: Install, Run portable, `--uninstall`, the registry entry, the shortcuts, the named mutex across processes, the swap and its recovery. There is no Windows PC or Wine here. They run in the tests with fakes, and the mutex across threads only on Windows. - **The Wine step of `release.yml`** (`wine64 …-setup.exe --self-check`): never run, since there is no runner yet. - The release workflow: it has not run yet. CI (`ci.yml`) runs on the Forgejo runner, and both of its jobs pass at ddca39cd. A real release on git.magicbean.dev: releases were tested against a fake Forgejo only. - A real game build, real Hookshot saves (only fake fixtures). Tested but not measured: the Linux AppImage starting and the second-start poke. ## Screenshots Drawn headless with Avalonia's Skia renderer on Linux (the Windows paths are set by hand). The first start of the Windows setup: ![Install or Run portable](https://git.magicbean.dev/attachments/f571c118-df5e-4752-a1c6-4a6815af8341) `--uninstall`: ![Uninstall](https://git.magicbean.dev/attachments/c9ffbb7b-d5ce-4bc4-9b3a-ae265e2eaf72) Settings on Linux, with *Add to applications menu*: ![Settings with the applications menu](https://git.magicbean.dev/attachments/dcae60cc-0bb0-4047-8e9a-34c8990ab6bb) Earlier screens (unchanged): [the Hookshot offer](https://git.magicbean.dev/attachments/55e7d8f9-d308-454b-979d-f80d31a00680), [the importer](https://git.magicbean.dev/attachments/3fd8a83f-44d3-441b-854f-a474e0b9118b). ## Risk and rollback - **Windows install and uninstall** are new and never ran on Windows. A failed Install leaves the download as it was and says why. A failed uninstall step shows its error, and *Apps & features* can run it again. - **The release workflow** now uses the job token. If Forgejo 17 refuses an operation with it, the release fails at that step, before anything is published. - **The release file names changed** (`-setup.exe`, `-portable.zip`). No launcher is released yet, so nothing out there reads the old name. - Nothing is released yet: reverting the PR is enough. ## Issues See MagicBeanDev/Longshot#13. ## Checklist ### For everyone - [x] One topic: the launcher, with its install, CI and releases. - [x] The title is a Conventional Commit. - [x] Rebased on current `main`. - [x] Tests run and passing (above). - [x] Tested in the game, or what wasn't is named under Not tested (no game involved). - [ ] Measured: n/a (no performance change). - [x] No game files, dumps, build output or generated files: the save tests use fake text fixtures. - [x] The license is OK: GPL-3.0-or-later; Inter keeps its OFL. ### If applicable - [x] Flags table filled in; README updated. - [x] Screenshots for what shows on screen. - [x] A game-free test for each fix (the program's name, the swap recovery, the portable zip, install and uninstall with fakes, the lock, a stalled download, a save copy with an injected failure). - [x] Host code builds on Linux; Windows-only code is guarded and compiled in the same build. - [ ] Docs: the shared `docs/releases.md` and `RELEASING.md` still name the old Windows file and `RELEASE_TOKEN`. They come over byte-identical from Longshot#13's next commit. ## Manual steps (none done) 1. **Org secret.** `RELEASE_SIGNING_KEY` (the key is built in: `Core/ReleaseTrust.cs`, id `5e18330371d7f5aa`), and the org variable `FORGEJO_UPLOAD_API`, which bypasses Cloudflare's 100 MB upload limit. `RELEASE_TOKEN` is no longer needed. 2. **Actions.** Enable Actions on this repository and register a Linux runner (Longshot's `docs/forgejo-runners.md`). The build job installs Wine with apt (as root, or with sudo). 3. **First release.** `v1.0.0-rc.1`, then `v1.0.0` (RELEASING.md). 4. **Repository settings.** Already done: squash only, branch deleted after merge, main protected with 1 approval.
The launcher from Hookshot's 3.0 launcher, renamed to Longshot and made a clean start at
Longshot's v3.0.0: no hand-over from a 2.x install, nothing read of Hookshot's settings,
folders, channels or versions.

- Three channels, stable, rc and nightly, for Longshot's builds and for the launcher
  itself, chosen in the window. Longshot's numbers start at v3.0.0, the launcher's at
  v1.0.0.
- Longshot's builder installed per version from its signed release (ECDSA P-256 over the
  release file, SHA-256 of every list and part), and moved to the channel's newest.
- Its own updates from its own signed releases: downloaded, checked, started with
  --self-check, swapped in one rename (Windows: aside, then in), --rollback to undo.
- The Hookshot save importer: finds Hookshot's saves, checks them, copies them (never
  moves), asks before replacing a Longshot save and backs it up first.
- CI and releases on Forgejo Actions (.forgejo/workflows), scripts/package.sh and
  scripts/release.py, docs/releases.md, docs/hookshot-saves.md, AGENTS.md and
  CONTRIBUTING.md.
HumanBean force-pushed feat/launcher from 4eb849feac
Some checks failed
Checks / checks (pull_request) Has been cancelled
to 008d015589
Some checks failed
Checks / checks (pull_request) Has been cancelled
2026-11-12 23:09:03 +00:00
Compare
HumanBean force-pushed feat/launcher from 008d015589
Some checks failed
Checks / checks (pull_request) Has been cancelled
to 28c07ac52c
Some checks failed
Checks / checks (pull_request) Has been cancelled
2026-11-12 23:09:12 +00:00
Compare
HumanBean force-pushed feat/launcher from 28c07ac52c
Some checks failed
Checks / checks (pull_request) Has been cancelled
to 1bac938012
Some checks failed
Checks / checks (pull_request) Failing after 1m16s
2026-11-12 23:25:48 +00:00
Compare
HumanBean force-pushed feat/launcher from 1bac938012
Some checks failed
Checks / checks (pull_request) Failing after 1m16s
to 887b4661ba
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
2026-11-13 00:12:02 +00:00
Compare
HumanBean changed title from feat: the Longshot Launcher v1.0.0: installs Longshot's builder, updates itself, imports Hookshot saves to feat: the Longshot Launcher v1.0.0: installs Longshot's builder, updates itself on its own channel, imports Hookshot saves 2026-11-13 00:13:26 +00:00
feat(release): the release key, id 5e18330371d7f5aa, built in
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
dab9b39b84
Author
Owner

Pushed dab9b39b: the release key (ECDSA P-256, key id 5e18330371d7f5aa) is built into Core/ReleaseTrust.cs, ReleaseKeys.BuiltIn. Its private half is the MagicBeanDev organization secret RELEASE_SIGNING_KEY, and Longshot lists the same key in scripts/release_keys.json (Longshot#13). tests/test_launcher_core.py --strict (471 checks) and tests/test_release.py pass.

Pushed dab9b39b: the release key (ECDSA P-256, key id `5e18330371d7f5aa`) is built into `Core/ReleaseTrust.cs`, `ReleaseKeys.BuiltIn`. Its private half is the MagicBeanDev organization secret `RELEASE_SIGNING_KEY`, and Longshot lists the same key in `scripts/release_keys.json` (Longshot#13). `tests/test_launcher_core.py --strict` (471 checks) and `tests/test_release.py` pass.
HumanBean left a comment

Read the whole launcher at dab9b39b, code only (nothing built or run). The trust chain holds up. The P-256 signature is checked over the exact bytes of SHA256SUMS before anything in it is parsed. Each file name carries its version, so an old signed SHA256SUMS can't be replayed under a newer tag. Size and SHA-256 are checked on every download, tools.lock lines included, and the AppImage and the new launcher only run after that. Links are https only, also after a redirect. Unpacking guards against zip-slip and symlinks (no write through a link, links checked against the staging root, CreateNew). The Hookshot import only reads Hookshot's folder, checks it, asks before replacing a save and backs up first. Nothing else of Hookshot is read. References are NotRexed/Hookshot or MagicBeanDev/* only. Credits and licence are fine.

Two things to fix before v1.0.0:

  1. Self-update never runs on Windows with the file as it's released (inline, SelfUpdate.cs).
  2. release.py releases() sees only drafts (draft=true filters to drafts on Forgejo). So nightlies are never pruned, and check-tag's "above every release" check compares against nothing (inline). The same file goes into Longshot#13, so it needs the same fix there.

The rest is smaller: no lock between two launchers, no recovery from a crash mid-swap on Windows, a save copy that fails half way, and a few nits (inline).

Read the whole launcher at dab9b39b, code only (nothing built or run). The trust chain holds up. The P-256 signature is checked over the exact bytes of SHA256SUMS before anything in it is parsed. Each file name carries its version, so an old signed SHA256SUMS can't be replayed under a newer tag. Size and SHA-256 are checked on every download, tools.lock lines included, and the AppImage and the new launcher only run after that. Links are https only, also after a redirect. Unpacking guards against zip-slip and symlinks (no write through a link, links checked against the staging root, `CreateNew`). The Hookshot import only reads Hookshot's folder, checks it, asks before replacing a save and backs up first. Nothing else of Hookshot is read. References are NotRexed/Hookshot or MagicBeanDev/* only. Credits and licence are fine. Two things to fix before v1.0.0: 1. **Self-update never runs on Windows** with the file as it's released (inline, `SelfUpdate.cs`). 2. **`release.py releases()` sees only drafts** (`draft=true` filters to drafts on Forgejo). So nightlies are never pruned, and `check-tag`'s "above every release" check compares against nothing (inline). The same file goes into Longshot#13, so it needs the same fix there. The rest is smaller: no lock between two launchers, no recovery from a crash mid-swap on Windows, a save copy that fails half way, and a few nits (inline).
@ -0,0 +73,4 @@
dotnet-version: '10.0.x'
- name: LongshotLauncher.exe and the AppImage
run: scripts/package.sh "${{ needs.version.outputs.tag }}" dist
- uses: https://data.forgejo.org/forgejo/upload-artifact@v4 # (Forgejo's: GitHub's v4 and later refuse a server that is not github.com)
Author
Owner

Nit: the comment says scripts/package.sh builds on Linux, but only the Linux program gets a --self-check there. The Windows exe ships without ever being started. Also, forgejo/upload-artifact@v4 and download-artifact@v4 are pinned to a tag, while the other actions are pinned to a SHA.

Nit: the comment says `scripts/package.sh` builds on Linux, but only the Linux program gets a `--self-check` there. The Windows exe ships without ever being started. Also, `forgejo/upload-artifact@v4` and `download-artifact@v4` are pinned to a tag, while the other actions are pinned to a SHA.
@ -0,0 +105,4 @@
var any = false;
foreach (var channel in Versions.Channels)
{
var info = await Releases.ChannelAsync(channel, cancel);
Author
Owner

Nit: each ChannelAsync fetches the whole release list again, so a check is 4 list fetches (3 Longshot channels plus the launcher's). Offline with a server that hangs, that's up to about 4 x (2 x 20 s + 2 s) before the home settles. Fetch the list once per product and pick the channels from it.

Nit: each `ChannelAsync` fetches the whole release list again, so a check is 4 list fetches (3 Longshot channels plus the launcher's). Offline with a server that hangs, that's up to about 4 x (2 x 20 s + 2 s) before the home settles. Fetch the list once per product and pick the channels from it.
@ -0,0 +234,4 @@
public bool HasLauncherUpdate => Service.LauncherUpdate != null;
public string LauncherUpdateText => Service.LauncherUpdate is { } r
? $"Longshot Launcher {Versions.Shown(r.Version)} is out (you have {Versions.Shown(Service.OwnTag)})."
Author
Owner

Nit: after the player switches the launcher's channel (rc to stable, say), UpdateTo offers that channel's newest even when it's older, which is fine as a choice. But this banner then says "Longshot Launcher v1.0.0 is out (you have v1.1.0-rc.1)". Say "switch to" when it isn't newer.

Nit: after the player switches the launcher's channel (rc to stable, say), `UpdateTo` offers that channel's newest even when it's older, which is fine as a choice. But this banner then says "Longshot Launcher v1.0.0 is out (you have v1.1.0-rc.1)". Say "switch to" when it isn't newer.
@ -0,0 +48,4 @@
Normalize();
}
/// builds.json was there when it was loaded (no file: the launcher's first start, when the 2.x settings are read)
Author
Owner

Nit, left over from the port: "when the 2.x settings are read". Nothing reads 2.x settings (good: clean break), and only the tests use Existed. Drop it or fix the comment.

Nit, left over from the port: "when the 2.x settings are read". Nothing reads 2.x settings (good: clean break), and only the tests use `Existed`. Drop it or fix the comment.
Core/Fetcher.cs Outdated
@ -0,0 +78,4 @@
while (true)
{
int n;
try { n = await from.ReadAsync(buffer, cancel).ConfigureAwait(false); }
Author
Owner

Nit: HttpClient.Timeout covers only the request up to the headers here (ResponseHeadersRead). A download that stalls mid-body (a multi-GB builder) waits until Cancel. A per-read timeout (a CTS reset on each chunk) would turn it into a FetchException the mirrors and Retry can handle.

Nit: `HttpClient.Timeout` covers only the request up to the headers here (`ResponseHeadersRead`). A download that stalls mid-body (a multi-GB builder) waits until Cancel. A per-read timeout (a CTS reset on each chunk) would turn it into a FetchException the mirrors and Retry can handle.
Core/Saves.cs Outdated
@ -0,0 +432,4 @@
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(backup)!);
CopyTree(dst, backup);
foreach (var s in SlotNames(dst)) Directory.Delete(System.IO.Path.Combine(dst, s), recursive: true);
Author
Owner

The target's slots are deleted here, then the source is copied in. If that copy fails (disk full, a file locked), the target is left with no slots, or only some. The UI shows only the IOException message, not where the backup is. Copying into a sibling temp folder first and swapping, or restoring from backup on failure, would make "backs up before it replaces" also mean "never leaves you with less". This applies to imports, exports and copies between builds.

The target's slots are deleted here, then the source is copied in. If that copy fails (disk full, a file locked), the target is left with no slots, or only some. The UI shows only the IOException message, not where the backup is. Copying into a sibling temp folder first and swapping, or restoring from `backup` on failure, would make "backs up before it replaces" also mean "never leaves you with less". This applies to imports, exports and copies between builds.
@ -0,0 +51,4 @@
if (platform == Platform.Linux)
return getenv("APPIMAGE") is { Length: > 0 } appImage && Path.IsPathFullyQualified(appImage) ? appImage : null;
// (either separator: the name is read the same on any system)
return processPath != null && processPath.Split('/', '\\')[^1].Equals("LongshotLauncher.exe", StringComparison.OrdinalIgnoreCase)
Author
Owner

Bug. The Windows release file is LongshotLauncher-<v>-windows-x64.exe (package.sh, README Download), but this only accepts a process named exactly LongshotLauncher.exe. A player who runs the download as it is gets "This launcher was not started from its own file, so it cannot replace itself" on every update, and --rollback fails the same way. The test only covers C:\L\LongshotLauncher.exe. Either accept any .exe that isn't dotnet.exe (or check that the entry assembly is this one), or release the file as LongshotLauncher.exe. Nit: after an update on Linux the AppImage keeps its old versioned name (...-v1.0.0-...AppImage running v1.1.0).

**Bug.** The Windows release file is `LongshotLauncher-<v>-windows-x64.exe` (package.sh, README *Download*), but this only accepts a process named exactly `LongshotLauncher.exe`. A player who runs the download as it is gets "This launcher was not started from its own file, so it cannot replace itself" on every update, and `--rollback` fails the same way. The test only covers `C:\L\LongshotLauncher.exe`. Either accept any `.exe` that isn't `dotnet.exe` (or check that the entry assembly is this one), or release the file as `LongshotLauncher.exe`. Nit: after an update on Linux the AppImage keeps its old versioned name (`...-v1.0.0-...AppImage` running v1.1.0).
@ -0,0 +130,4 @@
File.Move(staged, current, overwrite: true);
return;
}
File.Move(current, backup, overwrite: true);
Author
Owner

If the launcher is killed or the PC loses power between these two moves, there is no LongshotLauncher.exe any more, only .old and .new. The shortcuts break and --rollback can't be started. Rare, but nothing recovers from it. Options: at start, if current is missing and .new/.old exist, put one back (that needs a tiny stub, so it may not be worth it), or at least say in docs/releases.md which file to rename. The doc's "on failure the old one is put back" only covers an exception.

If the launcher is killed or the PC loses power between these two moves, there is no `LongshotLauncher.exe` any more, only `.old` and `.new`. The shortcuts break and `--rollback` can't be started. Rare, but nothing recovers from it. Options: at start, if `current` is missing and `.new`/`.old` exist, put one back (that needs a tiny stub, so it may not be worth it), or at least say in docs/releases.md which file to rename. The doc's "on failure the old one is put back" only covers an exception.
@ -0,0 +90,4 @@
var staging = dest + ".part";
Directory.CreateDirectory(root);
if (new DirectoryInfo(staging).LinkTarget != null) throw new IOException("The tools' staging folder is a symbolic link: " + staging);
if (Directory.Exists(staging)) DeleteTree(staging);
Author
Owner

Nothing stops two launchers running at once (no mutex or lock file anywhere). A second instance installing the same version deletes the first one's <tag>.part here. Both share .downloads/<sha>.zip.part, and AtomicFile uses a fixed <name>.tmp for builds.json and settings.json. A named mutex or a lock file in the home at start ("the launcher is already open") would cover all of it, an update during a build included.

Nothing stops two launchers running at once (no mutex or lock file anywhere). A second instance installing the same version deletes the first one's `<tag>.part` here. Both share `.downloads/<sha>.zip.part`, and `AtomicFile` uses a fixed `<name>.tmp` for builds.json and settings.json. A named mutex or a lock file in the home at start ("the launcher is already open") would cover all of it, an update during a build included.
@ -0,0 +393,4 @@
def releases(repo):
out, page = [], 1
while True:
items = api("GET", "/repos/%s/releases?draft=true&limit=50&page=%d" % (repo, page)) or []
Author
Owner

Bug. On Forgejo, draft=true means only drafts (it filters is_draft = true). It doesn't mean "include drafts". I checked on codeberg.org (Forgejo 17): /repos/forgejo/forgejo/releases?draft=true returns [], and without the parameter it returns the releases. Because of that:

  • prune never sees a published nightly, so none is ever deleted. docs/releases.md says the newest 5 are kept.
  • check-tag (and next) filter not r.get("draft") on a list that holds only drafts, so existing is always empty and "not above every release there is" never fires.
  • publish's "already published" guard never triggers. The POST then fails with a 409, which happens to be safe.

Fix: drop draft=true. With RELEASE_TOKEN (write access), Forgejo includes drafts by default. FakeForgejo in tests/test_release.py ignores the query, which is why the tests pass. Make it honour draft so the test covers this.

**Bug.** On Forgejo, `draft=true` means *only drafts* (it filters `is_draft = true`). It doesn't mean "include drafts". I checked on codeberg.org (Forgejo 17): `/repos/forgejo/forgejo/releases?draft=true` returns `[]`, and without the parameter it returns the releases. Because of that: - `prune` never sees a published nightly, so none is ever deleted. docs/releases.md says the newest 5 are kept. - `check-tag` (and `next`) filter `not r.get("draft")` on a list that holds only drafts, so `existing` is always empty and "not above every release there is" never fires. - `publish`'s "already published" guard never triggers. The POST then fails with a 409, which happens to be safe. Fix: drop `draft=true`. With `RELEASE_TOKEN` (write access), Forgejo includes drafts by default. `FakeForgejo` in tests/test_release.py ignores the query, which is why the tests pass. Make it honour `draft` so the test covers this.
HumanBean left a comment

Contract findings only, against Longshot#13 (MagicBeanDev/Longshot#13). The full review of this PR is someone else's.

  • The key is identical on both sides (id 5e18330371d7f5aa, P-256), and the signature, SHA256SUMS, asset names, channel rules, tools.lock and zip layout all match what #13 produces.
  • The new comment above the key breaks scripts/release.py's keys_from_cs. It cuts at the first ; after BuiltIn =, and 2026-11-13; RELEASING.md has one. trusted_keys() returns [], so this repository's release workflow fails at verify ("no key to check with"). Fix it in the shared release.py (cut at };, or skip // lines) in both repos, or drop the ; from the comment.
  • The shared release.py's releases() sends draft=true, which on Forgejo 17 returns drafts only. In this repository's workflow, check-tag's "above every release" and prune see nothing. The launcher itself (Releases.ListAsync) is fine. Fix: drop the parameter, in both repos.
Contract findings only, against Longshot#13 (MagicBeanDev/Longshot#13). The full review of this PR is someone else's. - **The key is identical on both sides** (id `5e18330371d7f5aa`, P-256), and the signature, SHA256SUMS, asset names, channel rules, tools.lock and zip layout all match what #13 produces. - **The new comment above the key breaks `scripts/release.py`'s `keys_from_cs`.** It cuts at the first `;` after `BuiltIn =`, and `2026-11-13; RELEASING.md` has one. `trusted_keys()` returns `[]`, so this repository's release workflow fails at `verify` ("no key to check with"). Fix it in the shared release.py (cut at `};`, or skip `//` lines) in both repos, or drop the `;` from the comment. - **The shared release.py's `releases()` sends `draft=true`, which on Forgejo 17 returns drafts only.** In this repository's workflow, check-tag's "above every release" and prune see nothing. The launcher itself (`Releases.ListAsync`) is fine. Fix: drop the parameter, in both repos.
@ -0,0 +37,4 @@
/// it here. Until then this launcher takes no release from the server, and says so.
public static readonly IReadOnlyList<ReleaseKey> BuiltIn = new ReleaseKey[]
{
// the release key, id 5e18330371d7f5aa (scripts/release.py keygen, 2026-11-13; RELEASING.md, One-time setup)
Author
Owner

The ; in this comment ends keys_from_cs's search before the FromSpki line, so release.py finds no key here and verify fails in CI.

The `;` in this comment ends `keys_from_cs`'s search before the `FromSpki` line, so release.py finds no key here and `verify` fails in CI.
HumanBean force-pushed feat/launcher from dab9b39b84
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
to 52de0cc084
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
2026-11-13 01:11:10 +00:00
Compare
Author
Owner

Pushed 52de0cc0 (amends the key commit): the comment above the key had a ;, which ended scripts/release.py keys_from_cs before the key, so release.py found no built-in key (from the #13 review). The comment has no ; now, and keys_from_cs reads key 5e18330371d7f5aa. The parser itself will also be made robust to comments in the fix round.

Pushed 52de0cc0 (amends the key commit): the comment above the key had a `;`, which ended `scripts/release.py keys_from_cs` before the key, so `release.py` found no built-in key (from the #13 review). The comment has no `;` now, and `keys_from_cs` reads key `5e18330371d7f5aa`. The parser itself will also be made robust to comments in the fix round.
Self-update works whatever the program is named, installed or portable, and keeps
its kind and name; a start puts right an update cut short (.old back, .new gone).
One launcher per home (a named mutex on Windows, a flock'd file on Linux): a second
start brings the first window forward. Settings and builds.json are written through
unique temporary files. A save copy is made beside the target and swapped in, so a
failure leaves the target as it was and names the backup.

The Windows launcher installs itself (per user, Start menu, Apps & features,
--uninstall) or runs portable (a portable file beside it); Linux adds the AppImage
to the applications menu and is portable with <AppImage>.home. Releases carry the
setup program and a portable zip.

Also: one release list fetch per product and update check, a read inactivity
timeout for downloads, an older launcher on a switched channel is no longer
announced as out, and the stale 2.x settings comment is gone.
ci(release): the setup program and the portable zip, the Windows program started under Wine, the job token
Some checks failed
CI / Compile and test (pull_request) Failing after 1m5s
CI / Conventional Commit title (pull_request) Has been cancelled
a442e8defc
The release is made with Forgejo Actions' own job token (no RELEASE_TOKEN secret,
no bot), with contents: write only on the publish job. The artifact actions are
pinned by commit.
Author
Owner

Pushed e17a7844 and a442e8de (two new commits on 52de0cc0). The review findings, one by one:

Review (#issuecomment-11990)

  1. Self-update on Windows (Core/SelfUpdate.cs CurrentFile): any .exe other than dotnet.exe is the launcher's own file now. The swap keeps the name it was installed or started as. An installed launcher updates LongshotLauncher.exe from the release's setup program. A portable one takes LongshotLauncher.exe out of the portable zip and updates in place beside its portable file (LauncherLayout.UpdateKey, DownloadAsync(zipEntry:)). --rollback works the same way. Tests: the download's own name, a renamed copy, dotnet.exe and a dll refused, an installed update staying installed, a portable update from the checked zip, and a zip without the program refused. On Linux the AppImage also keeps the name it was started as, since that is now the rule.
  2. Two launchers at once (Core/InstanceLock.cs, App/Program.cs): one launcher per home, a named mutex on Windows (Local\LongshotLauncher-<hash of the home>) and a flock'd launcher.lock on Linux. A second start writes launcher.show, the first one's watcher brings its window forward, and the second ends (Windows: AllowSetForegroundWindow first). --uninstall while the launcher is open says to close it first. AtomicFile writes through <name>.<guid>.tmp, flushed, then renamed, and never leaves a temporary file. Tests: a second lock refused and released, the mutex name per home, the poke, and 16 writers at once leaving one whole file. Checked on the AppImage: started on a home whose lock is held, it exits at once and pokes the first.
  3. A failed save copy (Core/Saves.cs Copy): the copy is made whole in .<target>.longshot-staging beside the target, then swapped in by two renames. Any failure deletes the staging folder and leaves the target as it was. If the target were lost in the swap, the backup is copied back. SaveFailure.Backup carries the backup's path: the Saves result page shows it with its Open backup button, Ready shows it after a failed move, and the message names it everywhere. A copy cut short between the renames is put back by the next one. Test: failures injected (Saves.Fault) during the staging copy and between the two renames, each leaving the target identical and naming a whole backup.
  4. A crash between the two renames (SelfUpdate.Recover, called at every start): with the program missing and its .old there, the .old goes back; a leftover .new, .new.part or .new.zip.part is removed. A launcher started from the .old itself (its program gone) puts it back and starts it. README, Installing, says to rename .old back by hand if nothing can start. Tested.
  5. Nits
    • One release list fetch per product and check: Releases.ChannelsAsync reads Longshot's three channels from one list, and the launcher's from its own (2 fetches, not 4). Tested by counting.
    • After a channel switch: "Longshot Launcher v1.0.0 is available on stable. It is older than yours (v1.1.0-rc.1)", with Switch to it. Only a newer one "is out" (SelfUpdate.Compare, the home's banner).
    • Stalled downloads: HttpFetcher has a read inactivity timeout (60 s, reset on every read). A stall becomes a transient FetchException that the mirrors and Retry take; Cancel stays Cancel. Tested with a stream that stalls.
    • The Windows exe in CI: release.yml's build job runs wine64 LongshotLauncher-<v>-windows-x64-setup.exe --self-check and fails unless it answers its tag. I chose Wine because there is no Windows runner. Not run yet: there is no runner.
    • The artifact actions are pinned by commit: upload 16871d9e…, download d8d0a990… (their v4).
    • The "2.x settings" comment is gone from BuildRegistry.Existed.

Contract (#issuecomment-11997)

  1. Launcher-side: the comment above the built-in key has no ; (52de0cc0), and release.py keys_from_cs reads key 5e18330371d7f5aa. The parser fix and releases()'s draft=true are in the shared scripts/release.py, fixed in Longshot#13. This PR takes the shared files (release.py, test_release.py, docs/releases.md, RELEASING.md, cliff.toml) byte-identical from #13's next commit, so they are unchanged here for now.

New in this round

  1. Installer and portable mode, with no installer tool (README, Installing):
    • Windows, first start: Install (to %LOCALAPPDATA%\Programs\LongshotLauncher\LongshotLauncher.exe, a Start menu shortcut, an HKCU Apps & features entry with Publisher "Magic Bean" and UninstallString "…" --uninstall, then the installed copy starts) or Run portable (a portable file beside it, its data in LongshotLauncher-data beside it, no registry or shortcuts).
    • --uninstall asks whether to delete the data, and never deletes builds or save backups.
    • Releases carry -windows-x64-setup.exe and -windows-x64-portable.zip, in place of the bare .exe.
    • Linux: Add to applications menu in Settings, and <AppImage>.home for portable.
    • Tests: install, uninstall, the portable layouts and update-keeps-kind, with a fake registry and fake shortcuts.
    • release.yml uses the job token with the least permissions: contents: read, and write only for publish.
  2. Credits: "HumanBean (hckrman101 on GitHub): wrote the launcher as Hookshot's 3.0 launcher…". No git.magicbean.dev/hckrman101/… URL was left.

dotnet build 0 warnings, core checks 518 passed, test_release.py 15 passed, scripts/package.sh builds all three files, and the AppImage answers --self-check. The description is updated, with screenshots of the two new windows and the Linux setting.

Pushed e17a7844 and a442e8de (two new commits on 52de0cc0). The review findings, one by one: **Review (#issuecomment-11990)** 1. **Self-update on Windows** (`Core/SelfUpdate.cs` `CurrentFile`): any `.exe` other than `dotnet.exe` is the launcher's own file now. The swap keeps the name it was installed or started as. An installed launcher updates `LongshotLauncher.exe` from the release's setup program. A portable one takes `LongshotLauncher.exe` out of the portable zip and updates in place beside its `portable` file (`LauncherLayout.UpdateKey`, `DownloadAsync(zipEntry:)`). `--rollback` works the same way. Tests: the download's own name, a renamed copy, `dotnet.exe` and a dll refused, an installed update staying installed, a portable update from the checked zip, and a zip without the program refused. On Linux the AppImage also keeps the name it was started as, since that is now the rule. 2. **Two launchers at once** (`Core/InstanceLock.cs`, `App/Program.cs`): one launcher per home, a named mutex on Windows (`Local\LongshotLauncher-<hash of the home>`) and a flock'd `launcher.lock` on Linux. A second start writes `launcher.show`, the first one's watcher brings its window forward, and the second ends (Windows: `AllowSetForegroundWindow` first). `--uninstall` while the launcher is open says to close it first. `AtomicFile` writes through `<name>.<guid>.tmp`, flushed, then renamed, and never leaves a temporary file. Tests: a second lock refused and released, the mutex name per home, the poke, and 16 writers at once leaving one whole file. Checked on the AppImage: started on a home whose lock is held, it exits at once and pokes the first. 3. **A failed save copy** (`Core/Saves.cs` `Copy`): the copy is made whole in `.<target>.longshot-staging` beside the target, then swapped in by two renames. Any failure deletes the staging folder and leaves the target as it was. If the target were lost in the swap, the backup is copied back. `SaveFailure.Backup` carries the backup's path: the Saves result page shows it with its *Open backup* button, Ready shows it after a failed move, and the message names it everywhere. A copy cut short between the renames is put back by the next one. Test: failures injected (`Saves.Fault`) during the staging copy and between the two renames, each leaving the target identical and naming a whole backup. 4. **A crash between the two renames** (`SelfUpdate.Recover`, called at every start): with the program missing and its `.old` there, the `.old` goes back; a leftover `.new`, `.new.part` or `.new.zip.part` is removed. A launcher started from the `.old` itself (its program gone) puts it back and starts it. README, *Installing*, says to rename `.old` back by hand if nothing can start. Tested. 5. **Nits** - One release list fetch per product and check: `Releases.ChannelsAsync` reads Longshot's three channels from one list, and the launcher's from its own (2 fetches, not 4). Tested by counting. - After a channel switch: "Longshot Launcher v1.0.0 is available on stable. It is older than yours (v1.1.0-rc.1)", with *Switch to it*. Only a newer one "is out" (`SelfUpdate.Compare`, the home's banner). - Stalled downloads: `HttpFetcher` has a read inactivity timeout (60 s, reset on every read). A stall becomes a transient `FetchException` that the mirrors and Retry take; Cancel stays Cancel. Tested with a stream that stalls. - The Windows exe in CI: `release.yml`'s build job runs `wine64 LongshotLauncher-<v>-windows-x64-setup.exe --self-check` and fails unless it answers its tag. I chose Wine because there is no Windows runner. Not run yet: there is no runner. - The artifact actions are pinned by commit: upload `16871d9e…`, download `d8d0a990…` (their `v4`). - The "2.x settings" comment is gone from `BuildRegistry.Existed`. **Contract (#issuecomment-11997)** 6. Launcher-side: the comment above the built-in key has no `;` (52de0cc0), and `release.py keys_from_cs` reads key `5e18330371d7f5aa`. The parser fix and `releases()`'s `draft=true` are in the shared `scripts/release.py`, fixed in Longshot#13. This PR takes the shared files (`release.py`, `test_release.py`, `docs/releases.md`, `RELEASING.md`, `cliff.toml`) byte-identical from #13's next commit, so they are unchanged here for now. **New in this round** 7. **Installer and portable mode**, with no installer tool (README, *Installing*): - **Windows, first start**: *Install* (to `%LOCALAPPDATA%\Programs\LongshotLauncher\LongshotLauncher.exe`, a Start menu shortcut, an HKCU *Apps & features* entry with Publisher "Magic Bean" and `UninstallString` `"…" --uninstall`, then the installed copy starts) or *Run portable* (a `portable` file beside it, its data in `LongshotLauncher-data` beside it, no registry or shortcuts). - **`--uninstall`** asks whether to delete the data, and never deletes builds or save backups. - **Releases** carry `-windows-x64-setup.exe` and `-windows-x64-portable.zip`, in place of the bare `.exe`. - **Linux**: *Add to applications menu* in Settings, and `<AppImage>.home` for portable. - **Tests**: install, uninstall, the portable layouts and update-keeps-kind, with a fake registry and fake shortcuts. - **`release.yml`** uses the job token with the least permissions: `contents: read`, and `write` only for publish. 8. **Credits**: "HumanBean (hckrman101 on GitHub): wrote the launcher as Hookshot's 3.0 launcher…". No `git.magicbean.dev/hckrman101/…` URL was left. `dotnet build` 0 warnings, core checks 518 passed, `test_release.py` 15 passed, `scripts/package.sh` builds all three files, and the AppImage answers `--self-check`. The description is updated, with screenshots of the two new windows and the Linux setting.
test(launcher): a stopped child that stays a zombie in a container has ended
Some checks failed
CI / Conventional Commit title (pull_request) Failing after 2s
CI / Compile and test (pull_request) Successful in 1m2s
95c24777fd
In CI's job container PID 1 is tail -f /dev/null, which never reaps the orphans
it is handed: the build's child, killed with the run, stays a zombie there, and
Process.HasExited calls a zombie running. The check reads its state from
/proc/<pid>/stat and takes Z (or gone) as ended; a child left running still fails.
ci: read the title rule from the pull request while main has no scripts/release.py
All checks were successful
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Compile and test (pull_request) Successful in 1m9s
ddca39cd7b
The title job reads release.py from the base so that a pull request cannot loosen
the rule for itself. main is still the README stub, so the first pull request,
the one that adds release.py, failed with no such file.
Author
Owner

Pushed 95c24777 and ddca39cd. CI is green at ddca39cd: Compile and test (0 warnings, core checks 518 passed, test_release.py 15 passed) and Conventional Commit title (ok: feat: the Longshot Launcher v1.0.0: …).

Why the core checks failed in CI (run 4). The test was sensitive to its environment; it was not a launcher bug. I reproduced it in ghcr.io/catthehacker/ubuntu:act-24.04, started the way the runner starts a job container (PID 1 is tail -f /dev/null).

  • What happens. A cancelled run stops the build's process tree (Process.Kill(entireProcessTree: true)), the stand-in compiler (sleep 60) included. The shell dies first, so the killed sleep is orphaned and handed to PID 1.
  • Container versus PC. On a PC or a Steam Deck that is systemd, which reaps it at once. In the job container PID 1 is tail, which never reaps, so the child stays a zombie (ps: Z, parent 1). It is dead and holds nothing, but Process.HasExited on a process the test did not start reports a zombie as running.
  • The fix (tests/launcher_core/ScreenChecks.cs, Ended): the check reads the state from /proc/<pid>/stat and counts Z (or gone) as ended. A child that was not stopped is still in S and still fails the check, so it proves the same thing. In the container: 518 checks pass (they failed before the change). Locally: 518 pass.

Why the title job failed. It reads scripts/release.py from the base so that a pull request cannot loosen the rule for itself. main is still the README stub, so the file was missing. Now, only while the base has no scripts/release.py, a second sparse checkout takes it from the pull request (if: hashFiles('scripts/release.py') == ''). After this merges, the rule comes from main again.

Pushed 95c24777 and ddca39cd. CI is green at ddca39cd: *Compile and test* (0 warnings, core checks 518 passed, `test_release.py` 15 passed) and *Conventional Commit title* (`ok: feat: the Longshot Launcher v1.0.0: …`). **Why the core checks failed in CI (run 4).** The test was sensitive to its environment; it was not a launcher bug. I reproduced it in `ghcr.io/catthehacker/ubuntu:act-24.04`, started the way the runner starts a job container (PID 1 is `tail -f /dev/null`). - **What happens.** A cancelled run stops the build's process tree (`Process.Kill(entireProcessTree: true)`), the stand-in compiler (`sleep 60`) included. The shell dies first, so the killed `sleep` is orphaned and handed to PID 1. - **Container versus PC.** On a PC or a Steam Deck that is systemd, which reaps it at once. In the job container PID 1 is `tail`, which never reaps, so the child stays a zombie (`ps`: `Z`, parent 1). It is dead and holds nothing, but `Process.HasExited` on a process the test did not start reports a zombie as running. - **The fix** (`tests/launcher_core/ScreenChecks.cs`, `Ended`): the check reads the state from `/proc/<pid>/stat` and counts `Z` (or gone) as ended. A child that was not stopped is still in `S` and still fails the check, so it proves the same thing. In the container: 518 checks pass (they failed before the change). Locally: 518 pass. **Why the title job failed.** It reads `scripts/release.py` from the base so that a pull request cannot loosen the rule for itself. `main` is still the README stub, so the file was missing. Now, only while the base has no `scripts/release.py`, a second sparse checkout takes it from the pull request (`if: hashFiles('scripts/release.py') == ''`). After this merges, the rule comes from main again.
chore(release): the shared release files from Longshot#13 @ 5171786f (all releases listed, comments in the key list, the job token, the setup and portable names)
All checks were successful
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Compile and test (pull_request) Successful in 1m8s
928d801df3
Author
Owner

Pushed 928d801d: the five shared release files (scripts/release.py, tests/test_release.py, docs/releases.md, RELEASING.md, cliff.toml) copied from Longshot#13 @ 5171786f, byte-identical. This brings:

  • all releases listed with paging, not only drafts (prune and check-tag now see published releases);
  • keys_from_cs ignores comments;
  • the built-in job token (RELEASE_TOKEN: ${{ forgejo.token }}, no bot, no extra secret);
  • the setup and portable file names, and the self-installing launcher documented.

Checks here: tests/test_release.py OK; tests/test_launcher_core.py --strict 518 passed; keys_from_cs reads key 5e18330371d7f5aa from Core/ReleaseTrust.cs.

Pushed 928d801d: the five shared release files (`scripts/release.py`, `tests/test_release.py`, `docs/releases.md`, `RELEASING.md`, `cliff.toml`) copied from Longshot#13 @ 5171786f, byte-identical. This brings: - all releases listed with paging, not only drafts (prune and check-tag now see published releases); - `keys_from_cs` ignores comments; - the built-in job token (`RELEASE_TOKEN: ${{ forgejo.token }}`, no bot, no extra secret); - the setup and portable file names, and the self-installing launcher documented. Checks here: `tests/test_release.py` OK; `tests/test_launcher_core.py --strict` 518 passed; `keys_from_cs` reads key `5e18330371d7f5aa` from `Core/ReleaseTrust.cs`.
HumanBean deleted branch feat/launcher 2026-11-13 02:07:51 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/LongshotLauncher!1
No description provided.