Skip to content

feat(launcher): replace Tauri prototype with egui + Azure Blob seed/patch manifest#343

Merged
Cadacious merged 11 commits into
mainfrom
feat/egui-launcher-azure-blob
May 23, 2026
Merged

feat(launcher): replace Tauri prototype with egui + Azure Blob seed/patch manifest#343
Cadacious merged 11 commits into
mainfrom
feat/egui-launcher-azure-blob

Conversation

@BangRocket
Copy link
Copy Markdown
Contributor

@BangRocket BangRocket commented May 21, 2026

Summary

  • Scraps the Tauri 2 WebView2 launcher in crates/launcher/ (~6,989 lines of JS/HTML/Rust + Tauri configs + auto-generated schemas).
  • Replaces with a native egui (eframe) Windows app driven off a seed + patch JSON manifest on Azure Blob Storage. Single ~5 MB .exe, no JS toolchain.
  • Adds 3 GitHub Actions workflows mirroring the server release pattern: path-filtered build CI, workflow_dispatch release, and /release-launcher ChatOps on merged PRs.
  • Adds 2 new docs (user/operator guide + Azure runbook) and supersedes the two Tauri-era plans.

What the new launcher does

1. Fetch manifest.json from Azure Blob (anonymous GET).
2. Diff manifest vs <install>/launcher-installed.json.
3. Install / Update:
     - If seed sha mismatches → download seed.zip (Range-resumable) → sha256 verify → extract.
     - For each missing patch in declared order: download → verify → extract overlay.
     - Byte-patch SGW.exe .rdata: replace `www.stargateworlds.com` with configured server host.
4. Launch buttons appear based on file detection:
     - SGW.exe → "Launch SGW.exe"
     - AteraLoader.exe + AtreaGameDebug.bat → "Launch Atera Debug"
     - AtreaFixASLR.bat → "Fix ASLR"
