feat: the Longshot Launcher v1.0.0: installs Longshot's builder, updates itself on its own channel, imports Hookshot saves #1
No reviewers
Labels
No labels
area/build
area/install
area/saves
area/ui
area/updates
good first issue
gpu/amd
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
platform/linux
platform/steam-deck
platform/windows
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/LongshotLauncher!1
Loading…
Reference in a new issue
No description provided.
Delete branch "HumanBean/LongshotLauncher:feat/launcher"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
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.Core/LauncherLayout.cs,Core/Installer.cs,App/SetupWindows.cs,App/WindowsInstall.cs; README, Installing). There is no installer tool: the launcher installs itself.%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.--installdoes it without asking.--uninstallremoves 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.portablefile beside the program; its data, its builds folder and its save backups go inLongshotLauncher-databeside it. It never writes the registry or shortcuts.~/.local/share/applications/longshot-launcher.desktopand its icon, pointing at the AppImage where it is. Portable is an<AppImage>.homefolder beside it.IUserRegistryandIShortcutMaker, so the tests use fakes and never touch the real ones.LongshotLauncher-<v>-windows-x64-setup.exe(the program, which offers Install),LongshotLauncher-<v>-windows-x64-portable.zip(the same program asLongshotLauncher.exe, with itsportablefile) andLongshotLauncher-<v>-x86_64.AppImage, all in the signedSHA256SUMS.release.ymlmakes 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, noRELEASE_TOKENsecret, no bot), withcontents: writeonly on the publish job. Every action is pinned by commit.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 signedSHA256SUMS, run it with--self-check, swap it in under the same name it was installed or started as, start it.--rollbackrestores the previous one. A start puts right an update cut short (the.oldback when the program is missing, a leftover.newremoved).Core/InstanceLock.cs): a named mutex on Windows, a flock'dlauncher.lockon Linux. A second start brings the open window forward.settings.jsonandbuilds.jsonare written through a temporary file of their own (unique name, flushed, renamed).Core/Saves.csCopy).SHA256SUMSagainst 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
--install--uninstallportablefile beside the programLongshotLauncher-databeside it<AppImage>.homefolderLONGSHOT_LAUNCHER_HOME,_OFFLINE,_PARTS,_TRUST,--launch,--update,--play,--self-check,--rollbackMeasurements
main:
d6743992(the README stub)this PR: feat/launcher @
ddca39cdHardware: Linux (CachyOS), .NET 10.0.404, Python 3.14
Scene: none (the launcher; no game)
Method:
dotnet buildwith 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 heldHow to reproduce: the commands above
d6743992ddca39cddotnet build LongshotLauncher.slnx -c Release -p:RestoreLockedMode=truetests/test_release.pyscripts/package.sh v1.1.0-rc.1--self-checkOKlauncher.show)Not tested
Not tested:
--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.release.yml(wine64 …-setup.exe --self-check): never run, since there is no runner yet.ci.yml) runs on the Forgejo runner, and both of its jobs pass atddca39cd. A real release on git.magicbean.dev: releases were tested against a fake Forgejo only.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:
--uninstall:Settings on Linux, with Add to applications menu:
Earlier screens (unchanged): the Hookshot offer, the importer.
Risk and rollback
-setup.exe,-portable.zip). No launcher is released yet, so nothing out there reads the old name.Issues
See MagicBeanDev/Longshot#13.
Checklist
For everyone
main.If applicable
docs/releases.mdandRELEASING.mdstill name the old Windows file andRELEASE_TOKEN. They come over byte-identical from Longshot#13's next commit.Manual steps (none done)
RELEASE_SIGNING_KEY(the key is built in:Core/ReleaseTrust.cs, id5e18330371d7f5aa), and the org variableFORGEJO_UPLOAD_API, which bypasses Cloudflare's 100 MB upload limit.RELEASE_TOKENis no longer needed.docs/forgejo-runners.md). The build job installs Wine with apt (as root, or with sudo).v1.0.0-rc.1, thenv1.0.0(RELEASING.md).4eb849feac008d015589008d01558928c07ac52c28c07ac52c1bac9380121bac938012887b4661bafeat: the Longshot Launcher v1.0.0: installs Longshot's builder, updates itself, imports Hookshot savesto feat: the Longshot Launcher v1.0.0: installs Longshot's builder, updates itself on its own channel, imports Hookshot savesPushed
dab9b39b: the release key (ECDSA P-256, key id5e18330371d7f5aa) is built intoCore/ReleaseTrust.cs,ReleaseKeys.BuiltIn. Its private half is the MagicBeanDev organization secretRELEASE_SIGNING_KEY, and Longshot lists the same key inscripts/release_keys.json(Longshot#13).tests/test_launcher_core.py --strict(471 checks) andtests/test_release.pypass.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:
SelfUpdate.cs).release.py releases()sees only drafts (draft=truefilters to drafts on Forgejo). So nightlies are never pruned, andcheck-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 AppImagerun: 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)Nit: the comment says
scripts/package.shbuilds on Linux, but only the Linux program gets a--self-checkthere. The Windows exe ships without ever being started. Also,forgejo/upload-artifact@v4anddownload-artifact@v4are 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);Nit: each
ChannelAsyncfetches 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)})."Nit: after the player switches the launcher's channel (rc to stable, say),
UpdateTooffers 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)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.@ -0,0 +78,4 @@while (true){int n;try { n = await from.ReadAsync(buffer, cancel).ConfigureAwait(false); }Nit:
HttpClient.Timeoutcovers 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.@ -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);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
backupon 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)Bug. The Windows release file is
LongshotLauncher-<v>-windows-x64.exe(package.sh, README Download), but this only accepts a process named exactlyLongshotLauncher.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--rollbackfails the same way. The test only coversC:\L\LongshotLauncher.exe. Either accept any.exethat isn'tdotnet.exe(or check that the entry assembly is this one), or release the file asLongshotLauncher.exe. Nit: after an update on Linux the AppImage keeps its old versioned name (...-v1.0.0-...AppImagerunning v1.1.0).@ -0,0 +130,4 @@File.Move(staged, current, overwrite: true);return;}File.Move(current, backup, overwrite: true);If the launcher is killed or the PC loses power between these two moves, there is no
LongshotLauncher.exeany more, only.oldand.new. The shortcuts break and--rollbackcan't be started. Rare, but nothing recovers from it. Options: at start, ifcurrentis missing and.new/.oldexist, 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);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>.parthere. Both share.downloads/<sha>.zip.part, andAtomicFileuses a fixed<name>.tmpfor 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 = [], 1while True:items = api("GET", "/repos/%s/releases?draft=true&limit=50&page=%d" % (repo, page)) or []Bug. On Forgejo,
draft=truemeans only drafts (it filtersis_draft = true). It doesn't mean "include drafts". I checked on codeberg.org (Forgejo 17):/repos/forgejo/forgejo/releases?draft=truereturns[], and without the parameter it returns the releases. Because of that:prunenever sees a published nightly, so none is ever deleted. docs/releases.md says the newest 5 are kept.check-tag(andnext) filternot r.get("draft")on a list that holds only drafts, soexistingis 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. WithRELEASE_TOKEN(write access), Forgejo includes drafts by default.FakeForgejoin tests/test_release.py ignores the query, which is why the tests pass. Make it honourdraftso the test covers this.Contract findings only, against Longshot#13 (MagicBeanDev/Longshot#13). The full review of this PR is someone else's.
5e18330371d7f5aa, P-256), and the signature, SHA256SUMS, asset names, channel rules, tools.lock and zip layout all match what #13 produces.scripts/release.py'skeys_from_cs. It cuts at the first;afterBuiltIn =, and2026-11-13; RELEASING.mdhas one.trusted_keys()returns[], so this repository's release workflow fails atverify("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.releases()sendsdraft=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)The
;in this comment endskeys_from_cs's search before theFromSpkiline, so release.py finds no key here andverifyfails in CI.dab9b39b8452de0cc084Pushed
52de0cc0(amends the key commit): the comment above the key had a;, which endedscripts/release.py keys_from_csbefore the key, sorelease.pyfound no built-in key (from the #13 review). The comment has no;now, andkeys_from_csreads key5e18330371d7f5aa. The parser itself will also be made robust to comments in the fix round.Pushed
e17a7844anda442e8de(two new commits on52de0cc0). The review findings, one by one:Review (#issuecomment-11990)
Core/SelfUpdate.csCurrentFile): any.exeother thandotnet.exeis the launcher's own file now. The swap keeps the name it was installed or started as. An installed launcher updatesLongshotLauncher.exefrom the release's setup program. A portable one takesLongshotLauncher.exeout of the portable zip and updates in place beside itsportablefile (LauncherLayout.UpdateKey,DownloadAsync(zipEntry:)).--rollbackworks the same way. Tests: the download's own name, a renamed copy,dotnet.exeand 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.Core/InstanceLock.cs,App/Program.cs): one launcher per home, a named mutex on Windows (Local\LongshotLauncher-<hash of the home>) and a flock'dlauncher.lockon Linux. A second start writeslauncher.show, the first one's watcher brings its window forward, and the second ends (Windows:AllowSetForegroundWindowfirst).--uninstallwhile the launcher is open says to close it first.AtomicFilewrites 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.Core/Saves.csCopy): the copy is made whole in.<target>.longshot-stagingbeside 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.Backupcarries 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.SelfUpdate.Recover, called at every start): with the program missing and its.oldthere, the.oldgoes back; a leftover.new,.new.partor.new.zip.partis removed. A launcher started from the.olditself (its program gone) puts it back and starts it. README, Installing, says to rename.oldback by hand if nothing can start. Tested.Releases.ChannelsAsyncreads Longshot's three channels from one list, and the launcher's from its own (2 fetches, not 4). Tested by counting.SelfUpdate.Compare, the home's banner).HttpFetcherhas a read inactivity timeout (60 s, reset on every read). A stall becomes a transientFetchExceptionthat the mirrors and Retry take; Cancel stays Cancel. Tested with a stream that stalls.release.yml's build job runswine64 LongshotLauncher-<v>-windows-x64-setup.exe --self-checkand fails unless it answers its tag. I chose Wine because there is no Windows runner. Not run yet: there is no runner.16871d9e…, downloadd8d0a990…(theirv4).BuildRegistry.Existed.Contract (#issuecomment-11997)
;(52de0cc0), andrelease.py keys_from_csreads key5e18330371d7f5aa. The parser fix andreleases()'sdraft=trueare in the sharedscripts/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
%LOCALAPPDATA%\Programs\LongshotLauncher\LongshotLauncher.exe, a Start menu shortcut, an HKCU Apps & features entry with Publisher "Magic Bean" andUninstallString"…" --uninstall, then the installed copy starts) or Run portable (aportablefile beside it, its data inLongshotLauncher-databeside it, no registry or shortcuts).--uninstallasks whether to delete the data, and never deletes builds or save backups.-windows-x64-setup.exeand-windows-x64-portable.zip, in place of the bare.exe.<AppImage>.homefor portable.release.ymluses the job token with the least permissions:contents: read, andwriteonly for publish.git.magicbean.dev/hckrman101/…URL was left.dotnet build0 warnings, core checks 518 passed,test_release.py15 passed,scripts/package.shbuilds 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
95c24777andddca39cd. CI is green atddca39cd: Compile and test (0 warnings, core checks 518 passed,test_release.py15 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 istail -f /dev/null).Process.Kill(entireProcessTree: true)), the stand-in compiler (sleep 60) included. The shell dies first, so the killedsleepis orphaned and handed to PID 1.tail, which never reaps, so the child stays a zombie (ps:Z, parent 1). It is dead and holds nothing, butProcess.HasExitedon a process the test did not start reports a zombie as running.tests/launcher_core/ScreenChecks.cs,Ended): the check reads the state from/proc/<pid>/statand countsZ(or gone) as ended. A child that was not stopped is still inSand 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.pyfrom the base so that a pull request cannot loosen the rule for itself.mainis still the README stub, so the file was missing. Now, only while the base has noscripts/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
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:keys_from_csignores comments;RELEASE_TOKEN: ${{ forgejo.token }}, no bot, no extra secret);Checks here:
tests/test_release.pyOK;tests/test_launcher_core.py --strict518 passed;keys_from_csreads key5e18330371d7f5aafromCore/ReleaseTrust.cs.