5. Upload Debug Logs: zip Binaries/sgwdebuglog* + Binaries/sessions/** → single PUT
   to Azure SAS URL. Content-digest dedupe ledger means repeat clicks against unchanged
   logs make zero HTTP requests.

State files: launcher-config.json next to .exe, launcher-installed.json inside the game dir (survives launcher reinstall), uploaded.json next to .exe.

Crate layout (10 flat src files)

File Purpose
main.rs + app.rs eframe entry + egui panels
manifest.rs schema-1 JSON parser + validate (forward-ref + duplicate-id checks)
install.rs resumable seed/patch download + sha256 verify + zip extract
patch_rdata.rs 22-byte .rdata hostname patch (salvaged from old launcher)
launch.rs Atera detection + SGW.exe / batch spawning
logs.rs log collection + zip + single Azure Blob PUT
state.rs InstalledState + UploadedLedger
worker.rs tokio worker, Command/Event channels

Workflows

File Trigger
.github/workflows/launcher-build.yml Path-filtered PRs touching crates/launcher/**. Runs fmt (ubuntu) + clippy/build/test (windows-latest).
.github/workflows/launcher-release.yml workflow_dispatch. Builds release exe with LAUNCHER_LOG_SAS_URL injected from secrets, creates GitHub Release tagged launcher-<date>-<sha7>.
.github/workflows/launcher-release-on-comment.yml issue_comment /release-launcher on merged PRs (mirror of release-on-comment.yml).

The launcher stays in WORKSPACE_EXCLUDES in test.yml so eframe Linux deps don't slow the main CI pipeline — launcher-build.yml owns its full cycle on windows-latest.

Docs

Verification (local, Windows)

cargo check  -p sgw-launcher                                 OK (4m07s fresh)
cargo clippy -p sgw-launcher --all-targets -- -D warnings    clean
cargo test   -p sgw-launcher                                 40/40 pass
cargo fmt    -p sgw-launcher -- --check                      exit 0
launcher GUI                                                 opens, panels
                                                             render, manifest
                                                             fetch path runs

Out-of-band setup required before release is functional

Beyond merging this PR:

  1. Create the Azure storage account + sgw container per launcher-storage-setup.md.
  2. Mint a SAS URL with c (create) permission scoped to the container.
  3. Add it as the repo secret LAUNCHER_LOG_SAS_URL.
  4. Build + publish the seed zip and (eventually) patch zips per launcher-guide.md Part 2.

Without the secret, the release workflow still builds — log upload just stays permanently disabled in that binary.

Test plan

  • CI: launcher workflow (path-filtered) runs and is green
  • CI: existing ci workflow stays green (launcher excluded via WORKSPACE_EXCLUDES)
  • Manual: download an artifact from a workflow run, launch it on a clean Windows machine, confirm window renders + manifest-error UI displays when storage account is not yet created
  • Manual (post-storage-setup): manifest fetch succeeds → Install / Update applies seed + patches → SGW.exe .rdata patched → Launch SGW.exe works
  • Manual: drop Atera files into install dir → "Launch Atera Debug" + "Fix ASLR" buttons appear
  • Manual: click "Upload Debug Logs" twice in a row with unchanged logs → second click reports "Already uploaded" without an HTTP request

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Native Windows launcher with modern GUI
    • Manifest-driven install pipeline with resumable downloads and integrity checks
    • Ed25519-signed manifest verification for tamper protection
    • Debug-log collection, deterministic zipping, and single-request upload with dedupe
    • Improved install progress/status and multiple launch modes (normal, debug, ASLR-fix)
  • Documentation

    • Added comprehensive user guide, implementation doc, and distribution/runbook for launcher

Review Change Stack

…atch manifest

Scraps the Tauri 2 WebView2 launcher in crates/launcher/ and replaces it
with a native egui (eframe) Windows app that consumes a JSON manifest
from Azure Blob Storage. The new launcher is ~5 MB single-file, has no
JS toolchain, and uses the original CME launcher's seed-plus-patches
model rather than the archive.org RAR-of-CABs first-run install.

Crate (crates/launcher/, 10 flat src files):
  - main.rs / app.rs    eframe entry + egui panels (Config / Install /
                        Launch / Upload Logs / Status)
  - manifest.rs         schema-1 JSON parser with forward-ref and
                        duplicate-id validation
  - install.rs          resumable HTTP downloads (Range), sha256 verify,
                        seed-first then ordered-patch overlay extraction
  - patch_rdata.rs      salvaged 22-byte .rdata hostname patch
  - launch.rs           detects SGW.exe, AteraLoader.exe + Atrea bats,
                        spawns via CreateProcess / cmd /C as appropriate
  - logs.rs             collects Binaries/sgwdebuglog* + sessions/**,
                        single PUT to an Azure SAS URL, content-digest
                        dedupe so repeat clicks against unchanged logs
                        make zero HTTP requests
  - state.rs            InstalledState (in game dir) + UploadedLedger
                        (next to .exe)
  - worker.rs           tokio worker, Command/Event channels, progress
                        forwarder

Workflows (mirror the server release-on-comment pattern):
  - launcher-build.yml              path-filtered fmt/clippy/build/test
                                    on PRs touching crates/launcher/**
  - launcher-release.yml            workflow_dispatch, bakes
                                    LAUNCHER_LOG_SAS_URL from secrets,
                                    creates a GitHub Release tagged
                                    launcher-<date>-<sha7>
  - launcher-release-on-comment.yml /release-launcher ChatOps on merged
                                    PRs, validates write access,
                                    dispatches launcher-release.yml

Docs:
  - docs/client/sgw-launcher.md                           rewritten
  - docs/client/launcher-guide.md                         new — user +
                                                          operator guide
  - docs/client/launcher-storage-setup.md                 new — Azure
                                                          container /
                                                          SAS runbook
  - docs/plans/2026-03-06-sgw-launcher-{design,plan}.md   superseded
  - crates/README.md + docs/readme.md + CLAUDE.md         index updates

Verified locally on Windows:
  cargo check  -p sgw-launcher                                 OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings    OK
  cargo test   -p sgw-launcher                                 40/40 pass
  cargo fmt    -p sgw-launcher -- --check                      OK
  launcher GUI opens, panels render, manifest-fetch path runs.

The launcher remains in WORKSPACE_EXCLUDES in test.yml so eframe Linux
deps do not slow the main CI pipeline; launcher-build.yml owns its
full clippy/build/test cycle on windows-latest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cadacious Cadacious requested a review from Copilot May 21, 2026 02:39
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Warning

Review limit reached

@Cadacious, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 1 review/hour. Refill in 41 minutes and 36 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 612594d4-1579-4e5c-b5f1-b5b5951f67df

📥 Commits

Reviewing files that changed from the base of the PR and between 1e665e1 and c54087c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • .github/workflows/launcher-build.yml
  • CLAUDE.md
  • crates/README.md
  • crates/launcher/src/config.rs
  • docs/readme.md
📝 Walkthrough

Walkthrough

Replaces the Tauri-based launcher with a native egui/eframe Windows launcher, implements manifest-authenticated seed+patch install with Ed25519 verification, resumable SHA-verified downloads, in-place SGW.exe hostname patching, deterministic log zipping and Azure SAS upload with ledger dedupe, and adds CI/release workflows and operator docs.

Changes

Complete launcher rewrite: UI framework, installation, and release pipeline

Layer / File(s) Summary
Release ChatOps & release workflow
.github/workflows/launcher-release-on-comment.yml, .github/workflows/launcher-release.yml
ChatOps /release-launcher dispatches launcher-release.yml; release workflow builds Windows sgw-launcher.exe, stamps tag launcher-<yyyymmdd>-<shortSHA>, uploads artifact, and creates GitHub Release.
CI: fmt, clippy, build, test, coverage
.github/workflows/launcher-build.yml
Workflow triggers on launcher path changes, runs cargo fmt, clippy (Windows), build, nextest, and an llvm-cov coverage job that uploads Cobertura and reports to Codecov.
Dependency swap & build script
crates/launcher/Cargo.toml, crates/launcher/build.rs
Replaces Tauri deps with eframe/egui, adjusts reqwest features, adds zip/walkdir/ed25519/fs4/chrono etc., and switches build-deps to winres with Windows icon embedding.
Native GUI entry and runtime
crates/launcher/src/main.rs
Main initializes tracing and Tokio runtime, enforces single-instance via launcher.lock, and starts eframe::run_native with LauncherApp.
LauncherApp UI container
crates/launcher/src/app.rs
Implements LauncherApp with config/manifest/installed state, drains worker Events, renders config/manifest/install/launch/log upload panels, and caps status log with tests.
Config and persisted state
crates/launcher/src/config.rs, crates/launcher/src/state.rs
Versioned LauncherConfig (schema_version, install_path:PathBuf, server_host, manifest_url), atomic JSON writes, InstalledState adds patched_host, and UploadedLedger dedup/pruning logic.
Manifest model and signature verification
crates/launcher/src/manifest.rs
Manifest JSON types (schema==1), validate patch ordering/uniqueness, fetch manifest over HTTPS, fetch detached .sig, and verify Ed25519 signature using embedded pubkey; blob_url resolves relative blobs.
Install pipeline: download, verify, extract
crates/launcher/src/install.rs
install_all coordinates seed+patch application with resumable Range downloads, safe zip extraction (no zip-slip), SHA256 verification, cancellation, and persisted applied-patches.
Binary .rdata patching
crates/launcher/src/patch_rdata.rs
Searches and overwrites a fixed 22-byte hostname slot in SGW.exe with validated hostname (zero-padded), supports re-patching, reports offsets, and applies atomically on-disk.
Launch detection & spawn helpers
crates/launcher/src/launch.rs
LaunchOptions::detect flags presence of SGW/Atera artifacts; launcher helpers canonicalize paths, prevent path-escape, spawn .exe or .bat safely, and probe install-dir writability.
Log collection and upload
crates/launcher/src/logs.rs
Collects debug logs under Binaries/, builds deterministic SHA digest and in-memory ZIP, inserts blob path into SAS URL, and PUTs zip to Azure with x-ms-blob-type; exposes blob_name_for.
Worker: async orchestration and events
crates/launcher/src/worker.rs
Tokio-backed Worker receiving Commands and emitting Events, spawning manifest fetch/install/launch/upload tasks, managing cancellation and progress forwarding.
Removed Tauri UI and legacy helpers
crates/launcher/src/commands.rs, crates/launcher/src/download.rs, crates/launcher/src/extract.rs, crates/launcher/src/updater.rs, crates/launcher/tauri.conf.json, crates/launcher/ui/*
Deletes Tauri command handlers, older download/extract/updater modules, and static web UI assets; responsibilities moved into new native modules.
Documentation & operator runbooks
docs/client/sgw-launcher.md, docs/client/launcher-guide.md, docs/client/launcher-distribution-setup.md, docs/plans/*, docs/readme.md, CLAUDE.md
Adds player/operator guides, distribution/setup runbook (Ed25519 signing, SAS guidance), rewrites sgw-launcher doc, marks old plans superseded, and updates docs index.
Codecov config update
codecov.yml
Removes global exclusion for crates/launcher/** and adds a launcher component in component_management to govern coverage reporting for the launcher.

Sequence Diagram (high-level install + upload flow)

sequenceDiagram
  participant UI
  participant Worker
  participant InstallModule as "install_all"
  participant HTTP as "reqwest"
  participant Logs as "logs::upload_blob"
  UI->>Worker: Command::Install(config, manifest)
  Worker->>InstallModule: spawn install_all(ctx)
  InstallModule->>HTTP: GET seed/patch (Range, resume)
  InstallModule->>InstallModule: verify SHA, extract ZIP
  InstallModule-->>Worker: InstallComplete / InstallError
  UI->>Worker: Command::UploadLogs(...)
  Worker->>Logs: upload_logs_task
  Logs->>HTTP: PUT ZIP to SAS URL
  Logs-->>Worker: UploadComplete / UploadError
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

🐰 I hopped from Tauri to egui tonight,
Signed manifests shine in moonlit byte,
Seeds and patches download, verify, and blend,
Logs zipped, deduped, to Azure they send,
A launcher reborn — hop, build, and write!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/egui-launcher-azure-blob

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the previous Tauri/WebView2 launcher prototype with a native Windows launcher crate (sgw-launcher) built on eframe/egui, using an Azure Blob–hosted seed + patch manifest for installs/updates and optional debug-log uploads. It also updates and adds documentation for both player usage and operator storage setup, and introduces dedicated GitHub Actions workflows for launcher CI and release.

Changes:

  • Replaced the Tauri-based launcher UI/tooling with an egui/eframe implementation backed by a manifest-driven seed/patch install pipeline.
  • Added debug-log zipping + Azure Blob single-PUT upload with local dedupe ledger, plus Atera debug tooling detection/launch.
  • Added launcher-specific CI/release workflows and updated docs/indexes to reflect the new launcher architecture and operational runbook.

Reviewed changes

Copilot reviewed 35 out of 39 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
docs/readme.md Updates docs index to include new launcher docs and revised launcher design description.
docs/plans/2026-03-06-sgw-launcher-plan.md Marks the old Tauri implementation plan as superseded and points to the new launcher/doc location.
docs/plans/2026-03-06-sgw-launcher-design.md Marks the old Tauri-era design decisions doc as superseded and links to the current design doc.
docs/client/sgw-launcher.md Rewrites launcher design doc around egui + Azure Blob manifest seed/patch model and log upload.
docs/client/launcher-storage-setup.md Adds Azure Blob operational runbook for anonymous reads and SAS-scoped log uploads.
docs/client/launcher-guide.md Adds player/operator guide for install/update behavior and patch publishing workflow.
crates/README.md Expands launcher crate description and links to the new launcher documentation.
crates/launcher/ui/styles.css Removes Tauri web UI CSS (no longer used).
crates/launcher/ui/main.js Removes Tauri web UI JS (no longer used).
crates/launcher/ui/index.html Removes Tauri web UI HTML (no longer used).
crates/launcher/tauri.conf.json Removes Tauri configuration (no longer used).
crates/launcher/src/worker.rs Adds tokio-backed worker translating UI commands into background tasks and events.
crates/launcher/src/updater.rs Removes old per-file patch updater implementation.
crates/launcher/src/state.rs Adds persisted installed-patch ledger and uploaded-log dedupe ledger state files.
crates/launcher/src/patch_rdata.rs Updates and retains .rdata hostname patching logic with refreshed docs/tests.
crates/launcher/src/manifest.rs Adds manifest schema, fetch, and validation, plus blob URL resolution helper.
crates/launcher/src/main.rs Replaces Tauri entrypoint with eframe/egui app boot + tokio runtime.
crates/launcher/src/logs.rs Adds log collection, stable content digest, zip creation, and Azure PUT upload.
crates/launcher/src/launch.rs Adds SGW/Atera detection and process/batch spawning helpers.
crates/launcher/src/install.rs Adds resumable seed/patch download, sha256 verification, zip extraction, and post-install hostname patch.
crates/launcher/src/extract.rs Removes old 7za-based RAR/CAB extraction helper (no longer used).
crates/launcher/src/download.rs Removes old download helper (replaced by new install pipeline).
crates/launcher/src/config.rs Reworks config to manifest_url/server_host/install_path + compile-time SAS option and paths next to exe.
crates/launcher/src/commands.rs Removes Tauri command layer (no longer used).
crates/launcher/src/app.rs Adds egui UI state machine/panels, dispatching worker commands and rendering progress/status.
crates/launcher/gen/schemas/capabilities.json Removes auto-generated Tauri schema artifact (no longer used).
crates/launcher/gen/schemas/acl-manifests.json Removes auto-generated Tauri schema artifact (no longer used).
crates/launcher/Cargo.toml Switches dependencies from Tauri to eframe/egui + reqwest(rustls) + zip/logging/state deps.
crates/launcher/capabilities/default.json Removes Tauri capabilities config (no longer used).
crates/launcher/build.rs Replaces tauri-build with winres-based icon embedding on Windows targets.
crates/launcher/binaries/README.md Removes Tauri sidecar binary instructions (no longer used).
crates/launcher/binaries/.gitkeep Keeps binaries directory placeholder after removing sidecar usage.
CLAUDE.md Updates workspace exclude guidance to include the new egui launcher crate.
.github/workflows/launcher-release.yml Adds workflow_dispatch release pipeline building exe and publishing a GitHub Release.
.github/workflows/launcher-release-on-comment.yml Adds ChatOps /release-launcher dispatcher workflow with permission/merge checks.
.github/workflows/launcher-build.yml Adds path-filtered launcher CI (fmt on ubuntu; clippy/build/nextest on windows).

Comment thread crates/launcher/src/install.rs Outdated
Comment on lines +239 to +244
fn reqwest_error_from(resp: &reqwest::Response) -> reqwest::Error {
// `error_for_status_ref` returns Err only for 4xx/5xx; on the call site
// we've already verified the status falls outside 2xx, so unwrap_err is
// safe.
resp.error_for_status_ref().unwrap_err()
}
Comment on lines +93 to +101
/// Resolves a blob path (e.g. `seed/sgw.zip`) against the manifest URL's
/// container by stripping the final path segment off the manifest URL.
pub fn blob_url(manifest_url: &str, blob_path: &str) -> String {
let base = match manifest_url.rsplit_once('/') {
Some((b, _)) => b,
None => manifest_url,
};
format!("{base}/{blob_path}")
}
Comment on lines +279 to +310
let file = std::fs::File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let total = archive.len();
for i in 0..total {
if cancel.is_cancelled() {
return Err(InstallError::Cancelled);
}
let mut entry = archive.by_index(i)?;
// `enclosed_name()` returns Option<&Path>; clone to PathBuf so the
// borrow on `entry` ends before we use `entry.is_dir()` / &mut entry.
let rel = match entry.enclosed_name() {
Some(p) => p.to_path_buf(),
None => continue,
};
let out = dest.join(&rel);
if entry.is_dir() {
std::fs::create_dir_all(&out)?;
} else {
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let mut f = std::fs::File::create(&out)?;
std::io::copy(&mut entry, &mut f)?;
}
let _ = progress.send(Progress::Extracting {
label: label.to_string(),
current: i + 1,
total,
filename: out.display().to_string(),
});
}
Ok(())
Comment on lines +68 to +103
pub fn compute_content_digest(install_dir: &Path) -> Result<Option<String>, LogError> {
let files = collect_log_inputs(install_dir);
if files.is_empty() {
return Ok(None);
}
let binaries = install_dir.join("Binaries");
let mut hasher = Sha256::new();
for path in &files {
let rel = rel_in_archive(path, &binaries);
hasher.update(rel.as_bytes());
hasher.update([0u8]);
let data = std::fs::read(path)?;
hasher.update(&data);
hasher.update([0u8]);
}
Ok(Some(format!("{:x}", hasher.finalize())))
}

pub fn build_log_zip(install_dir: &Path) -> Result<Option<Vec<u8>>, LogError> {
let files = collect_log_inputs(install_dir);
if files.is_empty() {
return Ok(None);
}
let binaries = install_dir.join("Binaries");
let mut buf: Vec<u8> = Vec::new();
{
let cursor = std::io::Cursor::new(&mut buf);
let mut zw = zip::ZipWriter::new(cursor);
let options: FileOptions<()> =
FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for path in &files {
let rel = rel_in_archive(path, &binaries);
zw.start_file(rel, options)?;
let data = std::fs::read(path)?;
zw.write_all(&data)?;
}
Comment thread docs/client/launcher-storage-setup.md Outdated
Comment on lines +61 to +63
# Account SAS (simplest — no Entra ID required). For higher security
# preferential is a user-delegation SAS bound to a service principal.
az storage account generate-sas \
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/launcher-release.yml:
- Around line 39-43: Update the launcher-release workflow to pin all GitHub
Actions to immutable commit SHAs and disable checkout credential persistence:
replace mutable tags actions/checkout@v6, dtolnay/rust-toolchain@stable,
Swatinem/rust-cache@v2 and actions/upload-artifact@v7 with their respective
commit SHAs, and add with: persist-credentials: false under the actions/checkout
step so credentials are not persisted after checkout; ensure you update the
action references in the same job where those symbols appear.

In `@crates/launcher/src/app.rs`:
- Around line 22-23: The status Vec<String> field is unbounded and should be
replaced or guarded to keep a fixed-cap history (e.g., 500–1000 entries) to
avoid memory growth; change the struct field (status) to a bounded container
(VecDeque<String> or keep Vec<String> but enforce a cap) and update the code
paths that push new status lines (the push/add_status logic that appends to
status and the display code that reads the latest 100 entries) so that after
inserting a new entry you drop/rotate oldest entries when length exceeds the
cap—implement this in the methods that append status (referencing the status
field and any add/push functions) and ensure existing display logic that slices
the last 100 still works with the capped buffer.

In `@crates/launcher/src/config.rs`:
- Around line 14-18: The constant LOG_UPLOAD_SAS_URL currently bakes writable
SAS credentials into the client binary; remove this compile-time secret and
replace it with a runtime flow: stop using the
option_env!("LAUNCHER_LOG_SAS_URL") constant and instead implement a runtime
fetch/getter (e.g., a get_log_upload_sas_url() or LogUploadClient method) that
obtains a short-lived, server-issued SAS token or a signed upload URL from a
trusted backend or relay endpoint with authentication and rate limits; keep the
client UI/logic tolerant of None (disable upload button) and ensure no writable
credentials are embedded in the executable.

In `@crates/launcher/src/install.rs`:
- Around line 188-190: The HTTP error path in download_to_file incorrectly calls
resp.error_for_status_ref().unwrap_err(), which can panic for non-4xx/5xx
statuses; instead, replace the unwrap_err usage by explicitly matching
resp.error_for_status_ref() (or simply avoid assuming an Err) and always convert
the response into a reqwest error via reqwest_error_from(&resp) when
status.is_success() is false and not resumed; update the error branch in
download_to_file to handle both Ok(_) and Err(_) from error_for_status_ref()
without panicking, referencing the download_to_file function and
reqwest_error_from helper to locate the change.

In `@crates/launcher/src/logs.rs`:
- Around line 121-136: The upload_blob function currently constructs a new
reqwest::Client per call which is inefficient; change it to reuse a shared
client by either (A) adding a reqwest::Client parameter (e.g. client:
&reqwest::Client) to upload_blob and use that client for the .put() call (update
all callers to pass a shared client), or (B) create a global lazy/static client
(e.g. with once_cell::sync::Lazy or lazy_static) and use that singleton inside
upload_blob. Keep existing behavior for insert_blob_path and LogError handling
unchanged; ensure function signature and call sites are updated if you choose
the parameter approach.

In `@crates/launcher/src/worker.rs`:
- Around line 1-6: worker.rs lacks unit tests for the async orchestration
(command→event flow) so add a test exercising the core behavior: spawn the
Worker runtime or call the function that accepts Command (use the public API in
worker.rs), send a Command (e.g., install/launch/upload) and assert Events on
the events_rx receiver for progress forwarding, cancellation handling, and that
ledger persistence is invoked after upload completes (mock or instrument the
ledger persistence call). Target symbols: Command, Event, the events_rx receiver
returned/owned by the worker, and the function that dispatches Commands in
worker.rs; if unit-testing is impractical, add a short doc comment in worker.rs
referencing the integration/smoke test file(s) that cover these scenarios and
why unit tests are omitted.

In `@docs/client/sgw-launcher.md`:
- Around line 188-189: Update the markdown link in docs/client/sgw-launcher.md
so the href points to the actual file ../../crates/launcher/icons/icon.ico
(keeping the link text "icon.ico"); locate the sentence that currently links to
../../crates/launcher/icons/ and replace the directory target with the specific
file path ../../crates/launcher/icons/icon.ico so the link resolves to the
embedded icon file referenced by build.rs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 33db2522-ce26-4905-9dc3-bc5214ff5acb

📥 Commits

Reviewing files that changed from the base of the PR and between 4ead68d and 5fee345.

⛔ Files ignored due to path filters (5)
  • Cargo.lock is excluded by !**/*.lock
  • crates/launcher/gen/schemas/acl-manifests.json is excluded by !**/gen/**
  • crates/launcher/gen/schemas/capabilities.json is excluded by !**/gen/**
  • crates/launcher/gen/schemas/desktop-schema.json is excluded by !**/gen/**
  • crates/launcher/gen/schemas/linux-schema.json is excluded by !**/gen/**
📒 Files selected for processing (34)
  • .github/workflows/launcher-build.yml
  • .github/workflows/launcher-release-on-comment.yml
  • .github/workflows/launcher-release.yml
  • CLAUDE.md
  • crates/README.md
  • crates/launcher/Cargo.toml
  • crates/launcher/binaries/.gitkeep
  • crates/launcher/binaries/README.md
  • crates/launcher/build.rs
  • crates/launcher/capabilities/default.json
  • crates/launcher/src/app.rs
  • crates/launcher/src/commands.rs
  • crates/launcher/src/config.rs
  • crates/launcher/src/download.rs
  • crates/launcher/src/extract.rs
  • crates/launcher/src/install.rs
  • crates/launcher/src/launch.rs
  • crates/launcher/src/logs.rs
  • crates/launcher/src/main.rs
  • crates/launcher/src/manifest.rs
  • crates/launcher/src/patch_rdata.rs
  • crates/launcher/src/state.rs
  • crates/launcher/src/updater.rs
  • crates/launcher/src/worker.rs
  • crates/launcher/tauri.conf.json
  • crates/launcher/ui/index.html
  • crates/launcher/ui/main.js
  • crates/launcher/ui/styles.css
  • docs/client/launcher-guide.md
  • docs/client/launcher-storage-setup.md
  • docs/client/sgw-launcher.md
  • docs/plans/2026-03-06-sgw-launcher-design.md
  • docs/plans/2026-03-06-sgw-launcher-plan.md
  • docs/readme.md
💤 Files with no reviewable changes (10)
  • crates/launcher/ui/styles.css
  • crates/launcher/capabilities/default.json
  • crates/launcher/ui/index.html
  • crates/launcher/binaries/README.md
  • crates/launcher/src/extract.rs
  • crates/launcher/src/updater.rs
  • crates/launcher/ui/main.js
  • crates/launcher/src/commands.rs
  • crates/launcher/src/download.rs
  • crates/launcher/tauri.conf.json

Comment thread .github/workflows/launcher-release.yml Outdated
Comment thread crates/launcher/src/app.rs
Comment thread crates/launcher/src/config.rs
Comment thread crates/launcher/src/install.rs
Comment thread crates/launcher/src/logs.rs Outdated
Comment on lines +1 to +6
//! Background worker.
//!
//! Hosts the tokio runtime and turns UI [`Command`]s into spawned tasks
//! that emit [`Event`]s back on an unbounded channel. The egui app
//! polls the channel each frame via `events_rx.try_recv()`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

No unit tests for worker orchestration.

The coding guidelines require tests for runtime behavior changes. While install.rs, launch.rs, and logs.rs have their own tests, worker.rs has none. The orchestration logic (especially the progress forwarding, cancellation handling, and ledger persistence after upload) could benefit from at least a basic test exercising the command→event flow.

If testing async orchestration is impractical here, consider documenting which integration or smoke tests cover this layer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/worker.rs` around lines 1 - 6, worker.rs lacks unit tests
for the async orchestration (command→event flow) so add a test exercising the
core behavior: spawn the Worker runtime or call the function that accepts
Command (use the public API in worker.rs), send a Command (e.g.,
install/launch/upload) and assert Events on the events_rx receiver for progress
forwarding, cancellation handling, and that ledger persistence is invoked after
upload completes (mock or instrument the ledger persistence call). Target
symbols: Command, Event, the events_rx receiver returned/owned by the worker,
and the function that dispatches Commands in worker.rs; if unit-testing is
impractical, add a short doc comment in worker.rs referencing the
integration/smoke test file(s) that cover these scenarios and why unit tests are
omitted.

Comment thread docs/client/sgw-launcher.md Outdated
@MyPalClara
Copy link
Copy Markdown
Collaborator

Category 1: Security Concerns (G1-G3)


G1: Zip-slip protection comment needed

Location: crates/launcher/src/install.rsextract_zip()
Severity: Low

The code correctly uses entry.enclosed_name() which rejects absolute paths and .. components. But there's no comment explaining this is the security gate.

Risk: A future maintainer might "simplify" to entry.name() and introduce zip-slip vulnerability.

Recommendation:

// SECURITY: enclosed_name() is critical here — it rejects absolute paths
// and path traversal (../) to prevent zip-slip attacks. Do NOT replace
// with entry.name() or entry.mangled_name().
let Some(relative) = entry.enclosed_name() else {
    continue;
};

Effort: 2 minutes


G2: SAS URL baked into binary

Location: crates/launcher/src/logs.rsAZURE_SAS_URL
Severity: Low-Medium

The SAS URL is compiled into the binary. If leaked (decompilation, memory dump), anyone can upload arbitrary blobs to your Azure container. The SAS has c (create) permission only, so they can't read/delete, but they can fill storage with garbage.

Options:

  1. Accept the risk — document it in the Azure runbook, set up storage alerts for unusual upload volume
  2. Rotate via server — launcher fetches short-lived SAS from your API on each upload
  3. Use managed identity — if launcher ever runs in Azure context

Recommendation: Option 1 is fine for a game launcher. Document the risk and set up monitoring.

Effort: 30 minutes (documentation + Azure alert)


G3: No hostname validation in patch_rdata

Location: crates/launcher/src/patch_rdata.rs
Severity: Low

No validation that server_host is a valid hostname. If someone enters malicious input as the server host, it gets written into the .exe. The .exe won't execute it (it's just a string in .rdata), but it's still weird and could cause confusion.

Recommendation:

fn is_valid_hostname(s: &str) -> bool {
    s.len() <= 22
        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
        && !s.starts_with('-')
        && !s.ends_with('-')
}

Effort: 15 minutes


Category 1 Total Effort: ~45 minutes

@MyPalClara
Copy link
Copy Markdown
Collaborator

Category 2: Error Handling (G4-G6)


G4: Range request handling — ACTUALLY FINE

Location: crates/launcher/src/install.rsdownload_to_file()
Severity: None (I was wrong)

I initially flagged this as a bug, but on closer inspection the code is correct:

let mut file = if resumed && existing_len > 0 {
    tokio::fs::OpenOptions::new().append(true).open(dest).await?
} else {
    tokio::fs::File::create(dest).await?  // truncates and starts fresh
};

If the server returns 200 (doesn't support Range), resumed is false, and File::create truncates the existing partial file. This is correct behavior.

Status: No action needed.


G5: Unbounded event channel

Location: crates/launcher/src/worker.rs
Severity: Low

The event channel from worker to UI is unbounded. If the UI thread stalls (e.g., during a long egui layout), events pile up unboundedly.

Why this is probably fine: The launcher UI is simple — no complex layouts, no blocking operations on the UI thread. Events are small (enum variants with a few fields). You'd need to stall for minutes to accumulate meaningful memory.

If you want to fix it:

let (tx, rx) = tokio::sync::mpsc::channel(100);  // bounded
// Then handle SendError by dropping oldest or blocking

Effort: 15 minutes (if desired)


G6: Config save failure doesn't block install

Location: crates/launcher/src/app.rs
Severity: Low

Config is saved on every "Install / Update" click. If the save fails, the install still proceeds. The error is logged to status, but the user might not notice.

Current behavior:

if let Err(e) = self.config.save() {
    self.status.push(format!("Warning: failed to save config: {e}"));
}
// install proceeds anyway

Why this might be intentional: Config save failing (disk full, permissions) shouldn't block the user from installing the game. The config will be re-saved on next successful operation.

If you want stricter behavior:

if let Err(e) = self.config.save() {
    self.status.push(format!("ERROR: Cannot save config: {e}"));
    return;  // block install
}

Effort: 5 minutes (if desired)


Category 2 Total Effort: ~20 minutes (all optional)

@MyPalClara
Copy link
Copy Markdown
Collaborator

Category 3: Missing Test Coverage (G7-G10)


G7: No test for download resume path

Location: crates/launcher/src/install.rs
Severity: Medium

The resume logic (Range header, 206 status, append mode) is the most complex code path in the file, but it's untested.

Recommendation:

#[tokio::test]
async fn download_resumes_partial_file() {
    // Start a mock server that supports Range requests
    let server = MockServer::start().await;
    let full_content = b"0123456789ABCDEF";
    
    Mock::given(method("GET"))
        .and(header("Range", "bytes=8-"))
        .respond_with(ResponseTemplate::new(206)
            .body(&full_content[8..])
            .insert_header("Content-Range", "bytes 8-15/16"))
        .mount(&server).await;
    
    // Create partial file with first 8 bytes
    let dir = tempfile::tempdir().unwrap();
    let dest = dir.path().join("test.bin");
    std::fs::write(&dest, &full_content[..8]).unwrap();
    
    // Resume should append remaining bytes
    download_to_file(&server.uri(), &dest, &tx, token).await.unwrap();
    
    assert_eq!(std::fs::read(&dest).unwrap(), full_content);
}

Effort: 45 minutes (including mock server setup)


G8: No test for cancellation during download/extract

Location: crates/launcher/src/install.rs
Severity: Low-Medium

The CancellationToken is threaded through, but there's no test that verifies cancellation actually stops the operation mid-stream.

Recommendation:

#[tokio::test]
async fn download_respects_cancellation() {
    let token = CancellationToken::new();
    let token_clone = token.clone();
    
    // Cancel after 100ms
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(100)).await;
        token_clone.cancel();
    });
    
    // Start a slow download
    let result = download_to_file(&slow_server_url, &dest, &tx, token).await;
    
    assert!(result.is_err());
    // Partial file should exist but be incomplete
}

Effort: 30 minutes


G9: No tests for worker.rs

Location: crates/launcher/src/worker.rs
Severity: Low

The worker is the glue between UI and async operations. It's simple (just a command dispatcher), but untested.

What to test:

  • Command::FetchManifest → Event::ManifestFetched or Event::Error
  • Command::Install → Event::Progress... → Event::InstallComplete
  • Command::Cancel → operations stop

Effort: 1 hour


G10: No tests for UI state transitions

Location: crates/launcher/src/app.rs
Severity: Low

The drain_events function has complex state transitions:

  • Event::ManifestFetched → updates manifest, installed_state
  • Event::InstallComplete → clears installing, updates installed_state
  • Event::Error → clears installing, pushes to status

These are untested. UI state machines are notoriously buggy.

Why this is hard to test: egui apps are typically tested via screenshot comparison or manual testing, not unit tests. The state transitions could be extracted into a pure function for testing.

Effort: 1-2 hours (if desired)


Category 3 Total Effort: ~3-4 hours

@MyPalClara
Copy link
Copy Markdown
Collaborator

Category 4: UX Concerns (G11-G12)


G11: Status log memory leak

Location: crates/launcher/src/app.rs
Severity: Low

The status log display is capped at 100 lines, but the underlying Vec<String> grows unboundedly:

// Display caps at 100
for line in self.status.iter().rev().take(100) {
    ui.label(line);
}

// But the Vec keeps growing
self.status.push(format!("Downloaded {} bytes", n));

Over a long session with many operations, this leaks memory.

Recommendation — Ring buffer:

const MAX_STATUS_LINES: usize = 100;

fn push_status(&mut self, msg: String) {
    if self.status.len() >= MAX_STATUS_LINES {
        self.status.remove(0);
    }
    self.status.push(msg);
}

Or use VecDeque for O(1) removal from front.

Effort: 10 minutes


G12: No download speed or ETA display

Location: crates/launcher/src/app.rs — progress bar
Severity: Low (nice-to-have)

The progress bar shows bytes downloaded and total, but not:

  • Download speed (MB/s)
  • Estimated time remaining

For a multi-GB seed download, users want to know how long they'll be waiting.

Recommendation:

struct DownloadProgress {
    started_at: Instant,
    bytes_downloaded: u64,
    bytes_total: u64,
}

impl DownloadProgress {
    fn speed_mbps(&self) -> f64 {
        let elapsed = self.started_at.elapsed().as_secs_f64();
        if elapsed > 0.0 {
            (self.bytes_downloaded as f64 / 1_000_000.0) / elapsed
        } else {
            0.0
        }
    }
    
    fn eta_seconds(&self) -> Option<u64> {
        let speed = self.speed_mbps();
        if speed > 0.0 {
            let remaining = self.bytes_total - self.bytes_downloaded;
            Some((remaining as f64 / (speed * 1_000_000.0)) as u64)
        } else {
            None
        }
    }
}

Effort: 30 minutes


Category 4 Total Effort: ~40 minutes

@MyPalClara
Copy link
Copy Markdown
Collaborator

Category 5: Documentation (G13-G14)


G13: No schema versioning strategy documented

Location: crates/launcher/src/manifest.rs
Severity: Low

The code rejects schema != 1, but there's no doc explaining how to add schema 2 in the future.

Questions a future maintainer will have:

  • Do I bump to schema 2 for any breaking change?
  • Should the launcher support multiple schema versions simultaneously?
  • What's the deprecation policy for old schemas?

Recommendation: Add to docs/launcher/manifest-schema.md:

## Schema Versioning

The manifest `schema` field is an integer version number.

### When to bump the schema version:
- Adding required fields → bump
- Removing fields → bump  
- Changing field semantics → bump
- Adding optional fields → no bump needed

### Backwards compatibility:
The launcher supports only the current schema version. When bumping:
1. Update `SUPPORTED_SCHEMA` constant
2. Update manifest generation in CI
3. All users must update their launcher

### Current schema: 1

Effort: 20 minutes


G14: No troubleshooting section in launcher guide

Location: docs/launcher/launcher-guide.md
Severity: Low

Users will encounter issues. The guide should help them self-diagnose.

Recommendation: Add a troubleshooting section:

## Troubleshooting

### "Failed to fetch manifest"
- Check your internet connection
- Verify the manifest URL is accessible in a browser
- Check if your firewall/antivirus is blocking the launcher

### "Hash mismatch" after download
- The download may have been corrupted
- Click "Install / Update" to re-download
- If persistent, the CDN cache may be stale — wait 15 minutes

### "SGW.exe won't launch"
- Ensure Visual C++ Redistributable 2015-2022 is installed
- Try running as Administrator
- Check Windows Event Viewer for crash details

### "Stuck at 0%" download
- Large seed files may take time to start
- Check if another download is in progress
- Try cancelling and restarting

### Logs location
Launcher logs are stored in: `<install_dir>/Logs/`
Upload them via the "Upload Logs" button for support.

Effort: 30 minutes


Category 5 Total Effort: ~50 minutes


Summary

Category Gaps Severity Effort
Security G1-G3 Low-Medium 45 min
Error Handling G4-G6 Low 20 min
Test Coverage G7-G10 Low-Medium 3-4 hr
UX G11-G12 Low 40 min
Documentation G13-G14 Low 50 min
Total 14 ~6 hours

Priority

Priority Gaps Action
🟢 Quick wins G1, G11 Zip-slip comment, ring buffer (15 min)
🟡 Should do G2, G14 Document SAS risk, add troubleshooting (1 hr)
🟡 Follow-up PR G7, G8 Resume + cancellation tests
⚪ Nice-to-have Everything else

Total: 14 gaps, ~6 hours of follow-up work, none blocking.

Addresses reviewer feedback from MyPalClara, CodeRabbit, and Copilot
on PR #343. All items either flagged by multiple reviewers or
classified as "quick win" by the human reviewer.

Code:
  - app.rs: cap status history at MAX_STATUS_LINES (1000) via
    push_status() helper. Was unbounded though display capped at 100.
    Flagged by both MyPalClara (G11) and CodeRabbit.
  - install.rs: replace the panic-prone unwrap_err() on
    error_for_status_ref() with a new InstallError::UnexpectedStatus
    variant. The old path would panic on a stray 1xx/3xx (unlikely
    given reqwest auto-follows redirects, but the comment claimed
    invariants the code didn't actually enforce). Flagged by both
    Copilot and CodeRabbit.
  - install.rs: SECURITY comment above the enclosed_name() call in
    extract_zip explaining it's the zip-slip gate. (MyPalClara G1).
  - manifest.rs: blob_url now strips ?query and #fragment from the
    manifest URL before splitting on the last "/", so a SAS-tokened
    manifest URL produces a valid blob URL. (Copilot.)
  - patch_rdata.rs: validate hostname before writing into SGW.exe's
    .rdata — ASCII alphanumeric + "." + "-", no leading/trailing
    "-" or ".", non-empty. New PatchError::InvalidHostname variant.
    (MyPalClara G3.)

Workflows:
  - launcher-release.yml: pin actions/checkout, dtolnay/rust-toolchain,
    Swatinem/rust-cache, actions/upload-artifact to immutable commit
    SHAs (with version comments for human readability), and add
    persist-credentials: false on actions/checkout. This is the
    privileged workflow that consumes the LAUNCHER_LOG_SAS_URL secret
    and creates GitHub Releases — the supply-chain surface from
    third-party actions matters most here. (CodeRabbit, with zizmor
    cross-reference.)
  - launcher-build.yml: add persist-credentials: false on every
    actions/checkout step (4 jobs). Doesn't touch secrets but cheap
    defence-in-depth.
  - launcher-release-on-comment.yml: no actions/checkout used (only
    gh API calls), so nothing to harden there.

Docs:
  - sgw-launcher.md: fix icon link to point at icons/icon.ico instead
    of the icons/ directory. (CodeRabbit.)
  - launcher-storage-setup.md: fix "For higher security preferential
    is..." grammar. (Copilot.) Add Threat-Model section documenting
    the SAS-in-binary trade-off, with required operator action items
    (storage cost cap, upload-rate anomaly alert, 6-month rotation
    cadence). (MyPalClara G2 + CodeRabbit; user decision: accept the
    risk + document rather than build a server-issued token broker.)
  - launcher-guide.md: new Schema Versioning Policy section under
    Step 3 — Write the Manifest. Covers when to bump vs not, the
    multi-schema transition procedure, and pointers for migrations
    that don't fit the additive-optional shape. (MyPalClara G13.)
  - launcher-guide.md: new Troubleshooting section covering manifest
    errors, hash mismatch, HTTP status codes, launch failures, SAS
    expiry, dedupe-skipped uploads, and where to find diagnostic
    files. (MyPalClara G14.)

Tests:
  - 9 new tests (40 → 49). Cover push_status cap behaviour,
    blob_url query/fragment stripping (3 cases), hostname validation
    (empty / disallowed chars / leading-or-trailing dash-or-dot /
    typical DNS names).

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    exit 0
  cargo check  -p sgw-launcher                               OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               49/49 pass

Deferred to follow-up issues (per reviewer classification and user
direction):
  - G7/G8: integration tests for download resume + cancellation paths
  - G9/G10: worker.rs / app.rs UI state-machine tests
  - Copilot: extract_zip + log builder on spawn_blocking / streaming
    file reads — perf, not correctness; current sizes are bounded
  - G12: download speed + ETA display
  - SAS broker endpoint (the "Major" CodeRabbit asked for) — needs a
    server-side issuer that does not exist yet; accept-and-document
    is the chosen disposition for this PR

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BangRocket
Copy link
Copy Markdown
Contributor Author

Review feedback addressed in ec2e78d

Tackled the quick wins + medium items locally, full pipeline green (cargo fmt --check / clippy -D warnings / test, 49/49 passing — up from 40 with 9 new tests on push_status cap, blob_url query/fragment stripping, and hostname validation).

Items addressed in this fixup

Code:

  • app.rs — Status Vec<String> capped at 1000 via push_status() helper. (MyPalClara G11 + CodeRabbit)
  • install.rs — Replaced panic-prone unwrap_err() with new InstallError::UnexpectedStatus variant; comment now matches the invariant. (Copilot + CodeRabbit)
  • install.rsSECURITY: comment above enclosed_name() in extract_zip documenting the zip-slip gate. (MyPalClara G1)
  • manifest.rsblob_url now strips ?query and #fragment from the manifest URL before splitting. SAS-tokened manifest URLs now produce valid blob URLs. (Copilot)
  • patch_rdata.rs — Hostname validation before writing to .rdata: ASCII alnum + . + -, no leading/trailing -/., non-empty. New PatchError::InvalidHostname. (MyPalClara G3)

Workflows:

  • launcher-release.yml — Pinned all 4 third-party actions to immutable commit SHAs (actions/checkout, dtolnay/rust-toolchain, Swatinem/rust-cache, actions/upload-artifact) with version comments. persist-credentials: false on checkout. This is the workflow that consumes the SAS secret + creates Releases, so the supply-chain surface matters most here. (CodeRabbit + zizmor)
  • launcher-build.ymlpersist-credentials: false on all 4 checkout steps as cheap defence-in-depth.
  • N/A — launcher-release-on-comment.yml has no actions/checkout, only gh api calls. Nothing to harden.

Docs:

  • sgw-launcher.md — Icon link points at icons/icon.ico not the icons/ dir. (CodeRabbit)
  • launcher-storage-setup.md — Grammar fix on "For higher security preferential is…". (Copilot)
  • launcher-storage-setup.md — New Threat model — SAS in the client binary section with concrete operator action items (storage cost cap, upload-rate anomaly alert, 6-month rotation cadence). (MyPalClara G2 + CodeRabbit — accept-and-document was the chosen disposition)
  • launcher-guide.md — New Schema Versioning Policy subsection in Step 3, covering when to bump vs not, the multi-schema transition procedure, and pointers for migrations that don't fit the additive-optional shape. (MyPalClara G13)
  • launcher-guide.md — New Troubleshooting section covering manifest errors, hash mismatch, HTTP status codes, launch failures, SAS expiry, dedupe-skipped uploads, and where to find diagnostic files. (MyPalClara G14)

Deferred to follow-up issues

Per reviewer classification ("Follow-up PR" / "Nice-to-have") and the chosen scope for this PR:

  • G7 / G8 — Integration tests for the download-resume and cancellation paths. Real coverage gaps; want a mock-HTTP-server harness rather than ad-hoc tests.
  • G9 / G10worker.rs orchestration tests + app.rs UI state-machine tests. Higher-lift; extracting state transitions into a pure fn first would make this cleaner.
  • Copilot perf itemsspawn_blocking for extract_zip and streaming file reads in logs::build_log_zip. Real but bounded: seed/patch zips run during user-driven install (UI freeze is fine) and log inputs are small (sgwdebuglog + sessions/, well under 100 MB in practice).
  • G12 — Download speed + ETA display.
  • SAS broker endpoint — The "Major" CodeRabbit asked for. Requires a server-side issuer that doesn't exist yet. Chosen disposition: accept + document, with the threat-model section and operator action items providing mitigation through monitoring + lifecycle policy. Revisit if the SAS ever gets extracted in the wild.

CI should rerun on the new commit; expect all 15 checks green again.

@Cadacious
Copy link
Copy Markdown
Contributor

Does it do what it says on the tin?

Mostly yes. Verified against the code:

  • Resumable download via HTTP Range (install.rs:177-208)
  • SHA-256 verify on every artifact
  • Patches applied in declared order, dedup via applied_patches (install.rs:88-95)
  • State file lives inside install dir, survives launcher reinstall (state.rs:31-34)
  • Content-digest dedupe survives re-zips (digest is over (relpath, bytes), not zip bytes)
  • enclosed_name() prevents zip-slip (install.rs:289)

Bug — hostname re-patch when server_host changes

needs_patching(data) is find_pattern(data, "www.stargateworlds.com").is_some() (patch_rdata.rs:52).

Sequence that breaks:

  1. User installs with server_host = play.cimmeria.gg → patch succeeds, original CME string is gone.
  2. User edits the host field to staging.cimmeria.gg → clicks Install / Update.
  3. needs_patching returns false (CME string already replaced) → patch is skipped.
  4. Client connects to old host, no warning to the user.

Remediation — store the patched-in host in state, repatch when it differs:

// state.rs
pub struct InstalledState {
    pub applied_patches: Vec<String>,
    pub seed_sha256: Option<String>,
    #[serde(default)]
    pub patched_host: Option<String>,
}
// install.rs - replace the current needs_patching gate
let needs_repatch = patch_rdata::needs_patching(&data)
    || state.patched_host.as_deref() != Some(ctx.server_host);

if needs_repatch {
    patch_rdata::patch_exe_any(
        &exe,
        ctx.server_host,
        state.patched_host.as_deref(),  // fallback pattern to match
    )?;
    state.patched_host = Some(ctx.server_host.to_string());
    state.save(ctx.install_dir)?;
}

Add a patch_exe_any helper that searches for either the original CME string or the previously-patched host (zero-padded to 22 bytes). Regression test reproduces the sequence above and fails before the fix — see Test Gaps comment §7c.

Minor: hostname patch is null-terminated C-string territory

patch_rdata.rs:45-47 zero-fills the 22-byte slot. Correct for C strings; if SGW.exe ever reads the field length-prefixed (it almost certainly doesn't) the zero-pad would be wrong. Worth a one-line note in the module doc.

Minor: Cancel doesn't clean up .tmp-* files

Intentional — enables resume on next run. But if cancel was triggered by correcting a wrong manifest URL, the stale tmp lingers and the next install's Range request asks an unrelated URL to continue from byte N. SHA verify catches it. Either document in launcher-guide.md ("if you change manifest URLs, delete .tmp-* from the install dir") or add a "Reset downloads" button.

@Cadacious
Copy link
Copy Markdown
Contributor

Feature gaps vs. what a real launcher needs

Solid v1. Gap matrix vs. typical MMO launcher functionality:

Capability Status Notes
Install game from manifest yes
Auto-update on launcher start manual button Should auto-apply or prompt
Self-update of the launcher itself no Every launcher fix = manual re-download from GH Releases
Repair / verify game files no Only recovery from corruption = nuke launcher-installed.json + re-seed (multi-GB)
News / patch notes no Manifest could carry news_url or changelog field
Server selection single host field OK for one shard; blocker for beta+live
EULA / first-run no Standard for binary patching of someone else's game
Crash detection / auto-log-upload manual button Detect abnormal SGW.exe exit, prompt to upload
Disk-space precheck no Mid-install ENOSPC just dumps an IO error
Pause / in-session resume cancel only Resume is per-launcher-run
ETA / speed display no Bytes only
Multi-channel (beta/live) no One manifest URL
Atomic install no Failed extract leaves half-applied patch
Concurrent-launcher protection no Two instances race on state.json + .tmp files
Admin-rights detection no Default C:\Program Files requires admin → opaque mid-extract failure
AV / SmartScreen no Unsigned .exe → warning on every first download

The two biggest — self-update and repair/verify

Without self-update, every launcher bug = a "please redownload from GH Releases" announcement.
Without repair, every AV-quarantined-a-file report = "delete the whole game, reinstall."

Both inexpensive.

Self-update sketch:

// New manifest field
pub struct Manifest {
    ...
    #[serde(default)]
    pub launcher: Option<LauncherEntry>,
}
pub struct LauncherEntry {
    pub version: String,
    pub blob: String,
    pub sha256: String,
}
// In LauncherApp::new, after manifest fetch:
if let Some(l) = &manifest.launcher {
    if l.version > env!("CARGO_PKG_VERSION") {
        // download to sgw-launcher.new.exe, rename current to .old,
        // rename new to current, std::process::Command::new(current).spawn(),
        // std::process::exit(0). Standard Windows self-update pattern.
    }
}

Repair sketch — store per-file sha for the seed:

// InstalledState gains
pub struct InstalledState {
    ...
    #[serde(default)]
    pub seed_files: std::collections::HashMap<String, String>,
}
// Populated at install time from a manifest of seed contents (publish step
// emits this alongside seed.zip).

// "Verify" button hashes each tracked file, lists mismatches, re-downloads
// seed.zip and overlays only the bad files.

Admin-rights / install path

Default install path is C:\Program Files\Stargate Worlds — requires admin to write. UAC isn't requested, so the install fails opaquely mid-extract with "Access denied".

Pick one:

  1. Best UX — default to %LOCALAPPDATA%\Stargate Worlds (no UAC). Matches Steam's new-install default.
  2. Adequate — runtime check, prompt to re-launch elevated.
  3. Adequate — early writability probe, bail before downloading:
fn install_dir_writable(dir: &Path) -> bool {
    let _ = std::fs::create_dir_all(dir);
    let probe = dir.join(".launcher-write-probe");
    let ok = std::fs::write(&probe, b"x").is_ok();
    let _ = std::fs::remove_file(&probe);
    ok
}

Gate the Install button on this, with a friendly error when it fails.

Atomic install

Mid-patch extract failure currently leaves half-applied state. Wrap each patch in a staging dir + rename:

async fn apply_patch(ctx: &InstallContext<'_>, patch: &PatchEntry) -> Result<(), InstallError> {
    let staging = ctx.install_dir.join(format!(".staging-{}", safe_id(&patch.id)));
    std::fs::create_dir_all(&staging)?;

    // download + verify into .tmp-*, extract into staging
    extract_zip(&tmp, &staging, ...)?;

    // Only after extract succeeds, overlay staging onto install_dir.
    move_overlay(&staging, ctx.install_dir)?;
    std::fs::remove_dir_all(&staging).ok();
    Ok(())
}

move_overlay walks staging and std::fs::renames each file into the install dir (same volume → atomic per-file). If anything fails before move_overlay, the staging dir is the only thing left to clean up.

Single-instance lock

// main.rs - before eframe::run_native
use fs2::FileExt;
let lock_path = config::exe_dir().join("launcher.lock");
let lock = std::fs::File::create(&lock_path)?;
if lock.try_lock_exclusive().is_err() {
    // Show a native message box or just stderr + exit
    eprintln!("Another Stargate Worlds launcher is already running.");
    std::process::exit(1);
}
// hold `lock` for process lifetime

Add fs2 = "0.4" to Cargo.toml. Test in §7f.

@Cadacious
Copy link
Copy Markdown
Contributor

Security improvements beyond the SAS

4a. Manifest is unsigned — biggest gap

fetch_manifest does reqwest::get(url). Anyone who can serve manifest.json (compromised Azure key, social-engineered support, MITM with HTTP downgrade) can ship any seed/patch — SHA verification just confirms what the attacker said the hash should be.

Remediation — embed an Ed25519 pubkey, ship detached signature:

// manifest.rs
const SIGNING_PUBKEY: &[u8; 32] = include_bytes!("../keys/manifest-signing.pub");

pub async fn fetch_manifest(client: &reqwest::Client, url: &str)
    -> Result<Manifest, ManifestError>
{
    let body = client.get(url).send().await?.error_for_status()?.bytes().await?;
    let sig = client.get(format!("{url}.sig")).send().await?
        .error_for_status()?.bytes().await?;

    use ed25519_dalek::{Signature, VerifyingKey, Verifier};
    let vk = VerifyingKey::from_bytes(SIGNING_PUBKEY.try_into().unwrap())?;
    let signature = Signature::from_slice(&sig)?;
    vk.verify(&body, &signature).map_err(|_| ManifestError::BadSignature)?;

    let manifest: Manifest = serde_json::from_slice(&body)?;
    manifest.validate()?;
    Ok(manifest)
}

Add ed25519-dalek = { version = "2", default-features = false, features = ["pkcs8"] } to Cargo.toml.

Operator workflow change: signing step in the publish flow runs ed25519sign manifest.json > manifest.json.sig with an offline private key. Document in launcher-guide.md Part 2.

This is the single highest-leverage security improvement. Removes manifest tampering and seed/patch substitution from the threat model. Anonymous-read on the container is fine once the manifest is signed.

4b. Replay protection — monotonic version

Once signed, the remaining attack is replaying an old (validly signed) manifest to roll back patches. Defense:

pub struct Manifest {
    pub schema: u32,
    pub version: u64,                                  // monotonic
    pub published_at: chrono::DateTime<chrono::Utc>,
    pub seed: SeedEntry,
    pub patches: Vec<PatchEntry>,
}

// state.rs
pub struct InstalledState {
    ...
    #[serde(default)]
    pub highest_manifest_version: Option<u64>,
}

// install.rs - reject regressions
if let Some(seen) = state.highest_manifest_version {
    if manifest.version < seen {
        return Err(InstallError::ManifestReplay {
            seen, got: manifest.version,
        });
    }
}
state.highest_manifest_version = Some(manifest.version);

4c. SGW.exe write is destructive without backup

patch_rdata.rs:56-61: read → modify → write. Power loss between truncate and finish leaves a corrupt .exe with no recovery beyond re-seeding (multi-GB).

Fix — atomic via rename:

pub fn patch_exe(exe_path: &Path, new_host: &str) -> Result<usize, PatchError> {
    let mut data = std::fs::read(exe_path)?;
    let offset = patch_hostname(&mut data, new_host)?;
    let tmp_path = exe_path.with_extension("exe.patching");
    std::fs::write(&tmp_path, &data)?;
    std::fs::rename(&tmp_path, exe_path)?;  // atomic on NTFS, same volume
    Ok(offset)
}

4d. HTTPS-only enforcement

Belt-and-braces against a user typing http://... in the manifest URL field:

// manifest.rs - top of fetch_manifest
if !url.starts_with("https://") {
    return Err(ManifestError::InsecureUrl(url.to_string()));
}

Same in logs.rs::upload_blob for the SAS URL. Plus on the reqwest::Client itself:

reqwest::Client::builder()
    .https_only(true)  // refuses to follow http:// redirects too
    .build()

4e. Concurrent install / state race

Covered functionally in the Feature Gaps comment (single-instance lock). Security framing: a second launcher process can write a malicious launcher-installed.json claiming patches are applied, causing the first to skip them. The file lock closes both.

4f. SAS-in-binary trade-offs

The SAS lives in .rdata of the launcher .exe. Anyone can strings sgw-launcher.exe | grep blob.core to extract it. c-only scope limits blast radius to:

  • Upload spam / DoS storage-account quota
  • Egress cost amplification
  • Arbitrary content under logs/ (SAS is container-scoped per launcher-storage-setup.md "Important" callout)

Mitigations short of removing the SAS:

To eliminate the SAS entirely — see Alternatives comment.

4g. LauncherConfig schema drift

No schema_version field. Future schema changes will silently revert to defaults via .ok().unwrap_or_default(). Add:

pub struct LauncherConfig {
    #[serde(default = "current_schema_version")]
    pub schema_version: u32,
    pub install_path: PathBuf,
    pub server_host: String,
    pub manifest_url: String,
}

fn current_schema_version() -> u32 { 1 }

impl LauncherConfig {
    pub fn load(path: &Path) -> Result<Self, ConfigError> {
        let cfg: Self = serde_json::from_str(&std::fs::read_to_string(path)?)?;
        if cfg.schema_version != current_schema_version() {
            return Err(ConfigError::SchemaVersion {
                got: cfg.schema_version, expected: current_schema_version()
            });
        }
        Ok(cfg)
    }
}

4h. launch.rs path-prefix sanity check

Command::new(install_dir.join("SGW.exe")) — install_dir is user-controllable via the config field. Not really an escalation (the user chose the path), but cheap hardening:

fn spawn(install_dir: &Path, file: &str, via_cmd: bool) -> Result<u32, LaunchError> {
    let install_dir = install_dir.canonicalize()?;
    let path = install_dir.join(file).canonicalize()?;
    if !path.starts_with(&install_dir) {
        return Err(LaunchError::PathEscape(path));
    }
    ...
}

@Cadacious
Copy link
Copy Markdown
Contributor

Alternatives to SAS / anonymous auth for blob hosting

Ranked by cost vs. security gain.

Option A — GitHub Releases as the CDN (cheapest, zero infra)

The launcher itself is already shipped via GH Releases. Ship seed.zip, patches/*.zip, and manifest.json as release assets too.

https://github.com/SandboxServers/Cimmeria/releases/download/launcher-2026-05-20-abc1234/manifest.json
https://github.com/SandboxServers/Cimmeria/releases/download/launcher-2026-05-20-abc1234/seed.zip
  • Cost: $0. Free egress.
  • Security: actions/attest-build-provenance provides sigstore attestation during the release workflow — solves §4a manifest signing as a side effect.
  • Downside: 2GB per-asset limit may force seed splitting; rate limits exist (generous but real for a popular emu); no per-blob ACL.

Implementation sketch:

# launcher-release.yml additions
- name: build seed + patches
  run: ./tools/build-seed.sh    # already-existing publish flow
- name: attest provenance
  uses: actions/attest-build-provenance@v1
  with:
    subject-path: 'release/seed.zip,release/patches/*.zip,release/manifest.json'
- name: upload assets
  run: |
    gh release upload "${TAG}" release/seed.zip release/patches/*.zip release/manifest.json

Option B — Cloudflare R2 + Worker auth (moderate cost, strong)

R2 has zero egress fees. Tiny Worker validates a launcher-supplied token (signed by your auth server, ~5min TTL) and either redirects to a signed R2 URL or proxies bytes.

  • Cost: ~free at MMO emu scale (R2 free tier + Workers free tier 100k req/day).
  • Security: Short-lived per-session tokens, rate-limited per-IP at the edge, container fully private.
  • Downside: Couples launcher to the auth server. Bootstrap problem: a brand-new user has no account. Solve with a separate anonymous "install token" endpoint (rate-limited, IP-throttled) or accept the coupling.

Option C — Azure Front Door + Function gateway (Azure-native)

Container fully private; Function App validates and rewrites to authenticated blob URLs.

  • Cost: ~$22/mo Front Door base + small Function spend.
  • Security: Full control, IP allowlist, rate limit, token validation.
  • Downside: Most engineering for a v1.

Option D — Self-hosted nginx with signed-URL middleware

secure_link module on a $5/mo VPS. URLs like https://patches.cimmeria.gg/seed.zip?md5=...&expires=....

  • Cost: $5/mo + ops time.
  • Security: Same model as CloudFront signed URLs.
  • Downside: Back in the "ops a server" business.

Option E — Sign the manifest, keep blobs anonymous

Covered in Security comment §4a. Highest-leverage, lowest cost (~50 LOC + a signing step in publish flow). Anonymous-read on Azure is fine afterward.

Recommendation

  1. Do manifest signing (§4a) immediately regardless of which option you pick for blob hosting.
  2. To drop SAS-in-binary: Option A (GH Releases) — lowest friction, already in your release pipeline, sigstore attestation free.
  3. Move log upload off Azure SAS entirely — small POST endpoint on the Cimmeria server with per-session rate limiting. Eliminates SAS attack surface and gives you a place to dedupe server-side too.

What a future ADR should capture

Drop into docs/architecture/launcher-distribution.md:

  • Decision: which option (or hybrid).
  • Why not the others: specifically the trade-offs being accepted.
  • Bootstrap: how a brand-new user with no account gets the launcher running.
  • Operator workflow: how a release is published (signing step, attestation, upload).
  • Disaster recovery: signing-key rotation flow. Embed key version in the launcher and accept multiple key versions during a transition window so a leaked key doesn't strand existing installs.

@Cadacious
Copy link
Copy Markdown
Contributor

Code-level issues

6a. install_path: String should be PathBuf

config.rs:30, and default_install_path() -> String. Works today; tomorrow it bites someone with a long path or non-UTF-16-safe Unicode boundary.

pub struct LauncherConfig {
    pub install_path: PathBuf,    // was String
    pub server_host: String,
    pub manifest_url: String,
}

Propagates to app.rs (TextEdit needs .display().to_string() for editing, parse back on save) and worker.rs.

6b. &PathBuf should be &Path (clippy::ptr_arg)

config.rs:46,51. Textbook lint. CI is -D warnings — either it's allowed somewhere or hasn't fired yet on this PR. Either way:

pub fn load(path: &Path) -> Result<Self, ConfigError> { ... }
pub fn save(&self, path: &Path) -> Result<(), ConfigError> { ... }

6c. State writes are non-atomic

InstalledState::save, UploadedLedger::save, LauncherConfig::save all do std::fs::write directly. Power loss mid-write corrupts the JSON; the load path falls back to default(), which silently means "nothing is installed" → re-download the entire seed.

Fix everywhere (promote tempfile to a runtime dep, or just rename a .tmp file manually):

// new utility in a shared module
pub fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    if let Some(p) = path.parent() {
        std::fs::create_dir_all(p)?;
    }
    let tmp = path.with_extension("tmp");
    std::fs::write(&tmp, bytes)?;
    std::fs::rename(&tmp, path)?;  // atomic on NTFS, same volume
    Ok(())
}

Use in:

  • state.rs::InstalledState::save
  • state.rs::UploadedLedger::save
  • config.rs::LauncherConfig::save
  • patch_rdata.rs::patch_exe (covered in Security comment §4c)

6d. status: Vec<String> grows unbounded

app.rs:22. UI displays last 100; Vec keeps everything. Long-session leak.

const STATUS_CAP: usize = 500;

impl LauncherApp {
    fn push_status(&mut self, line: String) {
        self.status.push(line);
        if self.status.len() > STATUS_CAP {
            let drop_n = self.status.len() - STATUS_CAP;
            self.status.drain(0..drop_n);
        }
    }
}

Replace every direct self.status.push(...) with self.push_status(...).

6e. Install-dir rescan runs on UI thread

app.rs:128last_refresh.elapsed() > 2s triggers refresh_install_state on the UI thread. On a slow network drive or Defender-scanned path, this stutters the UI. Move to the worker:

// worker.rs
pub enum Command {
    ...
    RefreshInstallState { install_dir: PathBuf },
}
pub enum Event {
    ...
    InstallStateRefreshed {
        installed: InstalledState,
        launch_opts: LaunchOptions,
    },
}

fn spawn_refresh(&self, install_dir: PathBuf) {
    let tx = self.events_tx.clone();
    // Refresh involves file I/O; use a blocking task.
    self.runtime.spawn_blocking(move || {
        let installed = InstalledState::load(&install_dir);
        let launch_opts = LaunchOptions::detect(&install_dir);
        let _ = tx.send(Event::InstallStateRefreshed { installed, launch_opts });
    });
}

App requests refresh on the 2s timer, applies the event when it arrives.

6f. Cancel token reuses across back-to-back installs

worker.rs:74-77. If two installs are dispatched back-to-back (rapid double-click), they share the cancel token and one cancel fires both. Per-task tokens:

pub struct Worker {
    runtime: Arc<Runtime>,
    pub events_rx: mpsc::UnboundedReceiver<Event>,
    events_tx: mpsc::UnboundedSender<Event>,
    current_install_cancel: Option<CancellationToken>,
}

fn spawn_install(&mut self, config: LauncherConfig, manifest: Manifest) {
    let cancel = CancellationToken::new();
    self.current_install_cancel = Some(cancel.clone());
    // pass `cancel` into the InstallContext
    ...
}

fn cancel_current_install(&mut self) {
    if let Some(t) = self.current_install_cancel.take() {
        t.cancel();
    }
}

6g. reqwest::Client is built per call in 3 places

install.rs:177, logs.rs:123, manifest.rs:87. Single shared Client reuses the connection pool across seed → patch1 → patch2:

// worker.rs
pub struct Worker {
    ...
    http: reqwest::Client,
}

impl Worker {
    pub fn new(runtime: Arc<Runtime>) -> Self {
        let http = reqwest::Client::builder()
            .https_only(true)          // §4d defense-in-depth
            .build()
            .expect("rustls client");
        Self { ..., http }
    }
}

Pass &Client into install_all, fetch_manifest, upload_blob.

6h. UploadedLedger never prunes

A player who clicks "Upload Logs" 1000 times grows the ledger to 1000 entries.

const LEDGER_KEEP: usize = 100;

pub fn record(&mut self, sha256: String, blob_name: String) {
    self.entries.push(UploadedEntry {
        sha256,
        blob_name,
        uploaded_at: chrono::Utc::now(),
    });
    if self.entries.len() > LEDGER_KEEP {
        let drop_n = self.entries.len() - LEDGER_KEEP;
        self.entries.drain(0..drop_n);
    }
}

6i. Tmp filename keys patches by id only, not (id, sha)

install.rs:144 uses .tmp-patch-{safe_id}.zip. If a patch is republished with the same id but a new sha (operationally wrong, but possible during a hot-fix), the launcher resumes the old tmp via Range → SHA fails → user must manually delete. Cheap fix: include &p.sha256[..12] in the tmp filename.

let safe_sha = &patch.sha256[..12];
let tmp = ctx.install_dir.join(
    format!(".tmp-patch-{safe_id}-{safe_sha}.zip")
);

6j. pub use discipline for module structure

10 flat files in src/. Per CLAUDE.md file-organization rules ("flat names for 2-3 siblings; directory for 4+"), this is on the high side. They are different themes (manifest, install, launch, logs, state, worker, app, config, patch_rdata, main), each at one file, so flat is justified — but as the launcher grows you'll want to promote some to directories (e.g., install/ for download/extract/state-update split). Not a v1 concern; flag for future.

@Cadacious
Copy link
Copy Markdown
Contributor

Test gaps

Coverage of the building blocks is strong (40 tests, well-targeted unit-level). Orchestration is what's missing — and §2's hostname re-patch bug would have been caught by a single regression test.

7a. End-to-end install_all with local HTTP fixture

Add wiremock = "0.6" (or mockito) as a dev-dep. Test seed downloads → patches apply in declared order → state file reflects truth → re-run is a no-op (zero HTTP requests on the second pass).

// install_e2e_tests.rs
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};

#[tokio::test]
async fn install_all_seed_then_patches_then_idempotent() {
    let server = MockServer::start().await;

    // Build synthetic seed.zip + patch zips as byte buffers
    let seed_bytes = build_zip(&[("Binaries/SGW.exe", b"www.stargateworlds.com..." as &[u8])]);
    let seed_sha = sha256_hex(&seed_bytes);

    let manifest = json!({
        "schema": 1,
        "seed": { "blob": "seed.zip", "size": seed_bytes.len(), "sha256": seed_sha },
        "patches": []
    });

    Mock::given(method("GET")).and(path("/seed.zip"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(seed_bytes.clone()))
        .expect(1)              // exactly once across the whole test
        .mount(&server).await;

    let dir = tempfile::tempdir().unwrap();
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let ctx = InstallContext {
        manifest_url: &format!("{}/manifest.json", server.uri()),
        install_dir: dir.path(),
        manifest: &serde_json::from_value(manifest).unwrap(),
        server_host: "play.cimmeria.gg",
        cancel: CancellationToken::new(),
        progress: tx.clone(),
    };
    install_all(ctx).await.unwrap();

    // Second pass: should be a no-op, mock expects exactly 1 GET.
    let ctx2 = InstallContext { /* same */ };
    install_all(ctx2).await.unwrap();
    // wiremock verifies .expect(1) on drop — second pass would push to 2 and fail.
}

7b. Reseed invalidates applied_patches

#[tokio::test]
async fn reseed_clears_applied_patches() {
    let dir = tempfile::tempdir().unwrap();
    let state = InstalledState {
        applied_patches: vec!["a".into(), "b".into()],
        seed_sha256: Some("OLD-SHA".into()),
        ..Default::default()
    };
    state.save(dir.path()).unwrap();

    // Run install_all with a manifest whose seed sha is NEW.
    install_all(/* manifest with seed_sha=NEW */).await.unwrap();

    let loaded = InstalledState::load(dir.path());
    assert!(loaded.applied_patches.is_empty(), "reseed should clear patch list");
    assert_eq!(loaded.seed_sha256.as_deref(), Some("NEW"));
}

7c. Repatches when server_host changes — the §2a bug as a failing guard

#[test]
fn repatches_when_server_host_changes() {
    let dir = tempfile::tempdir().unwrap();
    let exe = dir.path().join("SGW.exe");
    let mut data = vec![0u8; 100];
    data[40..62].copy_from_slice(b"www.stargateworlds.com");
    std::fs::write(&exe, &data).unwrap();

    // First patch with host A.
    patch_rdata::patch_exe(&exe, "play.cimmeria.gg").unwrap();
    let after_first = std::fs::read(&exe).unwrap();
    assert!(after_first.windows(b"play.cimmeria.gg".len())
        .any(|w| w == b"play.cimmeria.gg"));

    // Change host to B. After the fix from §2a, this must replace
    // play.cimmeria.gg with staging.cimmeria.gg in the binary.
    let state = InstalledState {
        patched_host: Some("play.cimmeria.gg".into()),
        ..Default::default()
    };
    patch_rdata::patch_exe_any(&exe, "staging.cimmeria.gg",
                               state.patched_host.as_deref()).unwrap();

    let after_second = std::fs::read(&exe).unwrap();
    assert!(after_second.windows(b"staging.cimmeria.gg".len())
        .any(|w| w == b"staging.cimmeria.gg"),
        "second patch with different host must update the binary");
    assert!(!after_second.windows(b"play.cimmeria.gg".len())
        .any(|w| w == b"play.cimmeria.gg"),
        "old host must be gone");
}

This test fails today — that's the point. It locks in the bug shape from the hostname re-patch finding.

7d. Resume from partial .tmp-*

#[tokio::test]
async fn resumes_partial_download_via_range_header() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/seed.zip"))
        .and(header("range", "bytes=512-"))
        .respond_with(ResponseTemplate::new(206).set_body_bytes(&second_half))
        .expect(1)
        .mount(&server).await;

    let dir = tempfile::tempdir().unwrap();
    let tmp = dir.path().join(".tmp-seed-abc123.zip");
    std::fs::write(&tmp, &first_half).unwrap();  // pre-existing partial

    download_to_file(&format!("{}/seed.zip", server.uri()), &tmp, ...).await.unwrap();

    let final_bytes = std::fs::read(&tmp).unwrap();
    assert_eq!(final_bytes, [&first_half[..], &second_half[..]].concat());
}

7e. Explicit zip-slip lock-in

enclosed_name() handles it, but lock the behavior so a future zip-crate version can't regress:

#[test]
fn extract_zip_rejects_path_traversal() {
    let dir = tempfile::tempdir().unwrap();
    let zip_path = dir.path().join("evil.zip");
    {
        let f = std::fs::File::create(&zip_path).unwrap();
        let mut zw = zip::ZipWriter::new(f);
        let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
        zw.start_file("../../../evil.txt", opts).unwrap();
        zw.write_all(b"pwned").unwrap();
        zw.finish().unwrap();
    }
    let out = dir.path().join("out");
    let (tx, _) = tokio::sync::mpsc::unbounded_channel();
    extract_zip(&zip_path, &out, &tx, "test", &CancellationToken::new()).unwrap();

    // Nothing written outside `out`:
    assert!(!dir.path().join("evil.txt").exists());
    assert!(!std::path::Path::new("/tmp/evil.txt").exists());
}

7f. Single-instance lock refusal

After the §3 single-instance lock lands:

#[test]
fn second_instance_cannot_acquire_lock() {
    use fs2::FileExt;
    let dir = tempfile::tempdir().unwrap();
    let lock_path = dir.path().join("launcher.lock");

    let first = std::fs::File::create(&lock_path).unwrap();
    first.try_lock_exclusive().unwrap();

    let second = std::fs::File::create(&lock_path).unwrap();
    assert!(second.try_lock_exclusive().is_err());
}

7g. Manifest signature verification (when §4a lands)

#[test]
fn fetch_manifest_rejects_bad_signature() {
    // Build a manifest, sign with key A, verify with key B → reject.
}

#[test]
fn fetch_manifest_accepts_valid_signature() {
    // Build a manifest, sign with the embedded test key, verify → accept.
}

Tests run against a MockServer serving manifest.json + manifest.json.sig.

@Cadacious
Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
.github/workflows/launcher-build.yml (2)

41-46: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use workspace-wide rustfmt check in CI.

Line 43 currently checks only one package; this can let cross-crate formatting drift through. Please switch to cargo fmt --all -- --check to match repo policy.

As per coding guidelines **/*.rs: “Always run cargo fmt --all -- --check before submitting a PR to ensure code formatting passes CI gates.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/launcher-build.yml around lines 41 - 46, The CI rustfmt
step currently runs "cargo fmt -p sgw-launcher -- --check" which only checks one
package; change that invocation to run workspace-wide formatting with "cargo fmt
--all -- --check" so the job verifies all crates follow repo policy and prevents
cross-crate formatting drift.

61-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align clippy step with the repository-required invocation.

Line 62 runs clippy only for sgw-launcher, but the required policy is the workspace command with explicit excludes. Please update this job to run the exact repository-standard clippy command.

As per coding guidelines **/*.rs: “Run cargo clippy --workspace --exclude cimmeria-app --exclude cimmeria-content-editor --exclude cimmeria-scene-editor --exclude sgw-launcher --all-targets -- -D warnings before submitting a PR.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/launcher-build.yml around lines 61 - 63, The clippy job
currently runs `cargo clippy -p sgw-launcher --all-targets -- -D warnings` (job
name "cargo clippy"); replace that run command with the repository-standard
workspace invocation: run `cargo clippy --workspace --exclude cimmeria-app
--exclude cimmeria-content-editor --exclude cimmeria-scene-editor --exclude
sgw-launcher --all-targets -- -D warnings` so the workflow runs clippy across
the workspace with the explicit excludes required by policy.
crates/launcher/src/install.rs (2)

174-200: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle 416 Range Not Satisfiable for already-complete temp files.

When existing_len > 0, a fully downloaded temp file can trigger 416 on resume; current logic returns UnexpectedStatus and blocks progress. Treat 416 as recoverable (e.g., verify local file hash and continue, or reset temp file and retry from zero).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/install.rs` around lines 174 - 200, The code currently
treats any non-success status (except 206) as fatal; add handling for 416 when
resuming: after getting resp and computing status/resumed, if status.as_u16() ==
416 && existing_len > 0 then treat as recoverable — either verify the local temp
file (e.g., compare its length or hash to the expected content length/ETag from
a HEAD or the response) and return success, or delete/reset the temp file and
retry the download without the Range header (re-create req via
reqwest::Client::new().get(url) and resend). Update the conditional that returns
InstallError::UnexpectedStatus to exclude the 416-resumable path and use the
symbols existing_len, resp/status/resumed, and InstallError::UnexpectedStatus to
locate the change.

179-184: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add an explicit HTTP timeout to download requests.

Line 183 performs a network call with no timeout, so a stalled connection can hang install/update indefinitely. Set a request/client timeout and map timeout failures into InstallError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/install.rs` around lines 179 - 184, The download request
currently uses reqwest::Client::new().get(url) with no timeout; create the
client with an explicit timeout (e.g., using
reqwest::Client::builder().timeout(Duration::from_secs(...)).build() or set a
per-request timeout via the RequestBuilder.timeout(...) call) and replace the
existing Client::new() usage around the `req` variable. Also update the
await/send error handling for `resp = req.send().await` to map timeout errors
into the crate's InstallError (detect via the reqwest error's is_timeout() or
matching the error kind) and return a clear InstallError variant (e.g.,
InstallError::Timeout) while mapping other errors as before; reference `req`,
`existing_len`, `resp`, and `InstallError` to locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/launcher/src/install.rs`:
- Around line 195-199: Add a regression test that triggers the new
unexpected-status branch by simulating a non-2xx/non-206 HTTP response and
asserting that the call returns InstallError::UnexpectedStatus; locate the test
alongside other tests for install.rs (e.g., in a #[cfg(test)] mod or integration
test) and call the function that exercises the path that checks
status/is_success() with resumed=false so the code path that returns
InstallError::UnexpectedStatus (referencing InstallError::UnexpectedStatus, the
status and resumed variables and the url argument) is executed, using a mock
HTTP client or test server to return a non-2xx/non-206 status and verifying the
error variant and contained status/url values.

In `@crates/launcher/src/patch_rdata.rs`:
- Around line 25-26: Update the InvalidHostname error text to accurately reflect
the validation rules: state that allowed characters are ASCII alphanumeric, '.'
and '-', and that hostnames must not start or end with either '.' or '-'. Modify
the message for the InvalidHostname variant (InvalidHostname { addr: String })
and the related error occurrences around the other block (lines 43-47) so
logs/UI show "no leading/trailing '.' or '-'" instead of only mentioning '-' to
match the actual validation logic.

In `@docs/client/launcher-guide.md`:
- Around line 435-438: Update the troubleshooting heading and copy that
currently reads "Install failed: Unexpected HTTP 4xx/5xx for …" to reflect the
installer now raises "Unexpected HTTP <status>" for any non-success response;
change the wording (the heading and the explanatory line about the seed or patch
blob URL) to state "non-success HTTP status" or "Unexpected HTTP <status>" so it
no longer implies only 4xx/5xx errors and accurately describes the installer
behavior.

---

Outside diff comments:
In @.github/workflows/launcher-build.yml:
- Around line 41-46: The CI rustfmt step currently runs "cargo fmt -p
sgw-launcher -- --check" which only checks one package; change that invocation
to run workspace-wide formatting with "cargo fmt --all -- --check" so the job
verifies all crates follow repo policy and prevents cross-crate formatting
drift.
- Around line 61-63: The clippy job currently runs `cargo clippy -p sgw-launcher
--all-targets -- -D warnings` (job name "cargo clippy"); replace that run
command with the repository-standard workspace invocation: run `cargo clippy
--workspace --exclude cimmeria-app --exclude cimmeria-content-editor --exclude
cimmeria-scene-editor --exclude sgw-launcher --all-targets -- -D warnings` so
the workflow runs clippy across the workspace with the explicit excludes
required by policy.

In `@crates/launcher/src/install.rs`:
- Around line 174-200: The code currently treats any non-success status (except
206) as fatal; add handling for 416 when resuming: after getting resp and
computing status/resumed, if status.as_u16() == 416 && existing_len > 0 then
treat as recoverable — either verify the local temp file (e.g., compare its
length or hash to the expected content length/ETag from a HEAD or the response)
and return success, or delete/reset the temp file and retry the download without
the Range header (re-create req via reqwest::Client::new().get(url) and resend).
Update the conditional that returns InstallError::UnexpectedStatus to exclude
the 416-resumable path and use the symbols existing_len, resp/status/resumed,
and InstallError::UnexpectedStatus to locate the change.
- Around line 179-184: The download request currently uses
reqwest::Client::new().get(url) with no timeout; create the client with an
explicit timeout (e.g., using
reqwest::Client::builder().timeout(Duration::from_secs(...)).build() or set a
per-request timeout via the RequestBuilder.timeout(...) call) and replace the
existing Client::new() usage around the `req` variable. Also update the
await/send error handling for `resp = req.send().await` to map timeout errors
into the crate's InstallError (detect via the reqwest error's is_timeout() or
matching the error kind) and return a clear InstallError variant (e.g.,
InstallError::Timeout) while mapping other errors as before; reference `req`,
`existing_len`, `resp`, and `InstallError` to locate the changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4ef2bcd1-506c-4848-ba80-6c068c9528cb

📥 Commits

Reviewing files that changed from the base of the PR and between 5fee345 and ec2e78d.

📒 Files selected for processing (9)
  • .github/workflows/launcher-build.yml
  • .github/workflows/launcher-release.yml
  • crates/launcher/src/app.rs
  • crates/launcher/src/install.rs
  • crates/launcher/src/manifest.rs
  • crates/launcher/src/patch_rdata.rs
  • docs/client/launcher-guide.md
  • docs/client/launcher-storage-setup.md
  • docs/client/sgw-launcher.md

Comment thread crates/launcher/src/install.rs
Comment thread crates/launcher/src/patch_rdata.rs Outdated
Comment thread docs/client/launcher-guide.md Outdated
BangRocket and others added 6 commits May 21, 2026 14:46
Fixes the silent failure mode Cady caught on PR #343: editing
`server_host` after install left the .exe pointing at the previously-
patched host because `needs_patching()` only searched for the original
CME literal (which the first patch already overwrote).

Changes
-------
patch_rdata.rs:
  - New `patch_hostname_any(data, new_host, previous_host)` that falls
    back to searching for `previous_host` (zero-padded to the 22-byte
    slot) when the original CME literal is gone. Old `patch_hostname`
    is removed from the public surface — tests use a #[cfg(test)] shim.
  - New `host_differs(data, expected, previous)` predicate the
    installer uses to decide whether to re-patch.
  - Updated `InvalidHostname` error text to mention both `.` and `-`
    (validation rejected both but message only mentioned `-`).
    Closes CodeRabbit #2.

state.rs:
  - `InstalledState.patched_host: Option<String>` records which host
    was last written into the .exe. Defaults to `None` for legacy
    state files (forward-compat tested).

install.rs:
  - install_all calls `host_differs` + `patch_exe_any` with the
    persisted `patched_host`, then writes the new host back to state.
  - Re-seed clears patched_host (the new seed has the original CME
    literal, so we patch fresh).
  - New regression test `download_to_file_returns_unexpected_status_on_non_2xx`
    using wiremock — guards the non-panicking HTTP status branch added
    in the prior fixup. Closes CodeRabbit #1.

app.rs:
  - Install panel surfaces "Server host changed — SGW.exe needs
    re-patching" and re-enables Install/Update when the only thing
    that drifted is the hostname. Without this, the UI hid the button
    after a successful install and the user had no in-launcher path to
    re-patch.

docs/client/launcher-guide.md:
  - Troubleshooting heading: "Unexpected HTTP <status>" instead of
    "4xx/5xx". Closes CodeRabbit #3.

Tests: 49 → 56 (+7). Adds wiremock dev-dep for HTTP mocking.

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               56/56 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Cady #5 (code-level) and #4g (LauncherConfig schema drift),
plus #2 admin-rights and #6h ledger pruning.

Atomic state writes
-------------------
state.rs exports an `atomic_write(path, bytes)` helper that stages to
`<path>.json.tmp` and renames onto the target. Used by InstalledState,
UploadedLedger, and LauncherConfig persistence. Power loss between
the truncate and the finish either leaves the old content or the new
content — never a half-written JSON that the load path treats as
"missing, fall back to default" (which would silently mean "nothing
installed" → multi-GB re-download). NTFS rename is atomic on the
same volume.

LauncherConfig hardening
------------------------
- `install_path: String` → `install_path: PathBuf` (Cady #6a). The
  egui TextEdit widget still binds to a `String` UI buffer
  (`LauncherApp::install_path_text`), synced into `config.install_path`
  on Save. Catches non-UTF-16-safe Unicode boundaries on Windows paths
  that would have hit `String` representations badly.
- `load(&PathBuf)` / `save(&PathBuf)` → `&Path` (Cady #6b, clippy::ptr_arg).
- New `schema_version: u32` field with `#[serde(default)]` for forward-
  compat (Cady #4g). Legacy config files without the field load with
  `schema_version = CONFIG_SCHEMA_VERSION`. Future-schema configs fail
  with `ConfigError::UnsupportedSchema { got, expected }` instead of
  silently coercing — protects fields the current struct doesn't know
  about.
- Default install path now `%LOCALAPPDATA%\Stargate Worlds` instead of
  `%ProgramFiles%\Stargate Worlds` so the launcher can write without
  UAC elevation (Cady #2 admin-rights gap). Falls back to
  `$HOME/.local/share/Stargate Worlds` for non-Windows dev builds.
- Default manifest URL flipped to the GitHub Releases convention
  (`content-current` rolling tag) ahead of the GH Releases hosting
  pivot. The Azure-Blob URL convention still works — `blob_url()`
  handles both — and the operator runbook switch lands in the
  dedicated hosting-pivot commit.

UploadedLedger pruning
----------------------
`record()` drops oldest entries beyond `LEDGER_KEEP = 100`. Each entry
is ~100 bytes, so the ledger file stays under ~10 KB even after
thousands of upload clicks. Pruning is transparent to dedupe checks:
`contains()` is "have we seen this digest in the last 100 uploads?"
which is sufficient for the wallet-protection goal.

Tests: 56 → 58. New legacy-load tests for both InstalledState and
LauncherConfig, plus unsupported-schema rejection.

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               58/58 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… tmp, atomic exe write

Addresses Cady #6f, #6g, #6i and #4c.

Shared reqwest::Client (Cady #6g)
---------------------------------
Worker owns one `reqwest::Client::builder().https_only(true).build()`
and clones the handle into every spawned task. Reuses the connection
pool across the seed download, all patch downloads, and the log
upload — previously each call built its own client and discarded the
pool. https_only(true) defends against http:// downgrade attacks and
catches misconfigured manifest URLs at the client layer (in addition
to the in-progress explicit URL prefix check landing in the security
hardening commit).

Signature changes:
  fetch_manifest(url)               → fetch_manifest(&http, url)
  upload_blob(sas, blob, body)      → upload_blob(&http, sas, blob, body)
  download_to_file(url, ...)        → download_to_file(&http, url, ...)
  InstallContext { ... }            → adds `http: &reqwest::Client`

Cancel token per install (Cady #6f)
-----------------------------------
Worker now owns `current_install_cancel: Option<CancellationToken>`.
Each `spawn_install` allocates a fresh token; `Command::Cancel` takes
and cancels whichever token is current. Previously a single token was
shared across all installs, so a double-click that fired two installs
and then a cancel would kill both (the new install would inherit the
already-cancelled token and exit immediately on first chunk).

The token is `take()`-d on cancel rather than cloned, so a subsequent
cancel is a no-op until the next install allocates a new token.

Sha-keyed tmp filename (Cady #6i)
---------------------------------
`.tmp-patch-{id}.zip` → `.tmp-patch-{id}-{sha12}.zip`. If an operator
republishes a patch with the same id but a new sha (operationally
wrong, but possible during a hot-fix), the launcher used to resume
the stale tmp via Range, SHA-verify fails, and the user had to
manually delete the tmp file. With the sha in the filename, different
sha → different tmp path → fresh download.

Atomic SGW.exe write (Cady #4c)
-------------------------------
`patch_exe_any` writes to `<exe_path>.exe.patching` then renames onto
SGW.exe. NTFS rename is atomic on the same volume, so a power loss
between the truncate and the finish leaves either the old SGW.exe or
the new one — never a corrupt .exe that would force a multi-GB
re-seed for recovery.

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               58/58 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, path-prefix check, writability probe

Addresses Cady #2 (admin-rights gap, single-instance lock), #4d (HTTPS
enforcement), and #4h (launch path-prefix check).

Single-instance file lock (Cady #2 / #4e)
-----------------------------------------
`main.rs` acquires an exclusive `fs4::FileExt::try_lock_exclusive` on
`<exe dir>/launcher.lock` before constructing any state. A second
launcher process fails the try-lock and exits with code 3 + a
stderr message. Previously two instances could race on
`launcher-installed.json` + `.tmp-*.zip` files: one declaring patches
applied while the other was still extracting them, etc.

Lock release happens automatically on process drop. The `File` is
held for the full process lifetime via an explicit `drop(lock_file)`
at the bottom of `main`.

HTTPS-only enforcement (Cady #4d)
---------------------------------
- `manifest::fetch_manifest` rejects non-`https://` URLs with
  `ManifestError::InsecureUrl(url)` before issuing the request.
- `logs::upload_blob` rejects non-`https://` SAS base URLs with
  `LogError::InsecureUrl` before issuing the PUT.
- Belt-and-braces with the worker's `Client::builder().https_only(true)`
  added in the previous commit. The explicit prefix check produces a
  friendlier user-facing error than reqwest's policy enforcement when
  a user types `http://...` in the manifest URL field.

Launch path-prefix sanity check (Cady #4h)
------------------------------------------
`launch::spawn` now canonicalizes both `install_dir` and the resolved
target path, then asserts `target.starts_with(install_dir)`. If the
install dir is a junction or the target resolves through a symlink
to an unexpected location, the spawn fails with
`LaunchError::PathEscape { install_dir, target }` instead of running
the binary with the install dir's cwd.

Not a privilege boundary (the user chose `install_dir`); cheap
defence-in-depth against config-injected paths.

Writability probe (Cady #2)
---------------------------
New `launch::install_dir_writable(&Path) -> bool` tries to create the
dir, write a probe file, remove it, and reports whether all three
succeeded. The Install panel calls this on every frame; the Install /
Update button is disabled with a red error label when the chosen
directory isn't writable. Without this, picking `C:\Program Files\…`
without UAC produces an opaque mid-extract "Access denied" after
gigabytes of seed download.

Probe file is always cleaned up; check survives missing parents
(creates them).

New tests: 58 → 62.
  - manifest::fetch_manifest_rejects_non_https
  - logs::upload_blob_rejects_non_https
  - launch::install_dir_writable_succeeds_on_temp
  - launch::install_dir_writable_creates_missing_parents

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               62/62 pass

Adds dep: fs4 = "0.13" (maintained fork of fs2, used for file lock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#4a)

Closes the manifest-tampering hole on PR #343: anyone who could MITM or
compromise the manifest host could previously ship arbitrary seed/patch
payloads because SHA verification of the artifacts was vacuous — the
launcher trusted whatever hashes the (untrusted) manifest claimed.
Cady ranked this "the single highest-leverage security improvement" in
his review.

Mechanism
---------
- `fetch_manifest` now fetches both `manifest.json` and
  `manifest.json.sig` (conventional `.sig` suffix). Signature file is
  hex-encoded (128 chars = 64 raw bytes) — chosen over raw binary so
  the file is grep-able in CI logs.
- Signature is verified against [`MANIFEST_SIGNING_PUBKEY`] — an
  embedded Ed25519 verifying key.
- If sig fetch or verify fails, the manifest is rejected. No unsigned
  fallback.

Key embedding
-------------
- `MANIFEST_SIGNING_PUBKEY` is a `LazyLock<VerifyingKey>` that reads
  `LAUNCHER_MANIFEST_PUBKEY_HEX` at compile time via `option_env!`.
  Production builds bake the operator's real pubkey from a repo secret.
- Default (no env override) derives the pubkey from `[0x2a; 32]` — a
  publicly-known dev private key, intentionally insecure so dev / PR /
  CI builds work without operator setup. Dev builds must never be
  released to players; the release workflow (next commit) injects the
  real key.
- `LAUNCHER_MANIFEST_PUBKEY_HEX` wiring into `launcher-release.yml` +
  operator documentation lands in a later commit alongside the
  GH Releases hosting pivot.

Why this works without infrastructure changes
---------------------------------------------
Anonymous-read on the blob host is fine once the manifest is signed
— an attacker tampering with the in-transit manifest can't produce a
valid signature without the offline private key. Seed/patch blob
integrity still rides on the SHA-256s _in the now-authenticated_
manifest. SAS-in-binary mitigation (the log upload SAS) is unaffected
— it's still bounded by the threat model documented in the storage
runbook.

Code
----
- New `ManifestError::BadSignature` (verification failed) and
  `BadSignatureFormat(&'static str)` (malformed hex / wrong length).
- Helper `hex_decode_32` / `hex_decode_64` (tiny inline impl, no new
  hex-crate dep).
- `verify_manifest_signature(body, sig_hex)` exposed for tests +
  potential reuse.

Tests: 62 → 67. New coverage:
  - verify_manifest_signature_accepts_signed_body
  - verify_manifest_signature_rejects_wrong_body
  - verify_manifest_signature_rejects_wrong_key
  - verify_manifest_signature_rejects_malformed_hex
  - hex_decode_32_round_trip

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               67/67 pass

Adds deps: ed25519-dalek = "2" (alloc only — no rand/pkcs8 features
needed since the launcher only verifies).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Option A)

Pivots the content hosting model away from "everything in one Azure
Blob container" to "GitHub Releases for content, Azure Blob only for
log uploads". Cady ranked this as the cleanest of the four hosting
options in his review — $0 cost, free egress, sigstore attestation
available for free via actions/attest-build-provenance, and the
launcher binary itself already ships via GH Releases so the operator
workflow is consistent.

Content layout
--------------
Two release-tag families:
  - content-current               mutable rolling tag. Contains
                                  manifest.json + manifest.json.sig.
                                  `gh release upload --clobber` to
                                  publish a new manifest.
  - content-YYYY-MM-DD-NNN        immutable per-publication tag.
                                  Contains seed.zip and/or per-patch
                                  zips. Never overwritten.

The manifest references seed/patch blobs by absolute URL pointing
into the immutable tags. That gives both properties at once:
  - manifest is mutable so publishing a new content drop is a
    single `gh release upload --clobber`.
  - blobs are immutable so existing installs can resume partial
    downloads safely (bytes at a given URL never change).

Code
----
- `manifest::blob_url` now passes absolute `http(s)://` URLs in the
  manifest's `blob` field through unchanged. Relative blob paths
  still resolve against the manifest URL's container — backwards-
  compatible with the previous Azure-only convention.
- New tests: blob_url_passes_absolute_https_through,
  blob_url_passes_absolute_http_through.
- `DEFAULT_MANIFEST_URL` in config.rs already pointed at the GH
  Releases convention (set in the atomic-writes commit).

Release workflow
----------------
`launcher-release.yml` now reads two repo secrets:
  - LAUNCHER_LOG_SAS_URL              (already there) — log upload SAS
  - LAUNCHER_MANIFEST_PUBKEY_HEX      (new) — 64-hex-char Ed25519
                                      pubkey, embedded into the
                                      release binary via option_env!
                                      in manifest.rs.

Without LAUNCHER_MANIFEST_PUBKEY_HEX set, the build falls back to a
publicly-known dev pubkey (intentional so PR / CI builds compile);
the release workflow is the only place the real production pubkey
is injected.

Docs
----
- `docs/client/launcher-storage-setup.md` → renamed and rewritten as
  `docs/client/launcher-distribution-setup.md`. Old doc was Azure-
  centric; new doc covers all three operator concerns in one place:
    Part 1 — Content distribution via GitHub Releases (the new model)
    Part 2 — Manifest signing keypair (Ed25519 generation + rotation)
    Part 3 — Debug-log uploads via Azure Blob (unchanged from before)
    Part 4 — Threat model: SAS in the client binary (unchanged)
- `docs/client/launcher-guide.md` Step 4 rewritten for the GH
  Releases publish flow (PowerShell + gh CLI instead of az CLI).
- `docs/client/sgw-launcher.md` install pipeline rewritten to cover
  the new signature-verify step + the atomic re-patch flow + the
  process-wide file lock.
- Cross-references in `docs/readme.md` updated.

Tests: 67 → 69. blob_url absolute-URL passthrough.

Verified locally on Windows:
  cargo fmt    -p sgw-launcher -- --check                    OK
  cargo clippy -p sgw-launcher --all-targets -- -D warnings  clean
  cargo test   -p sgw-launcher                               69/69 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BangRocket
Copy link
Copy Markdown
Contributor Author

Round 2 feedback addressed — 6 new commits

This is a big swing. Per scope choices (everything except self-update/repair + manifest signing + GH Releases pivot), the work landed across 6 focused commits on top of the previous fixup:

SHA Headline
e7e54dc 🐛 Bug: Re-patch SGW.exe when server_host changes + Cady #1 nits
9e01ffd Atomic writes, PathBuf, schema_version, ledger prune, %LOCALAPPDATA% default
44bcfb3 Shared reqwest::Client, cancel-per-install, sha-keyed tmp, atomic exe write
ed8c7b7 Security hardening: single-instance file lock, HTTPS-only, path-prefix check, writability probe
e300b5b 🔐 Manifest signing — Ed25519 (Cady #4a)
af6db5e 🚀 GH Releases pivot (Cady #4 Option A)

Tests: 49 → 69 (+20). All gates green locally (fmt --check, clippy -D warnings, full suite).


What's now fixed

Real bug (Cady §1)

  • Hostname re-patch silent skip when server_host changes. New patch_hostname_any / host_differs / patched_host state field. UI re-enables Install/Update when the only drift is the hostname. Regression test in e7e54dc.

Cady's code-level (§5)

  • 6a install_path: StringPathBuf (with install_path_text: String UI buffer)
  • 6b &PathBuf&Path in fn signatures
  • 6c Atomic JSON writes via shared atomic_write(path, bytes) (InstalledState, UploadedLedger, LauncherConfig) + atomic SGW.exe write via .exe.patching + rename
  • 6d Status Vec cap (already fixed in previous fixup)
  • 6f Cancel token per install (current_install_cancel: Option<CancellationToken>)
  • 6g Shared reqwest::Client with https_only(true) from the worker
  • 6h UploadedLedger prunes to LEDGER_KEEP = 100
  • 6i Tmp filename includes sha prefix (.tmp-patch-{id}-{sha12}.zip)

Deferred: 6e (rescan on worker) and 6j (file layout) — not v1 concerns per Cady.

Cady's security (§3)

  • 4a Manifest signing — Ed25519 detached signatures. Pubkey embedded via LAUNCHER_MANIFEST_PUBKEY_HEX env at compile time; dev default works for PR/CI builds. No unsigned-manifest fallback.
  • 4c Atomic SGW.exe write
  • 4d HTTPS-only enforcement (both Client::https_only(true) and explicit prefix checks in fetch_manifest / upload_blob)
  • 4g LauncherConfig.schema_version with explicit unsupported-schema rejection
  • 4h launch.rs canonicalize + starts_with(install_dir) check, returns LaunchError::PathEscape on violation
  • 4e Closed by the single-instance file lock

Deferred: 4b Manifest replay protection — needs the signing foundation that just landed; clean follow-up.

Cady's feature gaps (§2)

  • ✅ Single-instance lock via fs4::FileExt::try_lock_exclusive in main.rs
  • ✅ Default install path → %LOCALAPPDATA%\Stargate Worlds (no UAC required)
  • ✅ Writability probe gates the Install button with a friendly error
  • ✅ Manifest signing addresses the auth/signing column

Deferred (per "skip only self-update/repair"): self-update, repair/verify, news, server selection, EULA, crash detection, ETA display.

CodeRabbit's 3 new nits

  • InvalidHostname error text now mentions both . and -
  • ✅ Troubleshooting doc heading updated to "Unexpected HTTP <status>"
  • ✅ Regression test for InstallError::UnexpectedStatus (wiremock-based)

GH Releases pivot (af6db5e)

Two release-tag families:

  • content-current (mutable rolling): holds manifest.json + manifest.json.sig. Operator publishes a new manifest via gh release upload content-current --clobber.
  • content-YYYY-MM-DD-NNN (immutable per-publication): holds seed.zip and per-patch zips. Never overwritten.

Manifest references blobs by absolute URL into the immutable tags. blob_url() now passes absolute URLs through unchanged; relative paths still work for backwards compat. Default manifest_url flipped to the GH Releases convention.

The Azure Blob runbook (launcher-storage-setup.md) was rewritten as launcher-distribution-setup.md, covering all three operator concerns in one place: GH Releases publish flow, manifest signing keypair, Azure Blob SAS for log uploads. Log uploads stay on Azure SAS (GH Releases doesn't support unauthenticated writes), with the threat-model section unchanged.


Operator setup needed before the first release

Beyond merging this PR:

  1. Generate the manifest signing keypair offline. Private key → password manager. Public key → repo secret LAUNCHER_MANIFEST_PUBKEY_HEX (64 hex chars).
  2. Create the content-current release with gh release create content-current --notes "Rolling manifest pointer".
  3. Stand up the Azure storage for log uploads (same as before — runbook in launcher-distribution-setup.md Part 3).
  4. Build the first content drop: seed.zip + manifest.json + signed manifest.json.sig.

The dev-default pubkey lets CI builds and local dev work without operator setup. Only the release pipeline injects the real key.


Filed as follow-up issues (not in this PR)

These were deferred to keep PR scope tractable. Each is its own coherent change:

# Item Source
1 Atomic install via staging dir + rename overlay Cady §2
2 E2E install_all test with wiremock (seed → patches → idempotent re-run) Cady #7a
3 Resume from partial .tmp-* test Cady #7d / MyPalClara G7
4 Explicit zip-slip lock-in test Cady #7e
5 Single-instance lock refusal test Cady #7f
6 Manifest replay protection via manifest.version monotonic Cady #4b
7 Move install-dir rescan to worker (UI thread stutter on slow paths) Cady #6e
8 Self-update via manifest.launcher field Cady §2
9 Repair / verify per-file sha map Cady §2

The bug fix in e7e54dc already has a regression test (patch_hostname_any_repatches_when_host_changes) — that's the one Cady asked for in #7c.


CI

launcher-build.yml should run on the push and exercise the full Windows pipeline against all 6 commits. With 69 tests (up from 40 in the original PR) and the manifest-signing path exercised by 5 new unit tests, the surface area is meaningfully better-covered.

Watching for: the test suite to pass on windows-latest, no clippy regressions, and the workflow itself to handle the new LAUNCHER_MANIFEST_PUBKEY_HEX secret being unset gracefully (it should — option_env! falls back to the dev pubkey).

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/launcher/src/app.rs (1)

401-405: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the Atera debug filename in the help text.

The UI says AtreaGameDebug.bat, but the launcher looks for AteraGameDebug.bat. This points users at the wrong artifact when debug launch is unavailable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/app.rs` around lines 401 - 405, The help text shown when
!opts.atera_available() contains a typo: it says "AtreaGameDebug.bat" but the
launcher expects "AteraGameDebug.bat"; update the string passed to
egui::RichText::new in the ui.label call (the block guarded by if
!opts.atera_available()) to replace "AtreaGameDebug.bat" with
"AteraGameDebug.bat" so the UI matches the actual filename the launcher checks
for.
crates/launcher/src/logs.rs (1)

57-61: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't silently drop unreadable session files.

filter_map(|r| r.ok()) turns traversal failures into a “successful” partial upload. For diagnostics, that's misleading; return the first WalkDir error or surface an explicit warning/error event instead.

As per coding guidelines crates/**: “Focus on: … Error handling (Result, Option, ? operator)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/logs.rs` around lines 57 - 61, The WalkDir traversal
silently drops IO errors because it uses filter_map(|r| r.ok()); replace that
with explicit handling so unreadable session files are not ignored: iterate over
WalkDir::new(&sessions) and match each entry (Ok(e) => push e.path() into files;
Err(err) => either return Err(err) from the surrounding function or emit a clear
warning/log/event), using the function's Result return type and the ? operator
to propagate the first WalkDir error when appropriate; refer to the loop that
builds files, the sessions variable, and the files vector when making the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/launcher/src/app.rs`:
- Around line 189-192: The edited install path in install_path_text is only
copied into config.install_path on Save, causing stale paths for Install/Update,
writability checks, launch, and log upload; call
self.sync_install_path_from_text() at the start of any path-dependent action
(e.g., immediately before the existing call to
self.config.save(&self.config_path) in the Save button handler and likewise at
the top of the Install/Update handlers and any functions doing writability
gating, launch, or log upload) so that config.install_path is always up-to-date
before performing path-dependent logic.

In `@crates/launcher/src/install.rs`:
- Around line 162-166: Remove reviewer/PR identifiers from the source comments
while keeping the rationale: edit the comment above the safe_sha computation
(the comment that explains including a sha prefix in the tmp filename) to drop
"Cady `#6i`" and any "PR `#343`" references but preserve the explanation about
avoiding stale resumes by using a sha-based tmp path; likewise find and clean
the other comment block that contains reviewer/PR IDs (the block around the
later lines referencing the same rationale) and remove those provenance tokens
while retaining the technical reasoning (references: the safe_sha variable and
patch.sha256).
- Around line 130-131: The temp filename currently embeds unvalidated manifest
data (seed.sha256) into short_hash and builds tmp via
ctx.install_dir.join(format!(".tmp-seed-{short_hash}.zip")), which allows path
traversal if the manifest is malicious; validate or sanitize seed.sha256 before
using it in any path: ensure seed.sha256 matches a hex-only pattern (e.g. only
0-9a-fA-F) and take a bounded substring of the validated hex (or fallback to a
computed safe identifier like a hex of the first N bytes of a verified digest)
then use that safe short_hash when constructing tmp; apply the same
validation/sanitization to the other occurrence that builds temp filenames at
the 166–169 site so no untrusted characters reach ctx.install_dir.join or
format!.

In `@crates/launcher/src/launch.rs`:
- Around line 74-82: The comment above the canonicalize call references a
reviewer/provenance ("Cady `#4h`"); remove that provenance while preserving the
security rationale: update the comment that precedes let canon_install =
install_dir.canonicalize()? to explain canonicalizing both install_dir and the
target path and checking the target remains under the install dir (to catch
config/junction path escapes), but delete the "Cady `#4h`" phrase so the comment
contains only the rationale and not PR/reviewer metadata.

In `@crates/launcher/src/main.rs`:
- Around line 30-56: The lock-file failure paths around File::create(&lock_path)
and FileExt::try_lock_exclusive(&lock_file) currently only call eprintln! and
exit; change them to present a user-visible error before exiting by invoking a
small helper (e.g., show_error_dialog_and_log) that (1) shows a native modal
message box/dialog with the formatted error text (Windows MessageBox or
cross-platform crate) and (2) appends the same error text to a persistent log
(e.g., create/write "launcher-error.log" next to lock_path) so double-click
launches surface the problem even with windows_subsystem="windows"; call this
helper immediately before std::process::exit(2) and std::process::exit(3)
respectively.

In `@crates/launcher/src/manifest.rs`:
- Around line 20-34: Remove the explicit "PR `#343`" provenance from the module
doc comment in manifest.rs while preserving the explanatory rationale about
signing and why it closes the manifest-tampering hole; locate the top-of-file
module doc block (the comment that begins with "Signing model" and references
MANIFEST_SIGNING_PUBKEY) and delete or reword the "PR `#343`" token so the comment
no longer cites a specific PR number, leaving the rest of the description, the
reference to MANIFEST_SIGNING_PUBKEY, and the env-var guidance unchanged.
- Around line 183-195: The code currently builds sig_url by appending ".sig" to
the entire manifest URL (sig_url = format!("{url}.sig")), which places the
suffix after the query string and breaks signed/SAS URLs; change the logic in
manifest.rs where sig_url is built so the ".sig" is inserted before any query
string — either parse the url with the Url crate and append ".sig" to the path
segment or split the string at the first '?' and format as "{base}.sig{?query}"
— then use that corrected sig_url with http.get/send/..text() to fetch the
detached signature.
- Around line 55-66: The current MANIFEST_SIGNING_PUBKEY LazyLock silently falls
back to SigningKey::from_bytes(&[0x2a; 32]).verifying_key() when
option_env!("LAUNCHER_MANIFEST_PUBKEY_HEX") is absent; instead, change the
initializer so that if the env var is missing it panics (hard-fails) for
non-test/dev builds while still allowing test/dev runs (e.g. guard with
cfg!(test) || cfg!(debug_assertions) to permit the dev fallback). Update the
closure creating MANIFEST_SIGNING_PUBKEY to parse LAUNCHER_MANIFEST_PUBKEY_HEX
as now, but if option_env! returns None and the build is not a test/dev build,
call panic! with a clear message about the missing LAUNCHER_MANIFEST_PUBKEY_HEX;
only then allow the SigningKey::from_bytes(&[0x2a; 32]).verifying_key() path for
tests/devs to preserve CI/tests.

In `@crates/launcher/src/patch_rdata.rs`:
- Around line 145-150: Remove the reviewer/provenance token "Cady `#4c`" from the
comment block in patch_rdata.rs while preserving the technical explanation about
atomic writes and the .patching rename rationale; update the comment associated
with the write/rename behavior (the block that begins "Writes are **atomic**")
to omit any PR/issue/reviewer identifiers and keep only the rationale about
writing to a sibling .patching file and NTFS rename atomicity.

In `@crates/launcher/src/worker.rs`:
- Around line 122-127: spawn_install currently creates a fresh CancellationToken
and replaces self.current_install_cancel without cancelling any prior install,
allowing concurrent installs to race; change spawn_install (and its use of
self.current_install_cancel and CancellationToken) to first check for an
existing token and call cancel() on it (or otherwise reject the new install)
before creating and storing the new token so only one Install task runs at a
time and shared resources (temp zips, extraction, launcher-installed.json,
SGW.exe patching) are not raced.
- Around line 58-65: Remove the review-provenance tokens "Cady `#6f`" and "Cady
`#6g`" from the field doc comments above current_install_cancel and http, leaving
the explanatory rationale intact (e.g., that current_install_cancel is a
per-install CancellationToken to avoid shared kill switches and that http is a
shared reqwest::Client with connection pooling and https_only protection);
update the doc comment lines for the current_install_cancel and http fields to
omit any PR/issue/reviewer identifiers so they comply with the crate comment
guidelines.

In `@docs/client/launcher-guide.md`:
- Line 313: The Contents/TOC entry still references the old "Upload to Azure
Blob" anchor; update the TOC item so its visible text and anchor match the
renamed section header "Step 4 — Sign + publish to GitHub Releases" (i.e.,
change the TOC label and the in-page link/slug to the new GitHub Releases anchor
used by that header) so the in-page navigation points to the correct section.

---

Outside diff comments:
In `@crates/launcher/src/app.rs`:
- Around line 401-405: The help text shown when !opts.atera_available() contains
a typo: it says "AtreaGameDebug.bat" but the launcher expects
"AteraGameDebug.bat"; update the string passed to egui::RichText::new in the
ui.label call (the block guarded by if !opts.atera_available()) to replace
"AtreaGameDebug.bat" with "AteraGameDebug.bat" so the UI matches the actual
filename the launcher checks for.

In `@crates/launcher/src/logs.rs`:
- Around line 57-61: The WalkDir traversal silently drops IO errors because it
uses filter_map(|r| r.ok()); replace that with explicit handling so unreadable
session files are not ignored: iterate over WalkDir::new(&sessions) and match
each entry (Ok(e) => push e.path() into files; Err(err) => either return
Err(err) from the surrounding function or emit a clear warning/log/event), using
the function's Result return type and the ? operator to propagate the first
WalkDir error when appropriate; refer to the loop that builds files, the
sessions variable, and the files vector when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ea6f5da0-62c3-4438-b294-ca33609dd892

📥 Commits

Reviewing files that changed from the base of the PR and between ec2e78d and af6db5e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • .github/workflows/launcher-release.yml
  • crates/launcher/Cargo.toml
  • crates/launcher/src/app.rs
  • crates/launcher/src/config.rs
  • crates/launcher/src/install.rs
  • crates/launcher/src/launch.rs
  • crates/launcher/src/logs.rs
  • crates/launcher/src/main.rs
  • crates/launcher/src/manifest.rs
  • crates/launcher/src/patch_rdata.rs
  • crates/launcher/src/state.rs
  • crates/launcher/src/worker.rs
  • docs/client/launcher-distribution-setup.md
  • docs/client/launcher-guide.md
  • docs/client/sgw-launcher.md
  • docs/readme.md

Comment thread crates/launcher/src/app.rs Outdated
Comment thread crates/launcher/src/install.rs Outdated
Comment thread crates/launcher/src/install.rs Outdated
Comment thread crates/launcher/src/launch.rs Outdated
Comment thread crates/launcher/src/main.rs
Comment thread crates/launcher/src/manifest.rs
Comment thread crates/launcher/src/patch_rdata.rs Outdated
Comment thread crates/launcher/src/worker.rs Outdated
Comment thread crates/launcher/src/worker.rs
Comment thread docs/client/launcher-guide.md
Five real bugs + a stack of cleanup that came out of the CodeRabbit
review pass on the round-2 commits:

- worker.rs::spawn_install now cancels the previous install token before
  storing the new one. The UI button is gated against this today, but a
  rapid Cancel-then-Install flow or any non-UI dispatcher would have
  raced two installs on the temp zips, the extract, the state file, and
  the SGW.exe patch.

- manifest.rs::fetch_manifest now constructs the `.sig` URL via a new
  `sig_url_for()` helper that inserts the suffix on the path component
  before any query string. Appending ".sig" to the entire URL produced
  `manifest.json?token=....sig` against the GH Releases redirect / SAS
  URL, which fetched the wrong object every time.

- manifest.rs::MANIFEST_SIGNING_PUBKEY no longer falls back to the
  publicly-known dev key in release builds. If LAUNCHER_MANIFEST_PUBKEY_HEX
  is unset, empty, or malformed at compile time, release builds resolve
  to `None` and `verify_manifest_signature` returns the new
  `SigningKeyUnavailable` error. The previous behavior (silent dev-key
  acceptance in release) would have re-introduced the exact attack the
  signing scheme exists to close. Option<VerifyingKey> rather than
  panic! because the LazyLock body runs inside a tokio worker, where
  panics are caught and silently lost — that would have hung the UI on
  "Fetching manifest…" forever.

- app.rs::show_config_panel syncs install_path on TextEdit focus loss.
  Path-dependent actions (writability probe, refresh_install_state,
  Install/Update, Launch, Upload Logs) all see the live value the moment
  focus shifts to any other widget — including the buttons themselves,
  which egui processes after focus resolution. Used `.lost_focus()`
  rather than `.changed()` because the writability probe calls
  `create_dir_all`, so per-keystroke sync would have created partial
  directories ("C:\G", "C:\Ga", …) as the user typed.

- install.rs validates manifest sha256 fields as hex before splicing
  them into temp filenames (`safe_sha_prefix`). Signature verification
  authenticates the *bytes* of the manifest, not the well-formedness of
  individual fields — a malicious manifest signed with a compromised
  key could otherwise have escaped install_dir via crafted `sha256`
  strings like "../foo".

- main.rs surfaces fatal startup errors (lock-file failure, lock
  contention) via Windows MessageBoxW instead of just stderr. With
  `windows_subsystem = "windows"` in release builds, stderr is detached
  on a double-click, so the previous eprintln!+exit became a silent
  crash. windows-sys pinned to 0.61 to dedup with the version already
  in the workspace lockfile.

- logs.rs::collect_log_inputs returns Result instead of swallowing
  walkdir errors via filter_map(.ok()). A permission glitch on the
  sessions tree no longer silently produces a "successful" partial
  upload — for diagnostic logs that was actively misleading.

- launcher-guide.md TOC entry for Step 4 now points at the GH Releases
  anchor that replaced "Upload to Azure Blob" in the round-2 pivot.

- Drop `Cady #N` / `PR #343` review-provenance markers from source
  comments per the crate coding guidelines (provenance lives in the PR
  body, not in source).

Codecov:

- codecov.yml drops the `crates/launcher/**` ignore — the launcher
  has its own active CI now — and adds a `launcher` component with
  `target: auto` so the per-PR comment doesn't shout on the first
  upload while a baseline lands.

- launcher-build.yml adds a `coverage` job that runs `cargo llvm-cov
  nextest` on ubuntu (test logic is platform-agnostic; eframe Linux
  deps installed via apt) and uploads to Codecov under the `launcher`
  flag. `continue-on-error: true` so a coverage-tool flake doesn't
  gate the launcher pipeline.

7 new tests bring the launcher suite from 69 to 76:
- 4 for `sig_url_for` (no query, with query, with fragment, both)
- 3 for `safe_sha_prefix` (lower/upper hex accept, path-traversal
  reject, short-but-hex input accept)

CodeRabbit's "AtreaGameDebug.bat vs AteraGameDebug.bat" comment was a
false positive — the community-tool filename uses the transposed
spelling everywhere (detection, spawn, UI text are all consistent).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/launcher/src/logs.rs (1)

42-72: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't still swallow Binaries/ read errors.

The new hard-fail path only covers sessions/. If std::fs::read_dir(&binaries) fails with PermissionDenied or similar, this function still returns an empty list and the caller reports "No log files found." Surface non-NotFound errors here too.

Suggested fix
-    if let Ok(entries) = std::fs::read_dir(&binaries) {
-        for e in entries.flatten() {
-            let p = e.path();
-            if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
-                if name.starts_with("sgwdebuglog") && p.is_file() {
-                    files.push(p);
-                }
-            }
-        }
-    }
+    match std::fs::read_dir(&binaries) {
+        Ok(entries) => {
+            for e in entries.flatten() {
+                let p = e.path();
+                if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
+                    if name.starts_with("sgwdebuglog") && p.is_file() {
+                        files.push(p);
+                    }
+                }
+            }
+        }
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+        Err(e) => return Err(e.into()),
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/logs.rs` around lines 42 - 72, collect_log_inputs
currently swallows errors from std::fs::read_dir(&binaries) and only fails hard
for the sessions walk; change the read_dir handling in collect_log_inputs to
propagate non-NotFound errors instead of ignoring them: replace the if let
Ok(entries) = std::fs::read_dir(&binaries) { ... } with a match (or
map_err/and_then) that on Ok(entries) iterates as before, on Err(e) returns
Err(convert e into LogError) unless e.kind() == std::io::ErrorKind::NotFound (in
which case continue), ensuring you use the same LogError conversion used
elsewhere in this module so callers receive a hard failure for real IO errors.
♻️ Duplicate comments (2)
crates/launcher/src/worker.rs (1)

58-66: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Cancellation here still allows overlapping installs.

prev.cancel() only requests shutdown. The previous task can keep downloading or extracting until its next cancellation checkpoint, while the replacement task starts immediately on the same temp zips, extracted files, state file, and SGW.exe. Please serialize on actual task completion (for example by retaining a JoinHandle and awaiting it, or by rejecting a new install while one is still running) before claiming these installs cannot run concurrently.

Also applies to: 124-133

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/worker.rs` around lines 58 - 66, The
current_install_cancel field only calls prev.cancel() which requests shutdown
but does not wait for the previous install to finish, allowing overlap; change
the design to serialize installs by pairing the CancellationToken with the
running task's JoinHandle (e.g., replace or extend current_install_cancel:
Option<CancellationToken> with a struct or separate current_install_handle:
Option<JoinHandle<()>> and current_install_cancel: Option<CancellationToken>),
and after calling prev.cancel() await the JoinHandle to completion (or
reject/return the new install attempt if a handle exists) before starting the
replacement install; update every site that currently just calls prev.cancel()
(including the earlier usage noted around lines 124-133) to either await the
stored JoinHandle or refuse concurrent installs so temp zips, extraction,
launcher-installed.json, and SGW.exe patching cannot race.
crates/launcher/src/manifest.rs (1)

54-92: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This still lets a broken release ship and fail only at runtime.

option_env! is resolved at compile time. Returning None here means a release artifact can still build and be published when LAUNCHER_MANIFEST_PUBKEY_HEX is missing or malformed, and the launcher only breaks later when a user fetches the manifest. Please add a build/startup hard-fail outside this LazyLock so release workflow mistakes are caught before distribution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/launcher/src/manifest.rs` around lines 54 - 92, The manifest
public-key logic in MANIFEST_SIGNING_PUBKEY currently defers failure to runtime
via LazyLock and option_env!, allowing a malformed/missing
LAUNCHER_MANIFEST_PUBKEY_HEX to produce a broken release artifact; add a
compile-time/build-time hard-fail for release builds by adding a build.rs that
(when not test/debug_assertions) reads LAUNCHER_MANIFEST_PUBKEY_HEX, runs the
same validation you use (hex_decode_32 + VerifyingKey::from_bytes semantics) and
exits non‑zero or panics (failing the build) if the var is unset/empty/malformed
— keep the existing LazyLock only for the dev/test fallback path so local CI/dev
still works but releases cannot be built with an invalid key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/launcher-build.yml:
- Around line 108-149: The workflow uses several tag/branch-based action refs
that should be pinned to immutable commit SHAs: replace actions/checkout@v6,
dtolnay/rust-toolchain@stable, Swatinem/rust-cache@v2,
taiki-e/install-action@cargo-llvm-cov, taiki-e/install-action@nextest,
actions/upload-artifact@v7, and codecov/codecov-action@v6 with their
corresponding full commit SHA refs (e.g., actions/checkout@<sha>) in the
launcher coverage job; ensure each SHA is the exact commit you want to lock to,
verify the SHAs are valid on the respective repos, and keep the rest of the step
configuration unchanged.

---

Outside diff comments:
In `@crates/launcher/src/logs.rs`:
- Around line 42-72: collect_log_inputs currently swallows errors from
std::fs::read_dir(&binaries) and only fails hard for the sessions walk; change
the read_dir handling in collect_log_inputs to propagate non-NotFound errors
instead of ignoring them: replace the if let Ok(entries) =
std::fs::read_dir(&binaries) { ... } with a match (or map_err/and_then) that on
Ok(entries) iterates as before, on Err(e) returns Err(convert e into LogError)
unless e.kind() == std::io::ErrorKind::NotFound (in which case continue),
ensuring you use the same LogError conversion used elsewhere in this module so
callers receive a hard failure for real IO errors.

---

Duplicate comments:
In `@crates/launcher/src/manifest.rs`:
- Around line 54-92: The manifest public-key logic in MANIFEST_SIGNING_PUBKEY
currently defers failure to runtime via LazyLock and option_env!, allowing a
malformed/missing LAUNCHER_MANIFEST_PUBKEY_HEX to produce a broken release
artifact; add a compile-time/build-time hard-fail for release builds by adding a
build.rs that (when not test/debug_assertions) reads
LAUNCHER_MANIFEST_PUBKEY_HEX, runs the same validation you use (hex_decode_32 +
VerifyingKey::from_bytes semantics) and exits non‑zero or panics (failing the
build) if the var is unset/empty/malformed — keep the existing LazyLock only for
the dev/test fallback path so local CI/dev still works but releases cannot be
built with an invalid key.

In `@crates/launcher/src/worker.rs`:
- Around line 58-66: The current_install_cancel field only calls prev.cancel()
which requests shutdown but does not wait for the previous install to finish,
allowing overlap; change the design to serialize installs by pairing the
CancellationToken with the running task's JoinHandle (e.g., replace or extend
current_install_cancel: Option<CancellationToken> with a struct or separate
current_install_handle: Option<JoinHandle<()>> and current_install_cancel:
Option<CancellationToken>), and after calling prev.cancel() await the JoinHandle
to completion (or reject/return the new install attempt if a handle exists)
before starting the replacement install; update every site that currently just
calls prev.cancel() (including the earlier usage noted around lines 124-133) to
either await the stored JoinHandle or refuse concurrent installs so temp zips,
extraction, launcher-installed.json, and SGW.exe patching cannot race.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d50273b-b82d-43f1-bd05-06f52735f2bd

📥 Commits

Reviewing files that changed from the base of the PR and between af6db5e and 1e665e1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • .github/workflows/launcher-build.yml
  • codecov.yml
  • crates/launcher/Cargo.toml
  • crates/launcher/src/app.rs
  • crates/launcher/src/install.rs
  • crates/launcher/src/launch.rs
  • crates/launcher/src/logs.rs
  • crates/launcher/src/main.rs
  • crates/launcher/src/manifest.rs
  • crates/launcher/src/patch_rdata.rs
  • crates/launcher/src/worker.rs
  • docs/client/launcher-guide.md

Comment on lines +108 to +149
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: install eframe / egui Linux deps
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev \
libxrandr-dev libxi-dev libgl1-mesa-dev
- uses: Swatinem/rust-cache@v2
with:
shared-key: launcher-coverage
- name: install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: install nextest
uses: taiki-e/install-action@nextest
- name: clean stale counter data
run: cargo llvm-cov clean --workspace
- name: cargo llvm-cov nextest -p sgw-launcher
run: cargo llvm-cov --no-report nextest --profile=ci -p sgw-launcher
- name: render cobertura report
run: |
mkdir -p target/coverage
cargo llvm-cov report --cobertura --output-path target/coverage/cobertura.xml
cargo llvm-cov report --summary-only | tee target/coverage/summary.txt
- name: print summary to job log
run: |
echo "## Launcher coverage summary" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat target/coverage/summary.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: upload coverage artifact
uses: actions/upload-artifact@v7
with:
name: launcher-coverage
path: target/coverage/
retention-days: 14
- name: upload to Codecov
uses: codecov/codecov-action@v6
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

check_tag() {
  local repo="$1"
  local tag="$2"
  local url="https://api.github.com/repos/${repo}/git/ref/tags/${tag}"
  local code

  code=$(curl -sS -o /tmp/tag.json -w '%{http_code}' "$url")
  printf '%-30s %-10s -> HTTP %s\n' "$repo" "$tag" "$code"

  if [ "$code" = "200" ]; then
    jq -r '.ref' /tmp/tag.json
  else
    cat /tmp/tag.json
  fi
  echo
}

check_tag actions/checkout v6
check_tag actions/upload-artifact v7
check_tag codecov/codecov-action v6

Repository: SandboxServers/Cimmeria

Length of output: 271


🏁 Script executed:

#!/bin/bash
set -euo pipefail

check_ref() {
  local repo="$1"
  local ref="$2"
  local tag_url="https://api.github.com/repos/${repo}/git/ref/tags/${ref}"
  local head_url="https://api.github.com/repos/${repo}/git/ref/heads/${ref}"
  local code_ref

  code_ref=$(curl -sS -o /tmp/tag.json -w '%{http_code}' "$tag_url" || true)
  if [ "$code_ref" = "200" ]; then
    echo "$repo @ $ref -> tag -> HTTP 200 ($(jq -r '.ref' /tmp/tag.json))"
    return 0
  fi

  code_ref=$(curl -sS -o /tmp/head.json -w '%{http_code}' "$head_url" || true)
  if [ "$code_ref" = "200" ]; then
    echo "$repo @ $ref -> branch -> HTTP 200 ($(jq -r '.ref' /tmp/head.json))"
    return 0
  fi

  echo "$repo @ $ref -> not found as tag or branch (tag HTTP $(
    curl -sS -o /tmp/tag2.json -w '%{http_code}' "$tag_url" || true
  ), branch HTTP $(
    curl -sS -o /tmp/head2.json -w '%{http_code}' "$head_url" || true
  ))"
  return 1
}

check_ref dtolnay/rust-toolchain stable
check_ref Swatinem/rust-cache v2
check_ref taiki-e/install-action cargo-llvm-cov
check_ref taiki-e/install-action nextest

Repository: SandboxServers/Cimmeria

Length of output: 358


Pin GitHub Actions uses: refs in launcher coverage job to full commit SHAs

  • Even though the current refs resolve (actions/checkout@v6, dtolnay/rust-toolchain@stable, Swatinem/rust-cache@v2, taiki-e/install-action@cargo-llvm-cov, taiki-e/install-action@nextest, actions/upload-artifact@v7, codecov/codecov-action@v6), tag/branch-based uses: are still floating and can change behavior; pin each to an exact commit SHA.
🧰 Tools
🪛 zizmor (1.25.2)

[error] 108-108: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 111-111: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 120-120: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 124-124: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 126-126: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 143-143: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 149-149: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[info] 111-111: action functionality is already included by the runner (superfluous-actions): use rustup and/or cargo in a script step

(superfluous-actions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/launcher-build.yml around lines 108 - 149, The workflow
uses several tag/branch-based action refs that should be pinned to immutable
commit SHAs: replace actions/checkout@v6, dtolnay/rust-toolchain@stable,
Swatinem/rust-cache@v2, taiki-e/install-action@cargo-llvm-cov,
taiki-e/install-action@nextest, actions/upload-artifact@v7, and
codecov/codecov-action@v6 with their corresponding full commit SHA refs (e.g.,
actions/checkout@<sha>) in the launcher coverage job; ensure each SHA is the
exact commit you want to lock to, verify the SHAs are valid on the respective
repos, and keep the rest of the step configuration unchanged.

Cadacious and others added 2 commits May 22, 2026 22:08
Two fixes bundled:

1. Merge main into the branch. Cargo.lock had the only conflict;
   taken from main and re-resolved via `cargo check --workspace`
   to pick up any deltas this branch's deps need on top of main's
   recently-updated lockfile (reqwest 0.13 + cipher 0.5 family).

2. CI fix: `cargo llvm-cov (launcher)` was failing with
   `clang: error: invalid linker name in argument '-fuse-ld=mold'`.

   `.cargo/config.toml` pins `linker = "clang"` and
   `rustflags = ["-C", "link-arg=-fuse-ld=mold"]` for the Linux GNU
   target. Every other Linux job in the repo (`test.yml` jobs,
   `spec-lint.yml`) does `apt-get install -y clang mold` to satisfy
   that flag, but the launcher coverage job — added in this PR —
   only installed the egui/eframe display-server deps. Stock
   ubuntu-latest clang has no built-in mold support, so the
   `-fuse-ld=mold` flag bombed on the first build-script crate
   (quote / proc-macro2 / libc).

   Folded clang + mold into the existing `install eframe / egui
   Linux deps` step rather than adding a separate one, with a
   comment explaining the dependency on `.cargo/config.toml`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`default_uses_localappdata_when_set` failed on the ubuntu coverage
runner (`cargo llvm-cov (launcher)`) because the assertion compares
against `PathBuf::from("Z:\LocalApp\Stargate Worlds")`. On Windows
the embedded `\` is a path separator and the literal parses as three
components; on Linux it's a single component containing literal
backslashes, so `.join("Stargate Worlds")` produces a `/`-separated
mixed string that doesn't byte-equal the expected value.

The Windows `cargo nextest (Windows)` job passed because that runner
treats `\` as a separator. Only the Linux coverage job catches the
mismatch.

Gate the test with `#[cfg(target_os = "windows")]` — it's specifically
exercising the Windows `LOCALAPPDATA` convention inside
`default_install_path`. The Linux/`HOME` fallback branches in that
function are exercised implicitly by every other test that constructs
a `LauncherConfig::default()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cadacious Cadacious merged commit ed7c8ed into main May 23, 2026
13 checks passed
@codecov
Copy link
Copy Markdown

codecov Bot commented May 23, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants